feat(v1.1): complete UI overhaul with branded theme, garage, and image uploads
- Custom Material 3 theme with Hot Wheels branding (orange/red gradient, navy accents) - Locally bundled Poppins font (Regular, Medium, SemiBold, Bold) - Redesigned login screen with full-bleed background image and frosted glass form - Bottom navigation shell: My Garage / Scan / Profile tabs - My Garage screen with grid view, search, stats, detail bottom sheet - Edit and delete car details from the detail sheet - Skip button for quick-add with minimal info - Camera photo upload to Supabase Storage (UUID-based unguessable paths) - Change/add photo from car detail view - Profile screen with change password, about dialog, sign out - Scan tab with camera scanner and manual entry - Styled scanner screen with crosshair overlay and gradient buttons - Custom app icon with transparent background and adaptive icon support - Native splash screen with brand colors - Auto-refresh garage on tab switch
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/drawable-night-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?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>
|
||||||
|
</layer-list>
|
||||||
BIN
android/app/src/main/res/drawable-night/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?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>
|
||||||
|
</layer-list>
|
||||||
BIN
android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,6 @@
|
||||||
<?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>
|
|
||||||
<bitmap
|
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 163 KiB |
BIN
android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,6 @@
|
||||||
<?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>
|
|
||||||
<bitmap
|
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="16%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 77 KiB |
20
android/app/src/main/res/values-night-v31/styles.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?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">#1a1a2e</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
|
||||||
|
|
|
||||||
20
android/app/src/main/res/values-v31/styles.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?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">#f9a11b</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>
|
||||||
4
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#f9a11b</color>
|
||||||
|
</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/fonts/Poppins-Bold.ttf
Normal file
BIN
assets/fonts/Poppins-Medium.ttf
Normal file
BIN
assets/fonts/Poppins-Regular.ttf
Normal file
BIN
assets/fonts/Poppins-SemiBold.ttf
Normal file
BIN
assets/icon/app_icon.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/icon/app_icon_foreground.png
Normal file
|
After Width: | Height: | Size: 748 KiB |
BIN
assets/icon/app_icon_ios.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/img/loadingScreenMainScreen.png
Normal file
|
After Width: | Height: | Size: 8.8 MiB |
BIN
assets/img/login_bg.jpg
Normal file
|
After Width: | Height: | Size: 181 KiB |
|
|
@ -431,7 +431,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++";
|
||||||
|
|
@ -488,7 +488,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: 1.4 MiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 51 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,23 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage.png",
|
"filename" : "LaunchImage.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage@2x.png",
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage@3x.png",
|
"filename" : "LaunchImage@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: 69 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 69 B |
|
|
@ -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>
|
||||||
|
|
@ -33,5 +39,6 @@
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="168" height="185"/>
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
|
<image name="LaunchBackground" width="1" height="1"/>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,72 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>Hwhub</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>hwhub</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
|
||||||
<key>CFBundleSignature</key>
|
|
||||||
<string>????</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
|
||||||
<key>LSRequiresIPhoneOS</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSceneManifest</key>
|
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<false/>
|
<true/>
|
||||||
<key>UISceneConfigurations</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Hwhub</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>hwhub</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIWindowSceneSessionRoleApplication</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
<array>
|
<false/>
|
||||||
<dict>
|
<key>UISceneConfigurations</key>
|
||||||
<key>UISceneClassName</key>
|
<dict>
|
||||||
<string>UIWindowScene</string>
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
<key>UISceneConfigurationName</key>
|
<array>
|
||||||
<string>flutter</string>
|
<dict>
|
||||||
<key>UISceneDelegateClassName</key>
|
<key>UISceneClassName</key>
|
||||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
<string>UIWindowScene</string>
|
||||||
<key>UISceneStoryboardFile</key>
|
<key>UISceneConfigurationName</key>
|
||||||
<string>Main</string>
|
<string>flutter</string>
|
||||||
</dict>
|
<key>UISceneDelegateClassName</key>
|
||||||
</array>
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIStatusBarHidden</key>
|
||||||
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
<key>UILaunchStoryboardName</key>
|
|
||||||
<string>LaunchScreen</string>
|
|
||||||
<key>UIMainStoryboardFile</key>
|
|
||||||
<string>Main</string>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
335
lib/main.dart
|
|
@ -1,6 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'scanner_screen.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'screens/login_screen.dart';
|
||||||
|
import 'screens/home_shell.dart';
|
||||||
|
|
||||||
|
// Re-export so other files can `import '../main.dart'` for these.
|
||||||
|
export 'package:supabase_flutter/supabase_flutter.dart'
|
||||||
|
show AuthException, UserAttributes;
|
||||||
|
|
||||||
// ── Supabase credentials ──────────────────────────────────────────────
|
// ── Supabase credentials ──────────────────────────────────────────────
|
||||||
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
|
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
|
||||||
|
|
@ -49,22 +55,19 @@ class HWHubApp extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'HW Hub',
|
title: 'HW Collector Hub',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||||
theme: ThemeData(
|
theme: AppTheme.light,
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
darkTheme: AppTheme.dark,
|
||||||
useMaterial3: true,
|
themeMode: ThemeMode.system,
|
||||||
),
|
|
||||||
home: const AuthGate(),
|
home: const AuthGate(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth Gate ─────────────────────────────────────────────────────────
|
// ── Auth Gate ─────────────────────────────────────────────────────────
|
||||||
// Listens to Supabase auth state changes (including deep-link callbacks
|
|
||||||
// for password resets) and routes to Login or Home.
|
|
||||||
class AuthGate extends StatefulWidget {
|
class AuthGate extends StatefulWidget {
|
||||||
const AuthGate({super.key});
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
|
@ -80,12 +83,8 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// 1. Check for an existing session on cold start.
|
|
||||||
_session = supabase.auth.currentSession;
|
_session = supabase.auth.currentSession;
|
||||||
|
|
||||||
// 2. Listen for auth state changes — but only rebuild when login
|
|
||||||
// status actually changes (signed-in ↔ signed-out), NOT on
|
|
||||||
// every token refresh, to avoid tearing down open dialogs.
|
|
||||||
supabase.auth.onAuthStateChange.listen(
|
supabase.auth.onAuthStateChange.listen(
|
||||||
(AuthState authState) {
|
(AuthState authState) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -94,13 +93,10 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
final isLoggedIn = authState.session != null;
|
final isLoggedIn = authState.session != null;
|
||||||
_session = authState.session;
|
_session = authState.session;
|
||||||
|
|
||||||
// Only rebuild the tree when login state actually flips.
|
|
||||||
if (wasLoggedIn != isLoggedIn) {
|
if (wasLoggedIn != isLoggedIn) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user just clicked a password-reset link from their email,
|
|
||||||
// Supabase fires a PASSWORD_RECOVERY event.
|
|
||||||
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
||||||
_showResetPasswordDialog();
|
_showResetPasswordDialog();
|
||||||
}
|
}
|
||||||
|
|
@ -110,12 +106,9 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Done checking – remove the loading state.
|
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a dialog so the user can type a new password after clicking
|
|
||||||
/// the "Reset Password" link from their email.
|
|
||||||
Future<void> _showResetPasswordDialog() async {
|
Future<void> _showResetPasswordDialog() async {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: navigatorKey.currentContext!,
|
context: navigatorKey.currentContext!,
|
||||||
|
|
@ -131,12 +124,7 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return _session != null ? const HomeShell() : const LoginScreen();
|
||||||
if (_session != null) {
|
|
||||||
return const HomeScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
return const LoginScreen();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,10 +176,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
|
||||||
content: TextField(
|
content: TextField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'New password'),
|
||||||
labelText: 'New password',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _save(),
|
onSubmitted: (_) => _save(),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -213,297 +198,3 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Login Screen ──────────────────────────────────────────────────────
|
|
||||||
class LoginScreen extends StatefulWidget {
|
|
||||||
const LoginScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LoginScreen> createState() => _LoginScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
bool _isLoading = false;
|
|
||||||
|
|
||||||
/// Sign in with email + password.
|
|
||||||
Future<void> _signIn() async {
|
|
||||||
final email = _emailController.text.trim();
|
|
||||||
final password = _passwordController.text;
|
|
||||||
if (email.isEmpty || password.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await supabase.auth.signInWithPassword(
|
|
||||||
email: email,
|
|
||||||
password: password,
|
|
||||||
);
|
|
||||||
// AuthGate listener will pick up the new session automatically.
|
|
||||||
} on AuthException catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
showGlobalSnackBar(e.message, isError: true);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a password-reset email. The link in the email uses the
|
|
||||||
/// hwcollector://login deep link so it opens back in the app,
|
|
||||||
/// which triggers the PASSWORD_RECOVERY event in AuthGate.
|
|
||||||
Future<void> _forgotPassword() async {
|
|
||||||
final email = _emailController.text.trim();
|
|
||||||
if (email.isEmpty) {
|
|
||||||
showGlobalSnackBar('Enter your email first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await supabase.auth.resetPasswordForEmail(
|
|
||||||
email,
|
|
||||||
redirectTo: 'hwcollector://login',
|
|
||||||
);
|
|
||||||
showGlobalSnackBar('Password reset email sent! Check your inbox.');
|
|
||||||
} on AuthException catch (e) {
|
|
||||||
showGlobalSnackBar(e.message, isError: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_emailController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.directions_car, size: 72, color: Colors.deepPurple),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'Hot Wheels Tracker',
|
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
TextField(
|
|
||||||
controller: _emailController,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Email',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: _passwordController,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Password',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _signIn(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: _forgotPassword,
|
|
||||||
child: const Text('Forgot Password?'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: _isLoading ? null : _signIn,
|
|
||||||
child: _isLoading
|
|
||||||
? const SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('Sign In'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Home Screen ───────────────────────────────────────────────────────
|
|
||||||
class HomeScreen extends StatefulWidget {
|
|
||||||
const HomeScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<HomeScreen> createState() => _HomeScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
|
||||||
bool _isBusy = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final user = supabase.auth.currentUser;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Garage is Online! 🏎️'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.logout),
|
|
||||||
onPressed: () async {
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Signed in as ${user?.email ?? 'unknown'}',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
const Icon(Icons.directions_car, size: 80, color: Colors.deepPurple),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'Scan or enter a Hot Wheels ID\nto check your collection.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
|
||||||
icon: const Icon(Icons.camera_alt),
|
|
||||||
label: const Text('Scan'),
|
|
||||||
onPressed: _isBusy ? null : _openScanner,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the scanner, gets the hw_id, then queries the DB.
|
|
||||||
Future<void> _openScanner() async {
|
|
||||||
final hwId = await navigatorKey.currentState!.push<String>(
|
|
||||||
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hwId == null) return;
|
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final data = await supabase
|
|
||||||
.from('hotwheels')
|
|
||||||
.select()
|
|
||||||
.eq('hw_id', hwId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (mounted) setState(() => _isBusy = false);
|
|
||||||
|
|
||||||
if (data != null) {
|
|
||||||
await showGlobalDialog(
|
|
||||||
builder: (_) => _AlreadyExistsDialog(hwId: hwId),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
final added = await showGlobalDialog<bool>(
|
|
||||||
builder: (_) => _AddCarDialog(hwId: hwId),
|
|
||||||
);
|
|
||||||
if (added == true) {
|
|
||||||
showGlobalSnackBar('$hwId added to your collection! 🎉');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) setState(() => _isBusy = false);
|
|
||||||
showGlobalSnackBar('DB error: $e', isError: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── "Already in Collection" Dialog ────────────────────────────────────
|
|
||||||
class _AlreadyExistsDialog extends StatelessWidget {
|
|
||||||
final String hwId;
|
|
||||||
const _AlreadyExistsDialog({required this.hwId});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
icon: const Icon(Icons.check_circle, color: Colors.green, size: 48),
|
|
||||||
title: const Text('Already in Collection!'),
|
|
||||||
content: Text('$hwId is already in your shared garage.'),
|
|
||||||
actions: [
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Got it'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── "Add to Collection" Dialog ────────────────────────────────────────
|
|
||||||
class _AddCarDialog extends StatefulWidget {
|
|
||||||
final String hwId;
|
|
||||||
const _AddCarDialog({required this.hwId});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AddCarDialogState extends State<_AddCarDialog> {
|
|
||||||
bool _isAdding = false;
|
|
||||||
|
|
||||||
Future<void> _addCar() async {
|
|
||||||
setState(() => _isAdding = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await supabase.from('hotwheels').insert({
|
|
||||||
'hw_id': widget.hwId,
|
|
||||||
'user_id': supabase.auth.currentUser!.id,
|
|
||||||
});
|
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.of(context).pop(true);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _isAdding = false);
|
|
||||||
showGlobalSnackBar('Failed to add: $e', isError: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
icon: const Icon(Icons.add_circle_outline, color: Colors.deepPurple, size: 48),
|
|
||||||
title: const Text('New Car Found!'),
|
|
||||||
content: Text('${widget.hwId} is not in your collection yet.\nAdd it now?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: _isAdding ? null : () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _isAdding ? null : _addCar,
|
|
||||||
child: _isAdding
|
|
||||||
? const SizedBox(
|
|
||||||
height: 18,
|
|
||||||
width: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('Add to Collection'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||||
|
import 'theme/app_colors.dart';
|
||||||
|
|
||||||
/// Screen that uses the camera to scan text (OCR) from a Hot Wheels package
|
/// Screen that uses the camera to scan text (OCR) from a Hot Wheels package
|
||||||
/// and extract the hw_id (e.g. "JKF21").
|
/// and extract the hw_id (e.g. "JKF21").
|
||||||
|
|
@ -127,23 +128,60 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Scan Hot Wheels ID')),
|
extendBodyBehindAppBar: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'Scan HW ID',
|
||||||
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.black38,
|
||||||
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// ── Camera preview ──
|
// ── Camera preview ──
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _cameraReady
|
child: _cameraReady
|
||||||
? ClipRect(
|
? Stack(
|
||||||
child: SizedBox.expand(
|
fit: StackFit.expand,
|
||||||
child: FittedBox(
|
children: [
|
||||||
fit: BoxFit.cover,
|
ClipRect(
|
||||||
child: SizedBox(
|
child: SizedBox.expand(
|
||||||
width: _cameraController!.value.previewSize!.height,
|
child: FittedBox(
|
||||||
height: _cameraController!.value.previewSize!.width,
|
fit: BoxFit.cover,
|
||||||
child: CameraPreview(_cameraController!),
|
child: SizedBox(
|
||||||
|
width: _cameraController!.value.previewSize!.height,
|
||||||
|
height: _cameraController!.value.previewSize!.width,
|
||||||
|
child: CameraPreview(_cameraController!),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// Scan overlay / crosshair
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 240,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.orange.withValues(alpha: 0.8),
|
||||||
|
width: 2.5,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Align ID here',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
: const Center(child: CircularProgressIndicator()),
|
: const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
|
|
@ -152,21 +190,47 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
if (_lastDetected != null)
|
if (_lastDetected != null)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: Colors.green.shade50,
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
color: AppColors.success.withValues(alpha: 0.1),
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: AppColors.success.withValues(alpha: 0.4),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.check_circle, color: Colors.green),
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.success.withValues(alpha: 0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.check, color: AppColors.success, size: 20),
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
'Detected: $_lastDetected',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
children: [
|
||||||
|
const Text('Detected', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||||
|
Text(
|
||||||
|
_lastDetected!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
color: AppColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.of(context).pop(_lastDetected),
|
onPressed: () => Navigator.of(context).pop(_lastDetected),
|
||||||
child: const Text('Use'),
|
child: const Text('Use This'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -178,27 +242,43 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Manual entry button
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.keyboard),
|
icon: const Icon(Icons.keyboard),
|
||||||
label: const Text('Enter manually'),
|
label: const Text('Manual'),
|
||||||
onPressed: () => _showManualEntry(context),
|
onPressed: () => _showManualEntry(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Capture / scan button
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
flex: 2,
|
||||||
icon: _isBusy
|
child: SizedBox(
|
||||||
? const SizedBox(
|
height: 50,
|
||||||
height: 18,
|
child: DecoratedBox(
|
||||||
width: 18,
|
decoration: BoxDecoration(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
gradient: _isBusy ? null : AppColors.brandGradient,
|
||||||
)
|
borderRadius: BorderRadius.circular(50),
|
||||||
: const Icon(Icons.camera_alt),
|
),
|
||||||
label: Text(_isBusy ? 'Scanning...' : 'Scan'),
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isBusy ? null : _captureAndScan,
|
icon: _isBusy
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.camera_alt, color: Colors.white),
|
||||||
|
label: Text(
|
||||||
|
_isBusy ? 'Scanning…' : 'Capture & Scan',
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
onPressed: _isBusy ? null : _captureAndScan,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -255,7 +335,6 @@ class _ManualEntryDialogState extends State<_ManualEntryDialog> {
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'e.g. JKF21',
|
hintText: 'e.g. JKF21',
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _submit(),
|
onSubmitted: (_) => _submit(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
790
lib/screens/garage_screen.dart
Normal file
|
|
@ -0,0 +1,790 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../main.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../widgets/car_card.dart';
|
||||||
|
|
||||||
|
/// The "My Garage" screen — shows the user's collected cars in a grid.
|
||||||
|
class GarageScreen extends StatefulWidget {
|
||||||
|
const GarageScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GarageScreen> createState() => GarageScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class GarageScreenState extends State<GarageScreen> {
|
||||||
|
List<Map<String, dynamic>> _cars = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
String _searchQuery = '';
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCars();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public method so other screens can trigger a refresh.
|
||||||
|
void refresh() => _loadCars();
|
||||||
|
|
||||||
|
Future<void> _loadCars() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final userId = supabase.auth.currentUser?.id;
|
||||||
|
if (userId == null) return;
|
||||||
|
|
||||||
|
final data = await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.select()
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', ascending: false);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_cars = List<Map<String, dynamic>>.from(data);
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> get _filteredCars {
|
||||||
|
if (_searchQuery.isEmpty) return _cars;
|
||||||
|
final q = _searchQuery.toLowerCase();
|
||||||
|
return _cars.where((car) {
|
||||||
|
final id = (car['hw_id'] as String? ?? '').toLowerCase();
|
||||||
|
final name = (car['name'] as String? ?? '').toLowerCase();
|
||||||
|
final series = (car['series'] as String? ?? '').toLowerCase();
|
||||||
|
return id.contains(q) || name.contains(q) || series.contains(q);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// ── Header ──
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 140,
|
||||||
|
pinned: true,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
titlePadding:
|
||||||
|
const EdgeInsets.only(left: 20, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
'My Garage',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 22,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
background: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 24),
|
||||||
|
child: Icon(
|
||||||
|
Icons.directions_car_filled,
|
||||||
|
size: 72,
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Stats bar ──
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_StatChip(
|
||||||
|
icon: Icons.directions_car,
|
||||||
|
label: '${_cars.length}',
|
||||||
|
subtitle: 'Total Cars',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_StatChip(
|
||||||
|
icon: Icons.new_releases,
|
||||||
|
label: _recentCount(),
|
||||||
|
subtitle: 'This Week',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Search bar ──
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: (v) => setState(() => _searchQuery = v),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search by ID, name, or series…',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _searchQuery.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _searchQuery = '');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Content ──
|
||||||
|
if (_isLoading)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (_error != null)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: _ErrorView(
|
||||||
|
message: _error!,
|
||||||
|
onRetry: _loadCars,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_filteredCars.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: _EmptyGarage(hasSearch: _searchQuery.isNotEmpty),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 100),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 0.72,
|
||||||
|
mainAxisSpacing: 4,
|
||||||
|
crossAxisSpacing: 0,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final car = _filteredCars[index];
|
||||||
|
return CarCard(
|
||||||
|
hwId: car['hw_id'] as String? ?? '???',
|
||||||
|
name: car['name'] as String?,
|
||||||
|
series: car['series'] as String?,
|
||||||
|
year: car['year'] as int?,
|
||||||
|
color: car['color'] as String?,
|
||||||
|
imageUrl: car['image_url'] as String?,
|
||||||
|
addedAt: car['created_at'] != null
|
||||||
|
? DateTime.tryParse(car['created_at'])
|
||||||
|
: null,
|
||||||
|
onTap: () => _showCarDetails(car),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: _filteredCars.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _recentCount() {
|
||||||
|
final weekAgo = DateTime.now().subtract(const Duration(days: 7));
|
||||||
|
final count = _cars.where((c) {
|
||||||
|
final ts = c['created_at'];
|
||||||
|
if (ts == null) return false;
|
||||||
|
final d = DateTime.tryParse(ts.toString());
|
||||||
|
return d != null && d.isAfter(weekAgo);
|
||||||
|
}).length;
|
||||||
|
return '$count';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCarDetails(Map<String, dynamic> car) {
|
||||||
|
final hwId = car['hw_id'] as String? ?? '???';
|
||||||
|
final name = car['name'] as String?;
|
||||||
|
final series = car['series'] as String?;
|
||||||
|
final year = car['year'] as int?;
|
||||||
|
final notes = car['notes'] as String?;
|
||||||
|
final imageUrl = car['image_url'] as String?;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
builder: (_) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.55,
|
||||||
|
minChildSize: 0.3,
|
||||||
|
maxChildSize: 0.85,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) => ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
// Drag handle
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Car image ──
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 16 / 10,
|
||||||
|
child: imageUrl != null && imageUrl.isNotEmpty
|
||||||
|
? Image.network(
|
||||||
|
imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(),
|
||||||
|
)
|
||||||
|
: _imagePlaceholder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Change / Add photo button
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => _updatePhoto(car, context),
|
||||||
|
icon: Icon(
|
||||||
|
imageUrl != null ? Icons.camera_alt : Icons.add_a_photo,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(imageUrl != null ? 'Change Photo' : 'Add Photo'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
|
// ID badge
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
hwId,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 18,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (name != null && name.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
if (series != null && series.isNotEmpty)
|
||||||
|
_DetailRow(icon: Icons.collections, label: 'Series', value: series),
|
||||||
|
if (year != null)
|
||||||
|
_DetailRow(icon: Icons.calendar_today, label: 'Year', value: '$year'),
|
||||||
|
if (notes != null && notes.isNotEmpty)
|
||||||
|
_DetailRow(icon: Icons.notes, label: 'Notes', value: notes),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Edit & Delete buttons
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => _editCar(car, context),
|
||||||
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
|
label: const Text('Edit Details'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _deleteCar(car, context),
|
||||||
|
icon: const Icon(Icons.delete_outline, color: AppColors.error),
|
||||||
|
label: const Text('Remove',
|
||||||
|
style: TextStyle(color: AppColors.error)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: AppColors.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _imagePlaceholder() {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
return Container(
|
||||||
|
color: isDark ? AppColors.surfaceDark : AppColors.backgroundLight,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.directions_car_filled,
|
||||||
|
size: 48,
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.15)
|
||||||
|
: AppColors.orange.withValues(alpha: 0.25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a new photo and update the image_url for this car.
|
||||||
|
Future<void> _updatePhoto(
|
||||||
|
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final xFile = await picker.pickImage(
|
||||||
|
source: ImageSource.camera,
|
||||||
|
maxWidth: 800,
|
||||||
|
maxHeight: 800,
|
||||||
|
imageQuality: 60,
|
||||||
|
);
|
||||||
|
if (xFile == null) return;
|
||||||
|
|
||||||
|
showGlobalSnackBar('Uploading photo…');
|
||||||
|
|
||||||
|
final oldImageUrl = car['image_url'] as String?;
|
||||||
|
final url = await StorageService.uploadCarImage(
|
||||||
|
file: File(xFile.path),
|
||||||
|
oldImageUrl: oldImageUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (url == null) {
|
||||||
|
showGlobalSnackBar('Failed to upload photo.', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.update({'image_url': url})
|
||||||
|
.eq('id', car['id']);
|
||||||
|
|
||||||
|
showGlobalSnackBar('Photo updated!');
|
||||||
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
|
_loadCars(); // refresh grid
|
||||||
|
} catch (e) {
|
||||||
|
showGlobalSnackBar('Failed to save: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an edit dialog for this car, then update Supabase.
|
||||||
|
Future<void> _editCar(
|
||||||
|
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||||
|
final updated = await showDialog<Map<String, dynamic>>(
|
||||||
|
context: sheetContext,
|
||||||
|
builder: (_) => _EditCarDialog(car: car),
|
||||||
|
);
|
||||||
|
if (updated == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.update(updated)
|
||||||
|
.eq('id', car['id']);
|
||||||
|
|
||||||
|
showGlobalSnackBar('Car updated!');
|
||||||
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
|
_loadCars();
|
||||||
|
} catch (e) {
|
||||||
|
showGlobalSnackBar('Failed to update: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteCar(
|
||||||
|
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||||
|
if (!sheetContext.mounted) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: sheetContext,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Remove Car?'),
|
||||||
|
content: Text(
|
||||||
|
'Remove ${car['hw_id']} from your garage? This cannot be undone.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(sheetContext, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(sheetContext, true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
|
||||||
|
child: const Text('Remove'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete image from storage first (best-effort).
|
||||||
|
await StorageService.deleteCarImage(car['image_url'] as String?);
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.delete()
|
||||||
|
.eq('id', car['id']);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
if (sheetContext.mounted) {
|
||||||
|
Navigator.pop(sheetContext); // close bottom sheet
|
||||||
|
}
|
||||||
|
showGlobalSnackBar('${car['hw_id']} removed from your garage.');
|
||||||
|
_loadCars();
|
||||||
|
} catch (e) {
|
||||||
|
showGlobalSnackBar('Failed to remove: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper widgets ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _StatChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String subtitle;
|
||||||
|
const _StatChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.subtitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardTheme.color,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.orange.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 20, color: AppColors.orange),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyGarage extends StatelessWidget {
|
||||||
|
final bool hasSearch;
|
||||||
|
const _EmptyGarage({required this.hasSearch});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(40),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
hasSearch ? Icons.search_off : Icons.garage_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
hasSearch ? 'No cars match your search' : 'Your garage is empty',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
hasSearch
|
||||||
|
? 'Try a different search term'
|
||||||
|
: 'Scan your first Hot Wheels car to get started!',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorView extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
const _ErrorView({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(40),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 56, color: AppColors.error),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Car Dialog ─────────────────────────────────────────────────
|
||||||
|
class _EditCarDialog extends StatefulWidget {
|
||||||
|
final Map<String, dynamic> car;
|
||||||
|
const _EditCarDialog({required this.car});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EditCarDialog> createState() => _EditCarDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditCarDialogState extends State<_EditCarDialog> {
|
||||||
|
late final TextEditingController _nameCtrl;
|
||||||
|
late final TextEditingController _seriesCtrl;
|
||||||
|
late final TextEditingController _yearCtrl;
|
||||||
|
late final TextEditingController _notesCtrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nameCtrl = TextEditingController(text: widget.car['name'] as String? ?? '');
|
||||||
|
_seriesCtrl =
|
||||||
|
TextEditingController(text: widget.car['series'] as String? ?? '');
|
||||||
|
final year = widget.car['year'];
|
||||||
|
_yearCtrl = TextEditingController(text: year != null ? '$year' : '');
|
||||||
|
_notesCtrl =
|
||||||
|
TextEditingController(text: widget.car['notes'] as String? ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_seriesCtrl.dispose();
|
||||||
|
_yearCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final updates = <String, dynamic>{};
|
||||||
|
|
||||||
|
final name = _nameCtrl.text.trim();
|
||||||
|
final series = _seriesCtrl.text.trim();
|
||||||
|
final yearStr = _yearCtrl.text.trim();
|
||||||
|
final notes = _notesCtrl.text.trim();
|
||||||
|
|
||||||
|
updates['name'] = name.isEmpty ? null : name;
|
||||||
|
updates['series'] = series.isEmpty ? null : series;
|
||||||
|
updates['year'] = yearStr.isEmpty ? null : int.tryParse(yearStr);
|
||||||
|
updates['notes'] = notes.isEmpty ? null : notes;
|
||||||
|
|
||||||
|
Navigator.pop(context, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hwId = widget.car['hw_id'] as String? ?? '???';
|
||||||
|
return AlertDialog(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.edit, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
title: Text('Edit $hwId'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Car Name',
|
||||||
|
hintText: "e.g. '70 Dodge Charger",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _seriesCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Series',
|
||||||
|
hintText: 'e.g. HW Flames',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _yearCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Year',
|
||||||
|
hintText: 'e.g. 2025',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _notesCtrl,
|
||||||
|
maxLines: 2,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes',
|
||||||
|
hintText: 'Any extra info…',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _save,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailRow extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
const _DetailRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: AppColors.textHint),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'$label: ',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(value,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
lib/screens/home_shell.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'garage_screen.dart';
|
||||||
|
import 'scan_tab.dart';
|
||||||
|
import 'profile_screen.dart';
|
||||||
|
|
||||||
|
/// Bottom-navigation shell that hosts the three main tabs.
|
||||||
|
class HomeShell extends StatefulWidget {
|
||||||
|
const HomeShell({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeShell> createState() => _HomeShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeShellState extends State<HomeShell> {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
final _garageKey = GlobalKey<GarageScreenState>();
|
||||||
|
|
||||||
|
late final List<Widget> _pages = <Widget>[
|
||||||
|
GarageScreen(key: _garageKey),
|
||||||
|
const ScanTab(),
|
||||||
|
const ProfileScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
|
void _onTabSelected(int i) {
|
||||||
|
setState(() => _currentIndex = i);
|
||||||
|
// Refresh garage whenever user switches to it.
|
||||||
|
if (i == 0) {
|
||||||
|
_garageKey.currentState?.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _currentIndex,
|
||||||
|
children: _pages,
|
||||||
|
),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _currentIndex,
|
||||||
|
onDestinationSelected: _onTabSelected,
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.garage_outlined),
|
||||||
|
selectedIcon: Icon(Icons.garage),
|
||||||
|
label: 'Garage',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.qr_code_scanner_outlined),
|
||||||
|
selectedIcon: Icon(Icons.qr_code_scanner),
|
||||||
|
label: 'Scan',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.person_outline),
|
||||||
|
selectedIcon: Icon(Icons.person),
|
||||||
|
label: 'Profile',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
342
lib/screens/login_screen.dart
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../main.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// Branded login screen matching the HW Collector Hub email style.
|
||||||
|
class LoginScreen extends StatefulWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
late final AnimationController _fadeController;
|
||||||
|
late final Animation<double> _fadeAnim;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fadeController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
);
|
||||||
|
_fadeAnim = CurvedAnimation(
|
||||||
|
parent: _fadeController,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
_fadeController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_fadeController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth actions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _signIn() async {
|
||||||
|
final email = _emailController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
if (email.isEmpty || password.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await supabase.auth.signInWithPassword(
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showGlobalSnackBar(e.message, isError: true);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _forgotPassword() async {
|
||||||
|
final email = _emailController.text.trim();
|
||||||
|
if (email.isEmpty) {
|
||||||
|
showGlobalSnackBar('Enter your email first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await supabase.auth.resetPasswordForEmail(
|
||||||
|
email,
|
||||||
|
redirectTo: 'hwcollector://login',
|
||||||
|
);
|
||||||
|
showGlobalSnackBar('Password reset email sent! Check your inbox.');
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
showGlobalSnackBar(e.message, isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// ── Full-screen background image ──
|
||||||
|
Image.asset(
|
||||||
|
'assets/img/login_bg.jpg',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Dark overlay for readability ──
|
||||||
|
Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Scrollable content ──
|
||||||
|
SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.only(bottom: bottom),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: MediaQuery.of(context).size.height,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(height: topPadding + 40),
|
||||||
|
|
||||||
|
// ── Brand title ──
|
||||||
|
FadeTransition(
|
||||||
|
opacity: _fadeAnim,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.directions_car_filled,
|
||||||
|
size: 44,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'HW COLLECTOR HUB',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Track your collection',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
|
||||||
|
// ── Frosted glass form card ──
|
||||||
|
FadeTransition(
|
||||||
|
opacity: _fadeAnim,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Email
|
||||||
|
TextField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _glassInputDecoration(
|
||||||
|
label: 'Email',
|
||||||
|
icon: Icons.email_outlined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Password
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _signIn(),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _glassInputDecoration(
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
).copyWith(
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword
|
||||||
|
? Icons.visibility_off_outlined
|
||||||
|
: Icons.visibility_outlined,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _obscurePassword =
|
||||||
|
!_obscurePassword),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Forgot password
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _forgotPassword,
|
||||||
|
child: Text(
|
||||||
|
'Forgot Password?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Sign-in button (gradient)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 52,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.orange
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _signIn,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Start Your Engines!',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// ── Footer ──
|
||||||
|
Text(
|
||||||
|
'© 2026 HW Collector Hub',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input decoration styled for the frosted-glass card.
|
||||||
|
InputDecoration _glassInputDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
|
||||||
|
prefixIcon: Icon(icon, color: Colors.white70),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white.withValues(alpha: 0.08),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.15)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: AppColors.orange, width: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _GradientHeader is no longer needed — removed.
|
||||||
265
lib/screens/profile_screen.dart
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../main.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// Profile / settings tab.
|
||||||
|
class ProfileScreen extends StatelessWidget {
|
||||||
|
const ProfileScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final user = supabase.auth.currentUser;
|
||||||
|
final email = user?.email ?? 'unknown';
|
||||||
|
final createdAt = user?.createdAt;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// ── Header ──
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 200,
|
||||||
|
pinned: true,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Avatar
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 3),
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 44,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
email,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.white.withValues(alpha: 0.95),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (createdAt != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Member since ${_formatDate(createdAt)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Settings list ──
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Account',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
title: 'Change Password',
|
||||||
|
onTap: () => _changePassword(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'App',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: Icons.info_outline,
|
||||||
|
title: 'About',
|
||||||
|
subtitle: 'HW Collector Hub v1.1',
|
||||||
|
onTap: () => _showAbout(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// ── Sign out ──
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout, color: AppColors.error),
|
||||||
|
label: const Text(
|
||||||
|
'Sign Out',
|
||||||
|
style: TextStyle(color: AppColors.error),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: AppColors.error),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'© 2026 HW Collector Hub',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatDate(String iso) {
|
||||||
|
final d = DateTime.tryParse(iso);
|
||||||
|
if (d == null) return iso;
|
||||||
|
const months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||||
|
];
|
||||||
|
return '${months[d.month - 1]} ${d.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _changePassword(BuildContext context) {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Change Password'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'New password',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final pw = controller.text.trim();
|
||||||
|
if (pw.length < 6) {
|
||||||
|
showGlobalSnackBar('Password must be at least 6 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await supabase.auth.updateUser(
|
||||||
|
UserAttributes(password: pw),
|
||||||
|
);
|
||||||
|
if (context.mounted) Navigator.pop(context);
|
||||||
|
showGlobalSnackBar('Password updated!');
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
showGlobalSnackBar(e.message, isError: true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAbout(BuildContext context) {
|
||||||
|
showAboutDialog(
|
||||||
|
context: context,
|
||||||
|
applicationName: 'HW Collector Hub',
|
||||||
|
applicationVersion: '1.1.0',
|
||||||
|
applicationIcon: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.directions_car_filled,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const Text('Track and manage your Hot Wheels collection.'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings tile widget ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _SettingsTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _SettingsTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.orange.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: AppColors.orange, size: 22),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: subtitle != null
|
||||||
|
? Text(subtitle!, style: const TextStyle(fontSize: 12))
|
||||||
|
: null,
|
||||||
|
trailing: const Icon(Icons.chevron_right, color: AppColors.textHint),
|
||||||
|
onTap: onTap,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
449
lib/screens/scan_tab.dart
Normal file
|
|
@ -0,0 +1,449 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../main.dart';
|
||||||
|
import '../scanner_screen.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// The "Scan" tab — quick-access view for scanning / adding cars.
|
||||||
|
class ScanTab extends StatefulWidget {
|
||||||
|
const ScanTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ScanTab> createState() => _ScanTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanTabState extends State<ScanTab> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// ── Illustration ──
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradientSoft,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.qr_code_scanner,
|
||||||
|
size: 56,
|
||||||
|
color: AppColors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
const Text(
|
||||||
|
'Scan a Hot Wheels Car',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Point your camera at the model ID on the\npackaging to instantly add it to your garage.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
|
||||||
|
// ── Scan button (gradient) ──
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 56,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.orange.withValues(alpha: 0.35),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isBusy ? null : _openScanner,
|
||||||
|
icon: _isBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.camera_alt, color: Colors.white),
|
||||||
|
label: Text(
|
||||||
|
_isBusy ? 'Processing…' : 'Open Scanner',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ── Manual entry ──
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isBusy ? null : _manualEntry,
|
||||||
|
icon: const Icon(Icons.keyboard),
|
||||||
|
label: const Text('Enter ID Manually'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openScanner() async {
|
||||||
|
final hwId = await navigatorKey.currentState!.push<String>(
|
||||||
|
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hwId == null || !mounted) return;
|
||||||
|
await _processHwId(hwId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _manualEntry() async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final result = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Enter HW ID'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: const InputDecoration(hintText: 'e.g. JKF21'),
|
||||||
|
onSubmitted: (v) {
|
||||||
|
final val = v.trim().toUpperCase();
|
||||||
|
if (val.isNotEmpty) Navigator.pop(context, val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final val = controller.text.trim().toUpperCase();
|
||||||
|
if (val.isNotEmpty) Navigator.pop(context, val);
|
||||||
|
},
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null || !mounted) return;
|
||||||
|
await _processHwId(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _processHwId(String hwId) async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.select()
|
||||||
|
.eq('hw_id', hwId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
|
||||||
|
if (data != null) {
|
||||||
|
// Already in collection
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
icon: const Icon(Icons.check_circle,
|
||||||
|
color: AppColors.success, size: 48),
|
||||||
|
title: const Text('Already in Garage!'),
|
||||||
|
content:
|
||||||
|
Text('$hwId is already in your collection.'),
|
||||||
|
actions: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Got it'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// New — offer to add
|
||||||
|
final added = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _AddCarDialog(hwId: hwId),
|
||||||
|
);
|
||||||
|
if (added == true) {
|
||||||
|
showGlobalSnackBar('$hwId added to your garage! 🎉');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _isBusy = false);
|
||||||
|
showGlobalSnackBar('DB error: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
||||||
|
class _AddCarDialog extends StatefulWidget {
|
||||||
|
final String hwId;
|
||||||
|
const _AddCarDialog({required this.hwId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddCarDialog> createState() => _AddCarDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddCarDialogState extends State<_AddCarDialog> {
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _seriesController = TextEditingController();
|
||||||
|
final _yearController = TextEditingController();
|
||||||
|
final _notesController = TextEditingController();
|
||||||
|
bool _isAdding = false;
|
||||||
|
File? _pickedImage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_seriesController.dispose();
|
||||||
|
_yearController.dispose();
|
||||||
|
_notesController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage() async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final xFile = await picker.pickImage(
|
||||||
|
source: ImageSource.camera,
|
||||||
|
maxWidth: 800,
|
||||||
|
maxHeight: 800,
|
||||||
|
imageQuality: 60,
|
||||||
|
);
|
||||||
|
if (xFile != null && mounted) {
|
||||||
|
setState(() => _pickedImage = File(xFile.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _quickAdd() async {
|
||||||
|
setState(() => _isAdding = true);
|
||||||
|
try {
|
||||||
|
await supabase.from('hotwheels').insert({
|
||||||
|
'hw_id': widget.hwId,
|
||||||
|
'user_id': supabase.auth.currentUser!.id,
|
||||||
|
});
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isAdding = false);
|
||||||
|
showGlobalSnackBar('Failed to add: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _add() async {
|
||||||
|
setState(() => _isAdding = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final row = <String, dynamic>{
|
||||||
|
'hw_id': widget.hwId,
|
||||||
|
'user_id': supabase.auth.currentUser!.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional fields — only include if filled in.
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
final series = _seriesController.text.trim();
|
||||||
|
final yearStr = _yearController.text.trim();
|
||||||
|
final notes = _notesController.text.trim();
|
||||||
|
|
||||||
|
if (name.isNotEmpty) row['name'] = name;
|
||||||
|
if (series.isNotEmpty) row['series'] = series;
|
||||||
|
if (yearStr.isNotEmpty) {
|
||||||
|
final y = int.tryParse(yearStr);
|
||||||
|
if (y != null) row['year'] = y;
|
||||||
|
}
|
||||||
|
if (notes.isNotEmpty) row['notes'] = notes;
|
||||||
|
|
||||||
|
// Upload image if one was taken.
|
||||||
|
if (_pickedImage != null) {
|
||||||
|
final url = await StorageService.uploadCarImage(
|
||||||
|
file: _pickedImage!,
|
||||||
|
);
|
||||||
|
if (url != null) row['image_url'] = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.from('hotwheels').insert(row);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isAdding = false);
|
||||||
|
showGlobalSnackBar('Failed to add: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: AppColors.brandGradient,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
const Icon(Icons.add, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
title: Text('Add ${widget.hwId}'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// ── Photo picker ──
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _pickImage,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.backgroundLight,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.orange.withValues(alpha: 0.4),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
image: _pickedImage != null
|
||||||
|
? DecorationImage(
|
||||||
|
image: FileImage(_pickedImage!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: _pickedImage == null
|
||||||
|
? Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.add_a_photo,
|
||||||
|
size: 36,
|
||||||
|
color: AppColors.orange.withValues(alpha: 0.6)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Tap to take a photo',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close,
|
||||||
|
size: 16, color: Colors.white),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _pickedImage = null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Car Name',
|
||||||
|
hintText: "e.g. '70 Dodge Charger",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _seriesController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Series',
|
||||||
|
hintText: 'e.g. HW Flames',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _yearController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Year',
|
||||||
|
hintText: 'e.g. 2025',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _notesController,
|
||||||
|
maxLines: 2,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes',
|
||||||
|
hintText: 'Any extra info…',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isAdding ? null : () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _isAdding ? null : _quickAdd,
|
||||||
|
child: const Text('Skip'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isAdding ? null : _add,
|
||||||
|
child: _isAdding
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Add to Garage'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
lib/services/storage_service.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../main.dart';
|
||||||
|
|
||||||
|
/// Handles uploading / deleting car images in Supabase Storage.
|
||||||
|
///
|
||||||
|
/// Bucket: `car-images` (public, but URLs are unguessable)
|
||||||
|
/// Path: `cars/{uuid}.jpg` — random UUID per image.
|
||||||
|
///
|
||||||
|
/// Shared garage — any authenticated user can upload / replace / delete.
|
||||||
|
class StorageService {
|
||||||
|
StorageService._();
|
||||||
|
|
||||||
|
static const _bucket = 'car-images';
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// Upload a photo from [file].
|
||||||
|
///
|
||||||
|
/// If [oldImageUrl] is provided the previous file is deleted first.
|
||||||
|
/// Returns the public URL on success, or `null` on failure.
|
||||||
|
static Future<String?> uploadCarImage({
|
||||||
|
required File file,
|
||||||
|
String? oldImageUrl,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Clean up old image if re-uploading.
|
||||||
|
if (oldImageUrl != null) {
|
||||||
|
await _deleteByUrl(oldImageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = 'cars/${_uuid.v4()}.jpg';
|
||||||
|
|
||||||
|
await supabase.storage.from(_bucket).upload(
|
||||||
|
path,
|
||||||
|
file,
|
||||||
|
fileOptions: const FileOptions(
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the public URL.
|
||||||
|
final url = supabase.storage.from(_bucket).getPublicUrl(path);
|
||||||
|
return url;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('StorageService.uploadCarImage error: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the image at the given public [imageUrl].
|
||||||
|
static Future<void> deleteCarImage(String? imageUrl) async {
|
||||||
|
if (imageUrl == null || imageUrl.isEmpty) return;
|
||||||
|
await _deleteByUrl(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the storage path from a public URL and remove the file.
|
||||||
|
static Future<void> _deleteByUrl(String imageUrl) async {
|
||||||
|
try {
|
||||||
|
// Public URLs look like:
|
||||||
|
// .../storage/v1/object/public/car-images/cars/<uuid>.jpg
|
||||||
|
final marker = '/object/public/$_bucket/';
|
||||||
|
final idx = imageUrl.indexOf(marker);
|
||||||
|
if (idx == -1) return;
|
||||||
|
final path = imageUrl.substring(idx + marker.length);
|
||||||
|
await supabase.storage.from(_bucket).remove([path]);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('StorageService._deleteByUrl error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/theme/app_colors.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Brand colours pulled from the HW Collector Hub email template.
|
||||||
|
class AppColors {
|
||||||
|
AppColors._();
|
||||||
|
|
||||||
|
// ── Primary gradient (header) ──
|
||||||
|
static const Color orange = Color(0xFFF9A11B);
|
||||||
|
static const Color red = Color(0xFFD40000);
|
||||||
|
|
||||||
|
// ── Accent / CTA ──
|
||||||
|
static const Color navy = Color(0xFF003D7A);
|
||||||
|
static const Color navyLight = Color(0xFF0A5BA8);
|
||||||
|
|
||||||
|
// ── Surfaces ──
|
||||||
|
static const Color backgroundLight = Color(0xFFF4F4F4);
|
||||||
|
static const Color cardLight = Color(0xFFFFFFFF);
|
||||||
|
static const Color footerGrey = Color(0xFFEEEEEE);
|
||||||
|
|
||||||
|
// ── Text ──
|
||||||
|
static const Color textPrimary = Color(0xFF333333);
|
||||||
|
static const Color textSecondary = Color(0xFF777777);
|
||||||
|
static const Color textHint = Color(0xFF999999);
|
||||||
|
|
||||||
|
// ── Dark mode surfaces ──
|
||||||
|
static const Color backgroundDark = Color(0xFF1A1A2E);
|
||||||
|
static const Color cardDark = Color(0xFF24243E);
|
||||||
|
static const Color surfaceDark = Color(0xFF2D2D48);
|
||||||
|
|
||||||
|
// ── Utility ──
|
||||||
|
static const Color success = Color(0xFF2ECC71);
|
||||||
|
static const Color error = Color(0xFFE74C3C);
|
||||||
|
|
||||||
|
/// The brand gradient used for headers, buttons, etc.
|
||||||
|
static const LinearGradient brandGradient = LinearGradient(
|
||||||
|
colors: [orange, red],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A subtler version for cards / chips.
|
||||||
|
static const LinearGradient brandGradientSoft = LinearGradient(
|
||||||
|
colors: [Color(0xFFFFF3E0), Color(0xFFFFEBEE)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
);
|
||||||
|
}
|
||||||
347
lib/theme/app_theme.dart
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'app_colors.dart';
|
||||||
|
|
||||||
|
/// Central theme definition for HW Collector Hub.
|
||||||
|
class AppTheme {
|
||||||
|
AppTheme._();
|
||||||
|
|
||||||
|
// ─────────────────────────── LIGHT ───────────────────────────────────
|
||||||
|
static ThemeData get light {
|
||||||
|
final colorScheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: AppColors.orange,
|
||||||
|
primary: AppColors.orange,
|
||||||
|
secondary: AppColors.navy,
|
||||||
|
surface: AppColors.cardLight,
|
||||||
|
error: AppColors.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
scaffoldBackgroundColor: AppColors.backgroundLight,
|
||||||
|
|
||||||
|
// ── AppBar ──
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppColors.textPrimary,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Cards ──
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: AppColors.cardLight,
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: Colors.black12,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Elevated buttons ──
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Outlined buttons ──
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.navy,
|
||||||
|
side: const BorderSide(color: AppColors.navy),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Text buttons ──
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.navy,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Input fields ──
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide:
|
||||||
|
const BorderSide(color: AppColors.navy, width: 2),
|
||||||
|
),
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Bottom Navigation ──
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
indicatorColor: AppColors.orange.withValues(alpha: 0.15),
|
||||||
|
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.orange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return const IconThemeData(color: AppColors.orange);
|
||||||
|
}
|
||||||
|
return const IconThemeData(color: AppColors.textSecondary);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── FAB ──
|
||||||
|
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: AppColors.orange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: StadiumBorder(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Dialogs ──
|
||||||
|
dialogTheme: DialogThemeData(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Snackbar ──
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
contentTextStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Divider ──
|
||||||
|
dividerTheme: DividerThemeData(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
thickness: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── DARK ────────────────────────────────────
|
||||||
|
static ThemeData get dark {
|
||||||
|
final colorScheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: AppColors.orange,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primary: AppColors.orange,
|
||||||
|
secondary: AppColors.navyLight,
|
||||||
|
surface: AppColors.cardDark,
|
||||||
|
error: AppColors.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
scaffoldBackgroundColor: AppColors.backgroundDark,
|
||||||
|
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: AppColors.cardDark,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: Colors.black26,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
),
|
||||||
|
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.orange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.orange,
|
||||||
|
side: const BorderSide(color: AppColors.orange),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surfaceDark,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide:
|
||||||
|
const BorderSide(color: AppColors.orange, width: 2),
|
||||||
|
),
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: AppColors.cardDark,
|
||||||
|
indicatorColor: AppColors.orange.withValues(alpha: 0.2),
|
||||||
|
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.orange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return const IconThemeData(color: AppColors.orange);
|
||||||
|
}
|
||||||
|
return const IconThemeData(color: AppColors.textHint);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: AppColors.orange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: StadiumBorder(),
|
||||||
|
),
|
||||||
|
|
||||||
|
dialogTheme: DialogThemeData(
|
||||||
|
backgroundColor: AppColors.cardDark,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
fontFamily: 'Poppins',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
dividerTheme: const DividerThemeData(
|
||||||
|
color: AppColors.surfaceDark,
|
||||||
|
thickness: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
lib/widgets/car_card.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// A styled card for displaying a single Hot Wheels car in the garage.
|
||||||
|
class CarCard extends StatelessWidget {
|
||||||
|
final String hwId;
|
||||||
|
final String? name;
|
||||||
|
final String? series;
|
||||||
|
final int? year;
|
||||||
|
final String? color;
|
||||||
|
final String? imageUrl;
|
||||||
|
final DateTime? addedAt;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const CarCard({
|
||||||
|
super.key,
|
||||||
|
required this.hwId,
|
||||||
|
this.name,
|
||||||
|
this.series,
|
||||||
|
this.year,
|
||||||
|
this.color,
|
||||||
|
this.imageUrl,
|
||||||
|
this.addedAt,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── Image / placeholder area ──
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 16 / 10,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: isDark
|
||||||
|
? LinearGradient(
|
||||||
|
colors: [
|
||||||
|
AppColors.surfaceDark,
|
||||||
|
AppColors.cardDark,
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
)
|
||||||
|
: AppColors.brandGradientSoft,
|
||||||
|
),
|
||||||
|
child: imageUrl != null && imageUrl!.isNotEmpty
|
||||||
|
? Image.network(
|
||||||
|
imageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
|
_PlaceholderIcon(isDark: isDark),
|
||||||
|
)
|
||||||
|
: _PlaceholderIcon(isDark: isDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Info area ──
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// HW ID badge
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.orange.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
hwId,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.orange,
|
||||||
|
letterSpacing: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (name != null && name!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
name!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (series != null && series!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
series!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (year != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today,
|
||||||
|
size: 12, color: AppColors.textHint),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'$year',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder icon when no image is available.
|
||||||
|
class _PlaceholderIcon extends StatelessWidget {
|
||||||
|
final bool isDark;
|
||||||
|
const _PlaceholderIcon({required this.isDark});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.directions_car_filled,
|
||||||
|
size: 48,
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.15)
|
||||||
|
: AppColors.orange.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,11 +8,13 @@ import Foundation
|
||||||
import app_links
|
import app_links
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
224
pubspec.lock
|
|
@ -9,6 +9,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
ansicolor:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ansicolor
|
||||||
|
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
app_links:
|
app_links:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -41,6 +49,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -57,6 +81,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
cached_network_image:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cached_network_image
|
||||||
|
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.1"
|
||||||
|
cached_network_image_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cached_network_image_platform_interface
|
||||||
|
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
|
cached_network_image_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cached_network_image_web
|
||||||
|
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
camera:
|
camera:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -105,6 +153,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -153,6 +217,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -246,6 +318,22 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_cache_manager:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_cache_manager
|
||||||
|
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.1"
|
||||||
|
flutter_launcher_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_launcher_icons
|
||||||
|
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.4"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -254,6 +342,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_native_splash:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_native_splash
|
||||||
|
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.7"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -328,6 +424,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -344,6 +448,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
image_picker:
|
image_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -408,6 +520,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.2"
|
version: "0.2.2"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.2"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.11.0"
|
||||||
jwt_decode:
|
jwt_decode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -504,6 +632,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.3.0"
|
||||||
|
octo_image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: octo_image
|
||||||
|
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -608,6 +744,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.1"
|
version: "0.2.1"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -632,6 +776,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
postgrest:
|
postgrest:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -741,6 +893,46 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
|
sqflite:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_android
|
||||||
|
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2+2"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
sqflite_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_darwin
|
||||||
|
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_platform_interface
|
||||||
|
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -797,6 +989,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.12.0"
|
version: "2.12.0"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -821,6 +1021,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_io
|
||||||
|
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -885,6 +1093,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.5"
|
version: "3.1.5"
|
||||||
|
uuid:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -933,6 +1149,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
63
pubspec.yaml
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.1.0+2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|
@ -39,6 +39,11 @@ dependencies:
|
||||||
google_mlkit_text_recognition: ^0.14.0
|
google_mlkit_text_recognition: ^0.14.0
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
|
flutter_native_splash: ^2.4.6
|
||||||
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
intl: ^0.20.2
|
||||||
|
cached_network_image: ^3.4.1
|
||||||
|
uuid: ^4.5.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -62,33 +67,35 @@ flutter:
|
||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
assets:
|
||||||
# assets:
|
- assets/fonts/
|
||||||
# - images/a_dot_burr.jpeg
|
- assets/img/
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
fonts:
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
- family: Poppins
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/Poppins-Regular.ttf
|
||||||
|
weight: 400
|
||||||
|
- asset: assets/fonts/Poppins-Medium.ttf
|
||||||
|
weight: 500
|
||||||
|
- asset: assets/fonts/Poppins-SemiBold.ttf
|
||||||
|
weight: 600
|
||||||
|
- asset: assets/fonts/Poppins-Bold.ttf
|
||||||
|
weight: 700
|
||||||
|
|
||||||
# For details regarding adding assets from package dependencies, see
|
# ── Splash Screen config ──
|
||||||
# https://flutter.dev/to/asset-from-package
|
flutter_native_splash:
|
||||||
|
color: "#f9a11b"
|
||||||
|
color_dark: "#1a1a2e"
|
||||||
|
android_12:
|
||||||
|
color: "#f9a11b"
|
||||||
|
color_dark: "#1a1a2e"
|
||||||
|
|
||||||
# To add custom fonts to your application, add a fonts section here,
|
# ── Launcher Icon config ──
|
||||||
# in this "flutter" section. Each entry in this list should have a
|
flutter_launcher_icons:
|
||||||
# "family" key with the font family name, and a "fonts" key with a
|
android: true
|
||||||
# list giving the asset and other descriptors for the font. For
|
ios: true
|
||||||
# example:
|
image_path: "assets/icon/app_icon.png"
|
||||||
# fonts:
|
image_path_ios: "assets/icon/app_icon_ios.png"
|
||||||
# - family: Schyler
|
adaptive_icon_background: "#f9a11b"
|
||||||
# fonts:
|
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
|
||||||
# - asset: fonts/Schyler-Regular.ttf
|
|
||||||
# - asset: fonts/Schyler-Italic.ttf
|
|
||||||
# style: italic
|
|
||||||
# - family: Trajan Pro
|
|
||||||
# fonts:
|
|
||||||
# - asset: fonts/TrajanPro.ttf
|
|
||||||
# - asset: fonts/TrajanPro_Bold.ttf
|
|
||||||
# weight: 700
|
|
||||||
#
|
|
||||||
# For details regarding fonts from package dependencies,
|
|
||||||
# see https://flutter.dev/to/font-from-package
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html><html><head>
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<!--
|
<!--
|
||||||
If you are serving your web app in a path other than the root, change the
|
If you are serving your web app in a path other than the root, change the
|
||||||
href value below to reflect the base path you are serving from.
|
href value below to reflect the base path you are serving from.
|
||||||
|
|
@ -27,10 +25,82 @@
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png">
|
||||||
|
|
||||||
<title>hwhub</title>
|
<title>hwhub</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<style id="splash-screen-style">
|
||||||
|
html {
|
||||||
|
height: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background-color: #f9a11b;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
-ms-transform: translate(-50%, -50%);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contain {
|
||||||
|
display:block;
|
||||||
|
width:100%; height:100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stretch {
|
||||||
|
display:block;
|
||||||
|
width:100%; height:100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
display:block;
|
||||||
|
width:100%; height:100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
-ms-transform: translate(-50%, 0);
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomLeft {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomRight {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script id="splash-screen-script">
|
||||||
|
function removeSplashFromWeb() {
|
||||||
|
document.getElementById("splash")?.remove();
|
||||||
|
document.getElementById("splash-branding")?.remove();
|
||||||
|
document.body.style.background = "transparent";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<!--
|
||||||
|
|
@ -41,6 +111,7 @@
|
||||||
For more details:
|
For more details:
|
||||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||||
-->
|
-->
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async=""></script>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
</body></html>
|
||||||