A whole bunch of changes

This commit is contained in:
Courtney Arnold 2026-08-15 17:52:25 -05:00
parent 901ec678a3
commit 956231048e
122 changed files with 5442 additions and 914 deletions

View file

@ -64,5 +64,13 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Lets url_launcher open the Missouri DOR forms link on the
Reports tab — without this, canLaunchUrl/launchUrl can't see
any browser app on Android 11+ due to package visibility
restrictions. -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 9.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 4.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#101110</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#101110</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#24603A</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#24603A</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

BIN
assets/icon/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

BIN
assets/icon/hero_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -441,7 +441,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@ -498,7 +498,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

View file

@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 725 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 8.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -1,23 +1,56 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 204 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 421 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

View file

@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="1024" height="1024"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View file

@ -106,5 +106,7 @@
</array>
</dict>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/home_screen.dart';
import 'screens/main_shell.dart';
import 'services/app_state.dart';
import 'theme/app_theme.dart';
@ -16,12 +16,15 @@ class FuelTaxTrackerApp extends StatelessWidget {
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppState()..init(),
child: MaterialApp(
child: Consumer<AppState>(
builder: (context, appState, _) => MaterialApp(
title: 'Fuel Tax Tracker',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: appState.themeMode,
home: const AppRoot(),
),
),
);
}
}
@ -64,7 +67,7 @@ class AppRoot extends StatelessWidget {
),
);
}
return const HomeScreen();
return const MainShell();
},
);
}

View file

@ -12,11 +12,19 @@ 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.
/// omit it to create a new one. [promptToCreateForReceipt] is set when this
/// screen was reached because the user tried to log a receipt with no
/// vehicle to attach it to yet — it shows an explanatory dialog on arrival
/// so that's clear, rather than silently landing on the add-vehicle form.
class AddEditVehicleScreen extends StatefulWidget {
final Vehicle? vehicle;
final bool promptToCreateForReceipt;
const AddEditVehicleScreen({super.key, this.vehicle});
const AddEditVehicleScreen({
super.key,
this.vehicle,
this.promptToCreateForReceipt = false,
});
@override
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
@ -39,6 +47,24 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
super.initState();
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
if (widget.promptToCreateForReceipt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
content: const Text('Create a vehicle to attach receipt'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
});
}
}
@override
@ -48,8 +74,46 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
super.dispose();
}
void _showVinLocationHelp() {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Where to find the VIN'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"It's usually on a sticker in the driver's side door jamb, or on a "
'small plate at the base of the windshield.',
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset('assets/vin_help/door_jamb.jpg'),
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset('assets/vin_help/windshield.jpg'),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
],
),
);
}
Future<void> _scanVin() async {
final source = await chooseImageSource(context);
final source = await chooseImageSource(
context,
heading: 'VIN From',
onInfoTap: _showVinLocationHelp,
);
if (source == null || !mounted) return;
final picker = ImagePicker();
@ -89,13 +153,35 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
await _checkForExistingVin();
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Couldn't read a VIN from that photo. Please enter it manually."),
SnackBar(
content: const Text("Couldn't read a VIN from that photo. Please enter it manually."),
duration: const Duration(seconds: 8),
action: recognizedText.trim().isEmpty
? null
// Lets you see exactly what the on-device OCR read, rather
// than only knowing "no VIN was found in it" — the
// difference between "OCR misread a character" and "OCR
// read it fine but the VIN pattern itself needs a fix"
// isn't visible any other way.
: SnackBarAction(label: 'Show Text', onPressed: () => _showRecognizedText(recognizedText)),
),
);
}
}
void _showRecognizedText(String text) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Recognized Text'),
content: SingleChildScrollView(child: SelectableText(text)),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
],
),
);
}
/// 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
@ -160,24 +246,30 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
_error = null;
});
final vin = _vinController.text.trim();
final nickname = _nicknameController.text.trim();
final appState = context.read<AppState>();
try {
if (_isEditing) {
await appState.updateVehicle(widget.vehicle!.copyWith(
vin: _vinController.text.trim(),
vin: vin,
nickname: nickname.isEmpty ? null : nickname,
clearNickname: nickname.isEmpty,
));
} else {
await appState.addVehicle(
vin: _vinController.text.trim(),
nickname: nickname.isEmpty ? null : nickname,
);
await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname);
}
if (mounted) Navigator.of(context).pop();
if (mounted) {
// Reached from "log a receipt with no vehicle yet" — once the
// vehicle exists, continue straight into the receipt capture flow
// the user was actually trying to do, rather than just landing
// back on an (again) empty Receipts tab. The caller does the
// continuing; this just hands back which vehicle to do it for.
final continueToReceipt = !_isEditing && widget.promptToCreateForReceipt;
Navigator.of(context).pop(continueToReceipt ? appState.vehicleByVin(vin)?.id : null);
}
} on DuplicateVinException catch (e) {
setState(() => _error = e.toString());
} finally {

View file

@ -0,0 +1,550 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/cloud/cloud_storage_provider.dart';
import 'cloud_folder_browser_screen.dart';
/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage
/// connection, the two photo-handling switches, and an Advanced section
/// (stale sync lock timeout, plus the destructive purge actions — grouped
/// there rather than given their own top-level "Danger Zone" so they sit
/// behind the same disclosure as other rarely-touched settings).
class DataSettingsScreen extends StatefulWidget {
const DataSettingsScreen({super.key});
@override
State<DataSettingsScreen> createState() => _DataSettingsScreenState();
}
class _DataSettingsScreenState extends State<DataSettingsScreen> {
static final _dateFormat = DateFormat.yMMMd();
bool _busy = false;
String? _error;
DateTime? _purgeRangeStart;
DateTime? _purgeRangeEnd;
String? _purgeRangeError;
Future<void> _connect(CloudProviderId id) async {
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProvider(id);
} catch (e) {
setState(() => _error = 'Could not sign in: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _connectManual(CloudProviderId id, String providerName) async {
final credentials = await showDialog<_WebDavCredentials>(
context: context,
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
);
if (credentials == null || !mounted) return;
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProviderWithCredentials(
id,
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
);
} catch (e) {
setState(() => _error = 'Could not connect: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _disconnect() async {
setState(() => _busy = true);
try {
await context.read<AppState>().disconnectCloud();
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _chooseFolder() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
);
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await context.read<AppState>().syncNow();
} finally {
if (mounted) setState(() => _busy = false);
}
}
/// Shared warning-dialog shell for both purge actions — [message] is the
/// caller's job to make specific and unambiguous, since this is the only
/// thing standing between the user and an unrecoverable delete.
Future<bool> _confirmPurge({required String title, required String message}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Delete'),
),
],
),
);
return confirmed ?? false;
}
/// Extra context appended to a purge warning when a cloud account is
/// connected — a purely local delete would otherwise just get silently
/// re-imported from the remote copy on the very next sync, which isn't
/// obvious from the app's normal behavior.
String _cloudSyncCaveat(AppState appState) {
if (!appState.isCloudConnected) return '';
return '\n\nThis device is connected to ${appState.activeProvider!.displayName}. The '
'deletion will be pushed there on the next sync, same as any other delete.';
}
Future<void> _purgeAllData() async {
final appState = context.read<AppState>();
final confirmed = await _confirmPurge(
title: 'Purge all data?',
message: 'This permanently deletes every vehicle, fuel entry, and receipt photo on '
"this device — starting completely fresh. This can't be undone."
'${_cloudSyncCaveat(appState)}',
);
if (!confirmed || !mounted) return;
setState(() => _busy = true);
try {
await appState.purgeAllData();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data purged.')),
);
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _pickPurgeRangeDate({required bool isStart}) async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: (isStart ? _purgeRangeStart : _purgeRangeEnd) ?? now,
firstDate: DateTime(2000),
lastDate: now,
);
if (picked == null) return;
setState(() {
_purgeRangeError = null;
if (isStart) {
_purgeRangeStart = picked;
} else {
_purgeRangeEnd = picked;
}
});
}
Future<void> _purgeDataInRange() async {
final start = _purgeRangeStart;
final end = _purgeRangeEnd;
if (start == null || end == null) return;
if (end.isBefore(start)) {
setState(() => _purgeRangeError = 'End date must be on or after the start date.');
return;
}
final appState = context.read<AppState>();
final confirmed = await _confirmPurge(
title: 'Purge data in range?',
message: 'This permanently deletes every fuel entry and receipt photo dated from '
'${_dateFormat.format(start)} to ${_dateFormat.format(end)}, across all '
"vehicles. Vehicles themselves aren't affected. This can't be undone."
'${_cloudSyncCaveat(appState)}',
);
if (!confirmed || !mounted) return;
setState(() => _busy = true);
try {
await appState.purgeFuelEntriesInRange(start, end);
if (mounted) {
setState(() {
_purgeRangeStart = null;
_purgeRangeEnd = null;
_purgeRangeError = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fuel entries in range purged.')),
);
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(title: const Text('Data')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (!appState.isCloudConnected) ...[
Text(
'Connect a cloud storage account to share vehicles and fuel '
'receipts with other people, and to keep a backup off this device.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final provider in appState.availableProviders)
FilledButton.icon(
onPressed: _busy
? null
: () => provider is ManualCredentialCloudStorageProvider
? _connectManual(provider.id, provider.displayName)
: _connect(provider.id),
icon: const Icon(Icons.login),
label: Text('Connect ${provider.displayName}'),
),
],
),
] else ...[
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
const SizedBox(height: 4),
Text(
appState.cloudFolderPath == null
? 'No folder selected yet.'
: 'Folder: ${appState.cloudFolderPath}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _busy ? null : _chooseFolder,
icon: const Icon(Icons.folder_open),
label: Text(appState.cloudFolderPath == null
? 'Choose Folder'
: 'Change Folder'),
),
OutlinedButton.icon(
onPressed: (_busy || appState.cloudFolderPath == null)
? null
: _syncNow,
icon: appState.isSyncing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.sync),
label: const Text('Sync Now'),
),
TextButton.icon(
onPressed: _busy ? null : _disconnect,
icon: const Icon(Icons.logout),
label: const Text('Disconnect'),
),
],
),
const SizedBox(height: 8),
if (appState.lastSyncedAt != null)
Text(
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
style: Theme.of(context).textTheme.bodySmall,
),
if (appState.lastSyncError != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Last sync failed: ${appState.lastSyncError}',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep photos on this phone after syncing'),
subtitle: const Text(
'Otherwise, a receipt photo is removed from this device once '
"it's safely uploaded to the cloud.",
),
value: appState.keepReceiptPhotosLocally,
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep max quality images'),
subtitle: const Text(
'Otherwise, receipt photos are downscaled to a reasonable size '
'before storing — smaller cloud storage and faster syncs, with '
'no loss of legibility for a printed receipt.',
),
value: appState.keepMaxQualityReceiptPhotos,
onChanged: (value) =>
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
),
),
const SizedBox(height: 16),
Card(
child: ExpansionTile(
title: const Text('Advanced'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
),
const SizedBox(height: 4),
Text(
"If another device disconnects mid-sync without releasing its lock, "
"this is how long to wait before treating it as abandoned and clearing "
"it so sync can continue.",
style: Theme.of(context).textTheme.bodySmall,
),
Slider(
value: appState.staleLockMinutes.toDouble(),
min: minStaleLockMinutes.toDouble(),
max: maxStaleLockMinutes.toDouble(),
divisions: maxStaleLockMinutes - minStaleLockMinutes,
label: '${appState.staleLockMinutes} min',
onChanged: (value) =>
context.read<AppState>().setStaleLockMinutes(value.round()),
),
Text('${appState.staleLockMinutes} minute(s)'),
const Divider(height: 32),
Text(
'Danger Zone',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
Text('Purge All Data', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(
'Deletes every vehicle, fuel entry, and receipt photo on this device '
'and starts completely fresh.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _busy ? null : _purgeAllData,
icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
label: Text(
'Purge All Data',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: Theme.of(context).colorScheme.error),
),
),
),
const Divider(height: 32),
Text('Purge Data by Date Range', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(
'Deletes fuel entries and receipt photos dated within a range, across '
'all vehicles. Vehicles themselves are kept.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: true),
child: Text(
_purgeRangeStart == null
? 'Start date'
: _dateFormat.format(_purgeRangeStart!),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: false),
child: Text(
_purgeRangeEnd == null ? 'End date' : _dateFormat.format(_purgeRangeEnd!),
),
),
),
],
),
if (_purgeRangeError != null) ...[
const SizedBox(height: 8),
Text(
_purgeRangeError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: (_busy || _purgeRangeStart == null || _purgeRangeEnd == null)
? null
: _purgeDataInRange,
icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
label: Text(
'Purge Range',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: Theme.of(context).colorScheme.error),
),
),
),
],
),
),
],
),
);
}
}
class _WebDavCredentials {
final String serverUrl;
final String username;
final String password;
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
}
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
/// needs, since (unlike the OAuth providers) there's no browser flow to
/// gather these instead.
class _WebDavCredentialsDialog extends StatefulWidget {
final String providerName;
const _WebDavCredentialsDialog({required this.providerName});
@override
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
}
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(_WebDavCredentials(
serverUrl: _serverController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text,
));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Connect ${widget.providerName}'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
),
keyboardType: TextInputType.url,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}

View file

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../widgets/receipt_thumbnail.dart';
/// Lets the user correct the *data* logged about a receipt (date, gallons,
/// price/gal, total cost) — not the receipt photo itself, which is shown
/// read-only up top (tap it to view full-screen, same as everywhere else
/// it's shown) and can't be replaced from here.
class EditFuelEntryScreen extends StatefulWidget {
final FuelEntry entry;
const EditFuelEntryScreen({super.key, required this.entry});
@override
State<EditFuelEntryScreen> createState() => _EditFuelEntryScreenState();
}
class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _gallonsController;
late final TextEditingController _priceController;
late final TextEditingController _totalController;
late DateTime _date;
bool _saving = false;
@override
void initState() {
super.initState();
_date = widget.entry.date;
_gallonsController = TextEditingController(text: widget.entry.gallons.toStringAsFixed(3));
_priceController = TextEditingController(text: widget.entry.pricePerGallon.toStringAsFixed(3));
_totalController = TextEditingController(text: widget.entry.totalCost.toStringAsFixed(2));
}
@override
void dispose() {
_gallonsController.dispose();
_priceController.dispose();
_totalController.dispose();
super.dispose();
}
void _calculateTotal() {
final gallons = double.tryParse(_gallonsController.text);
final price = double.tryParse(_priceController.text);
if (gallons != null && price != null) {
setState(() {
_totalController.text = (gallons * price).toStringAsFixed(2);
});
}
}
Future<void> _pickDate() async {
// initialDate must fall within [firstDate, lastDate] or the picker
// throws — widen the bounds to cover _date in case it's already
// outside the normal 5-years-back-to-today window.
final today = DateTime.now();
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
? _date
: today.subtract(const Duration(days: 365 * 5));
final lastDate = _date.isAfter(today) ? _date : today;
final pickedDate = await showDatePicker(
context: context,
initialDate: _date,
firstDate: firstDate,
lastDate: lastDate,
);
if (pickedDate == null || !mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_date),
);
if (pickedTime == null) return;
setState(() {
_date = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
});
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
await context.read<AppState>().updateFuelEntry(
entryId: widget.entry.id,
date: _date,
gallons: double.parse(_gallonsController.text),
pricePerGallon: double.parse(_priceController.text),
totalCost: double.parse(_totalController.text),
);
if (mounted) Navigator.of(context).pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
String? _requiredDecimal(String? value) {
if (value == null || value.trim().isEmpty) return 'Required';
if (double.tryParse(value) == null) return 'Enter a valid number';
return null;
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMMMd().add_jm();
return Scaffold(
appBar: AppBar(title: const Text('Edit Fuel Entry')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: ReceiptThumbnail(entry: widget.entry, size: 180),
),
const SizedBox(height: 8),
Text(
'Tap the photo to view it full-screen. The photo itself can\'t '
"be changed here — only the data below can.",
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Date & time'),
subtitle: Text(dateFormat.format(_date)),
trailing: const Icon(Icons.edit_calendar_outlined),
onTap: _pickDate,
),
const SizedBox(height: 8),
TextFormField(
controller: _gallonsController,
decoration: const InputDecoration(labelText: 'Gallons', suffixText: 'gal'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 12),
TextFormField(
controller: _priceController,
decoration: const InputDecoration(labelText: 'Price per gallon', prefixText: '\$'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 12),
TextFormField(
controller: _totalController,
decoration: InputDecoration(
labelText: 'Total cost',
prefixText: '\$',
suffixIcon: IconButton(
icon: const Icon(Icons.calculate_outlined),
tooltip: 'Calculate from gallons × price',
onPressed: _calculateTotal,
),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save Changes'),
),
],
),
),
);
}
}

View file

@ -4,10 +4,10 @@ import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import 'add_edit_vehicle_screen.dart';
import 'report_screen.dart';
import 'settings_screen.dart';
import 'vehicle_detail_screen.dart';
/// The "Vehicles" tab body — Settings and Reports are reached via the
/// bottom nav now (see [MainShell]), not from icons here.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@ -18,20 +18,14 @@ class HomeScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: const Text('My Vehicles'),
title: const Text('Vehicles'),
actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
icon: const Icon(Icons.summarize_outlined),
tooltip: 'Fuel Report',
icon: const Icon(Icons.add),
tooltip: 'Add Vehicle',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ReportScreen()),
),
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
),
),
],
@ -48,13 +42,6 @@ class HomeScreen extends StatelessWidget {
return _VehicleCard(vehicle: vehicle, totalGallons: gallons);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
),
icon: const Icon(Icons.add),
label: const Text('Add Vehicle'),
),
);
}
}
@ -72,19 +59,44 @@ class _VehicleCard extends StatelessWidget {
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
title: Text(vehicle.displayLabel),
// Only repeat the VIN here when the title is already showing the
// nickname instead — otherwise the title (falling back to the VIN
// when there's no nickname) would show it twice.
subtitle: Text(hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine),
isThreeLine: hasNickname,
trailing: const Icon(Icons.chevron_right),
child: InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// Row cross-axis defaults to center, so the chevron sits
// vertically centered against the title+subtitle block even when
// the nickname line makes that block taller — ListTile's
// isThreeLine forces leading/trailing to the top instead, per
// Material spec, which is what pinned it to the top before.
child: Row(
children: [
const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(vehicle.displayLabel, style: Theme.of(context).textTheme.titleMedium),
// Only repeat the VIN here when the title is already
// showing the nickname instead — otherwise the title
// (falling back to the VIN when there's no nickname)
// would show it twice.
Text(
hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}

View file

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'home_screen.dart';
import 'receipts_screen.dart';
import 'report_screen.dart';
import 'settings_screen.dart';
/// App root once loaded: a persistent bottom nav bar over the four main
/// sections — Receipts (all vehicles), Vehicles (the vehicle list),
/// Reports, and Settings. Each tab keeps its own [Scaffold]/[AppBar]; this
/// shell only owns the [PageView]/[NavigationBar] and which tab is
/// selected.
///
/// A right-to-left swipe on the body advances to the next tab (and
/// left-to-right goes back) with the same sliding animation tapping a
/// [NavigationDestination] uses — both go through [_goToTab], which is the
/// only thing that moves [_pageController]. [AutomaticKeepAliveClientMixin]
/// on the three stateful tab screens (Receipts/Report/Settings) is what
/// keeps each tab's state — scroll position, toggles, in-progress form
/// fields — alive when [PageView] builds/tears down pages outside its
/// cache extent, the same guarantee the previous [IndexedStack]-based
/// version gave "for free".
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _index = 0;
final _pageController = PageController();
static const _tabs = [
ReceiptsScreen(),
HomeScreen(),
ReportScreen(),
SettingsScreen(),
];
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _goToTab(int index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
controller: _pageController,
onPageChanged: (index) => setState(() => _index = index),
children: _tabs,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: _goToTab,
destinations: const [
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: 'Receipts',
),
NavigationDestination(
icon: Icon(Icons.directions_car_outlined),
selectedIcon: Icon(Icons.directions_car),
label: 'Vehicles',
),
NavigationDestination(
icon: Icon(Icons.summarize_outlined),
selectedIcon: Icon(Icons.summarize),
label: 'Reports',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}

View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../widgets/receipt_thumbnail.dart';
import 'edit_fuel_entry_screen.dart';
/// The single place to view a receipt's photo and its logged data
/// together, with edit/delete for the entry — reached by tapping a receipt
/// row on either the Receipts tab or a vehicle's detail page. Looks up
/// [entryId] fresh from [AppState] on every build (rather than taking a
/// [FuelEntry] directly) so it reflects an edit made via
/// [EditFuelEntryScreen] as soon as that screen is popped back to this one.
class ReceiptDetailScreen extends StatelessWidget {
final String entryId;
const ReceiptDetailScreen({super.key, required this.entryId});
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete fuel entry?'),
content: const Text('This will remove the entry and its receipt photo.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && context.mounted) {
await context.read<AppState>().deleteFuelEntry(entry.id);
if (context.mounted) Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final entry = appState.fuelEntryById(entryId);
if (entry == null) {
return const Scaffold(body: Center(child: Text('Receipt not found')));
}
final vehicle = appState.vehicleById(entry.vehicleId);
final dateFormat = DateFormat.yMMMd().add_jm();
final currencyFormat = NumberFormat.simpleCurrency();
return Scaffold(
appBar: AppBar(
title: const Text('Receipt'),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit fuel entry',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => EditFuelEntryScreen(entry: entry)),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete fuel entry',
onPressed: () => _deleteEntry(context, entry),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(child: ReceiptThumbnail(entry: entry, size: 240)),
const SizedBox(height: 8),
Text(
'Tap the photo to view it full-screen.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
if (vehicle != null) ...[
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.directions_car_outlined),
title: Text(vehicle.displayLabel),
subtitle: Text('VIN: ${vehicle.vin}'),
),
const Divider(height: 24),
],
_DetailRow(label: 'Date & time', value: dateFormat.format(entry.date)),
_DetailRow(label: 'Gallons', value: '${entry.gallons.toStringAsFixed(3)} gal'),
_DetailRow(label: 'Price per gallon', value: currencyFormat.format(entry.pricePerGallon)),
_DetailRow(label: 'Total cost', value: currencyFormat.format(entry.totalCost)),
],
),
);
}
}
class _DetailRow extends StatelessWidget {
final String label;
final String value;
const _DetailRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Text(value, style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
}

View file

@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import '../theme/app_theme.dart';
import '../widgets/hero_banner.dart';
import '../widgets/receipt_capture.dart';
import 'add_edit_vehicle_screen.dart';
import 'receipt_detail_screen.dart';
/// The three summary periods selectable below the hero totals — the
/// totals and the "Recent Activity" list below are both filtered to
/// whichever one is currently selected.
enum _DateRange {
currentMonth('Current Month'),
last3Months('Last 3 Months'),
yearToDate('Year to Date');
final String label;
const _DateRange(this.label);
/// Inclusive start of this range, relative to [now]. There's no
/// explicit end — an entry just needs to not be *before* this to count,
/// since receipts are never dated in the future in practice.
DateTime startDate(DateTime now) {
switch (this) {
case _DateRange.currentMonth:
return DateTime(now.year, now.month, 1);
case _DateRange.last3Months:
// Current month plus the two before it, from the 1st of the
// earliest — DateTime normalizes a zero/negative month across a
// year boundary correctly (e.g. month 0 becomes December prior year).
return DateTime(now.year, now.month - 2, 1);
case _DateRange.yearToDate:
return DateTime(now.year, 1, 1);
}
}
}
/// The "Receipts" tab: every fuel entry across every vehicle, newest
/// first, styled after the theme mockups' receipt-focused layout — a
/// hero total, a date-range filter, then a flat, divided list of receipts
/// (date + amount + which vehicle) for that range, each tapping straight
/// through to that receipt's photo rather than to the vehicle it belongs to.
class ReceiptsScreen extends StatefulWidget {
const ReceiptsScreen({super.key});
@override
State<ReceiptsScreen> createState() => _ReceiptsScreenState();
}
class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAliveClientMixin {
_DateRange _range = _DateRange.yearToDate;
// Keeps this tab's state (the selected date range) alive when MainShell's
// PageView builds/tears down tabs outside its cache extent as the user
// swipes between them.
@override
bool get wantKeepAlive => true;
Future<void> _addReceipt(BuildContext context) async {
final vehicles = context.read<AppState>().vehicles;
if (vehicles.isEmpty) {
final newVehicleId = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => const AddEditVehicleScreen(promptToCreateForReceipt: true),
),
);
// Creating the vehicle was only ever a means to an end here — continue
// straight into the receipt capture the user actually came here for,
// rather than dropping them back on a Receipts tab that's still empty.
if (newVehicleId != null && context.mounted) {
await captureReceiptForVehicle(context, newVehicleId);
}
return;
}
if (vehicles.length == 1) {
await captureReceiptForVehicle(context, vehicles.first.id);
return;
}
final chosen = await showModalBottomSheet<Vehicle>(
context: context,
builder: (context) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('Which vehicle is this receipt for?'),
),
for (final vehicle in vehicles)
ListTile(
leading: const Icon(Icons.directions_car_outlined),
title: Text(vehicle.displayLabel),
onTap: () => Navigator.of(context).pop(vehicle),
),
],
),
),
);
if (chosen != null && context.mounted) {
await captureReceiptForVehicle(context, chosen.id);
}
}
void _openReceipt(BuildContext context, FuelEntry entry) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ReceiptDetailScreen(entryId: entry.id)),
);
}
@override
Widget build(BuildContext context) {
super.build(context);
final appState = context.watch<AppState>();
final rangeStart = _range.startDate(DateTime.now());
final entries = appState.fuelEntries.where((e) => !e.date.isBefore(rangeStart)).toList()
..sort((a, b) => b.date.compareTo(a.date));
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
final totalGallons = entries.fold(0.0, (sum, e) => sum + e.gallons);
final dateFormat = DateFormat.yMMMd();
final currencyFormat = NumberFormat.simpleCurrency();
final gallonsFormat = NumberFormat('#,##0.000');
// The hero stat is a big headline number, not a precise figure — round
// to whole gallons there rather than showing the same 3-decimal
// precision the per-entry rows use.
final heroGallonsFormat = NumberFormat('#,##0');
return Scaffold(
appBar: AppBar(
title: const Text('Receipts'),
actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Log Fuel Receipt',
onPressed: () => _addReceipt(context),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
_HeroTotal(
totalCost: totalCost,
totalGallons: totalGallons,
currencyFormat: currencyFormat,
gallonsFormat: heroGallonsFormat,
selectedRange: _range,
onRangeSelected: (range) => setState(() => _range = range),
),
Padding(
padding: const EdgeInsets.fromLTRB(2, 14, 2, 8),
child: Text(
'Recent Activity',
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
if (entries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No fuel entries in this range.'),
)
else
for (var i = 0; i < entries.length; i++) ...[
if (i > 0) const Divider(height: 1),
_ReceiptRow(
entry: entries[i],
vehicle: appState.vehicleById(entries[i].vehicleId),
dateFormat: dateFormat,
currencyFormat: currencyFormat,
gallonsFormat: gallonsFormat,
onTap: () => _openReceipt(context, entries[i]),
),
],
],
),
);
}
}
/// Sits inside the hero banner (on the gradient, not the plain scaffold
/// background), so selected/unselected use the hero's own white/translucent
/// text colors rather than the surface-based colorScheme ones.
class _DateRangeSelector extends StatelessWidget {
final _DateRange selected;
final ValueChanged<_DateRange> onSelected;
const _DateRangeSelector({required this.selected, required this.onSelected});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final range in _DateRange.values)
GestureDetector(
onTap: () => onSelected(range),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
range.label,
style: TextStyle(
color: range == selected
? AppTheme.heroText
: AppTheme.heroTextSecondary(context),
fontWeight: range == selected ? FontWeight.w800 : FontWeight.w600,
fontSize: 12.5,
),
),
),
),
],
);
}
}
class _HeroTotal extends StatelessWidget {
final double totalCost;
final double totalGallons;
final NumberFormat currencyFormat;
final NumberFormat gallonsFormat;
final _DateRange selectedRange;
final ValueChanged<_DateRange> onRangeSelected;
const _HeroTotal({
required this.totalCost,
required this.totalGallons,
required this.currencyFormat,
required this.gallonsFormat,
required this.selectedRange,
required this.onRangeSelected,
});
@override
Widget build(BuildContext context) {
return HeroBanner(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HeroStatsRow(
gallonsValue: gallonsFormat.format(totalGallons),
costValue: currencyFormat.format(totalCost),
),
const SizedBox(height: 7),
_DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected),
],
),
);
}
}
class _ReceiptRow extends StatelessWidget {
final FuelEntry entry;
final Vehicle? vehicle;
final DateFormat dateFormat;
final NumberFormat currencyFormat;
final NumberFormat gallonsFormat;
final VoidCallback onTap;
const _ReceiptRow({
required this.entry,
required this.vehicle,
required this.dateFormat,
required this.currencyFormat,
required this.gallonsFormat,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFormat.format(entry.date),
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(height: 3),
Text(
'${gallonsFormat.format(entry.gallons)} gal',
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
currencyFormat.format(entry.totalCost),
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Text(
vehicle?.displayLabel ?? 'Unknown vehicle',
style: TextStyle(
color: colorScheme.primary,
fontSize: 10.5,
fontWeight: FontWeight.w700,
),
),
),
],
),
],
),
),
);
}
}

View file

@ -1,12 +1,24 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:printing/printing.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/app_state.dart';
import '../services/fuel_report.dart';
import '../services/fuel_report_images.dart';
import '../services/fuel_report_pdf.dart';
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
/// file for the user — it's a state form, submitted to the state — so the
/// Reports tab just links out to where to get it rather than trying to
/// reproduce or embed it.
final _refundFormUrl = Uri.parse(
'https://dor.mo.gov/forms/?formName=Motor+Fuel+Refund+Claim&category=&year=99',
);
class ReportScreen extends StatefulWidget {
const ReportScreen({super.key});
@ -14,13 +26,30 @@ class ReportScreen extends StatefulWidget {
State<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends State<ReportScreen> {
class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClientMixin {
static final _dateFormat = DateFormat.yMMMd();
DateTime? _startDate;
DateTime? _endDate;
bool _includeReceiptImages = false;
bool _includeLineItems = false;
bool _busy = false;
/// Which vehicles (by id) to include in the report — checkboxes next to
/// each vehicle below the date range. [_seenVehicleIds] tracks every
/// vehicle a checkbox has ever been shown for, so a vehicle newly
/// appearing in range (first time it's seen) defaults to selected without
/// clobbering a selection the user already made for one seen before (e.g.
/// after picking a different range and back).
final Set<String> _selectedVehicleIds = {};
final Set<String> _seenVehicleIds = {};
// Keeps this tab's state (chosen date range, include-image/line-item
// toggles) alive when MainShell's PageView builds/tears down tabs
// outside its cache extent as the user swipes between them.
@override
bool get wantKeepAlive => true;
Future<void> _pickDate({required bool isStart}) async {
final now = DateTime.now();
final picked = await showDatePicker(
@ -39,10 +68,39 @@ class _ReportScreenState extends State<ReportScreen> {
});
}
Future<Uint8List> _buildPdfBytes(FuelReport report) async {
var receiptImages = const <String, Uint8List>{};
if (_includeReceiptImages) {
final loaded = await loadReceiptImageBytes(
report: report,
activeProvider: context.read<AppState>().activeProvider,
);
receiptImages = loaded.bytesByEntryId;
if (loaded.failedEntries.isNotEmpty && mounted) {
final count = loaded.failedEntries.length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"$count receipt photo${count == 1 ? '' : 's'} couldn't be included — offline, "
'not signed in to the provider that has it, or the remote file is gone.',
),
duration: const Duration(seconds: 6),
),
);
}
}
return buildFuelReportPdf(
report,
includeLineItems: _includeLineItems,
includeReceiptImages: _includeReceiptImages,
receiptImages: receiptImages,
);
}
Future<void> _share(FuelReport report) async {
setState(() => _busy = true);
try {
final bytes = await buildFuelReportPdf(report);
final bytes = await _buildPdfBytes(report);
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
} finally {
if (mounted) setState(() => _busy = false);
@ -52,31 +110,54 @@ class _ReportScreenState extends State<ReportScreen> {
Future<void> _print(FuelReport report) async {
setState(() => _busy = true);
try {
final bytes = await buildFuelReportPdf(report);
final bytes = await _buildPdfBytes(report);
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _openRefundFormLink() async {
final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication);
if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Couldn't open the link.")),
);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
final appState = context.watch<AppState>();
String? rangeError;
FuelReport? report;
FuelReport? rawReport;
if (_startDate != null && _endDate != null) {
if (_endDate!.isBefore(_startDate!)) {
rangeError = 'End date must be on or after the start date.';
} else {
report = buildFuelReport(
rawReport = buildFuelReport(
vehicles: appState.vehicles,
fuelEntries: appState.fuelEntries,
startDate: _startDate!,
endDate: _endDate!,
);
for (final row in rawReport.rows) {
if (_seenVehicleIds.add(row.vehicle.id)) {
_selectedVehicleIds.add(row.vehicle.id);
}
}
}
}
final report = rawReport == null
? null
: FuelReport(
startDate: rawReport.startDate,
endDate: rawReport.endDate,
rows: rawReport.rows.where((r) => _selectedVehicleIds.contains(r.vehicle.id)).toList(),
);
return Scaffold(
appBar: AppBar(title: const Text('Fuel Report')),
@ -104,38 +185,67 @@ class _ReportScreenState extends State<ReportScreen> {
const SizedBox(height: 8),
Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 16),
if (report == null)
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Include Receipt Images'),
subtitle: const Text(
'Every receipt photo in the date range, grouped by vehicle and '
'sorted by date — packed as many to a page as will fit.',
),
value: _includeReceiptImages,
onChanged: (value) => setState(() => _includeReceiptImages = value),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Include Receipt Line Items'),
subtitle: const Text(
'Every receipt as its own line (date, gallons, price/gal, '
'cost), grouped by vehicle and sorted by date.',
),
value: _includeLineItems,
onChanged: (value) => setState(() => _includeLineItems = value),
),
if (_includeReceiptImages && _includeLineItems)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Each line item and its receipt photo will be tagged with a '
'matching number.',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
if (rawReport == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('Choose a start and end date to generate a report.'),
)
else if (report.rows.isEmpty)
else if (rawReport.rows.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No vehicles had a receipted fuel entry in that date range.'),
)
else ...[
for (final row in report.rows)
Card(
child: ListTile(
title: Text(row.vehicle.displayLabel),
subtitle: Text(
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
),
isThreeLine: true,
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${row.totalGallons.toStringAsFixed(3)} gal'),
Text(NumberFormat.simpleCurrency().format(row.totalCost)),
],
),
),
for (final row in rawReport.rows)
_VehicleSelectionCard(
row: row,
selected: _selectedVehicleIds.contains(row.vehicle.id),
onTap: () => setState(() {
if (_selectedVehicleIds.contains(row.vehicle.id)) {
_selectedVehicleIds.remove(row.vehicle.id);
} else {
_selectedVehicleIds.add(row.vehicle.id);
}
}),
),
const Divider(height: 32),
if (report!.rows.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No vehicles selected — tap at least one above to build a report.'),
)
else ...[
ListTile(
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
trailing: Text(
@ -149,7 +259,7 @@ class _ReportScreenState extends State<ReportScreen> {
children: [
Expanded(
child: FilledButton.icon(
onPressed: _busy ? null : () => _share(report!),
onPressed: _busy ? null : () => _share(report),
icon: const Icon(Icons.share_outlined),
label: const Text('Share'),
),
@ -157,7 +267,7 @@ class _ReportScreenState extends State<ReportScreen> {
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _print(report!),
onPressed: _busy ? null : () => _print(report),
icon: const Icon(Icons.print_outlined),
label: const Text('Print'),
),
@ -166,6 +276,110 @@ class _ReportScreenState extends State<ReportScreen> {
),
],
],
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Need the refund claim form?', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
"This app tracks your fuel purchases, but the actual refund claim is "
"filed with the state. Get the official form from Missouri's "
'Department of Revenue website.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
InkWell(
onTap: _openRefundFormLink,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.open_in_new, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 6),
Flexible(
child: Text(
'Get the Motor Fuel Refund Claim form',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
),
],
),
),
],
),
),
),
],
),
);
}
}
/// A per-vehicle row in the vehicle-selection list — tapping the whole
/// card toggles [selected], indicated purely by color rather than a
/// checkbox: [selected] uses the same primary/on-primary theming as the
/// "Share" [FilledButton] below it, [!selected] looks like a plain
/// unselected [Card] (default surface color, normal text colors).
class _VehicleSelectionCard extends StatelessWidget {
final VehicleReportRow row;
final bool selected;
final VoidCallback onTap;
const _VehicleSelectionCard({required this.row, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface;
final foregroundVariant = selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant;
return Card(
color: selected ? colorScheme.primary : null,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
row.vehicle.displayLabel,
style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 16),
),
const SizedBox(height: 2),
Text(
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
style: TextStyle(color: foregroundVariant),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${row.totalGallons.toStringAsFixed(3)} gal', style: TextStyle(color: foreground)),
Text(
NumberFormat.simpleCurrency().format(row.totalCost),
style: TextStyle(color: foreground),
),
],
),
],
),
),
),
);
}

View file

@ -1,85 +1,16 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/cloud/cloud_storage_provider.dart';
import 'cloud_folder_browser_screen.dart';
import 'data_settings_screen.dart';
import 'ui_settings_screen.dart';
class SettingsScreen extends StatefulWidget {
/// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen]
/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
/// advanced/purge) — plus a read-only summary of what's stored locally.
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _busy = false;
String? _error;
Future<void> _connect(CloudProviderId id) async {
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProvider(id);
} catch (e) {
setState(() => _error = 'Could not sign in: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _connectManual(CloudProviderId id, String providerName) async {
final credentials = await showDialog<_WebDavCredentials>(
context: context,
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
);
if (credentials == null || !mounted) return;
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProviderWithCredentials(
id,
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
);
} catch (e) {
setState(() => _error = 'Could not connect: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _disconnect() async {
setState(() => _busy = true);
try {
await context.read<AppState>().disconnectCloud();
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _chooseFolder() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
);
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await context.read<AppState>().syncNow();
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@ -90,153 +21,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (!appState.isCloudConnected) ...[
Text(
'Connect a cloud storage account to share vehicles and fuel '
'receipts with other people, and to keep a backup off this device.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final provider in appState.availableProviders)
FilledButton.icon(
onPressed: _busy
? null
: () => provider is ManualCredentialCloudStorageProvider
? _connectManual(provider.id, provider.displayName)
: _connect(provider.id),
icon: const Icon(Icons.login),
label: Text('Connect ${provider.displayName}'),
),
],
),
] else ...[
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
const SizedBox(height: 4),
Text(
appState.cloudFolderPath == null
? 'No folder selected yet.'
: 'Folder: ${appState.cloudFolderPath}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _busy ? null : _chooseFolder,
icon: const Icon(Icons.folder_open),
label: Text(appState.cloudFolderPath == null
? 'Choose Folder'
: 'Change Folder'),
),
OutlinedButton.icon(
onPressed: (_busy || appState.cloudFolderPath == null)
? null
: _syncNow,
icon: appState.isSyncing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.sync),
label: const Text('Sync Now'),
),
TextButton.icon(
onPressed: _busy ? null : _disconnect,
icon: const Icon(Icons.logout),
label: const Text('Disconnect'),
),
],
),
const SizedBox(height: 8),
if (appState.lastSyncedAt != null)
Text(
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
style: Theme.of(context).textTheme.bodySmall,
),
if (appState.lastSyncError != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Last sync failed: ${appState.lastSyncError}',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
],
child: ListTile(
leading: const Icon(Icons.palette_outlined),
title: const Text('UI'),
subtitle: const Text('Appearance'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UiSettingsScreen()),
),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep photos on this phone after syncing'),
subtitle: const Text(
'Otherwise, a receipt photo is removed from this device once '
"it's safely uploaded to the cloud.",
child: ListTile(
leading: const Icon(Icons.storage_outlined),
title: const Text('Data'),
subtitle: const Text('Cloud storage, photo syncing, advanced'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
),
value: appState.keepReceiptPhotosLocally,
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep max quality images'),
subtitle: const Text(
'Otherwise, receipt photos are downscaled to a reasonable size '
'before storing — smaller cloud storage and faster syncs, with '
'no loss of legibility for a printed receipt.',
),
value: appState.keepMaxQualityReceiptPhotos,
onChanged: (value) =>
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
),
),
const SizedBox(height: 16),
Card(
child: ExpansionTile(
title: const Text('Advanced'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
),
const SizedBox(height: 4),
Text(
"If another device disconnects mid-sync without releasing its lock, "
"this is how long to wait before treating it as abandoned and clearing "
"it so sync can continue.",
style: Theme.of(context).textTheme.bodySmall,
),
Slider(
value: appState.staleLockMinutes.toDouble(),
min: minStaleLockMinutes.toDouble(),
max: maxStaleLockMinutes.toDouble(),
divisions: maxStaleLockMinutes - minStaleLockMinutes,
label: '${appState.staleLockMinutes} min',
onChanged: (value) =>
context.read<AppState>().setStaleLockMinutes(value.round()),
),
Text('${appState.staleLockMinutes} minute(s)'),
],
),
),
const SizedBox(height: 16),
@ -260,93 +64,3 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
}
class _WebDavCredentials {
final String serverUrl;
final String username;
final String password;
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
}
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
/// needs, since (unlike the OAuth providers) there's no browser flow to
/// gather these instead.
class _WebDavCredentialsDialog extends StatefulWidget {
final String providerName;
const _WebDavCredentialsDialog({required this.providerName});
@override
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
}
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(_WebDavCredentials(
serverUrl: _serverController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text,
));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Connect ${widget.providerName}'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
),
keyboardType: TextInputType.url,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
/// "UI" settings submenu, reached from [SettingsScreen] — currently just
/// Appearance (theme), but its own screen so more display-related settings
/// have somewhere to go without crowding the Settings landing page.
class UiSettingsScreen extends StatelessWidget {
const UiSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(title: const Text('UI')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Appearance', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.system, label: Text('System')),
ButtonSegment(value: ThemeMode.light, label: Text('Light')),
ButtonSegment(value: ThemeMode.dark, label: Text('Dark')),
],
selected: {appState.themeMode},
onSelectionChanged: (selection) =>
context.read<AppState>().setThemeMode(selection.first),
),
],
),
),
),
],
),
);
}
}

View file

@ -1,174 +1,66 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../services/ocr_service.dart';
import '../services/receipt_parser.dart';
import '../widgets/image_source_sheet.dart';
import '../widgets/hero_banner.dart';
import '../widgets/receipt_capture.dart';
import '../widgets/receipt_thumbnail.dart';
import 'add_edit_vehicle_screen.dart';
import 'confirm_fuel_entry_screen.dart';
import 'receipt_image_screen.dart';
import 'receipt_detail_screen.dart';
/// Long edge and JPEG quality a receipt photo is downscaled to unless
/// "keep max quality" is on — a receipt is a photo of small printed text,
/// not something that benefits from a multi-megapixel original, and this
/// keeps typical files in the low hundreds of KB instead of several MB.
const receiptImageMaxDimension = 1600.0;
const receiptImageQuality = 70;
class VehicleDetailScreen extends StatelessWidget {
/// A swipeable [PageView] over every vehicle (in the same order as the
/// Vehicles tab's list), starting on [vehicleId] — sliding left/right moves
/// to the next/previous vehicle, the same "modern navigation" swipe
/// [MainShell] uses for the bottom-nav tabs. Each page is a full,
/// independent [_VehicleDetailPage] (own AppBar/FAB), so this widget only
/// owns the [PageController].
class VehicleDetailScreen extends StatefulWidget {
final String vehicleId;
const VehicleDetailScreen({super.key, required this.vehicleId});
Future<void> _captureReceipt(BuildContext context) async {
final source = await chooseImageSource(context);
if (source == null || !context.mounted) return;
final keepMaxQuality = context.read<AppState>().keepMaxQualityReceiptPhotos;
final picker = ImagePicker();
XFile? photo;
try {
photo = await picker.pickImage(
source: source,
imageQuality: keepMaxQuality ? null : receiptImageQuality,
maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
);
} catch (e) {
if (context.mounted) {
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open $sourceLabel: $e')),
);
}
return;
}
if (photo == null) return;
final imageFile = File(photo.path);
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final ocrService = OcrService();
String recognizedText = '';
try {
recognizedText = await ocrService.recognizeText(imageFile);
} catch (_) {
recognizedText = '';
} finally {
ocrService.dispose();
@override
State<VehicleDetailScreen> createState() => _VehicleDetailScreenState();
}
final parsed = ReceiptParser.parse(recognizedText);
class _VehicleDetailScreenState extends State<VehicleDetailScreen> {
late final PageController _pageController;
if (!context.mounted) return;
Navigator.of(context).pop(); // close the OCR loading spinner
final state = parsed.state;
if (state != null && state != 'MO') {
final proceed = await _confirmNonMissouriPurchase(context, state);
if (!proceed || !context.mounted) return;
@override
void initState() {
super.initState();
final vehicles = context.read<AppState>().vehicles;
final initialIndex = vehicles.indexWhere((v) => v.id == widget.vehicleId);
_pageController = PageController(initialPage: initialIndex < 0 ? 0 : initialIndex);
}
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ConfirmFuelEntryScreen(
vehicleId: vehicleId,
imageFile: imageFile,
parsed: parsed,
),
),
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final vehicles = context.watch<AppState>().vehicles;
if (vehicles.isEmpty) {
return const Scaffold(body: Center(child: Text('Vehicle not found')));
}
return PageView.builder(
controller: _pageController,
itemCount: vehicles.length,
itemBuilder: (context, index) => _VehicleDetailPage(vehicleId: vehicles[index].id),
);
}
}
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
/// warn if the receipt's address is somewhere else, and let the user
/// decide whether to log it anyway (e.g. it might still be worth
/// tracking for other reasons even if it won't qualify for a refund).
Future<bool> _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
final stateName = usStateNames[stateCode] ?? stateCode;
final proceed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Non-Missouri Purchase'),
content: Text(
'This receipt looks like it\'s from a gas station in $stateName. '
'The Missouri fuel tax refund only applies to fuel purchased in '
'Missouri.\n\nDo you still want to add this entry?',
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
],
),
);
return proceed ?? false;
}
class _VehicleDetailPage extends StatelessWidget {
final String vehicleId;
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete fuel entry?'),
content: const Text('This will remove the entry and its receipt photo.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && context.mounted) {
await context.read<AppState>().deleteFuelEntry(entry.id);
}
}
Widget _buildReceiptLeading(BuildContext context, FuelEntry entry) {
if (entry.receiptImagePath != null) {
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(
File(entry.receiptImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
);
}
if (entry.isReceiptUploadedToDrive) {
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId),
),
),
child: const CircleAvatar(child: Icon(Icons.cloud_outlined)),
);
}
return const CircleAvatar(child: Icon(Icons.receipt_long));
}
const _VehicleDetailPage({required this.vehicleId});
@override
Widget build(BuildContext context) {
@ -181,8 +73,12 @@ class VehicleDetailScreen extends StatelessWidget {
final entries = appState.entriesForVehicle(vehicleId);
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
final dateFormat = DateFormat.yMMMd().add_jm();
final currencyFormat = NumberFormat.simpleCurrency();
// The hero stat is a big headline number, not a precise figure — round
// to whole gallons there, matching the Receipts tab's hero banner.
final heroGallonsFormat = NumberFormat('#,##0');
return Scaffold(
appBar: AppBar(
@ -199,42 +95,74 @@ class VehicleDetailScreen extends StatelessWidget {
),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Total gallons logged', style: Theme.of(context).textTheme.labelLarge),
Text(
'${totalGallons.toStringAsFixed(3)} gal',
style: Theme.of(context).textTheme.headlineMedium,
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: HeroBanner(
child: HeroStatsRow(
gallonsValue: heroGallonsFormat.format(totalGallons),
costValue: currencyFormat.format(totalCost),
),
],
),
),
Expanded(
child: entries.isEmpty
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
: ListView.separated(
// Keyed by vehicle so each vehicle's scroll position is
// remembered independently as the user swipes between
// pages — PageView.builder can dispose an off-screen
// page's element, which would otherwise reset it.
key: PageStorageKey('vehicle-entries-$vehicleId'),
padding: const EdgeInsets.all(12),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final entry = entries[index];
void openDetail() => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptDetailScreen(entryId: entry.id),
),
);
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: _buildReceiptLeading(context, entry),
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
subtitle: Text(
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
child: InkWell(
onTap: openDetail,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// Row cross-axis defaults to center, so the
// thumbnail sits vertically centered against the
// title+subtitle block even though it's three
// lines tall — ListTile's isThreeLine would
// instead force leading/trailing to the top, per
// Material spec (the same issue the vehicle list
// cards had).
child: Row(
children: [
ReceiptThumbnail(entry: entry, onTap: openDetail),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${entry.gallons.toStringAsFixed(3)} gal • '
'${currencyFormat.format(entry.totalCost)}',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${currencyFormat.format(entry.pricePerGallon)}/gal\n'
'${dateFormat.format(entry.date)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
isThreeLine: true,
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _deleteEntry(context, entry),
),
),
);
@ -244,7 +172,7 @@ class VehicleDetailScreen extends StatelessWidget {
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _captureReceipt(context),
onPressed: () => captureReceiptForVehicle(context, vehicleId),
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('Log Fuel Receipt'),
),

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show ThemeMode;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
@ -22,6 +23,7 @@ const _prefsKeyCloudFolderPath = 'cloud_folder_path';
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
const _prefsKeyThemeMode = 'theme_mode';
const defaultStaleLockMinutes = 10;
const minStaleLockMinutes = 1;
@ -85,6 +87,8 @@ class AppState extends ChangeNotifier {
/// the original camera/gallery image is kept as-is.
bool keepMaxQualityReceiptPhotos = false;
ThemeMode themeMode = ThemeMode.system;
/// Set if [init] fails. The UI shows this (with a retry option) instead
/// of spinning forever — an unhandled exception here previously left
/// `isLoading` stuck at true with no feedback at all.
@ -109,6 +113,10 @@ class AppState extends ChangeNotifier {
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
keepMaxQualityReceiptPhotos =
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
themeMode = ThemeMode.values.firstWhere(
(mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
orElse: () => ThemeMode.system,
);
} catch (e) {
initError = e;
isLoading = false;
@ -289,6 +297,13 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
Future<void> setThemeMode(ThemeMode mode) async {
themeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyThemeMode, mode.name);
notifyListeners();
}
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
/// Settings "Advanced" section, which restricts the picker to that range
/// anyway; this is a defensive backstop for any other caller.
@ -373,6 +388,34 @@ class AppState extends ChangeNotifier {
return entry;
}
/// Corrects the logged values (date, gallons, price/gal, total cost) for
/// an existing fuel entry — the receipt photo itself isn't editable here,
/// only the data recorded about it (e.g. fixing a misread OCR value).
/// [entryId]'s receipt photo reference (local path and/or cloud file id)
/// carries over untouched.
Future<void> updateFuelEntry({
required String entryId,
required DateTime date,
required double gallons,
required double pricePerGallon,
required double totalCost,
}) async {
final existing = fuelEntries.firstWhere((e) => e.id == entryId);
final updated = FuelEntry(
id: existing.id,
vehicleId: existing.vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
receiptImagePath: existing.receiptImagePath,
receiptDriveFileId: existing.receiptDriveFileId,
updatedAt: DateTime.now().toUtc(),
);
await database.saveFuelEntry(updated);
await _persist();
}
Future<void> deleteFuelEntry(String entryId) async {
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
await database.deleteReceiptImageFile(entry.receiptImagePath);
@ -380,6 +423,30 @@ class AppState extends ChangeNotifier {
await _persist();
}
/// Wipes every vehicle, fuel entry, and local receipt photo — "starting
/// completely fresh". Uses the same soft-delete tombstones as any other
/// delete rather than a hard SQL `DELETE`, so if a cloud sync is
/// connected, the deletion pushes on next sync instead of the old data
/// just getting silently re-imported from the remote copy. This is purely
/// local + whatever propagates through sync — it never reaches into the
/// cloud folder directly to delete anything sitting there.
Future<void> purgeAllData() async {
await database.purgeAllData(DateTime.now().toUtc());
await _persist();
}
/// Wipes every fuel entry (and its local receipt photo) dated within
/// [start]–[end] inclusive, across all vehicles. Vehicles themselves are
/// left alone. Same soft-delete/sync-propagation reasoning as
/// [purgeAllData].
Future<void> purgeFuelEntriesInRange(DateTime start, DateTime end) async {
final localPaths = await database.purgeFuelEntriesInRange(start, end, DateTime.now().toUtc());
for (final path in localPaths) {
await database.deleteReceiptImageFile(path);
}
await _persist();
}
List<FuelEntry> entriesForVehicle(String vehicleId) {
final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList();
list.sort((a, b) => b.date.compareTo(a.date));
@ -401,6 +468,14 @@ class AppState extends ChangeNotifier {
}
}
FuelEntry? fuelEntryById(String id) {
try {
return fuelEntries.firstWhere((e) => e.id == id);
} catch (_) {
return null;
}
}
/// 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

View file

@ -267,6 +267,67 @@ class DatabaseService {
return copied.path;
}
/// Soft-deletes every active vehicle and fuel entry — as if the user had
/// deleted each one individually (see [softDeleteVehicle]/
/// [softDeleteFuelEntry] for why these aren't real `DELETE`s: the
/// tombstones are what let the deletion propagate to other synced devices
/// on the next sync, rather than the rows just being silently re-imported
/// from the cloud copy). Also empties the local receipts folder outright
/// rather than deleting file-by-file, to catch any orphaned file a
/// tracked path might have missed.
Future<void> purgeAllData(DateTime deletedAt) async {
final ts = deletedAt.millisecondsSinceEpoch;
await _db.update(
'fuel_entries',
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
where: 'deleted_at IS NULL',
);
await _db.update(
'vehicles',
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
where: 'deleted_at IS NULL',
);
if (await receiptsDirectory.exists()) {
await receiptsDirectory.delete(recursive: true);
}
await receiptsDirectory.create(recursive: true);
}
/// Soft-deletes every active fuel entry (across all vehicles) whose
/// [FuelEntry.date] falls within [start]–[end] inclusive, and returns
/// their local receipt image paths so the caller can clean those files up
/// too. Vehicles are left untouched — a date range only makes sense
/// against the fuel entries logged against them, not the vehicles
/// themselves.
Future<List<String>> purgeFuelEntriesInRange(
DateTime start,
DateTime end,
DateTime deletedAt,
) async {
final rangeStart = DateTime(start.year, start.month, start.day).millisecondsSinceEpoch;
final rangeEndExclusive =
DateTime(end.year, end.month, end.day).add(const Duration(days: 1)).millisecondsSinceEpoch;
final ts = deletedAt.millisecondsSinceEpoch;
final rows = await _db.query(
'fuel_entries',
where: 'deleted_at IS NULL AND date >= ? AND date < ?',
whereArgs: [rangeStart, rangeEndExclusive],
);
final localPaths =
rows.map((r) => r['receipt_image_path'] as String?).whereType<String>().toList();
await _db.update(
'fuel_entries',
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
where: 'deleted_at IS NULL AND date >= ? AND date < ?',
whereArgs: [rangeStart, rangeEndExclusive],
);
return localPaths;
}
Future<void> deleteReceiptImageFile(String? path) async {
if (path == null) return;
final file = File(path);

View file

@ -1,19 +1,55 @@
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
/// One vehicle's totals within a [FuelReport]'s date range.
/// One vehicle's totals — and, for the line-items/receipt-images report
/// sections, its individual entries — within a [FuelReport]'s date range.
/// [entries] is sorted oldest-first, the natural order for a ledger of
/// line items building up to a total.
class VehicleReportRow {
final Vehicle vehicle;
final int entryCount;
final List<FuelEntry> entries;
final double totalGallons;
final double totalCost;
VehicleReportRow({
required this.vehicle,
required this.entryCount,
required this.entries,
required this.totalGallons,
required this.totalCost,
});
int get entryCount => entries.length;
}
/// One entry paired with the vehicle it belongs to — [FuelEntry] only knows
/// [FuelEntry.vehicleId], not the [Vehicle] itself, so anything that needs
/// to show both together (like [FuelReport.allEntries], for a combined
/// cross-vehicle list) needs this pairing.
class ReportEntry {
final FuelEntry entry;
final Vehicle vehicle;
ReportEntry({required this.entry, required this.vehicle});
}
/// Every qualifying entry in a single calendar month, across every vehicle
/// in the report — the "Gallons by month" breakdown is this, one per
/// month present in [FuelReport.monthlyTotals].
class MonthlyTotal {
/// The first of the month, e.g. `DateTime(2026, 8)` for August 2026.
final DateTime month;
final int fillCount;
final double totalGallons;
final double totalCost;
MonthlyTotal({
required this.month,
required this.fillCount,
required this.totalGallons,
required this.totalCost,
});
double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
}
/// Per-vehicle fuel totals for entries with a receipt, dated within
@ -27,6 +63,47 @@ class FuelReport {
double get totalGallons => rows.fold(0.0, (sum, r) => sum + r.totalGallons);
double get totalCost => rows.fold(0.0, (sum, r) => sum + r.totalCost);
int get fillCount => rows.fold(0, (sum, r) => sum + r.entryCount);
double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
/// Every entry across every vehicle, oldest first — the natural order for
/// a combined "purchase detail" ledger that isn't grouped by vehicle.
List<ReportEntry> get allEntries {
final list = [
for (final row in rows)
for (final entry in row.entries) ReportEntry(entry: entry, vehicle: row.vehicle),
];
list.sort((a, b) => a.entry.date.compareTo(b.entry.date));
return list;
}
/// [allEntries] grouped by calendar month, in chronological order — the
/// "Purchase detail" section's grouping, and the source data for
/// [monthlyTotals].
Map<DateTime, List<ReportEntry>> get entriesByMonth {
final map = <DateTime, List<ReportEntry>>{};
for (final reportEntry in allEntries) {
final month = DateTime(reportEntry.entry.date.year, reportEntry.entry.date.month);
(map[month] ??= []).add(reportEntry);
}
return map;
}
/// One [MonthlyTotal] per calendar month with qualifying entries, oldest
/// first — the "Gallons by month" chart/table's data.
List<MonthlyTotal> get monthlyTotals {
final byMonth = entriesByMonth;
final months = byMonth.keys.toList()..sort();
return [
for (final month in months)
MonthlyTotal(
month: month,
fillCount: byMonth[month]!.length,
totalGallons: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.gallons),
totalCost: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.totalCost),
),
];
}
}
/// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive,
@ -68,11 +145,12 @@ FuelReport buildFuelReport({
// No matching (active) vehicle — e.g. deleted since — nothing sane to
// attribute these entries to.
if (vehicle == null) continue;
final sortedEntries = entry.value.toList()..sort((a, b) => a.date.compareTo(b.date));
rows.add(VehicleReportRow(
vehicle: vehicle,
entryCount: entry.value.length,
totalGallons: entry.value.fold(0.0, (sum, e) => sum + e.gallons),
totalCost: entry.value.fold(0.0, (sum, e) => sum + e.totalCost),
entries: sortedEntries,
totalGallons: sortedEntries.fold(0.0, (sum, e) => sum + e.gallons),
totalCost: sortedEntries.fold(0.0, (sum, e) => sum + e.totalCost),
));
}
rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel));

View file

@ -0,0 +1,70 @@
import 'dart:io';
import 'dart:typed_data';
import '../models/fuel_entry.dart';
import 'cloud/cloud_storage_provider.dart';
import 'fuel_report.dart';
/// Result of [loadReceiptImageBytes]: the photo bytes that were resolved,
/// plus which entries couldn't be — e.g. because the receipt was uploaded
/// through a *different* cloud provider than the one currently connected
/// (its stored reference is meaningless to the active provider's API), or
/// the entry is offline/the remote file's since been deleted. Surfacing
/// [failedEntries] instead of only ever silently dropping them is what
/// makes that distinguishable from "there was never a receipt at all".
class ReceiptImageLoadResult {
final Map<String, Uint8List> bytesByEntryId;
final List<FuelEntry> failedEntries;
ReceiptImageLoadResult({required this.bytesByEntryId, required this.failedEntries});
}
/// Reads (or downloads) the receipt photo for every entry in [report].
///
/// Prefers the local copy when one's still on-device; falls back to
/// downloading via [activeProvider] for entries that have already been
/// uploaded and cleared locally. An entry that fails to download (offline,
/// revoked cloud access, since-deleted remote file, a reference belonging
/// to a different provider than the one currently connected, ...) is left
/// out of [ReceiptImageLoadResult.bytesByEntryId] and reported in
/// [ReceiptImageLoadResult.failedEntries] instead — one missing receipt
/// photo shouldn't block the rest of the document, but it also shouldn't
/// vanish without a trace.
Future<ReceiptImageLoadResult> loadReceiptImageBytes({
required FuelReport report,
required CloudStorageProvider? activeProvider,
}) async {
final bytesByEntryId = <String, Uint8List>{};
final failedEntries = <FuelEntry>[];
CloudStorageSession? session;
try {
for (final row in report.rows) {
for (final entry in row.entries) {
final localPath = entry.receiptImagePath;
if (localPath != null) {
final file = File(localPath);
if (await file.exists()) {
bytesByEntryId[entry.id] = Uint8List.fromList(await file.readAsBytes());
continue;
}
}
final driveFileId = entry.receiptDriveFileId;
if (driveFileId == null || activeProvider == null || !activeProvider.isSignedIn) {
failedEntries.add(entry);
continue;
}
try {
session ??= activeProvider.beginSession();
final bytes = await session.downloadFileBytes(driveFileId);
bytesByEntryId[entry.id] = Uint8List.fromList(bytes);
} catch (_) {
failedEntries.add(entry);
}
}
}
} finally {
session?.close();
}
return ReceiptImageLoadResult(bytesByEntryId: bytesByEntryId, failedEntries: failedEntries);
}

View file

@ -7,75 +7,594 @@ import 'package:pdf/widgets.dart' as pw;
import 'fuel_report.dart';
final _dateFormat = DateFormat.yMMMd();
final _dateTimeFormat = DateFormat.yMMMd().add_jm();
final _monthFormat = DateFormat('MMM');
final _monthHeadingFormat = DateFormat.yMMMM();
final _dayMonthFormat = DateFormat('d MMM');
final _generatedFormat = DateFormat('d MMM yyyy');
final _currencyFormat = NumberFormat.simpleCurrency();
/// Renders [report] as a single-page-friendly PDF: a per-vehicle table
/// (VIN, nickname, entry count, gallons, total cost) plus a grand-total
/// row, suitable for attaching to a fuel tax refund submission or handing
/// to a printer.
Future<Uint8List> buildFuelReportPdf(FuelReport report) async {
// Palette lifted from the report design mockup — kept in one place so
// every section (banner, cards, tables, watermark) draws from the same
// set of colors rather than each picking its own.
final _bannerBackground = PdfColor.fromHex('#EAF3DE');
final _bannerText = PdfColor.fromHex('#27500A');
final _textPrimary = PdfColor.fromHex('#1F1E1C');
final _textMuted = PdfColor.fromHex('#6B6A64');
final _textFaint = PdfColor.fromHex('#94938C');
final _borderLight = PdfColor.fromHex('#E5E3DB');
final _borderStrong = PdfColor.fromHex('#CFCDC4');
final _accentGreen = PdfColor.fromHex('#639922');
final _accentGreenDark = PdfColor.fromHex('#3B6D11');
final _cardBackground = PdfColor.fromHex('#F7F6F1');
final _tableBorder = pw.TableBorder.all(color: _borderLight, width: 0.5);
final _tableHeaderDecoration = pw.BoxDecoration(color: PdfColor.fromHex('#EFEEE7'));
final _tableEvenRowDecoration = const pw.BoxDecoration(color: PdfColors.white);
final _tableOddRowDecoration = pw.BoxDecoration(color: _cardBackground);
final _sectionHeadingStyle = pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary);
/// Renders [report] as a PDF, styled after the app's report design mockup:
/// a branded letterhead banner, a headline total, three at-a-glance stat
/// cards, a "gallons by month" bar chart + table, and a by-vehicle
/// breakdown. When requested, a "Line Items" section (a table per vehicle)
/// and/or a "Receipt Images" section (photos per vehicle, sized to
/// actually be legible — not a small inline thumbnail) follow, each
/// grouped by vehicle and sorted oldest-first within it. When both are on,
/// each line item and its receipt photo are tagged with the same number
/// (assigned once, continuing across vehicles) so the two can be matched
/// up by eye despite living in separate sections.
///
/// [receiptImages] supplies the actual photo bytes, keyed by
/// [FuelEntry.id] — see `loadReceiptImageBytes`; an entry with no entry in
/// that map (couldn't be read locally or downloaded) is left out of the
/// images section rather than shown as a broken image.
Future<Uint8List> buildFuelReportPdf(
FuelReport report, {
bool includeLineItems = false,
bool includeReceiptImages = false,
Map<String, Uint8List> receiptImages = const {},
}) async {
final doc = pw.Document();
final generatedAt = DateTime.now();
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.letter,
header: (context) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
pw.Text(
'${_dateFormat.format(report.startDate)} – ${_dateFormat.format(report.endDate)}',
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
margin: pw.EdgeInsets.zero,
header: (context) => _buildBanner(generatedAt),
footer: (context) => _buildFooter(report, context),
build: (context) => _buildBody(
report,
includeLineItems: includeLineItems,
includeReceiptImages: includeReceiptImages,
receiptImages: receiptImages,
),
),
);
return doc.save();
}
/// A *flat* list of top-level widgets — deliberately not one big [pw.Column]
/// wrapping everything. [pw.MultiPage] paginates by walking its `build:`
/// list one top-level item at a time, resetting its "stuck" bookkeeping
/// only once each one fully finishes; a [pw.Table]/[pw.Wrap] (both of which
/// span pages on their own, e.g. a long line-items table or a big receipt
/// photo grid) nested *inside* one enclosing spanning [pw.Column] instead
/// of being its own top-level entry defeats that bookkeeping and throws
/// `TooManyPagesException` on anything long enough to need several page
/// breaks — even though the content itself is perfectly valid.
List<pw.Widget> _buildBody(
FuelReport report, {
required bool includeLineItems,
required bool includeReceiptImages,
required Map<String, Uint8List> receiptImages,
}) {
pw.Widget pad(pw.Widget child) =>
pw.Padding(padding: const pw.EdgeInsets.symmetric(horizontal: 36), child: child);
if (report.rows.isEmpty) {
return [
pw.Padding(
padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
child: _buildTitleBlock(report),
),
pad(pw.Text('No vehicles had a receipted fuel entry in this date range.')),
];
}
final widgets = <pw.Widget>[
pw.Padding(
padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
child: _buildTitleBlock(report),
),
pad(_buildHeroStat(report)),
pw.SizedBox(height: 22),
pad(_buildStatCardsRow(report)),
pw.SizedBox(height: 26),
pad(_buildMonthlySection(report)),
pw.SizedBox(height: 26),
pad(_buildVehicleSection(report)),
];
if (includeLineItems || includeReceiptImages) {
widgets.add(pw.SizedBox(height: 26));
widgets.addAll(
_buildLineItemsAndImagesSections(
report,
includeLineItems: includeLineItems,
includeReceiptImages: includeReceiptImages,
receiptImages: receiptImages,
).map(pad),
);
}
widgets.add(pw.SizedBox(height: 8));
return widgets;
}
pw.Widget _buildBanner(DateTime generatedAt) {
// The watermark lives here, inside the banner's own bounded height,
// rather than overlaid on the flowing body content below — a Stack
// wrapping unbounded/multi-page content doesn't paginate (MultiPage
// needs a SpanningWidget or a fixed-size child for that), so scoping it
// to this fixed-height strip is what keeps it safe across page breaks.
return pw.Container(
color: _bannerBackground,
child: pw.Stack(
children: [
pw.Positioned(
right: 12,
top: -14,
child: pw.SizedBox(width: 60, height: 60, child: _buildWatermark(alpha: 0.14)),
),
pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 36, vertical: 14),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Show Me The Fuel Refund',
style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold, color: _bannerText),
),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: pw.BoxDecoration(
color: PdfColors.white,
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
),
child: pw.Text(
'Generated ${_generatedFormat.format(generatedAt)}',
style: pw.TextStyle(fontSize: 10, color: _bannerText),
),
),
pw.SizedBox(height: 16),
],
),
build: (context) => [
if (report.rows.isEmpty)
pw.Text('No vehicles had a receipted fuel entry in this date range.')
else
),
],
),
);
}
pw.Widget _buildFooter(FuelReport report, pw.Context context) {
return pw.Container(
padding: const pw.EdgeInsets.fromLTRB(36, 8, 36, 20),
decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderStrong, width: 0.5))),
margin: const pw.EdgeInsets.only(top: 6),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'${report.fillCount} purchase${report.fillCount == 1 ? '' : 's'} · '
'${report.rows.length} vehicle${report.rows.length == 1 ? '' : 's'} · '
'${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}',
style: pw.TextStyle(fontSize: 9, color: _textFaint),
),
pw.Text(
'Page ${context.pageNumber} of ${context.pagesCount}',
style: pw.TextStyle(fontSize: 9, color: _textFaint),
),
],
),
);
}
/// A faint brand-colored fuel-can silhouette — purely decorative,
/// matching the report mockup's watermark shape.
pw.Widget _buildWatermark({required double alpha}) {
const svg = '''
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M12,8 L76,8 L79,22 L73,33 L83,47 L78,58 L84,67 L86,89 L77,89 L77,70 L20,70 L20,30 Z" fill="#639922"/>
</svg>
''';
return pw.SvgImage(svg: svg, colorFilter: _accentGreen.withAlpha(alpha));
}
pw.Widget _buildTitleBlock(FuelReport report) {
final months = report.monthlyTotals.length;
final vehicles = report.rows.length;
final fills = report.fillCount;
final subtitle = StringBuffer('${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}');
if (report.rows.isNotEmpty) {
subtitle.write(
' · $months month${months == 1 ? '' : 's'} · $vehicles vehicle${vehicles == 1 ? '' : 's'} · '
'$fills fill-up${fills == 1 ? '' : 's'}',
);
}
return pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 20),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
pw.SizedBox(height: 3),
pw.Text(subtitle.toString(), style: pw.TextStyle(fontSize: 12, color: _textMuted)),
],
),
);
}
pw.Widget _buildHeroStat(FuelReport report) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Total fuel purchased', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
pw.SizedBox(height: 4),
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(
report.totalGallons.toStringAsFixed(3),
style: pw.TextStyle(fontSize: 34, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
),
pw.SizedBox(width: 6),
pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 5),
child: pw.Text('gal', style: pw.TextStyle(fontSize: 13, color: _accentGreenDark)),
),
],
),
],
);
}
pw.Widget _buildStatCardsRow(FuelReport report) {
pw.Widget statCard(String label, String value) => pw.Expanded(
child: pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
color: _cardBackground,
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(label, style: pw.TextStyle(fontSize: 10, color: _textMuted)),
pw.SizedBox(height: 3),
pw.Text(value, style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
],
),
),
);
return pw.Row(
children: [
statCard('Spent at the pump', _currencyFormat.format(report.totalCost)),
pw.SizedBox(width: 10),
statCard('Avg price/gal', _currencyFormat.format(report.avgPricePerGallon)),
pw.SizedBox(width: 10),
statCard('Fill-ups logged', report.fillCount.toString()),
],
);
}
pw.Widget _buildMonthlySection(FuelReport report) {
final months = report.monthlyTotals;
if (months.isEmpty) return pw.SizedBox();
const chartHeight = 64.0;
final maxGallons = months.fold(0.0, (max, m) => m.totalGallons > max ? m.totalGallons : max);
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Gallons by month', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
pw.SizedBox(height: 8),
if (months.length > 1) ...[
pw.SizedBox(
height: chartHeight,
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
for (final month in months)
pw.Expanded(
child: pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 3),
child: pw.Container(
height: maxGallons == 0
? 2
: (chartHeight * (month.totalGallons / maxGallons)).clamp(2, chartHeight),
decoration: pw.BoxDecoration(
color: _accentGreen,
borderRadius: const pw.BorderRadius.only(
topLeft: pw.Radius.circular(2),
topRight: pw.Radius.circular(2),
),
),
),
),
),
],
),
),
pw.SizedBox(height: 4),
pw.Row(
children: [
for (final month in months)
pw.Expanded(
child: pw.Center(
child: pw.Text(_monthFormat.format(month.month), style: pw.TextStyle(fontSize: 9, color: _textFaint)),
),
),
],
),
pw.SizedBox(height: 10),
],
pw.TableHelper.fromTextArray(
headers: ['Vehicle', 'VIN', 'Entries', 'Gallons', 'Total Cost'],
headers: ['Month', 'Fills', 'Gallons', 'Spent', 'Avg/gal'],
data: [
for (final row in report.rows)
for (final month in months)
[
row.vehicle.nickname?.trim().isNotEmpty == true ? row.vehicle.nickname! : '—',
row.vehicle.vin,
row.entryCount.toString(),
row.totalGallons.toStringAsFixed(3),
_currencyFormat.format(row.totalCost),
_monthHeadingFormat.format(month.month),
month.fillCount.toString(),
month.totalGallons.toStringAsFixed(3),
_currencyFormat.format(month.totalCost),
_currencyFormat.format(month.avgPricePerGallon),
],
],
cellAlignments: const {
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
},
headerAlignments: const {
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
},
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
cellStyle: const pw.TextStyle(fontSize: 10),
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
border: _tableBorder,
headerDecoration: _tableHeaderDecoration,
rowDecoration: _tableEvenRowDecoration,
oddRowDecoration: _tableOddRowDecoration,
),
pw.SizedBox(height: 12),
pw.Divider(),
if (months.length > 1) ...[
pw.SizedBox(height: 6),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10)),
pw.Text(
'${report.totalGallons.toStringAsFixed(3)} gal · ${_currencyFormat.format(report.totalCost)}',
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
'${report.fillCount} · ${report.totalGallons.toStringAsFixed(3)} gal · '
'${_currencyFormat.format(report.totalCost)} · ${_currencyFormat.format(report.avgPricePerGallon)}',
style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
),
],
),
],
),
],
);
}
return doc.save();
pw.Widget _buildVehicleSection(FuelReport report) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('By vehicle', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
for (final row in report.rows)
pw.Container(
padding: const pw.EdgeInsets.symmetric(vertical: 10),
decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderLight, width: 0.5))),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Expanded(
flex: 2,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.Text(row.vehicle.vin, style: pw.TextStyle(fontSize: 9, color: _textFaint)),
],
),
),
pw.Expanded(
child: pw.Text(
'${row.entryCount} fill${row.entryCount == 1 ? '' : 's'}',
textAlign: pw.TextAlign.right,
style: pw.TextStyle(fontSize: 11, color: _textMuted),
),
),
pw.Expanded(
child: pw.Text(
_currencyFormat.format(row.totalCost),
textAlign: pw.TextAlign.right,
style: const pw.TextStyle(fontSize: 11),
),
),
pw.Expanded(
child: pw.Text(
'${row.totalGallons.toStringAsFixed(3)} gal',
textAlign: pw.TextAlign.right,
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
),
),
],
),
),
],
);
}
/// Item numbers (when both [includeLineItems] and [includeReceiptImages]
/// are on) are assigned once, in report order — vehicle by vehicle,
/// oldest-entry-first within each — and reused by both the "Line Items"
/// table and the "Receipt Images" section below it, so a photo and its
/// line item carry the same number despite living in separate sections.
List<pw.Widget> _buildLineItemsAndImagesSections(
FuelReport report, {
required bool includeLineItems,
required bool includeReceiptImages,
required Map<String, Uint8List> receiptImages,
}) {
final showItemNumbers = includeLineItems && includeReceiptImages;
final itemNumbers = <String, int>{};
var nextNumber = 1;
for (final row in report.rows) {
for (final entry in row.entries) {
itemNumbers[entry.id] = nextNumber++;
}
}
final widgets = <pw.Widget>[];
if (includeLineItems) {
widgets.add(pw.Text('Line Items', style: _sectionHeadingStyle));
for (final row in report.rows) {
widgets.add(pw.SizedBox(height: 14));
widgets.add(_buildVehicleSubheading(row));
widgets.add(pw.SizedBox(height: 6));
widgets.add(_buildLineItemsTable(row, showItemNumbers: showItemNumbers, itemNumbers: itemNumbers));
}
}
if (includeReceiptImages) {
final rowsWithImages = report.rows.where((row) => row.entries.any((e) => receiptImages.containsKey(e.id)));
if (rowsWithImages.isNotEmpty) {
if (includeLineItems) widgets.add(pw.SizedBox(height: 26));
widgets.add(pw.Text('Receipt Images', style: _sectionHeadingStyle));
for (final row in rowsWithImages) {
widgets.add(pw.SizedBox(height: 14));
widgets.add(_buildVehicleSubheading(row));
widgets.add(pw.SizedBox(height: 8));
widgets.add(_buildReceiptImagesWrap(
row,
receiptImages,
showItemNumbers: showItemNumbers,
itemNumbers: itemNumbers,
));
}
}
}
return widgets;
}
pw.Widget _buildVehicleSubheading(VehicleReportRow row) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
pw.Text('VIN: ${row.vehicle.vin}', style: pw.TextStyle(fontSize: 9, color: _textFaint)),
],
);
}
pw.Widget _buildLineItemsTable(
VehicleReportRow row, {
required bool showItemNumbers,
required Map<String, int> itemNumbers,
}) {
return pw.TableHelper.fromTextArray(
headers: [
if (showItemNumbers) '#',
'Date',
'Gallons',
'Price/Gal',
'Total Cost',
],
data: [
for (final entry in row.entries)
[
if (showItemNumbers) itemNumbers[entry.id].toString(),
_dateTimeFormat.format(entry.date),
entry.gallons.toStringAsFixed(3),
_currencyFormat.format(entry.pricePerGallon),
_currencyFormat.format(entry.totalCost),
],
],
cellAlignments: showItemNumbers
? const {
0: pw.Alignment.centerLeft,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
}
: const {
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
},
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
cellStyle: const pw.TextStyle(fontSize: 10),
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
border: _tableBorder,
headerDecoration: _tableHeaderDecoration,
rowDecoration: _tableEvenRowDecoration,
oddRowDecoration: _tableOddRowDecoration,
);
}
/// 140×140 — comfortably legible (the previous design's inline thumbnails,
/// at ~34×44, were too small to make out) while still fitting several per
/// row on a letter-width page.
const _receiptImageSize = 140.0;
pw.Widget _buildReceiptImagesWrap(
VehicleReportRow row,
Map<String, Uint8List> receiptImages, {
required bool showItemNumbers,
required Map<String, int> itemNumbers,
}) {
return pw.Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final entry in row.entries)
if (receiptImages[entry.id] case final bytes?)
pw.Column(
children: [
pw.Container(
width: _receiptImageSize,
height: _receiptImageSize,
decoration: pw.BoxDecoration(
color: _cardBackground,
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
border: pw.Border.all(color: _borderLight, width: 0.5),
),
child: pw.ClipRRect(
horizontalRadius: 6,
verticalRadius: 6,
child: pw.Image(pw.MemoryImage(bytes), fit: pw.BoxFit.cover),
),
),
pw.SizedBox(height: 4),
pw.Text(
showItemNumbers
? '#${itemNumbers[entry.id]} · ${_dayMonthFormat.format(entry.date)}'
: _dayMonthFormat.format(entry.date),
style: pw.TextStyle(fontSize: 9, color: _textMuted),
),
],
),
],
);
}
/// `fuel-report-<start>-<end>.pdf`, e.g. `fuel-report-20260101-20260331.pdf`.

View file

@ -2,18 +2,24 @@
/// sticker, dashboard plate, title, etc).
///
/// Real VINs never contain the letters I, O, or Q (they're excluded from
/// the standard specifically so they can't be confused with 1 and 0), so
/// requiring that charset both matches genuine VINs and rules out a lot of
/// incidental 17-character noise elsewhere on the sticker (barcodes text,
/// weight ratings, date codes, etc).
/// the standard specifically so they can't be confused with 1, 0, and 9 —
/// which is exactly the kind of confusion OCR itself is prone to: a
/// genuine VIN's "0" is sometimes read back as "O"). So rather than reject
/// a candidate for containing an I or O, [_normalize] treats it as a
/// misread digit and corrects it — Q is left alone, since it doesn't
/// closely resemble any digit and so is more likely to mean the candidate
/// isn't a VIN at all (e.g. noise from elsewhere on the sticker).
class VinParser {
// V\.?\s*I\.?\s*N tolerates the "V.I.N" (period between each letter)
// styling common on manufacturer compliance/data plates, not just the
// plain "VIN" a door-jamb sticker or title usually uses.
static final _labeledVinPattern =
RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
RegExp(r'V\.?\s*I\.?\s*N[:.\s]*([A-PR-Z0-9]{17})\b', caseSensitive: false);
// \b on both sides matters: since digits and letters are both "word"
// characters, this only matches a maximal run of exactly 17 eligible
// characters — not a 17-character slice out of an 18+ character run.
static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
static final _bareVinPattern = RegExp(r'\b([A-PR-Z0-9]{17})\b', caseSensitive: false);
/// Returns the VIN in uppercase, or null if nothing matching the VIN
/// charset/length was found. A labeled "VIN: ..." match is preferred
@ -21,11 +27,24 @@ class VinParser {
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
static String? parse(String text) {
final labeled = _labeledVinPattern.firstMatch(text);
if (labeled != null) return labeled.group(1)!.toUpperCase();
if (labeled != null) return _normalize(labeled.group(1)!);
final bare = _bareVinPattern.firstMatch(text);
if (bare != null) return bare.group(1)!.toUpperCase();
if (bare != null) return _normalize(bare.group(1)!);
// Last resort: OCR sometimes splits a VIN across a stray space (e.g.
// an etched/curved surface gets read as two separate text regions) —
// retry once against the text with all whitespace removed. Tried last,
// after both whitespace-preserving attempts, since collapsing
// whitespace elsewhere in a busy label risks gluing unrelated text
// together into a spurious 17-character run.
final stripped = text.replaceAll(RegExp(r'\s+'), '');
final strippedMatch = _bareVinPattern.firstMatch(stripped);
if (strippedMatch != null) return _normalize(strippedMatch.group(1)!);
return null;
}
static String _normalize(String candidate) =>
candidate.toUpperCase().replaceAll('O', '0').replaceAll('I', '1');
}

View file

@ -31,6 +31,20 @@ class AppTheme {
static const _darkOutline = Color(0xFF7E877F); // --text-3
static const _darkOutlineVariant = Color(0xFF262926); // --line
// --- Hero card (used by ReceiptsScreen) ---
static const heroGradientLight = [Color(0xFF316F44), Color(0xFF1B4527)]; // --hero-from/to
static const heroGradientDark = [Color(0xFF2E6B41), Color(0xFF17331F)];
static const heroText = Colors.white; // --hero-text (same in both themes)
static const heroTextSecondaryLight = Color(0xC7FFFFFF); // --hero-text-2, 78% white
static const heroTextSecondaryDark = Color(0xB8FFFFFF); // --hero-text-2, 72% white
/// The hero card's gradient colors for whichever brightness is active.
static List<Color> heroGradient(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark ? heroGradientDark : heroGradientLight;
static Color heroTextSecondary(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark ? heroTextSecondaryDark : heroTextSecondaryLight;
static ThemeData get light {
final colorScheme = ColorScheme.fromSeed(
seedColor: _lightPrimary,
@ -91,6 +105,7 @@ class AppTheme {
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
surfaceTintColor: Colors.transparent,
centerTitle: true,
titleTextStyle: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,

View file

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Green gradient hero banner background shared by every screen that shows
/// a totals summary (Receipts, Vehicle Detail) — callers supply their own
/// stat row / controls as [child], keeping the container/gradient styling
/// itself in one place.
class HeroBanner extends StatelessWidget {
final Widget child;
const HeroBanner({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppTheme.heroGradient(context),
),
),
child: child,
);
}
}
/// A single label/value pair inside a [HeroBanner], e.g. "Total Gallons".
class HeroStat extends StatelessWidget {
final String label;
final String value;
final bool alignEnd;
const HeroStat({super.key, required this.label, required this.value, required this.alignEnd});
@override
Widget build(BuildContext context) {
final textAlign = alignEnd ? TextAlign.end : TextAlign.start;
return Column(
crossAxisAlignment: alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Text(
label,
textAlign: textAlign,
style: TextStyle(
color: AppTheme.heroTextSecondary(context),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
value,
textAlign: textAlign,
style: const TextStyle(
color: AppTheme.heroText,
fontSize: 28,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
],
);
}
}
/// The icon + two-stat row shared by every hero banner: gallons on the
/// left, the hero icon in the middle, cost on the right.
class HeroStatsRow extends StatelessWidget {
final String gallonsValue;
final String costValue;
final String gallonsLabel;
final String costLabel;
const HeroStatsRow({
super.key,
required this.gallonsValue,
required this.costValue,
this.gallonsLabel = 'Total Gallons',
this.costLabel = 'Total Fuel Cost',
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: HeroStat(label: gallonsLabel, value: gallonsValue, alignEnd: false)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
// Tall enough to extend past the bottom of the label+value text
// next to it, not just match the label's height — width scales
// automatically from the source's own aspect ratio since only
// height is given.
child: Image.asset(
'assets/icon/hero_icon.png',
height: 58,
fit: BoxFit.contain,
),
),
Expanded(child: HeroStat(label: costLabel, value: costValue, alignEnd: true)),
],
);
}
}

View file

@ -4,13 +4,46 @@ import 'package:image_picker/image_picker.dart';
/// Bottom sheet letting the user pick a photo from the camera or their
/// existing library. Shared by any screen that needs to snap or choose a
/// photo (receipt capture, VIN scanning) so the choice looks and behaves
/// the same everywhere.
Future<ImageSource?> chooseImageSource(BuildContext context) {
/// the same everywhere. Pass [heading] to label what the photo is for
/// (e.g. receipt capture passes "Receipt From") — omitted by callers where
/// that's already obvious from context. [onInfoTap], if given, adds an
/// info button next to [heading] (e.g. VIN scanning uses it to explain
/// where to find a VIN); ignored if [heading] is null, since there'd be
/// nowhere to put it.
Future<ImageSource?> chooseImageSource(
BuildContext context, {
String? heading,
VoidCallback? onInfoTap,
}) {
return showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
child: Wrap(
children: [
if (heading != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
heading,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
if (onInfoTap != null)
IconButton(
icon: const Icon(Icons.info_outline),
iconSize: 20,
tooltip: 'Where do I find this?',
onPressed: onInfoTap,
),
],
),
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Take Photo'),

Some files were not shown because too many files have changed in this diff Show more