A whole bunch of changes
|
|
@ -64,5 +64,13 @@
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</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>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
BIN
android/app/src/main/res/drawable-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
android/app/src/main/res/drawable-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
android/app/src/main/res/drawable-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
android/app/src/main/res/drawable-night-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
android/app/src/main/res/drawable-night-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
android/app/src/main/res/drawable-night-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
android/app/src/main/res/drawable-night-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
android/app/src/main/res/drawable-night-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 112 KiB |
BIN
android/app/src/main/res/drawable-night-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 232 KiB |
BIN
android/app/src/main/res/drawable-night-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 385 KiB |
BIN
android/app/src/main/res/drawable-night-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
android/app/src/main/res/drawable-night/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -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>
|
||||||
BIN
android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,9 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
<!-- You can insert your own image assets here -->
|
</item>
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|
|
||||||
BIN
android/app/src/main/res/drawable-xhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 385 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,9 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
<!-- You can insert your own image assets here -->
|
</item>
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 46 KiB |
22
android/app/src/main/res/values-night-v31/styles.xml
Normal 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>
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<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>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|
|
||||||
22
android/app/src/main/res/values-v31/styles.xml
Normal 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>
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<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>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|
|
||||||
BIN
assets/icon/app_icon.png
Normal file
|
After Width: | Height: | Size: 803 KiB |
BIN
assets/icon/hero_icon.png
Normal file
|
After Width: | Height: | Size: 530 KiB |
BIN
assets/splash/splash_logo.png
Normal file
|
After Width: | Height: | Size: 697 KiB |
BIN
assets/splash/splash_logo_android12.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
assets/vin_help/door_jamb.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
assets/vin_help/windshield.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -441,7 +441,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
@ -498,7 +498,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
|
||||||
|
|
@ -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":"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"}}
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 725 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 32 KiB |
22
ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,23 +1,56 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage.png",
|
"filename" : "LaunchImage.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "LaunchImageDark.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
"filename" : "LaunchImage@2x.png",
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "LaunchImageDark@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
"filename" : "LaunchImage@3x.png",
|
"filename" : "LaunchImage@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "LaunchImageDark@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"version" : 1,
|
"author" : "xcode",
|
||||||
"author" : "xcode"
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 421 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png
vendored
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 421 KiB |
|
|
@ -16,13 +16,19 @@
|
||||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||||
</imageView>
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
<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>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
|
|
@ -32,6 +38,7 @@
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="168" height="185"/>
|
<image name="LaunchImage" width="1024" height="1024"/>
|
||||||
|
<image name="LaunchBackground" width="1" height="1"/>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|
|
||||||
|
|
@ -106,5 +106,7 @@
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIStatusBarHidden</key>
|
||||||
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/main_shell.dart';
|
||||||
import 'services/app_state.dart';
|
import 'services/app_state.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
|
|
@ -16,12 +16,15 @@ class FuelTaxTrackerApp extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ChangeNotifierProvider(
|
return ChangeNotifierProvider(
|
||||||
create: (_) => AppState()..init(),
|
create: (_) => AppState()..init(),
|
||||||
child: MaterialApp(
|
child: Consumer<AppState>(
|
||||||
|
builder: (context, appState, _) => MaterialApp(
|
||||||
title: 'Fuel Tax Tracker',
|
title: 'Fuel Tax Tracker',
|
||||||
theme: AppTheme.light,
|
theme: AppTheme.light,
|
||||||
darkTheme: AppTheme.dark,
|
darkTheme: AppTheme.dark,
|
||||||
|
themeMode: appState.themeMode,
|
||||||
home: const AppRoot(),
|
home: const AppRoot(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +67,7 @@ class AppRoot extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const HomeScreen();
|
return const MainShell();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,19 @@ import '../widgets/image_source_sheet.dart';
|
||||||
import 'vehicle_detail_screen.dart';
|
import 'vehicle_detail_screen.dart';
|
||||||
|
|
||||||
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
||||||
/// omit it to create a new one.
|
/// omit it to create a new one. [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 {
|
class AddEditVehicleScreen extends StatefulWidget {
|
||||||
final Vehicle? vehicle;
|
final Vehicle? vehicle;
|
||||||
|
final bool promptToCreateForReceipt;
|
||||||
|
|
||||||
const AddEditVehicleScreen({super.key, this.vehicle});
|
const AddEditVehicleScreen({
|
||||||
|
super.key,
|
||||||
|
this.vehicle,
|
||||||
|
this.promptToCreateForReceipt = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
|
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
|
||||||
|
|
@ -39,6 +47,24 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
|
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
|
||||||
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
|
_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
|
@override
|
||||||
|
|
@ -48,8 +74,46 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
super.dispose();
|
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 {
|
Future<void> _scanVin() async {
|
||||||
final source = await chooseImageSource(context);
|
final source = await chooseImageSource(
|
||||||
|
context,
|
||||||
|
heading: 'VIN From',
|
||||||
|
onInfoTap: _showVinLocationHelp,
|
||||||
|
);
|
||||||
if (source == null || !mounted) return;
|
if (source == null || !mounted) return;
|
||||||
|
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
|
|
@ -89,13 +153,35 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
await _checkForExistingVin();
|
await _checkForExistingVin();
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text("Couldn't read a VIN from that photo. Please enter it manually."),
|
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
|
/// Field-level counterpart to [_checkForExistingVin]'s dialog: catches
|
||||||
/// "Required" as before, plus flags the field itself as invalid when the
|
/// "Required" as before, plus flags the field itself as invalid when the
|
||||||
/// VIN belongs to another active vehicle — excluding this vehicle's own
|
/// VIN belongs to another active vehicle — excluding this vehicle's own
|
||||||
|
|
@ -160,24 +246,30 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final vin = _vinController.text.trim();
|
||||||
final nickname = _nicknameController.text.trim();
|
final nickname = _nicknameController.text.trim();
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (_isEditing) {
|
if (_isEditing) {
|
||||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||||
vin: _vinController.text.trim(),
|
vin: vin,
|
||||||
nickname: nickname.isEmpty ? null : nickname,
|
nickname: nickname.isEmpty ? null : nickname,
|
||||||
clearNickname: nickname.isEmpty,
|
clearNickname: nickname.isEmpty,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
await appState.addVehicle(
|
await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname);
|
||||||
vin: _vinController.text.trim(),
|
|
||||||
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) {
|
} on DuplicateVinException catch (e) {
|
||||||
setState(() => _error = e.toString());
|
setState(() => _error = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
550
lib/screens/data_settings_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
186
lib/screens/edit_fuel_entry_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,10 @@ import 'package:provider/provider.dart';
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import 'add_edit_vehicle_screen.dart';
|
import 'add_edit_vehicle_screen.dart';
|
||||||
import 'report_screen.dart';
|
|
||||||
import 'settings_screen.dart';
|
|
||||||
import 'vehicle_detail_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 {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -18,20 +18,14 @@ class HomeScreen extends StatelessWidget {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('My Vehicles'),
|
title: const Text('Vehicles'),
|
||||||
|
actionsPadding: const EdgeInsets.only(right: 20),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.summarize_outlined),
|
icon: const Icon(Icons.add),
|
||||||
tooltip: 'Fuel Report',
|
tooltip: 'Add Vehicle',
|
||||||
onPressed: () => Navigator.of(context).push(
|
onPressed: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const ReportScreen()),
|
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.settings_outlined),
|
|
||||||
tooltip: 'Settings',
|
|
||||||
onPressed: () => Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -48,13 +42,6 @@ class HomeScreen extends StatelessWidget {
|
||||||
return _VehicleCard(vehicle: vehicle, totalGallons: gallons);
|
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(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: ListTile(
|
child: InkWell(
|
||||||
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),
|
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
91
lib/screens/main_shell.dart
Normal 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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/screens/receipt_detail_screen.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
332
lib/screens/receipts_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/fuel_report.dart';
|
import '../services/fuel_report.dart';
|
||||||
|
import '../services/fuel_report_images.dart';
|
||||||
import '../services/fuel_report_pdf.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 {
|
class ReportScreen extends StatefulWidget {
|
||||||
const ReportScreen({super.key});
|
const ReportScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -14,13 +26,30 @@ class ReportScreen extends StatefulWidget {
|
||||||
State<ReportScreen> createState() => _ReportScreenState();
|
State<ReportScreen> createState() => _ReportScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReportScreenState extends State<ReportScreen> {
|
class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClientMixin {
|
||||||
static final _dateFormat = DateFormat.yMMMd();
|
static final _dateFormat = DateFormat.yMMMd();
|
||||||
|
|
||||||
DateTime? _startDate;
|
DateTime? _startDate;
|
||||||
DateTime? _endDate;
|
DateTime? _endDate;
|
||||||
|
bool _includeReceiptImages = false;
|
||||||
|
bool _includeLineItems = false;
|
||||||
bool _busy = 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 {
|
Future<void> _pickDate({required bool isStart}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final picked = await showDatePicker(
|
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 {
|
Future<void> _share(FuelReport report) async {
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
try {
|
try {
|
||||||
final bytes = await buildFuelReportPdf(report);
|
final bytes = await _buildPdfBytes(report);
|
||||||
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
|
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _busy = false);
|
||||||
|
|
@ -52,31 +110,54 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
Future<void> _print(FuelReport report) async {
|
Future<void> _print(FuelReport report) async {
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
try {
|
try {
|
||||||
final bytes = await buildFuelReportPdf(report);
|
final bytes = await _buildPdfBytes(report);
|
||||||
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
|
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
|
|
||||||
String? rangeError;
|
String? rangeError;
|
||||||
FuelReport? report;
|
FuelReport? rawReport;
|
||||||
if (_startDate != null && _endDate != null) {
|
if (_startDate != null && _endDate != null) {
|
||||||
if (_endDate!.isBefore(_startDate!)) {
|
if (_endDate!.isBefore(_startDate!)) {
|
||||||
rangeError = 'End date must be on or after the start date.';
|
rangeError = 'End date must be on or after the start date.';
|
||||||
} else {
|
} else {
|
||||||
report = buildFuelReport(
|
rawReport = buildFuelReport(
|
||||||
vehicles: appState.vehicles,
|
vehicles: appState.vehicles,
|
||||||
fuelEntries: appState.fuelEntries,
|
fuelEntries: appState.fuelEntries,
|
||||||
startDate: _startDate!,
|
startDate: _startDate!,
|
||||||
endDate: _endDate!,
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Fuel Report')),
|
appBar: AppBar(title: const Text('Fuel Report')),
|
||||||
|
|
@ -104,38 +185,67 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 8),
|
||||||
if (report == null)
|
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(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
child: Text('Choose a start and end date to generate a report.'),
|
child: Text('Choose a start and end date to generate a report.'),
|
||||||
)
|
)
|
||||||
else if (report.rows.isEmpty)
|
else if (rawReport.rows.isEmpty)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
child: Text('No vehicles had a receipted fuel entry in that date range.'),
|
child: Text('No vehicles had a receipted fuel entry in that date range.'),
|
||||||
)
|
)
|
||||||
else ...[
|
else ...[
|
||||||
for (final row in report.rows)
|
for (final row in rawReport.rows)
|
||||||
Card(
|
_VehicleSelectionCard(
|
||||||
child: ListTile(
|
row: row,
|
||||||
title: Text(row.vehicle.displayLabel),
|
selected: _selectedVehicleIds.contains(row.vehicle.id),
|
||||||
subtitle: Text(
|
onTap: () => setState(() {
|
||||||
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
|
if (_selectedVehicleIds.contains(row.vehicle.id)) {
|
||||||
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
|
_selectedVehicleIds.remove(row.vehicle.id);
|
||||||
),
|
} else {
|
||||||
isThreeLine: true,
|
_selectedVehicleIds.add(row.vehicle.id);
|
||||||
trailing: Column(
|
}
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
}),
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text('${row.totalGallons.toStringAsFixed(3)} gal'),
|
|
||||||
Text(NumberFormat.simpleCurrency().format(row.totalCost)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
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(
|
ListTile(
|
||||||
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
|
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
|
|
@ -149,7 +259,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _busy ? null : () => _share(report!),
|
onPressed: _busy ? null : () => _share(report),
|
||||||
icon: const Icon(Icons.share_outlined),
|
icon: const Icon(Icons.share_outlined),
|
||||||
label: const Text('Share'),
|
label: const Text('Share'),
|
||||||
),
|
),
|
||||||
|
|
@ -157,7 +267,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _busy ? null : () => _print(report!),
|
onPressed: _busy ? null : () => _print(report),
|
||||||
icon: const Icon(Icons.print_outlined),
|
icon: const Icon(Icons.print_outlined),
|
||||||
label: const Text('Print'),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,16 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/cloud/cloud_storage_provider.dart';
|
import 'data_settings_screen.dart';
|
||||||
import 'cloud_folder_browser_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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
|
|
@ -90,153 +21,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: ListTile(
|
||||||
padding: const EdgeInsets.all(16),
|
leading: const Icon(Icons.palette_outlined),
|
||||||
child: Column(
|
title: const Text('UI'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
subtitle: const Text('Appearance'),
|
||||||
children: [
|
trailing: const Icon(Icons.chevron_right),
|
||||||
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
|
onTap: () => Navigator.of(context).push(
|
||||||
const SizedBox(height: 8),
|
MaterialPageRoute(builder: (_) => const UiSettingsScreen()),
|
||||||
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),
|
const SizedBox(height: 16),
|
||||||
Card(
|
Card(
|
||||||
child: SwitchListTile(
|
child: ListTile(
|
||||||
title: const Text('Keep photos on this phone after syncing'),
|
leading: const Icon(Icons.storage_outlined),
|
||||||
subtitle: const Text(
|
title: const Text('Data'),
|
||||||
'Otherwise, a receipt photo is removed from this device once '
|
subtitle: const Text('Cloud storage, photo syncing, advanced'),
|
||||||
"it's safely uploaded to the cloud.",
|
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),
|
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
47
lib/screens/ui_settings_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,174 +1,66 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../models/fuel_entry.dart';
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/ocr_service.dart';
|
import '../widgets/hero_banner.dart';
|
||||||
import '../services/receipt_parser.dart';
|
import '../widgets/receipt_capture.dart';
|
||||||
import '../widgets/image_source_sheet.dart';
|
import '../widgets/receipt_thumbnail.dart';
|
||||||
import 'add_edit_vehicle_screen.dart';
|
import 'add_edit_vehicle_screen.dart';
|
||||||
import 'confirm_fuel_entry_screen.dart';
|
import 'receipt_detail_screen.dart';
|
||||||
import 'receipt_image_screen.dart';
|
|
||||||
|
|
||||||
/// Long edge and JPEG quality a receipt photo is downscaled to unless
|
/// A swipeable [PageView] over every vehicle (in the same order as the
|
||||||
/// "keep max quality" is on — a receipt is a photo of small printed text,
|
/// Vehicles tab's list), starting on [vehicleId] — sliding left/right moves
|
||||||
/// not something that benefits from a multi-megapixel original, and this
|
/// to the next/previous vehicle, the same "modern navigation" swipe
|
||||||
/// keeps typical files in the low hundreds of KB instead of several MB.
|
/// [MainShell] uses for the bottom-nav tabs. Each page is a full,
|
||||||
const receiptImageMaxDimension = 1600.0;
|
/// independent [_VehicleDetailPage] (own AppBar/FAB), so this widget only
|
||||||
const receiptImageQuality = 70;
|
/// owns the [PageController].
|
||||||
|
class VehicleDetailScreen extends StatefulWidget {
|
||||||
class VehicleDetailScreen extends StatelessWidget {
|
|
||||||
final String vehicleId;
|
final String vehicleId;
|
||||||
|
|
||||||
const VehicleDetailScreen({super.key, required this.vehicleId});
|
const VehicleDetailScreen({super.key, required this.vehicleId});
|
||||||
|
|
||||||
Future<void> _captureReceipt(BuildContext context) async {
|
@override
|
||||||
final source = await chooseImageSource(context);
|
State<VehicleDetailScreen> createState() => _VehicleDetailScreenState();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final parsed = ReceiptParser.parse(recognizedText);
|
class _VehicleDetailScreenState extends State<VehicleDetailScreen> {
|
||||||
|
late final PageController _pageController;
|
||||||
|
|
||||||
if (!context.mounted) return;
|
@override
|
||||||
Navigator.of(context).pop(); // close the OCR loading spinner
|
void initState() {
|
||||||
|
super.initState();
|
||||||
final state = parsed.state;
|
final vehicles = context.read<AppState>().vehicles;
|
||||||
if (state != null && state != 'MO') {
|
final initialIndex = vehicles.indexWhere((v) => v.id == widget.vehicleId);
|
||||||
final proceed = await _confirmNonMissouriPurchase(context, state);
|
_pageController = PageController(initialPage: initialIndex < 0 ? 0 : initialIndex);
|
||||||
if (!proceed || !context.mounted) return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
@override
|
||||||
Navigator.of(context).push(
|
void dispose() {
|
||||||
MaterialPageRoute(
|
_pageController.dispose();
|
||||||
builder: (_) => ConfirmFuelEntryScreen(
|
super.dispose();
|
||||||
vehicleId: vehicleId,
|
}
|
||||||
imageFile: imageFile,
|
|
||||||
parsed: parsed,
|
@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 —
|
class _VehicleDetailPage extends StatelessWidget {
|
||||||
/// warn if the receipt's address is somewhere else, and let the user
|
final String vehicleId;
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
|
const _VehicleDetailPage({required this.vehicleId});
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -181,8 +73,12 @@ class VehicleDetailScreen extends StatelessWidget {
|
||||||
|
|
||||||
final entries = appState.entriesForVehicle(vehicleId);
|
final entries = appState.entriesForVehicle(vehicleId);
|
||||||
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
|
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
|
||||||
|
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
|
||||||
final dateFormat = DateFormat.yMMMd().add_jm();
|
final dateFormat = DateFormat.yMMMd().add_jm();
|
||||||
final currencyFormat = NumberFormat.simpleCurrency();
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -199,42 +95,74 @@ class VehicleDetailScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Padding(
|
||||||
width: double.infinity,
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||||
padding: const EdgeInsets.all(16),
|
child: HeroBanner(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
child: HeroStatsRow(
|
||||||
child: Column(
|
gallonsValue: heroGallonsFormat.format(totalGallons),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
costValue: currencyFormat.format(totalCost),
|
||||||
children: [
|
|
||||||
Text('Total gallons logged', style: Theme.of(context).textTheme.labelLarge),
|
|
||||||
Text(
|
|
||||||
'${totalGallons.toStringAsFixed(3)} gal',
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: entries.isEmpty
|
child: entries.isEmpty
|
||||||
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
|
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
|
||||||
: ListView.separated(
|
: 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),
|
padding: const EdgeInsets.all(12),
|
||||||
itemCount: entries.length,
|
itemCount: entries.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final entry = entries[index];
|
final entry = entries[index];
|
||||||
|
void openDetail() => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ReceiptDetailScreen(entryId: entry.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: ListTile(
|
child: InkWell(
|
||||||
leading: _buildReceiptLeading(context, entry),
|
onTap: openDetail,
|
||||||
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
|
child: Padding(
|
||||||
subtitle: Text(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
|
// 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(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () => _captureReceipt(context),
|
onPressed: () => captureReceiptForVehicle(context, vehicleId),
|
||||||
icon: const Icon(Icons.camera_alt_outlined),
|
icon: const Icon(Icons.camera_alt_outlined),
|
||||||
label: const Text('Log Fuel Receipt'),
|
label: const Text('Log Fuel Receipt'),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart' show ThemeMode;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ const _prefsKeyCloudFolderPath = 'cloud_folder_path';
|
||||||
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
||||||
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
||||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
||||||
|
const _prefsKeyThemeMode = 'theme_mode';
|
||||||
|
|
||||||
const defaultStaleLockMinutes = 10;
|
const defaultStaleLockMinutes = 10;
|
||||||
const minStaleLockMinutes = 1;
|
const minStaleLockMinutes = 1;
|
||||||
|
|
@ -85,6 +87,8 @@ class AppState extends ChangeNotifier {
|
||||||
/// the original camera/gallery image is kept as-is.
|
/// the original camera/gallery image is kept as-is.
|
||||||
bool keepMaxQualityReceiptPhotos = false;
|
bool keepMaxQualityReceiptPhotos = false;
|
||||||
|
|
||||||
|
ThemeMode themeMode = ThemeMode.system;
|
||||||
|
|
||||||
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
||||||
/// of spinning forever — an unhandled exception here previously left
|
/// of spinning forever — an unhandled exception here previously left
|
||||||
/// `isLoading` stuck at true with no feedback at all.
|
/// `isLoading` stuck at true with no feedback at all.
|
||||||
|
|
@ -109,6 +113,10 @@ class AppState extends ChangeNotifier {
|
||||||
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
|
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
|
||||||
keepMaxQualityReceiptPhotos =
|
keepMaxQualityReceiptPhotos =
|
||||||
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
|
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
|
||||||
|
themeMode = ThemeMode.values.firstWhere(
|
||||||
|
(mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
|
||||||
|
orElse: () => ThemeMode.system,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
initError = e;
|
initError = e;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
@ -289,6 +297,13 @@ class AppState extends ChangeNotifier {
|
||||||
notifyListeners();
|
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
|
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
|
||||||
/// Settings "Advanced" section, which restricts the picker to that range
|
/// Settings "Advanced" section, which restricts the picker to that range
|
||||||
/// anyway; this is a defensive backstop for any other caller.
|
/// anyway; this is a defensive backstop for any other caller.
|
||||||
|
|
@ -373,6 +388,34 @@ class AppState extends ChangeNotifier {
|
||||||
return entry;
|
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 {
|
Future<void> deleteFuelEntry(String entryId) async {
|
||||||
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||||
await database.deleteReceiptImageFile(entry.receiptImagePath);
|
await database.deleteReceiptImageFile(entry.receiptImagePath);
|
||||||
|
|
@ -380,6 +423,30 @@ class AppState extends ChangeNotifier {
|
||||||
await _persist();
|
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) {
|
List<FuelEntry> entriesForVehicle(String vehicleId) {
|
||||||
final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList();
|
final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList();
|
||||||
list.sort((a, b) => b.date.compareTo(a.date));
|
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
|
/// Exact match against an active vehicle's VIN — same comparison
|
||||||
/// [DatabaseService.vinExists] does, not case-insensitive, since VIN
|
/// [DatabaseService.vinExists] does, not case-insensitive, since VIN
|
||||||
/// isn't normalized to any particular case on manual entry (only OCR
|
/// isn't normalized to any particular case on manual entry (only OCR
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,67 @@ class DatabaseService {
|
||||||
return copied.path;
|
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 {
|
Future<void> deleteReceiptImageFile(String? path) async {
|
||||||
if (path == null) return;
|
if (path == null) return;
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,55 @@
|
||||||
import '../models/fuel_entry.dart';
|
import '../models/fuel_entry.dart';
|
||||||
import '../models/vehicle.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 {
|
class VehicleReportRow {
|
||||||
final Vehicle vehicle;
|
final Vehicle vehicle;
|
||||||
final int entryCount;
|
final List<FuelEntry> entries;
|
||||||
final double totalGallons;
|
final double totalGallons;
|
||||||
final double totalCost;
|
final double totalCost;
|
||||||
|
|
||||||
VehicleReportRow({
|
VehicleReportRow({
|
||||||
required this.vehicle,
|
required this.vehicle,
|
||||||
required this.entryCount,
|
required this.entries,
|
||||||
required this.totalGallons,
|
required this.totalGallons,
|
||||||
required this.totalCost,
|
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
|
/// 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 totalGallons => rows.fold(0.0, (sum, r) => sum + r.totalGallons);
|
||||||
double get totalCost => rows.fold(0.0, (sum, r) => sum + r.totalCost);
|
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,
|
/// 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
|
// No matching (active) vehicle — e.g. deleted since — nothing sane to
|
||||||
// attribute these entries to.
|
// attribute these entries to.
|
||||||
if (vehicle == null) continue;
|
if (vehicle == null) continue;
|
||||||
|
final sortedEntries = entry.value.toList()..sort((a, b) => a.date.compareTo(b.date));
|
||||||
rows.add(VehicleReportRow(
|
rows.add(VehicleReportRow(
|
||||||
vehicle: vehicle,
|
vehicle: vehicle,
|
||||||
entryCount: entry.value.length,
|
entries: sortedEntries,
|
||||||
totalGallons: entry.value.fold(0.0, (sum, e) => sum + e.gallons),
|
totalGallons: sortedEntries.fold(0.0, (sum, e) => sum + e.gallons),
|
||||||
totalCost: entry.value.fold(0.0, (sum, e) => sum + e.totalCost),
|
totalCost: sortedEntries.fold(0.0, (sum, e) => sum + e.totalCost),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel));
|
rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel));
|
||||||
|
|
|
||||||
70
lib/services/fuel_report_images.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -7,75 +7,594 @@ import 'package:pdf/widgets.dart' as pw;
|
||||||
import 'fuel_report.dart';
|
import 'fuel_report.dart';
|
||||||
|
|
||||||
final _dateFormat = DateFormat.yMMMd();
|
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();
|
final _currencyFormat = NumberFormat.simpleCurrency();
|
||||||
|
|
||||||
/// Renders [report] as a single-page-friendly PDF: a per-vehicle table
|
// Palette lifted from the report design mockup — kept in one place so
|
||||||
/// (VIN, nickname, entry count, gallons, total cost) plus a grand-total
|
// every section (banner, cards, tables, watermark) draws from the same
|
||||||
/// row, suitable for attaching to a fuel tax refund submission or handing
|
// set of colors rather than each picking its own.
|
||||||
/// to a printer.
|
final _bannerBackground = PdfColor.fromHex('#EAF3DE');
|
||||||
Future<Uint8List> buildFuelReportPdf(FuelReport report) async {
|
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 doc = pw.Document();
|
||||||
|
final generatedAt = DateTime.now();
|
||||||
|
|
||||||
doc.addPage(
|
doc.addPage(
|
||||||
pw.MultiPage(
|
pw.MultiPage(
|
||||||
pageFormat: PdfPageFormat.letter,
|
pageFormat: PdfPageFormat.letter,
|
||||||
header: (context) => pw.Column(
|
margin: pw.EdgeInsets.zero,
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
header: (context) => _buildBanner(generatedAt),
|
||||||
children: [
|
footer: (context) => _buildFooter(report, context),
|
||||||
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
|
build: (context) => _buildBody(
|
||||||
pw.Text(
|
report,
|
||||||
'${_dateFormat.format(report.startDate)} – ${_dateFormat.format(report.endDate)}',
|
includeLineItems: includeLineItems,
|
||||||
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
|
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(
|
pw.TableHelper.fromTextArray(
|
||||||
headers: ['Vehicle', 'VIN', 'Entries', 'Gallons', 'Total Cost'],
|
headers: ['Month', 'Fills', 'Gallons', 'Spent', 'Avg/gal'],
|
||||||
data: [
|
data: [
|
||||||
for (final row in report.rows)
|
for (final month in months)
|
||||||
[
|
[
|
||||||
row.vehicle.nickname?.trim().isNotEmpty == true ? row.vehicle.nickname! : '—',
|
_monthHeadingFormat.format(month.month),
|
||||||
row.vehicle.vin,
|
month.fillCount.toString(),
|
||||||
row.entryCount.toString(),
|
month.totalGallons.toStringAsFixed(3),
|
||||||
row.totalGallons.toStringAsFixed(3),
|
_currencyFormat.format(month.totalCost),
|
||||||
_currencyFormat.format(row.totalCost),
|
_currencyFormat.format(month.avgPricePerGallon),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
cellAlignments: const {
|
cellAlignments: const {
|
||||||
|
1: pw.Alignment.centerRight,
|
||||||
2: pw.Alignment.centerRight,
|
2: pw.Alignment.centerRight,
|
||||||
3: pw.Alignment.centerRight,
|
3: pw.Alignment.centerRight,
|
||||||
4: pw.Alignment.centerRight,
|
4: pw.Alignment.centerRight,
|
||||||
},
|
},
|
||||||
headerAlignments: const {
|
headerAlignments: const {
|
||||||
|
1: pw.Alignment.centerRight,
|
||||||
2: pw.Alignment.centerRight,
|
2: pw.Alignment.centerRight,
|
||||||
3: pw.Alignment.centerRight,
|
3: pw.Alignment.centerRight,
|
||||||
4: 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),
|
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
border: _tableBorder,
|
||||||
|
headerDecoration: _tableHeaderDecoration,
|
||||||
|
rowDecoration: _tableEvenRowDecoration,
|
||||||
|
oddRowDecoration: _tableOddRowDecoration,
|
||||||
),
|
),
|
||||||
pw.SizedBox(height: 12),
|
if (months.length > 1) ...[
|
||||||
pw.Divider(),
|
pw.SizedBox(height: 6),
|
||||||
pw.Row(
|
pw.Row(
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
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(
|
pw.Text(
|
||||||
'${report.totalGallons.toStringAsFixed(3)} gal · ${_currencyFormat.format(report.totalCost)}',
|
'${report.fillCount} · ${report.totalGallons.toStringAsFixed(3)} gal · '
|
||||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
'${_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`.
|
/// `fuel-report-<start>-<end>.pdf`, e.g. `fuel-report-20260101-20260331.pdf`.
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,24 @@
|
||||||
/// sticker, dashboard plate, title, etc).
|
/// sticker, dashboard plate, title, etc).
|
||||||
///
|
///
|
||||||
/// Real VINs never contain the letters I, O, or Q (they're excluded from
|
/// 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
|
/// the standard specifically so they can't be confused with 1, 0, and 9 —
|
||||||
/// requiring that charset both matches genuine VINs and rules out a lot of
|
/// which is exactly the kind of confusion OCR itself is prone to: a
|
||||||
/// incidental 17-character noise elsewhere on the sticker (barcodes text,
|
/// genuine VIN's "0" is sometimes read back as "O"). So rather than reject
|
||||||
/// weight ratings, date codes, etc).
|
/// 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 {
|
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 =
|
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"
|
// \b on both sides matters: since digits and letters are both "word"
|
||||||
// characters, this only matches a maximal run of exactly 17 eligible
|
// characters, this only matches a maximal run of exactly 17 eligible
|
||||||
// characters — not a 17-character slice out of an 18+ character run.
|
// 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
|
/// Returns the VIN in uppercase, or null if nothing matching the VIN
|
||||||
/// charset/length was found. A labeled "VIN: ..." match is preferred
|
/// 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).
|
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
|
||||||
static String? parse(String text) {
|
static String? parse(String text) {
|
||||||
final labeled = _labeledVinPattern.firstMatch(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);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _normalize(String candidate) =>
|
||||||
|
candidate.toUpperCase().replaceAll('O', '0').replaceAll('I', '1');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,20 @@ class AppTheme {
|
||||||
static const _darkOutline = Color(0xFF7E877F); // --text-3
|
static const _darkOutline = Color(0xFF7E877F); // --text-3
|
||||||
static const _darkOutlineVariant = Color(0xFF262926); // --line
|
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 {
|
static ThemeData get light {
|
||||||
final colorScheme = ColorScheme.fromSeed(
|
final colorScheme = ColorScheme.fromSeed(
|
||||||
seedColor: _lightPrimary,
|
seedColor: _lightPrimary,
|
||||||
|
|
@ -91,6 +105,7 @@ class AppTheme {
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
foregroundColor: colorScheme.onSurface,
|
foregroundColor: colorScheme.onSurface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
|
centerTitle: true,
|
||||||
titleTextStyle: TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|
|
||||||
108
lib/widgets/hero_banner.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,46 @@ import 'package:image_picker/image_picker.dart';
|
||||||
/// Bottom sheet letting the user pick a photo from the camera or their
|
/// 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
|
/// existing library. Shared by any screen that needs to snap or choose a
|
||||||
/// photo (receipt capture, VIN scanning) so the choice looks and behaves
|
/// photo (receipt capture, VIN scanning) so the choice looks and behaves
|
||||||
/// the same everywhere.
|
/// the same everywhere. Pass [heading] to label what the photo is for
|
||||||
Future<ImageSource?> chooseImageSource(BuildContext context) {
|
/// (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>(
|
return showModalBottomSheet<ImageSource>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
children: [
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.camera_alt_outlined),
|
leading: const Icon(Icons.camera_alt_outlined),
|
||||||
title: const Text('Take Photo'),
|
title: const Text('Take Photo'),
|
||||||
|
|
|
||||||