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
This commit is contained in:
Lukas Müllner 2026-02-24 07:46:06 +01:00
parent 7635b0d85b
commit 91f25e0cef
80 changed files with 3176 additions and 601 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View 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>

View file

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

View file

@ -0,0 +1,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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#f9a11b</color>
</resources>

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/icon/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 MiB

BIN
assets/img/login_bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

View file

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

View file

@ -1,7 +1,7 @@
<?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">
<plist version="1.0">
<dict>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
@ -66,5 +66,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View file

@ -1,6 +1,12 @@
import 'package:flutter/material.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
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
@ -49,22 +55,19 @@ class HWHubApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'HW Hub',
title: 'HW Collector Hub',
debugShowCheckedModeBanner: false,
navigatorKey: navigatorKey,
scaffoldMessengerKey: scaffoldMessengerKey,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
home: const AuthGate(),
);
}
}
// 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 {
const AuthGate({super.key});
@ -80,12 +83,8 @@ class _AuthGateState extends State<AuthGate> {
void initState() {
super.initState();
// 1. Check for an existing session on cold start.
_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(
(AuthState authState) {
if (!mounted) return;
@ -94,13 +93,10 @@ class _AuthGateState extends State<AuthGate> {
final isLoggedIn = authState.session != null;
_session = authState.session;
// Only rebuild the tree when login state actually flips.
if (wasLoggedIn != isLoggedIn) {
setState(() {});
}
// If the user just clicked a password-reset link from their email,
// Supabase fires a PASSWORD_RECOVERY event.
if (authState.event == AuthChangeEvent.passwordRecovery) {
_showResetPasswordDialog();
}
@ -110,12 +106,9 @@ class _AuthGateState extends State<AuthGate> {
},
);
// Done checking remove the loading state.
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 {
await showDialog<void>(
context: navigatorKey.currentContext!,
@ -131,12 +124,7 @@ class _AuthGateState extends State<AuthGate> {
body: Center(child: CircularProgressIndicator()),
);
}
if (_session != null) {
return const HomeScreen();
}
return const LoginScreen();
return _session != null ? const HomeShell() : const LoginScreen();
}
}
@ -188,10 +176,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
content: TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'New password',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'New password'),
onSubmitted: (_) => _save(),
),
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'),
),
],
);
}
}

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:camera/camera.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
/// and extract the hw_id (e.g. "JKF21").
@ -127,13 +128,24 @@ class _ScannerScreenState extends State<ScannerScreen> {
@override
Widget build(BuildContext context) {
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(
children: [
// Camera preview
Expanded(
child: _cameraReady
? ClipRect(
? Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.cover,
@ -144,6 +156,32 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
),
),
),
// 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()),
),
@ -152,21 +190,47 @@ class _ScannerScreenState extends State<ScannerScreen> {
if (_lastDetected != null)
Container(
width: double.infinity,
color: Colors.green.shade50,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
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(
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),
Expanded(
child: Text(
'Detected: $_lastDetected',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(
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),
child: Row(
children: [
// Manual entry button
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.keyboard),
label: const Text('Enter manually'),
label: const Text('Manual'),
onPressed: () => _showManualEntry(context),
),
),
const SizedBox(width: 12),
// Capture / scan button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: _isBusy ? null : AppColors.brandGradient,
borderRadius: BorderRadius.circular(50),
),
child: ElevatedButton.icon(
icon: _isBusy
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5, color: Colors.white),
)
: const Icon(Icons.camera_alt),
label: Text(_isBusy ? 'Scanning...' : 'Scan'),
: 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,
decoration: const InputDecoration(
hintText: 'e.g. JKF21',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _submit(),
),

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

View 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',
),
],
),
);
}
}

View 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.

View 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
View 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'),
),
],
);
}
}

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

View file

@ -8,11 +8,13 @@ import Foundation
import app_links
import file_selector_macos
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View file

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
app_links:
dependency: transitive
description:
@ -41,6 +49,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -57,6 +81,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -105,6 +153,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -153,6 +217,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@ -246,6 +318,22 @@ packages:
description: flutter
source: sdk
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:
dependency: "direct dev"
description:
@ -254,6 +342,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -328,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
@ -344,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker:
dependency: "direct main"
description:
@ -408,6 +520,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -504,6 +632,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -608,6 +744,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@ -632,6 +776,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
postgrest:
dependency: transitive
description:
@ -741,6 +893,46 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -797,6 +989,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.12.0"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@ -821,6 +1021,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -885,6 +1093,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -933,6 +1149,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:

View file

@ -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
# 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.
version: 1.0.0+1
version: 1.1.0+2
environment:
sdk: ^3.11.0
@ -39,6 +39,11 @@ dependencies:
google_mlkit_text_recognition: ^0.14.0
image_picker: ^1.1.2
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:
flutter_test:
@ -62,33 +67,35 @@ flutter:
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/fonts/
- assets/img/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
fonts:
- 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
# https://flutter.dev/to/asset-from-package
# ── Splash Screen config ──
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,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - 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
# ── Launcher Icon config ──
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/icon/app_icon.png"
image_path_ios: "assets/icon/app_icon_ios.png"
adaptive_icon_background: "#f9a11b"
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"

View file

@ -1,6 +1,4 @@
<!DOCTYPE html>
<html>
<head>
<!DOCTYPE html><html><head>
<!--
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.
@ -27,10 +25,82 @@
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="icon" type="image/png" href="favicon.png">
<title>hwhub</title>
<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>
<body>
<!--
@ -41,6 +111,7 @@
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
<script src="flutter_bootstrap.js" async=""></script>
</body></html>