merge: review fixes and stability improvements

This commit is contained in:
Lukas Müllner 2026-03-09 18:32:45 +01:00
commit 3153644398
121 changed files with 4486 additions and 991 deletions

View file

@ -0,0 +1,5 @@
{
"SUPABASE_URL": "https://your-project.supabase.co",
"SUPABASE_ANON_KEY": "your_anon_key",
"SUPABASE_USE_PKCE": "true"
}

15
.gitignore vendored
View file

@ -44,5 +44,16 @@ app.*.map.json
/android/app/profile /android/app/profile
/android/app/release /android/app/release
TPB.md /TPB.md
supabase_migration.sql /supabase_migration.sql
/TPB_APP_CHECKLIST.md
# Local run configuration with secrets
/.vscode/launch.json
/.env/flutter_defines.json
# Local testing artifacts
/flutter_*.png
/devtools_options.yaml
FINDINGS.md

44
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,44 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Flutter Build APK (Release)",
"type": "shell",
"command": "flutter",
"args": [
"build",
"apk",
"--release",
"--dart-define-from-file=.env/flutter_defines.json"
],
"group": "build",
"problemMatcher": []
},
{
"label": "Flutter Build App Bundle (Release)",
"type": "shell",
"command": "flutter",
"args": [
"build",
"appbundle",
"--release",
"--dart-define-from-file=.env/flutter_defines.json"
],
"group": "build",
"problemMatcher": []
},
{
"label": "Flutter Build iOS IPA (Release)",
"type": "shell",
"command": "flutter",
"args": [
"build",
"ipa",
"--release",
"--dart-define-from-file=.env/flutter_defines.json"
],
"group": "build",
"problemMatcher": []
}
]
}

74
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,74 @@
# Contributing Guide
Thanks for contributing to car64.
## Workflow
- Create a branch from `main`:
- `feature/<short-name>` for features
- `fix/<short-name>` for bug fixes
- `docs/<short-name>` for documentation updates
- Keep pull requests focused and small when possible.
- Write clear commit messages (Conventional Commit style is preferred).
## Development Setup
1. Install Flutter stable and run `flutter doctor`.
2. Copy `.env/flutter_defines.example.json` to `.env/flutter_defines.json`.
3. Fill in your own Supabase config values.
4. Run:
```bash
flutter pub get
flutter analyze
```
## Code Style
- Follow existing project style and naming patterns.
- Prefer small, explicit methods over deeply nested logic.
- Preserve backend contract names (`hotwheels`, RPC names, etc.) unless migration is intentional.
## Testing & Validation
Before opening a PR:
- Run `flutter analyze`
- Run available tests
- Manually test affected flows (scanner, collections, auth, storage upload)
## Pull Request Checklist
- [ ] Scope is clear and focused
- [ ] Analyzer passes
- [ ] User-facing strings are intentional and reviewed
- [ ] No secrets/config values were committed
- [ ] README/docs updated if behavior changed
## Commit Message Examples
Preferred format:
```text
type(scope): short summary
```
Common types used in this repository:
- `feat`: new feature
- `fix`: bug fix
- `perf`: performance improvement
- `docs`: documentation-only change
- `refactor`: code cleanup without behavior change
- `test`: tests added/updated
- `chore`: maintenance/tooling/config updates
Examples:
- `feat(scanner): add adaptive cooldown for repeated OCR misses`
- `fix(garage): prevent duplicate copy into target collection`
- `perf(collections): coalesce overlapping refresh requests`
- `docs(readme): add VS Code release build task usage`
- `refactor(auth): simplify session guard flow in AuthGate`
- `test(utils): cover scanner id extraction edge cases`
- `chore(vscode): add release build tasks for apk and appbundle`

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lukas Müllner @derkauzigekoala
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

201
README.md
View file

@ -1,17 +1,198 @@
# hwhub <p align="center">
<img src="assets/icon/app_icon.png" alt="car64 logo" width="120" />
</p>
A new Flutter project. <h1 align="center">car64</h1>
<p align="center">
A modern Flutter + Supabase app for tracking and managing die-cast car collections.
</p>
---
## Table of Contents
- [Overview](#overview)
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Prerequisites](#prerequisites)
- [Configuration](#configuration)
- [Getting Started](#getting-started)
- [VS Code Workflows](#vs-code-workflows)
- [Project Structure (high level)](#project-structure-high-level)
- [Troubleshooting](#troubleshooting)
- [Release Checklist](#release-checklist)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [Security Notes](#security-notes)
- [License](#license)
- [Reference](#reference)
## Overview
car64 helps collectors scan model IDs, organize personal and shared collections, and keep a clean catalog with community validation/reporting flows.
## Features
- Fast scan workflow (camera OCR + manual entry fallback)
- Multi-collection support with member roles (owner/member/viewer)
- Private image storage with signed URL access
- Collection collaboration and member management
- Community validation and issue reporting for catalog entries
- Profile/settings flows including password updates and report tracking
## Tech Stack
- Flutter (Material 3)
- Supabase (Auth, PostgREST, Storage, RPC)
- Shared Preferences (local settings)
- Google ML Kit Text Recognition (scanner)
## Prerequisites
- Flutter SDK (stable)
- A Supabase project
- For iOS builds: macOS + Xcode
## Configuration
Supabase config is required at runtime/build time (no embedded fallback values).
### Option A: local defines file (recommended)
Use:
- `.env/flutter_defines.json` (local, ignored by git)
- [.env/flutter_defines.example.json](.env/flutter_defines.example.json) (tracked template)
Expected shape:
```json
{
"SUPABASE_URL": "https://your-project.supabase.co",
"SUPABASE_ANON_KEY": "your_anon_key",
"SUPABASE_USE_PKCE": "true"
}
```
### Option B: direct dart-define flags
```bash
flutter run \
--dart-define=SUPABASE_URL=https://your-project.supabase.co \
--dart-define=SUPABASE_ANON_KEY=your_anon_key \
--dart-define=SUPABASE_USE_PKCE=true
```
## Getting Started ## Getting Started
This project is a starting point for a Flutter application. 1. Install dependencies
A few resources to get you started if this is your first Flutter project: ```bash
flutter pub get
```
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) 2. Run analyze
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) ```bash
flutter analyze
```
3. Launch app
```bash
flutter run
```
## VS Code Workflows
### Run / Debug (`launch.json`)
Use **Run and Debug** with your local launch config.
- `Flutter (Supabase Local - Debug)`
- `Flutter (Supabase Local - Profile)`
- `Flutter (Supabase Local - Release)`
These configurations read:
`--dart-define-from-file=.env/flutter_defines.json`
### Build Tasks (`tasks.json`)
Use **Terminal → Run Task**:
- `Flutter Build APK (Release)`
- `Flutter Build App Bundle (Release)`
- `Flutter Build iOS IPA (Release)`
Task definition file: [.vscode/tasks.json](.vscode/tasks.json)
## Project Structure (high level)
- `lib/screens/` UI screens and flows
- `lib/services/` Supabase integration/services
- `lib/widgets/` reusable UI components
- `lib/utils/` helpers and formatting utilities
- `lib/theme/` app theme and colors
## Troubleshooting
### App fails at startup with Supabase config error
- Ensure `.env/flutter_defines.json` exists locally.
- Confirm all required keys are present:
- `SUPABASE_URL`
- `SUPABASE_ANON_KEY`
- `SUPABASE_USE_PKCE`
### Build task works but app cannot connect to backend
- Verify the Supabase URL/key pair belong to the same project.
- Check Supabase RLS policies and RPC permissions.
### iOS IPA task fails on Windows
- `flutter build ipa` requires macOS + Xcode.
## Release Checklist
- [ ] `flutter pub get`
- [ ] `flutter analyze`
- [ ] Manual smoke test on Android
- [ ] Manual smoke test on iOS
- [ ] Confirm `.env/flutter_defines.json` points to production Supabase
- [ ] Build Android `appbundle`
- [ ] Build iOS `ipa`
- [ ] Verify auth, scan flow, collections, and upload flows
## Roadmap
- [ ] Optional dark/light theme toggle in settings
- [ ] Extended scanner confidence hints and retry UX
- [ ] Bulk actions and better collection analytics
- [ ] Improved offline behavior for low-connectivity sessions
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for branching strategy, commit message rules, and PR guidelines.
## Security Notes
- Supabase anon keys are intentionally client-side, but RLS and RPC permissions must be strict.
- Sensitive local config files are git-ignored.
- User-facing errors are sanitized and shown via global overlays.
For reporting vulnerabilities, see [SECURITY.md](SECURITY.md).
## License
This project is licensed under the MIT License.
See [LICENSE](LICENSE) for details.
## Reference
- Contributor guidelines: [CONTRIBUTING.md](CONTRIBUTING.md)
- Security policy: [SECURITY.md](SECURITY.md)
- DB schema export query pack: [db/export_schema.sql](db/export_schema.sql)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

31
SECURITY.md Normal file
View file

@ -0,0 +1,31 @@
# Security Policy
## Supported Versions
This project currently supports the latest active branch in this repository.
## Reporting a Vulnerability
If you discover a security issue, please do not open a public issue with exploit details.
Preferred process:
1. Share a private report with:
- A clear description of the issue
- Reproduction steps
- Impact assessment
- Suggested fix (if available)
2. Allow time for triage and remediation before public disclosure.
## Scope Notes
- Supabase anon keys are client-side by design and are not secret credentials.
- Real protection depends on strict RLS policies, RPC authorization, and server-side validation.
- Local config files with runtime values should stay out of version control.
## Recommended Hardening
- Keep Supabase keys in local/CI `dart-define` configuration only.
- Rotate keys when moving between environments or if misuse is suspected.
- Audit RLS policies after every schema/function change.
- Sanitize user-facing error messages (avoid leaking backend internals).

View file

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <application
android:label="HW Collector Hub" android:label="car64"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View file

@ -3,4 +3,7 @@
<item> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/> <bitmap android:gravity="fill" android:src="@drawable/background"/>
</item> </item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list> </layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

View file

@ -3,4 +3,7 @@
<item> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/> <bitmap android:gravity="fill" android:src="@drawable/background"/>
</item> </item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list> </layer-list>

View file

@ -3,4 +3,7 @@
<item> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/> <bitmap android:gravity="fill" android:src="@drawable/background"/>
</item> </item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list> </layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

View file

@ -3,4 +3,7 @@
<item> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/> <bitmap android:gravity="fill" android:src="@drawable/background"/>
</item> </item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list> </layer-list>

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -7,6 +7,7 @@
<item name="android:windowDrawsSystemBarBackgrounds">false</item> <item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#1a1a2e</item> <item name="android:windowSplashScreenBackground">#1a1a2e</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your

View file

@ -7,6 +7,7 @@
<item name="android:windowDrawsSystemBarBackgrounds">false</item> <item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#f9a11b</item> <item name="android:windowSplashScreenBackground">#f9a11b</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 748 KiB

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 433 KiB

114
db/export_schema.sql Normal file
View file

@ -0,0 +1,114 @@
-- Run in Supabase SQL Editor or psql and export results as CSV/JSON.
-- 1) App tables and columns (public only)
select
c.table_name,
c.ordinal_position,
c.column_name,
c.data_type,
c.udt_name,
c.is_nullable,
c.column_default
from information_schema.columns c
join information_schema.tables t
on t.table_schema = c.table_schema
and t.table_name = c.table_name
where c.table_schema = 'public'
and t.table_type = 'BASE TABLE'
order by c.table_name, c.ordinal_position;
-- 2) Constraints
select
rel.relname as table_name,
con.conname as constraint_name,
case con.contype
when 'p' then 'PRIMARY KEY'
when 'u' then 'UNIQUE'
when 'f' then 'FOREIGN KEY'
when 'c' then 'CHECK'
else con.contype::text
end as constraint_type,
pg_get_constraintdef(con.oid, true) as definition
from pg_constraint con
join pg_class rel on rel.oid = con.conrelid
join pg_namespace n on n.oid = rel.relnamespace
where n.nspname = 'public'
order by rel.relname, con.conname;
-- 3) Indexes
select
tablename,
indexname,
indexdef
from pg_indexes
where schemaname = 'public'
order by tablename, indexname;
-- 4) Views
select
c.relname as view_name,
case c.relkind when 'v' then 'VIEW' when 'm' then 'MATERIALIZED VIEW' end as view_type,
pg_get_viewdef(c.oid, true) as definition
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('v', 'm')
order by c.relname;
-- 5) Functions / Procedures
select
p.proname as routine_name,
case p.prokind when 'p' then 'PROCEDURE' else 'FUNCTION' end as routine_type,
l.lanname as language,
pg_get_function_identity_arguments(p.oid) as args,
pg_get_functiondef(p.oid) as definition
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
join pg_language l on l.oid = p.prolang
where n.nspname = 'public'
order by p.proname;
-- 6) Triggers
select
c.relname as table_name,
t.tgname as trigger_name,
pg_get_triggerdef(t.oid, true) as definition
from pg_trigger t
join pg_class c on c.oid = t.tgrelid
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and not t.tgisinternal
order by c.relname, t.tgname;
-- 7) RLS status
select
c.relname as table_name,
c.relrowsecurity as rls_enabled,
c.relforcerowsecurity as rls_forced
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
order by c.relname;
-- 8) RLS policies
select
tablename,
policyname,
permissive,
roles,
cmd,
qual,
with_check
from pg_policies
where schemaname = 'public'
order by tablename, policyname;
-- 9) Grants (tables)
select
table_name,
privilege_type,
grantee
from information_schema.role_table_grants
where table_schema = 'public'
order by table_name, grantee, privilege_type;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -5,15 +5,48 @@
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark.png",
"idiom" : "universal",
"scale" : "1x"
},
{ {
"filename" : "LaunchImage@2x.png", "filename" : "LaunchImage@2x.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "2x" "scale" : "2x"
}, },
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{ {
"filename" : "LaunchImage@3x.png", "filename" : "LaunchImage@3x.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "3x" "scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark@3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

View file

@ -38,7 +38,7 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="168" height="185"/> <image name="LaunchImage" width="500" height="500"/>
<image name="LaunchBackground" width="1" height="1"/> <image name="LaunchBackground" width="1" height="1"/>
</resources> </resources>
</document> </document>

View file

@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>HW Collector Hub</string> <string>car64</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>HW Collector Hub</string> <string>car64</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View file

@ -1,26 +1,55 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'services/collection_service.dart';
import 'services/main_collection_sync.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/home_shell.dart'; import 'screens/home_shell.dart';
import 'utils/error_utils.dart';
import 'utils/preferences_utils.dart';
// Re-export so other files can `import '../main.dart'` for these. // Re-export so other files can `import '../main.dart'` for these.
export 'package:supabase_flutter/supabase_flutter.dart' export 'package:supabase_flutter/supabase_flutter.dart'
show AuthException, UserAttributes; show AuthException, UserAttributes;
// Supabase credentials // Supabase credentials
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co'; const _supabaseUrl = String.fromEnvironment(
const _supabaseAnonKey = 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL'; 'SUPABASE_URL',
defaultValue: '',
);
const _supabaseAnonKey = String.fromEnvironment(
'SUPABASE_ANON_KEY',
defaultValue: '',
);
const _usePkceAuthFlow = bool.fromEnvironment(
'SUPABASE_USE_PKCE',
defaultValue: true,
);
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
if (_supabaseUrl.trim().isEmpty || _supabaseAnonKey.trim().isEmpty) {
throw StateError(
'Missing Supabase configuration. Provide --dart-define=SUPABASE_URL and '
'--dart-define=SUPABASE_ANON_KEY.',
);
}
await Supabase.initialize( await Supabase.initialize(
url: _supabaseUrl, url: _supabaseUrl,
anonKey: _supabaseAnonKey, anonKey: _supabaseAnonKey,
authOptions: FlutterAuthClientOptions(
authFlowType: _usePkceAuthFlow
? AuthFlowType.pkce
: AuthFlowType.implicit,
),
); );
runApp(const HWHubApp()); runApp(const Car64App());
} }
/// Convenience accessor used throughout the app. /// Convenience accessor used throughout the app.
@ -29,33 +58,133 @@ final supabase = Supabase.instance.client;
/// Global keys so dialogs & snackbars survive widget-tree rebuilds. /// Global keys so dialogs & snackbars survive widget-tree rebuilds.
final navigatorKey = GlobalKey<NavigatorState>(); final navigatorKey = GlobalKey<NavigatorState>();
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
OverlayEntry? _activeMessageOverlay;
Timer? _activeMessageOverlayTimer;
enum GlobalMessageType { info, success, error }
/// Show a snackbar safely through the global key. /// Show a snackbar safely through the global key.
void showGlobalSnackBar(String message, {bool isError = false}) { void showGlobalSnackBar(String message, {bool isError = false}) {
scaffoldMessengerKey.currentState?.showSnackBar( _showGlobalMessageOverlay(
SnackBar( message,
content: Text(message), type: isError ? GlobalMessageType.error : GlobalMessageType.info,
backgroundColor: isError ? Colors.red : null, );
), }
void showGlobalSuccess(String message) {
_showGlobalMessageOverlay(message, type: GlobalMessageType.success);
}
void showGlobalInfo(String message) {
_showGlobalMessageOverlay(message, type: GlobalMessageType.info);
}
void _showGlobalMessageOverlay(
String message, {
required GlobalMessageType type,
}) {
final overlay = navigatorKey.currentState?.overlay;
if (overlay == null) return;
_activeMessageOverlayTimer?.cancel();
_activeMessageOverlay?.remove();
final backgroundColor = switch (type) {
GlobalMessageType.error => Colors.red,
GlobalMessageType.success => Colors.green,
GlobalMessageType.info => const Color(0xFF1F2937),
};
final leadingIcon = switch (type) {
GlobalMessageType.error => Icons.error_outline,
GlobalMessageType.success => Icons.check_circle_outline,
GlobalMessageType.info => Icons.info_outline,
};
_activeMessageOverlay = OverlayEntry(
builder: (context) {
final topPadding = MediaQuery.of(context).padding.top;
return Positioned(
top: topPadding + 12,
left: 12,
right: 12,
child: Material(
color: Colors.transparent,
child: IgnorePointer(
ignoring: true,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 8,
offset: Offset(0, 3),
),
],
),
child: Row(
children: [
Icon(leadingIcon, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(color: Colors.white),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
);
},
);
overlay.insert(_activeMessageOverlay!);
_activeMessageOverlayTimer = Timer(const Duration(seconds: 4), () {
_activeMessageOverlay?.remove();
_activeMessageOverlay = null;
_activeMessageOverlayTimer = null;
});
}
void showGlobalError(
Object error, {
String fallback = 'Something went wrong. Please try again.',
}) {
logError('ui', error);
showGlobalSnackBar(
userMessageForError(error, fallback: fallback),
isError: true,
); );
} }
/// Show a dialog safely through the global navigator key. /// Show a dialog safely through the global navigator key.
Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) { Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
final context = navigatorKey.currentContext;
if (context == null) {
return Future<T?>.value(null);
}
return showDialog<T>( return showDialog<T>(
context: navigatorKey.currentContext!, context: context,
builder: builder, builder: builder,
); );
} }
// Root App Widget // Root App Widget
class HWHubApp extends StatelessWidget { class Car64App extends StatelessWidget {
const HWHubApp({super.key}); const Car64App({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'HW Collector Hub', title: 'car64',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
scaffoldMessengerKey: scaffoldMessengerKey, scaffoldMessengerKey: scaffoldMessengerKey,
@ -77,15 +206,19 @@ class AuthGate extends StatefulWidget {
class _AuthGateState extends State<AuthGate> { class _AuthGateState extends State<AuthGate> {
bool _isLoading = true; bool _isLoading = true;
bool _isInPasswordRecoveryFlow = false;
Session? _session; Session? _session;
String? _lastEnsuredUserId;
StreamSubscription<AuthState>? _authStateSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_session = supabase.auth.currentSession; _session = supabase.auth.currentSession;
_ensureDefaultCollectionIfNeeded();
supabase.auth.onAuthStateChange.listen( _authStateSubscription = supabase.auth.onAuthStateChange.listen(
(AuthState authState) { (AuthState authState) {
if (!mounted) return; if (!mounted) return;
@ -97,24 +230,113 @@ class _AuthGateState extends State<AuthGate> {
setState(() {}); setState(() {});
} }
_ensureDefaultCollectionIfNeeded();
if (authState.event == AuthChangeEvent.passwordRecovery) { if (authState.event == AuthChangeEvent.passwordRecovery) {
setState(() => _isInPasswordRecoveryFlow = true);
_showResetPasswordDialog(); _showResetPasswordDialog();
} }
}, },
onError: (error) { onError: (error) {
showGlobalSnackBar('Auth error: $error', isError: true); showGlobalError(
error,
fallback: 'Authentication error. Please sign in again.',
);
}, },
); );
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@override
void dispose() {
_authStateSubscription?.cancel();
super.dispose();
}
Future<void> _ensureDefaultCollectionIfNeeded() async {
final userId = _session?.user.id;
// Intentional policy: attempt bootstrap once per user per app session.
// This avoids repeated retries/toasts when backend issues are transient.
// Users can recover on next session or via a manual retry entry point.
if (userId == null || userId == _lastEnsuredUserId) return;
_lastEnsuredUserId = userId;
try {
final defaultCollectionId = await CollectionService.ensureDefaultCollection();
await _ensureMainCollectionPreference(defaultCollectionId);
} catch (e) {
showGlobalError(
e,
fallback: 'Collection setup failed. Please try again.',
);
}
}
Future<void> _ensureMainCollectionPreference(String? defaultCollectionId) async {
final userId = _session?.user.id;
if (userId == null) return;
final prefs = await SharedPreferences.getInstance();
final persisted = await readActiveCollectionId(prefs, userId: userId);
Future<bool> hasMembership(String collectionId) async {
final membership = await supabase
.from('collection_members')
.select('id')
.eq('user_id', userId)
.eq('collection_id', collectionId)
.maybeSingle();
return membership != null;
}
if (persisted != null && await hasMembership(persisted)) {
return;
}
String? nextActiveId;
if (defaultCollectionId != null && await hasMembership(defaultCollectionId)) {
nextActiveId = defaultCollectionId;
} else {
final firstMembership = await supabase
.from('collection_members')
.select('collection_id')
.eq('user_id', userId)
.limit(1);
if (firstMembership.isNotEmpty) {
nextActiveId = firstMembership.first['collection_id'] as String;
}
}
if (nextActiveId == null) {
await clearActiveCollectionId(prefs, userId: userId);
return;
}
await writeActiveCollectionId(
prefs,
userId: userId,
collectionId: nextActiveId,
);
MainCollectionSync.notifyChanged();
}
Future<void> _showResetPasswordDialog() async { Future<void> _showResetPasswordDialog() async {
final context = navigatorKey.currentContext;
if (context == null) {
_isInPasswordRecoveryFlow = false;
return;
}
await showDialog<void>( await showDialog<void>(
context: navigatorKey.currentContext!, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => const _ResetPasswordDialog(), builder: (_) => const _ResetPasswordDialog(),
); );
if (mounted) {
setState(() => _isInPasswordRecoveryFlow = false);
}
} }
@override @override
@ -124,6 +346,7 @@ class _AuthGateState extends State<AuthGate> {
body: Center(child: CircularProgressIndicator()), body: Center(child: CircularProgressIndicator()),
); );
} }
if (_isInPasswordRecoveryFlow) return const LoginScreen();
return _session != null ? const HomeShell() : const LoginScreen(); return _session != null ? const HomeShell() : const LoginScreen();
} }
} }
@ -161,7 +384,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
); );
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pop(); Navigator.of(context).pop();
showGlobalSnackBar('Password updated successfully!'); showGlobalSuccess('Password updated successfully!');
} on AuthException catch (e) { } on AuthException catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _isSaving = false); setState(() => _isSaving = false);

View file

@ -1,89 +1,200 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'services/collection_service.dart';
import 'theme/app_colors.dart'; import 'theme/app_colors.dart';
import 'utils/error_utils.dart';
import 'utils/scanner_utils.dart';
/// Screen that uses the camera to scan text (OCR) from a Hot Wheels package /// Screen that uses the camera to scan text (OCR) from a die-cast package
/// and extract the hw_id (e.g. "JKF21"). /// and extract the hw_id (e.g. "JKF21").
/// ///
/// The detected ID is returned via Navigator.pop(context, hwId). /// The detected ID is returned via Navigator.pop(context, hwId).
class ScannerScreen extends StatefulWidget { class ScannerScreen extends StatefulWidget {
const ScannerScreen({super.key}); final Future<bool> Function(String hwId)? onDetected;
final List<Collection> collections;
final String? activeCollectionId;
final ValueChanged<String>? onCollectionChanged;
const ScannerScreen({
super.key,
this.onDetected,
this.collections = const [],
this.activeCollectionId,
this.onCollectionChanged,
});
@override @override
State<ScannerScreen> createState() => _ScannerScreenState(); State<ScannerScreen> createState() => _ScannerScreenState();
} }
class _ScannerScreenState extends State<ScannerScreen> { class _ScannerScreenState extends State<ScannerScreen>
with WidgetsBindingObserver {
static const _autoScanTickInterval = Duration(milliseconds: 700);
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
static const _scanCooldownError = Duration(milliseconds: 2600);
static const _scanCooldownNoMatchMax = Duration(milliseconds: 5000);
CameraController? _cameraController; CameraController? _cameraController;
late final TextRecognizer _textRecognizer; late final TextRecognizer _textRecognizer;
bool _isBusy = false; bool _isBusy = false;
bool _cameraReady = false; bool _cameraReady = false;
String? _lastDetected; String? _lastDetected;
bool _scanAccepted = false;
// Matches typical Hot Wheels model IDs: 25 uppercase letters followed by bool _scanNotFound = false;
// 24 digits, e.g. JKF21, HCV73, GRX33, FYD83. String _statusText = 'Ready to scan';
final _hwIdPattern = RegExp(r'\b([A-Z]{2,5}\d{2,4})\b'); String? _activeCollectionId;
bool _autoScanEnabled = false;
Timer? _autoScanTimer;
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
bool _isInitializingCamera = false;
String? _cameraError;
int _consecutiveMisses = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_activeCollectionId = widget.activeCollectionId;
_autoScanEnabled = widget.onDetected != null;
_textRecognizer = TextRecognizer(); _textRecognizer = TextRecognizer();
_initCamera(); _initCamera();
} }
Future<void> _initCamera() async { @override
final cameras = await availableCameras(); void didChangeAppLifecycleState(AppLifecycleState state) {
if (cameras.isEmpty) { if (state == AppLifecycleState.inactive ||
if (!mounted) return; state == AppLifecycleState.paused ||
ScaffoldMessenger.of(context).showSnackBar( state == AppLifecycleState.detached) {
const SnackBar(content: Text('No camera available')), _disposeCamera();
);
return; return;
} }
// Use the first back-facing camera. if (state == AppLifecycleState.resumed) {
final backCamera = cameras.firstWhere( _initCamera(force: true);
(c) => c.lensDirection == CameraLensDirection.back, }
orElse: () => cameras.first,
);
_cameraController = CameraController(
backCamera,
ResolutionPreset.high,
enableAudio: false,
);
await _cameraController!.initialize();
if (!mounted) return;
setState(() => _cameraReady = true);
} }
/// Capture a photo, run OCR, and look for a Hot Wheels ID. void _handleCollectionChanged(String? value) {
if (value == null) return;
setState(() => _activeCollectionId = value);
widget.onCollectionChanged?.call(value);
}
Future<void> _initCamera({bool force = false}) async {
if (_isInitializingCamera) return;
if (_cameraReady && !force) return;
_isInitializingCamera = true;
try {
await _disposeCamera();
final cameras = await availableCameras();
if (cameras.isEmpty) {
throw const ValidationException('No camera available');
}
final backCamera = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(
backCamera,
ResolutionPreset.medium,
enableAudio: false,
);
await controller.initialize();
if (!mounted) {
await controller.dispose();
return;
}
_cameraController = controller;
setState(() {
_cameraReady = true;
_cameraError = null;
_statusText = 'Ready to scan';
});
_startAutoScanLoop();
} catch (e) {
if (!mounted) return;
setState(() {
_cameraReady = false;
_cameraError = 'Camera unavailable. Please retry.';
_statusText = 'Camera unavailable';
});
logError('scanner.initCamera', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
userMessageForError(
e,
fallback: 'Failed to start camera. Please try again.',
),
),
backgroundColor: Colors.red,
),
);
} finally {
_isInitializingCamera = false;
}
}
Future<void> _disposeCamera() async {
_autoScanTimer?.cancel();
_autoScanTimer = null;
final controller = _cameraController;
_cameraController = null;
_cameraReady = false;
if (controller != null) {
await controller.dispose();
}
}
void _startAutoScanLoop() {
_autoScanTimer?.cancel();
_autoScanTimer = Timer.periodic(_autoScanTickInterval, (_) {
if (!_autoScanEnabled || _isBusy || !_cameraReady || !mounted) return;
if (DateTime.now().isBefore(_nextScanAllowedAt)) return;
_captureAndScan();
});
}
void _toggleAutoScan() {
setState(() {
_autoScanEnabled = !_autoScanEnabled;
_scanNotFound = false;
_statusText = _autoScanEnabled ? 'Auto scan enabled' : 'Auto scan paused';
});
}
/// Capture a photo, run OCR, and look for a die-cast model ID.
Future<void> _captureAndScan() async { Future<void> _captureAndScan() async {
if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return; if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return;
if (_cameraController!.value.isTakingPicture) return;
setState(() => _isBusy = true); setState(() {
_isBusy = true;
_scanNotFound = false;
_statusText = 'Scanning…';
});
try { try {
final xFile = await _cameraController!.takePicture(); final xFile = await _cameraController!.takePicture();
final inputImage = InputImage.fromFilePath(xFile.path); final inputImage = InputImage.fromFilePath(xFile.path);
final recognized = await _textRecognizer.processImage(inputImage); final recognized = await _textRecognizer.processImage(inputImage);
// Search all recognized text blocks for something matching the HW ID pattern. final found = extractHwIdFromLines(
String? found; recognized.blocks
for (final block in recognized.blocks) { .expand((block) => block.lines)
for (final line in block.lines) { .map((line) => line.text),
final match = _hwIdPattern.firstMatch(line.text.toUpperCase()); );
if (match != null) {
found = match.group(1);
break;
}
}
if (found != null) break;
}
// Clean up the temp image. // Clean up the temp image.
try { try {
@ -93,34 +204,113 @@ class _ScannerScreenState extends State<ScannerScreen> {
if (!mounted) return; if (!mounted) return;
if (found != null) { if (found != null) {
_consecutiveMisses = 0;
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
setState(() => _lastDetected = found); setState(() => _lastDetected = found);
if (widget.onDetected != null) {
await _submitDetected(found);
}
} else { } else {
// Show all detected text so user knows what was seen. _consecutiveMisses += 1;
final allText = recognized.blocks.map((b) => b.text).join('\n'); final missBackoffMs = (_scanCooldownNoMatch.inMilliseconds +
ScaffoldMessenger.of(context).showSnackBar( (_consecutiveMisses * 300))
SnackBar( .clamp(
content: Text( _scanCooldownNoMatch.inMilliseconds,
allText.isEmpty _scanCooldownNoMatchMax.inMilliseconds,
? 'No text detected — try again closer.' );
: 'No HW ID found. Detected:\n$allText', _nextScanAllowedAt = DateTime.now().add(
), Duration(milliseconds: missBackoffMs),
duration: const Duration(seconds: 4),
),
); );
setState(() {
_scanAccepted = false;
_scanNotFound = true;
_statusText = 'No HW ID found';
});
}
} catch (e) {
_consecutiveMisses += 1;
_nextScanAllowedAt = DateTime.now().add(_scanCooldownError);
if (!mounted) return;
logError('scanner.capture', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
userMessageForError(
e,
fallback: 'Scan failed. Please try again.',
),
),
backgroundColor: Colors.red,
),
);
setState(() => _statusText = 'Scan failed, try again');
} finally {
if (mounted) {
setState(() {
_isBusy = false;
if (_statusText == 'Scanning…') {
_statusText = 'Ready to scan';
}
});
}
}
}
Future<void> _submitDetected(String hwId) async {
if (widget.onDetected == null) {
if (mounted) Navigator.of(context).pop(hwId);
return;
}
try {
final keepScanning = await widget.onDetected!(hwId);
if (!mounted) return;
if (keepScanning) {
setState(() {
_lastDetected = null;
_scanAccepted = true;
_scanNotFound = false;
_statusText = 'Saved. Ready for next scan';
});
Future<void>.delayed(const Duration(milliseconds: 650), () {
if (!mounted) return;
setState(() {
_scanAccepted = false;
if (_statusText == 'Saved. Ready for next scan') {
_statusText = 'Ready to scan';
}
});
});
} else {
Navigator.of(context).pop();
} }
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
logError('scanner.submitDetected', e);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scan error: $e'), backgroundColor: Colors.red), SnackBar(
content: Text(
userMessageForError(
e,
fallback: 'Could not process this scan. Please try again.',
),
),
backgroundColor: Colors.red,
),
); );
} finally {
if (mounted) setState(() => _isBusy = false);
} }
} }
@override @override
void dispose() { void dispose() {
_cameraController?.dispose(); WidgetsBinding.instance.removeObserver(this);
_autoScanTimer?.cancel();
final controller = _cameraController;
_cameraController = null;
if (controller != null) {
controller.dispose();
}
_textRecognizer.close(); _textRecognizer.close();
super.dispose(); super.dispose();
} }
@ -139,9 +329,104 @@ class _ScannerScreenState extends State<ScannerScreen> {
), ),
body: Column( body: Column(
children: [ children: [
if (widget.collections.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
color: Colors.black.withValues(alpha: 0.55),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: _activeCollectionId,
dropdownColor: AppColors.navy,
iconEnabledColor: Colors.white,
style: const TextStyle(color: Colors.white),
hint: const Text(
'Select collection',
style: TextStyle(color: Colors.white70),
),
items: widget.collections
.map(
(c) => DropdownMenuItem<String>(
value: c.id,
child: Text(
c.name,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: _isBusy ? null : _handleCollectionChanged,
),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
color: _scanAccepted
? AppColors.success.withValues(alpha: 0.12)
: _scanNotFound
? AppColors.error.withValues(alpha: 0.14)
: Colors.black.withValues(alpha: 0.55),
child: Row(
children: [
Expanded(
child: Text(
_statusText,
textAlign: TextAlign.center,
style: TextStyle(
color: _scanAccepted
? AppColors.success
: _scanNotFound
? AppColors.error
: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
if (widget.onDetected != null)
TextButton(
onPressed: _isBusy ? null : _toggleAutoScan,
style: TextButton.styleFrom(
foregroundColor:
_autoScanEnabled ? AppColors.success : Colors.white,
minimumSize: const Size(0, 26),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(_autoScanEnabled ? 'AUTO ON' : 'AUTO OFF'),
),
],
),
),
// Camera preview // Camera preview
Expanded( Expanded(
child: _cameraReady child: _cameraError != null
? Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.camera_alt_outlined,
size: 46, color: AppColors.error),
const SizedBox(height: 12),
Text(
_cameraError!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _initCamera(force: true),
icon: const Icon(Icons.refresh),
label: const Text('Retry Camera'),
),
],
),
),
)
: _cameraReady
? Stack( ? Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@ -164,7 +449,12 @@ class _ScannerScreenState extends State<ScannerScreen> {
height: 100, height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: AppColors.orange.withValues(alpha: 0.8), color: (_scanAccepted
? AppColors.success
: _scanNotFound
? AppColors.error
: AppColors.orange)
.withValues(alpha: 0.9),
width: 2.5, width: 2.5,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@ -183,7 +473,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
), ),
], ],
) )
: const Center(child: CircularProgressIndicator()), : const Center(child: CircularProgressIndicator()),
), ),
// Detected ID confirmation area // Detected ID confirmation area
@ -229,7 +519,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
), ),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.of(context).pop(_lastDetected), onPressed: _isBusy || _lastDetected == null
? null
: () => _submitDetected(_lastDetected!),
child: const Text('Use This'), child: const Text('Use This'),
), ),
], ],
@ -272,7 +564,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
_isBusy ? 'Scanning…' : 'Capture & Scan', _isBusy ? 'Scanning…' : 'Capture & Scan',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
), ),
onPressed: _isBusy ? null : _captureAndScan, onPressed: _isBusy || !_cameraReady ? null : _captureAndScan,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
@ -298,7 +590,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
); );
if (result != null && context.mounted) { if (result != null && context.mounted) {
Navigator.of(context).pop(result); await _submitDetected(result);
} }
} }
} }

View file

@ -12,6 +12,8 @@ class AboutScreen extends StatefulWidget {
} }
class _AboutScreenState extends State<AboutScreen> { class _AboutScreenState extends State<AboutScreen> {
static const _supportEmail = 'support@car64.app';
PackageInfo? _packageInfo; PackageInfo? _packageInfo;
@override @override
@ -25,9 +27,23 @@ class _AboutScreenState extends State<AboutScreen> {
if (mounted) setState(() => _packageInfo = info); if (mounted) setState(() => _packageInfo = info);
} }
Future<void> _openUrl(String url) async { Future<void> _contactSupport() async {
final uri = Uri.parse(url); final uri = Uri(
await launchUrl(uri, mode: LaunchMode.externalApplication); scheme: 'mailto',
path: _supportEmail,
queryParameters: {
'subject': 'car64 Support Request',
},
);
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not open mail app. Please email support@car64.app.'),
),
);
}
} }
@override @override
@ -55,17 +71,24 @@ class _AboutScreenState extends State<AboutScreen> {
gradient: AppColors.brandGradient, gradient: AppColors.brandGradient,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: Padding(
Icons.directions_car_filled, padding: const EdgeInsets.all(16),
color: Colors.white, child: Image.asset(
size: 52, 'assets/img/icon_bg_removed.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => const Icon(
Icons.directions_car_filled,
color: Colors.white,
size: 52,
),
),
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Center( Center(
child: Text( child: Text(
'HW Collector Hub', 'car64',
style: theme.textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@ -83,7 +106,7 @@ class _AboutScreenState extends State<AboutScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Center( Center(
child: Text( child: Text(
'Track and manage your Hot Wheels collection.', 'Track and manage your die-cast car collection.',
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: AppColors.textHint, color: AppColors.textHint,
), ),
@ -95,29 +118,6 @@ class _AboutScreenState extends State<AboutScreen> {
const Divider(), const Divider(),
const SizedBox(height: 16), const SizedBox(height: 16),
// Links section
Text(
'Links',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
_LinkTile(
icon: Icons.coffee,
iconColor: const Color(0xFFFFDD00),
title: 'Buy Me a Coffee',
subtitle: 'Support the development',
onTap: () =>
_openUrl('https://buymeacoffee.com/derkauzigekoala'),
),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 16),
// Technical details // Technical details
Text( Text(
'Technical', 'Technical',
@ -138,6 +138,16 @@ class _AboutScreenState extends State<AboutScreen> {
value: _packageInfo?.packageName ?? '', value: _packageInfo?.packageName ?? '',
), ),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _contactSupport,
icon: const Icon(Icons.support_agent),
label: const Text('Contact Support'),
),
),
const SizedBox(height: 32), const SizedBox(height: 32),
// Powered by // Powered by
@ -152,7 +162,7 @@ class _AboutScreenState extends State<AboutScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'© 2026 HW Collector Hub', '© 2026 car64',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.textHint, color: AppColors.textHint,
), ),
@ -167,49 +177,6 @@ class _AboutScreenState extends State<AboutScreen> {
} }
} }
// Link tile
class _LinkTile extends StatelessWidget {
final IconData icon;
final Color iconColor;
final String title;
final String subtitle;
final VoidCallback onTap;
const _LinkTile({
required this.icon,
required this.iconColor,
required this.title,
required 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: iconColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: iconColor, size: 22),
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
trailing: const Icon(Icons.open_in_new, color: AppColors.textHint, size: 18),
onTap: onTap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
);
}
}
// Info row (label : value) // Info row (label : value)
class _InfoRow extends StatelessWidget { class _InfoRow extends StatelessWidget {

View file

@ -1,7 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../main.dart'; import '../main.dart';
import '../services/collection_service.dart'; import '../services/collection_service.dart';
import '../services/main_collection_sync.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../utils/error_utils.dart';
import '../utils/preferences_utils.dart';
import 'garage_screen.dart'; import 'garage_screen.dart';
import 'manage_collection_screen.dart'; import 'manage_collection_screen.dart';
@ -13,39 +17,127 @@ class CollectionsScreen extends StatefulWidget {
State<CollectionsScreen> createState() => CollectionsScreenState(); State<CollectionsScreen> createState() => CollectionsScreenState();
} }
class CollectionsScreenState extends State<CollectionsScreen> { class CollectionsScreenState extends State<CollectionsScreen>
with WidgetsBindingObserver {
List<Collection> _collections = []; List<Collection> _collections = [];
bool _isLoading = true; bool _isLoading = true;
bool _isLoadInFlight = false;
bool _reloadQueued = false;
String? _error; String? _error;
String? _activeCollectionId;
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
MainCollectionSync.changeToken.addListener(_handleSyncChanged);
_load(); _load();
} }
@override
void dispose() {
MainCollectionSync.changeToken.removeListener(_handleSyncChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
void _handleSyncChanged() {
if (!mounted) return;
_load();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
refreshIfStale(maxAge: const Duration(seconds: 3));
}
}
/// Public so other tabs can trigger a refresh. /// Public so other tabs can trigger a refresh.
void refresh() => _load(); void refresh() => _load();
Future<void> _load() async { void refreshIfStale({Duration maxAge = const Duration(seconds: 10)}) {
setState(() { if (DateTime.now().difference(_lastLoadedAt) > maxAge) {
_isLoading = true; _load();
_error = null; }
}); }
Future<void> _load({bool showLoading = true}) async {
if (_isLoadInFlight) {
_reloadQueued = true;
return;
}
_isLoadInFlight = true;
if (showLoading) {
setState(() {
_isLoading = true;
_error = null;
});
}
try { try {
final list = await CollectionService.getMyCollections(); var list = await CollectionService.getMyCollections();
if (list.isEmpty && supabase.auth.currentUser != null) {
await CollectionService.ensureDefaultCollection();
list = await CollectionService.getMyCollections();
}
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw const AuthRequiredException(
'You must be signed in to load collections.',
);
}
final prefs = await SharedPreferences.getInstance();
final persisted = await readActiveCollectionId(prefs, userId: userId);
String? activeId = persisted;
if (activeId == null && list.isNotEmpty) {
activeId = list.first.id;
}
if (activeId != null && !list.any((c) => c.id == activeId)) {
activeId = list.isNotEmpty ? list.first.id : null;
}
final shouldNotifySync = activeId != null && activeId != persisted;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_collections = list; _collections = list;
_activeCollectionId = activeId;
_isLoading = false; _isLoading = false;
_lastLoadedAt = DateTime.now();
}); });
if (activeId != null) {
await writeActiveCollectionId(
prefs,
userId: userId,
collectionId: activeId,
);
if (shouldNotifySync) {
MainCollectionSync.notifyChanged();
}
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = e.toString(); _error = userMessageForError(
e,
fallback: 'Failed to load collections. Please try again.',
);
_isLoading = false; _isLoading = false;
}); });
logError('collections.load', e);
} finally {
_isLoadInFlight = false;
if (_reloadQueued) {
_reloadQueued = false;
Future<void>.microtask(() => _load(showLoading: false));
}
} }
} }
@ -77,7 +169,7 @@ class CollectionsScreenState extends State<CollectionsScreen> {
maxLength: 50, maxLength: 50,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Name', labelText: 'Name',
hintText: 'e.g. Hot Wheels, Matchbox…', hintText: 'e.g. Die-Cast Cars, Matchbox…',
), ),
validator: (value) { validator: (value) {
final trimmed = value?.trim() ?? ''; final trimmed = value?.trim() ?? '';
@ -122,10 +214,13 @@ class CollectionsScreenState extends State<CollectionsScreen> {
name: nameCtrl.text.trim(), name: nameCtrl.text.trim(),
description: descCtrl.text.trim(), description: descCtrl.text.trim(),
); );
showGlobalSnackBar('Collection created!'); showGlobalSuccess('Collection created!');
_load(); _load();
} catch (e) { } catch (e) {
showGlobalSnackBar('Failed: $e', isError: true); showGlobalError(
e,
fallback: 'Could not create collection. Please try again.',
);
} }
} }
@ -135,7 +230,7 @@ class CollectionsScreenState extends State<CollectionsScreen> {
builder: (_) => GarageScreen( builder: (_) => GarageScreen(
collectionId: c.id, collectionId: c.id,
collectionName: c.name, collectionName: c.name,
isOwner: c.isOwner, userRole: c.role,
), ),
), ),
).then((_) => _load()); // refresh counts when coming back ).then((_) => _load()); // refresh counts when coming back
@ -149,10 +244,31 @@ class CollectionsScreenState extends State<CollectionsScreen> {
).then((_) => _load()); ).then((_) => _load());
} }
Future<void> _setActiveCollection(String collectionId) async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
showGlobalSnackBar('Please sign in again.', isError: true);
return;
}
final prefs = await SharedPreferences.getInstance();
await writeActiveCollectionId(
prefs,
userId: userId,
collectionId: collectionId,
);
if (!mounted) return;
setState(() => _activeCollectionId = collectionId);
MainCollectionSync.notifyChanged();
showGlobalInfo('Main collection set for scanning.');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: CustomScrollView( body: RefreshIndicator(
onRefresh: _load,
child: CustomScrollView(
slivers: [ slivers: [
// Header // Header
SliverAppBar( SliverAppBar(
@ -176,17 +292,33 @@ class CollectionsScreenState extends State<CollectionsScreen> {
), ),
), ),
background: Container( background: Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.brandGradient, image: const DecorationImage(
image: AssetImage('assets/img/login_bg.jpg'),
fit: BoxFit.cover,
),
color: Colors.black.withValues(alpha: 0.15),
), ),
child: Align( child: Container(
alignment: Alignment.centerRight, decoration: BoxDecoration(
child: Padding( gradient: LinearGradient(
padding: const EdgeInsets.only(right: 24), begin: Alignment.topCenter,
child: Icon( end: Alignment.bottomCenter,
Icons.collections_bookmark, colors: [
size: 72, Colors.black.withValues(alpha: 0.3),
color: Colors.white.withValues(alpha: 0.15), Colors.black.withValues(alpha: 0.55),
],
),
),
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 24),
child: Icon(
Icons.collections_bookmark,
size: 72,
color: Colors.white.withValues(alpha: 0.18),
),
), ),
), ),
), ),
@ -267,6 +399,8 @@ class CollectionsScreenState extends State<CollectionsScreen> {
final c = _collections[index]; final c = _collections[index];
return _CollectionCard( return _CollectionCard(
collection: c, collection: c,
isActive: _activeCollectionId == c.id,
onSetActive: () => _setActiveCollection(c.id),
onTap: () => _openCollection(c), onTap: () => _openCollection(c),
onManage: () => _manageCollection(c), onManage: () => _manageCollection(c),
); );
@ -275,7 +409,8 @@ class CollectionsScreenState extends State<CollectionsScreen> {
), ),
), ),
), ),
], ],
),
), ),
floatingActionButton: _collections.isNotEmpty floatingActionButton: _collections.isNotEmpty
? FloatingActionButton( ? FloatingActionButton(
@ -291,11 +426,15 @@ class CollectionsScreenState extends State<CollectionsScreen> {
class _CollectionCard extends StatelessWidget { class _CollectionCard extends StatelessWidget {
final Collection collection; final Collection collection;
final bool isActive;
final VoidCallback onSetActive;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onManage; final VoidCallback onManage;
const _CollectionCard({ const _CollectionCard({
required this.collection, required this.collection,
required this.isActive,
required this.onSetActive,
required this.onTap, required this.onTap,
required this.onManage, required this.onManage,
}); });
@ -363,31 +502,70 @@ class _CollectionCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: c.isOwner color: c.isOwner
? AppColors.orange.withValues(alpha: 0.15) ? AppColors.orange.withValues(alpha: 0.15)
: AppColors.navy.withValues(alpha: 0.1), : c.isViewer
? AppColors.textHint.withValues(alpha: 0.15)
: AppColors.navy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: Text( child: Text(
c.isOwner ? 'Owner' : 'Member', c.isOwner
? 'Owner'
: c.isViewer
? 'Viewer'
: 'Member',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: c.isOwner color: c.isOwner
? AppColors.orange ? AppColors.orange
: AppColors.navy, : c.isViewer
? AppColors.textSecondary
: AppColors.navy,
), ),
), ),
), ),
const SizedBox(width: 8),
if (isActive)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Main',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.success,
),
),
),
], ],
), ),
], ],
), ),
), ),
// Manage button Column(
IconButton( children: [
icon: const Icon(Icons.settings_outlined, IconButton(
color: AppColors.textHint), tooltip: isActive
onPressed: onManage, ? 'Already main collection'
: 'Make main collection',
icon: Icon(
isActive ? Icons.my_location : Icons.location_searching,
color: isActive ? AppColors.success : AppColors.textHint,
),
onPressed: isActive ? null : onSetActive,
),
IconButton(
icon: const Icon(Icons.settings_outlined,
color: AppColors.textHint),
onPressed: onManage,
),
],
), ),
], ],
), ),

File diff suppressed because it is too large Load diff

View file

@ -15,8 +15,7 @@ class _HomeShellState extends State<HomeShell> {
int _currentIndex = 0; int _currentIndex = 0;
final _collectionsKey = GlobalKey<CollectionsScreenState>(); final _collectionsKey = GlobalKey<CollectionsScreenState>();
final _scanKey = GlobalKey<ScanTabState>(); final _scanKey = GlobalKey<ScanTabState>();
final Map<int, DateTime> _lastRefreshed = {}; late final PageController _pageController;
static const _refreshDebounce = Duration(seconds: 30);
late final List<Widget> _pages = <Widget>[ late final List<Widget> _pages = <Widget>[
CollectionsScreen(key: _collectionsKey), CollectionsScreen(key: _collectionsKey),
@ -24,24 +23,44 @@ class _HomeShellState extends State<HomeShell> {
const ProfileScreen(), const ProfileScreen(),
]; ];
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onTabSelected(int i) { void _onTabSelected(int i) {
if (_currentIndex == i) return;
setState(() => _currentIndex = i); setState(() => _currentIndex = i);
final now = DateTime.now(); _pageController.animateToPage(
final last = _lastRefreshed[i]; i,
if (last != null && now.difference(last) < _refreshDebounce) return; duration: const Duration(milliseconds: 260),
_lastRefreshed[i] = now; curve: Curves.easeOutCubic,
);
if (i == 0) { if (i == 0) {
_collectionsKey.currentState?.refresh(); _collectionsKey.currentState?.refreshIfStale();
} else if (i == 1) {
_scanKey.currentState?.refresh();
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: IndexedStack( body: PageView(
index: _currentIndex, controller: _pageController,
onPageChanged: (index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
}
if (index == 0) {
_collectionsKey.currentState?.refreshIfStale();
}
},
children: _pages, children: _pages,
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(

View file

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import '../main.dart'; import '../main.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
/// Branded login screen matching the HW Collector Hub email style. /// Branded login screen matching the car64 style.
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -76,9 +76,9 @@ class _LoginScreenState extends State<LoginScreen>
try { try {
await supabase.auth.resetPasswordForEmail( await supabase.auth.resetPasswordForEmail(
email, email,
redirectTo: 'hwcollector://login', redirectTo: 'hwcollector://login/recovery',
); );
showGlobalSnackBar('Password reset email sent! Check your inbox.'); showGlobalSuccess('Password reset email sent! Check your inbox.');
} on AuthException catch (e) { } on AuthException catch (e) {
showGlobalSnackBar(e.message, isError: true); showGlobalSnackBar(e.message, isError: true);
} }
@ -134,15 +134,22 @@ class _LoginScreenState extends State<LoginScreen>
width: 2, width: 2,
), ),
), ),
child: const Icon( child: Padding(
Icons.directions_car_filled, padding: const EdgeInsets.all(14),
size: 44, child: Image.asset(
color: Colors.white, 'assets/img/icon_bg_removed.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => const Icon(
Icons.directions_car_filled,
size: 44,
color: Colors.white,
),
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
'HW COLLECTOR HUB', 'CAR64',
style: TextStyle( style: TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@ -300,7 +307,7 @@ class _LoginScreenState extends State<LoginScreen>
// Footer // Footer
Text( Text(
'© 2026 HW Collector Hub', '© 2026 car64',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.white.withValues(alpha: 0.5), color: Colors.white.withValues(alpha: 0.5),

View file

@ -18,6 +18,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
late Collection _collection; late Collection _collection;
List<CollectionMember> _members = []; List<CollectionMember> _members = [];
bool _isLoading = true; bool _isLoading = true;
bool _isInviting = false;
@override @override
void initState() { void initState() {
@ -39,7 +40,10 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _isLoading = false); setState(() => _isLoading = false);
showGlobalSnackBar('Failed to load members: $e', isError: true); showGlobalError(
e,
fallback: 'Failed to load members. Please try again.',
);
} }
} }
@ -121,18 +125,24 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
memberCount: _collection.memberCount, memberCount: _collection.memberCount,
); );
}); });
showGlobalSnackBar('Collection renamed!'); showGlobalSuccess('Collection renamed!');
} catch (e) { } catch (e) {
showGlobalSnackBar('Failed: $e', isError: true); showGlobalError(
e,
fallback: 'Could not rename collection. Please try again.',
);
} }
} }
Future<void> _inviteMember() async { Future<void> _inviteMember() async {
if (_isInviting) return;
final emailCtrl = TextEditingController(); final emailCtrl = TextEditingController();
String inviteRole = 'member';
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
builder: (_) => AlertDialog( builder: (_) => StatefulBuilder(
builder: (context, setSheetState) => AlertDialog(
icon: Container( icon: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: const BoxDecoration( decoration: const BoxDecoration(
@ -142,29 +152,64 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
child: const Icon(Icons.person_add, color: Colors.white, size: 28), child: const Icon(Icons.person_add, color: Colors.white, size: 28),
), ),
title: const Text('Invite Member'), title: const Text('Invite Member'),
content: Column( content: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: ConstrainedBox(
children: [ constraints: const BoxConstraints(maxWidth: 420),
const Text( child: Column(
'Enter the email address of the person you want to invite. ' mainAxisSize: MainAxisSize.min,
'They must already have an account.', children: [
style: TextStyle( const Text(
fontSize: 13, 'Enter the email address of the person you want to invite. '
color: AppColors.textSecondary, 'They must already have an account.',
), style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
TextField(
controller: emailCtrl,
autofocus: true,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email address',
hintText: 'user@example.com',
prefixIcon: Icon(Icons.email_outlined),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
isExpanded: true,
initialValue: inviteRole,
decoration: const InputDecoration(
labelText: 'Role',
prefixIcon: Icon(Icons.security_outlined),
),
items: const [
DropdownMenuItem(value: 'member', child: Text('Member')),
DropdownMenuItem(value: 'viewer', child: Text('Viewer')),
],
onChanged: (value) {
if (value == null) return;
setSheetState(() => inviteRole = value);
},
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
inviteRole == 'viewer'
? 'Viewer: read-only access.'
: 'Member: can add, copy, edit, and remove cars.',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
],
), ),
const SizedBox(height: 16), ),
TextField(
controller: emailCtrl,
autofocus: true,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email address',
hintText: 'user@example.com',
prefixIcon: Icon(Icons.email_outlined),
),
),
],
), ),
actions: [ actions: [
TextButton( TextButton(
@ -179,6 +224,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
child: const Text('Invite'), child: const Text('Invite'),
), ),
], ],
),
), ),
); );
@ -192,14 +238,23 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
} }
try { try {
setState(() => _isInviting = true);
await CollectionService.inviteByEmail( await CollectionService.inviteByEmail(
collectionId: _collection.id, collectionId: _collection.id,
email: email, email: email,
role: inviteRole,
); );
showGlobalSnackBar('Member invited!'); showGlobalSuccess(
_loadMembers(); inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!',
);
await _loadMembers();
} catch (e) { } catch (e) {
showGlobalSnackBar('$e', isError: true); showGlobalError(
e,
fallback: 'Could not send invitation. Please try again.',
);
} finally {
if (mounted) setState(() => _isInviting = false);
} }
} }
@ -229,12 +284,15 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
try { try {
await CollectionService.removeMember( await CollectionService.removeMember(
collectionId: _collection.id, collectionId: _collection.id,
membershipId: member.id, memberUserId: member.userId,
); );
showGlobalSnackBar('Member removed.'); showGlobalSuccess('Member removed.');
_loadMembers(); await _loadMembers();
} catch (e) { } catch (e) {
showGlobalSnackBar('Failed: $e', isError: true); showGlobalError(
e,
fallback: 'Could not remove member. Please try again.',
);
} }
} }
@ -264,10 +322,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
try { try {
await CollectionService.leave(_collection.id); await CollectionService.leave(_collection.id);
showGlobalSnackBar('Left "${_collection.name}".'); showGlobalSuccess('Left "${_collection.name}".');
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} catch (e) { } catch (e) {
showGlobalSnackBar('Failed: $e', isError: true); showGlobalError(
e,
fallback: 'Could not leave collection. Please try again.',
);
} }
} }
@ -297,10 +358,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
try { try {
await CollectionService.delete(_collection.id); await CollectionService.delete(_collection.id);
showGlobalSnackBar('Collection deleted.'); showGlobalSuccess('Collection deleted.');
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} catch (e) { } catch (e) {
showGlobalSnackBar('Failed: $e', isError: true); showGlobalError(
e,
fallback: 'Could not delete collection. Please try again.',
);
} }
} }
@ -321,129 +385,148 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
), ),
], ],
), ),
body: ListView( body: RefreshIndicator(
padding: const EdgeInsets.all(16), onRefresh: _loadMembers,
children: [ child: ListView(
// Description padding: const EdgeInsets.all(16),
if (_collection.description != null && children: [
_collection.description!.isNotEmpty) ...[ if (_collection.description != null &&
Text( _collection.description!.isNotEmpty) ...[
_collection.description!,
style: const TextStyle(
fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 16),
],
// Members section
Row(
children: [
Text( Text(
'Members', _collection.description!,
style: theme.textTheme.titleMedium?.copyWith( style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14, color: AppColors.textSecondary),
),
), ),
const Spacer(), const SizedBox(height: 16),
if (_collection.isOwner)
TextButton.icon(
onPressed: _inviteMember,
icon: const Icon(Icons.person_add, size: 18),
label: const Text('Invite'),
),
], ],
),
const SizedBox(height: 8),
if (_isLoading) Row(
const Center( children: [
child: Padding( Text(
padding: EdgeInsets.all(24), 'Members',
child: CircularProgressIndicator(), style: theme.textTheme.titleMedium?.copyWith(
), fontWeight: FontWeight.w600,
) ),
else ),
...List.generate(_members.length, (i) { const Spacer(),
final m = _members[i]; if (_collection.isOwner)
return Card( TextButton.icon(
margin: const EdgeInsets.only(bottom: 8), onPressed: _isInviting ? null : _inviteMember,
child: ListTile( icon: const Icon(Icons.person_add, size: 18),
leading: CircleAvatar( label: Text(_isInviting ? 'Inviting…' : 'Invite'),
backgroundColor: m.isOwner ),
? AppColors.orange ],
: AppColors.navy, ),
child: Icon( const SizedBox(height: 8),
m.isOwner ? Icons.star : Icons.person,
color: Colors.white, if (_isLoading)
size: 20, const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
),
)
else
...List.generate(_members.length, (i) {
final member = _members[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor:
member.isOwner ? AppColors.orange : AppColors.navy,
child: Icon(
member.isOwner ? Icons.star : Icons.person,
color: Colors.white,
size: 20,
),
), ),
title: Text(
member.email,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
_roleLabel(member.role),
style: const TextStyle(fontSize: 12),
),
trailing: (!member.isOwner &&
_collection.isOwner &&
member.userId != currentUserId)
? IconButton(
icon: const Icon(Icons.remove_circle_outline,
color: AppColors.error),
onPressed: () => _removeMember(member),
)
: null,
), ),
title: Text( );
m.email, }),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
m.isOwner ? 'Owner' : 'Member',
style: const TextStyle(fontSize: 12),
),
trailing: (!m.isOwner &&
_collection.isOwner &&
m.userId != currentUserId)
? IconButton(
icon: const Icon(Icons.remove_circle_outline,
color: AppColors.error),
onPressed: () => _removeMember(m),
)
: null,
),
);
}),
const SizedBox(height: 32), const SizedBox(height: 32),
const Divider(), const Divider(),
const SizedBox(height: 16), const SizedBox(height: 16),
// Danger zone Text(
Text( 'Danger Zone',
'Danger Zone', style: theme.textTheme.titleMedium?.copyWith(
style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, color: AppColors.error,
color: AppColors.error,
),
),
const SizedBox(height: 12),
if (!_collection.isOwner)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _leaveCollection,
icon: const Icon(Icons.exit_to_app, color: AppColors.error),
label: const Text('Leave Collection',
style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 14),
),
), ),
), ),
const SizedBox(height: 12),
if (_collection.isOwner) if (!_collection.isOwner)
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _deleteCollection, onPressed: _leaveCollection,
icon: const Icon(Icons.delete_forever, color: AppColors.error), icon: const Icon(Icons.exit_to_app, color: AppColors.error),
label: const Text('Delete Collection', label: const Text('Leave Collection',
style: TextStyle(color: AppColors.error)), style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error), side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
),
),
)
else ...[
const Text(
'As owner, you cannot leave this collection. You can delete it instead.',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
), ),
), ),
), const SizedBox(height: 10),
], SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _deleteCollection,
icon: const Icon(Icons.delete_forever,
color: AppColors.error),
label: const Text('Delete Collection',
style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
],
),
), ),
); );
} }
String _roleLabel(String role) {
switch (role) {
case 'owner':
return 'Owner';
case 'viewer':
return 'Viewer (read-only)';
default:
return 'Member';
}
}
} }

View file

@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import '../main.dart';
import '../theme/app_colors.dart';
import '../utils/error_utils.dart';
import '../utils/reporting_utils.dart';
class MyReportsScreen extends StatefulWidget {
const MyReportsScreen({super.key});
@override
State<MyReportsScreen> createState() => _MyReportsScreenState();
}
class _MyReportsScreenState extends State<MyReportsScreen> {
bool _isLoading = true;
String? _error;
List<Map<String, dynamic>> _reports = [];
@override
void initState() {
super.initState();
_loadReports();
}
Future<void> _loadReports() async {
final user = supabase.auth.currentUser;
if (user == null) {
if (!mounted) return;
setState(() {
_reports = [];
_error = 'Please sign in to view reports.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final rows = await supabase
.from('car_reports')
.select('id, hw_id, reason, note, status, created_at, global_cars(name, series)')
.eq('reporter_user_id', user.id)
.order('created_at', ascending: false);
if (!mounted) return;
setState(() {
_reports = List<Map<String, dynamic>>.from(rows);
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = userMessageForError(
e,
fallback: 'Failed to load reports. Please try again.',
);
_isLoading = false;
});
logError('reports.load', e);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Reports'),
),
body: RefreshIndicator(
onRefresh: _loadReports,
child: _buildBody(),
),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
const SizedBox(height: 120),
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const Icon(Icons.error_outline, color: AppColors.error, size: 42),
const SizedBox(height: 10),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _loadReports,
child: const Text('Retry'),
),
],
),
),
),
],
);
}
if (_reports.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 130),
Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Icon(Icons.flag_outlined, size: 48, color: AppColors.textHint),
SizedBox(height: 12),
Text(
'No reports yet',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'When you report a catalog issue, it will appear here.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textSecondary),
),
],
),
),
),
],
);
}
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
itemBuilder: (_, index) {
final report = _reports[index];
final global = report['global_cars'] as Map<String, dynamic>?;
final status = (report['status'] as String? ?? 'open').toLowerCase();
final createdAt = DateTime.tryParse(report['created_at'] as String? ?? '');
final note = report['note'] as String?;
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
report['hw_id'] as String? ?? 'Unknown ID',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
),
_StatusChip(status: status),
],
),
if ((global?['name'] as String?) != null) ...[
const SizedBox(height: 6),
Text(
global?['name'] as String,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
],
const SizedBox(height: 8),
Text(
'Reason: ${report['reason'] as String? ?? 'Unknown'}',
style: const TextStyle(color: AppColors.textSecondary),
),
if (note != null && note.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
note,
style: const TextStyle(color: AppColors.textHint),
),
],
if (createdAt != null) ...[
const SizedBox(height: 10),
Text(
formatReportDateTime(createdAt),
style: const TextStyle(fontSize: 12, color: AppColors.textHint),
),
],
],
),
),
);
},
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemCount: _reports.length,
);
}
}
class _StatusChip extends StatelessWidget {
final String status;
const _StatusChip({required this.status});
@override
Widget build(BuildContext context) {
final normalized = normalizeReportStatus(status);
final label = reportStatusLabel(normalized);
final color = switch (normalized) {
ReportStatus.reviewed => Colors.blueGrey,
ReportStatus.resolved => AppColors.success,
ReportStatus.dismissed => AppColors.error,
ReportStatus.open => AppColors.orange,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../main.dart'; import '../main.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'about_screen.dart'; import 'about_screen.dart';
import 'my_reports_screen.dart';
/// Profile / settings tab. /// Profile / settings tab.
class ProfileScreen extends StatelessWidget { class ProfileScreen extends StatelessWidget {
@ -22,52 +23,68 @@ class ProfileScreen extends StatelessWidget {
expandedHeight: 200, expandedHeight: 200,
pinned: true, pinned: true,
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: Container( background: Stack(
decoration: const BoxDecoration( fit: StackFit.expand,
gradient: AppColors.brandGradient, children: [
), Image.asset(
child: SafeArea( 'assets/img/login_bg.jpg',
child: Column( fit: BoxFit.cover,
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ Container(
const SizedBox(height: 16), decoration: BoxDecoration(
// Avatar gradient: LinearGradient(
Container( begin: Alignment.topCenter,
width: 80, end: Alignment.bottomCenter,
height: 80, colors: [
decoration: BoxDecoration( Colors.black.withValues(alpha: 0.3),
shape: BoxShape.circle, Colors.black.withValues(alpha: 0.55),
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, SafeArea(
style: TextStyle( child: Column(
fontSize: 16, mainAxisAlignment: MainAxisAlignment.center,
fontWeight: FontWeight.w500, children: [
color: Colors.white.withValues(alpha: 0.95), const SizedBox(height: 16),
), // Avatar
), Container(
if (createdAt != null) ...[ width: 80,
const SizedBox(height: 4), height: 80,
Text( decoration: BoxDecoration(
'Member since ${_formatDate(createdAt)}', shape: BoxShape.circle,
style: TextStyle( border: Border.all(color: Colors.white, width: 3),
fontSize: 12, color: Colors.white.withValues(alpha: 0.2),
color: Colors.white.withValues(alpha: 0.7), ),
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.75),
),
),
],
], ],
], ),
), ),
), ],
), ),
), ),
), ),
@ -93,6 +110,12 @@ class ProfileScreen extends StatelessWidget {
title: 'Change Password', title: 'Change Password',
onTap: () => _changePassword(context), onTap: () => _changePassword(context),
), ),
_SettingsTile(
icon: Icons.flag_outlined,
title: 'My Reports',
subtitle: 'Track report status',
onTap: () => _showMyReports(context),
),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'App', 'App',
@ -105,7 +128,7 @@ class ProfileScreen extends StatelessWidget {
_SettingsTile( _SettingsTile(
icon: Icons.info_outline, icon: Icons.info_outline,
title: 'About', title: 'About',
subtitle: 'HW Collector Hub', subtitle: 'car64',
onTap: () => _showAbout(context), onTap: () => _showAbout(context),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@ -131,7 +154,7 @@ class ProfileScreen extends StatelessWidget {
const SizedBox(height: 40), const SizedBox(height: 40),
const Center( const Center(
child: Text( child: Text(
'© 2026 HW Collector Hub', '© 2026 car64',
style: style:
TextStyle(fontSize: 12, color: AppColors.textHint), TextStyle(fontSize: 12, color: AppColors.textHint),
), ),
@ -185,7 +208,7 @@ class ProfileScreen extends StatelessWidget {
UserAttributes(password: pw), UserAttributes(password: pw),
); );
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
showGlobalSnackBar('Password updated!'); showGlobalSuccess('Password updated!');
} on AuthException catch (e) { } on AuthException catch (e) {
showGlobalSnackBar(e.message, isError: true); showGlobalSnackBar(e.message, isError: true);
} }
@ -202,6 +225,12 @@ class ProfileScreen extends StatelessWidget {
MaterialPageRoute(builder: (_) => const AboutScreen()), MaterialPageRoute(builder: (_) => const AboutScreen()),
); );
} }
void _showMyReports(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const MyReportsScreen()),
);
}
} }
// Settings tile widget // Settings tile widget

View file

@ -1,14 +1,13 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../main.dart'; import '../main.dart';
import '../scanner_screen.dart'; import '../scanner_screen.dart';
import '../services/collection_service.dart'; import '../services/collection_service.dart';
import '../services/storage_service.dart'; import '../services/main_collection_sync.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../utils/error_utils.dart';
import '../utils/preferences_utils.dart';
/// The "Scan" tab quick-access view for scanning / adding cars.
class ScanTab extends StatefulWidget { class ScanTab extends StatefulWidget {
const ScanTab({super.key}); const ScanTab({super.key});
@ -17,36 +16,81 @@ class ScanTab extends StatefulWidget {
} }
class ScanTabState extends State<ScanTab> { class ScanTabState extends State<ScanTab> {
static const _duplicateCooldown = Duration(seconds: 2);
bool _isBusy = false; bool _isBusy = false;
List<Collection> _collections = []; List<Collection> _collections = [];
Collection? _selectedCollection; Collection? _selectedCollection;
bool _loadingCollections = true; bool _loadingCollections = true;
String? _lastProcessedHwId;
DateTime? _lastProcessedAt;
bool get _canAddToSelectedCollection =>
(_selectedCollection?.canModifyCars ?? false);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
MainCollectionSync.changeToken.addListener(_handleMainCollectionChanged);
_loadCollections(); _loadCollections();
} }
/// Public so HomeShell can trigger a refresh when switching to this tab. @override
void dispose() {
MainCollectionSync.changeToken.removeListener(_handleMainCollectionChanged);
super.dispose();
}
void refresh() => _loadCollections(); void refresh() => _loadCollections();
void _handleMainCollectionChanged() {
if (!mounted) return;
_loadCollections();
}
Future<void> _loadCollections() async { Future<void> _loadCollections() async {
try { try {
final list = await CollectionService.getMyCollections(); final list = await CollectionService.getMyCollections();
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw const AuthRequiredException(
'You must be signed in to load collections.',
);
}
final prefs = await SharedPreferences.getInstance();
final persistedId = await readActiveCollectionId(prefs, userId: userId);
Collection? selected;
if (persistedId != null) {
final matching = list.where((c) => c.id == persistedId);
if (matching.isNotEmpty) {
selected = matching.first;
}
}
selected ??= list.isNotEmpty ? list.first : null;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_collections = list; _collections = list;
_selectedCollection = list.isNotEmpty ? list.first : null; _selectedCollection = selected;
_loadingCollections = false; _loadingCollections = false;
}); });
if (selected != null) {
await writeActiveCollectionId(
prefs,
userId: userId,
collectionId: selected.id,
);
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _loadingCollections = false); setState(() => _loadingCollections = false);
ScaffoldMessenger.of(context).showSnackBar( showGlobalError(
SnackBar( e,
content: Text('Failed to load collections: $e'), fallback: 'Failed to load collections. Please try again.',
),
); );
} }
} }
@ -60,7 +104,6 @@ class ScanTabState extends State<ScanTab> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Illustration
Container( Container(
width: 120, width: 120,
height: 120, height: 120,
@ -76,15 +119,12 @@ class ScanTabState extends State<ScanTab> {
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
const Text( const Text(
'Scan a Hot Wheels Car', 'Scan a Die-Cast Car',
style: TextStyle( style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
fontSize: 22,
fontWeight: FontWeight.w700,
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
const Text( const Text(
'Point your camera at the model ID on the\npackaging to instantly add it to your collection.', 'Point your camera at the model ID and add cars\ninstantly to your selected collection.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
@ -93,8 +133,6 @@ class ScanTabState extends State<ScanTab> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Collection picker
if (_loadingCollections) if (_loadingCollections)
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.symmetric(vertical: 8),
@ -149,19 +187,21 @@ class ScanTabState extends State<ScanTab> {
)) ))
.toList(), .toList(),
onChanged: (id) { onChanged: (id) {
setState(() { _setSelectedCollection(id);
final matching =
_collections.where((c) => c.id == id);
_selectedCollection =
matching.isNotEmpty ? matching.first : null;
});
}, },
), ),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
if (_selectedCollection?.isViewer == true)
// Scan button (gradient) const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Text(
'Viewer role is read-only. Choose an owner/member collection to add cars.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textSecondary),
),
),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 56, height: 56,
@ -178,7 +218,7 @@ class ScanTabState extends State<ScanTab> {
], ],
), ),
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _isBusy || _selectedCollection == null onPressed: _isBusy || !_canAddToSelectedCollection
? null ? null
: _openScanner, : _openScanner,
icon: _isBusy icon: _isBusy
@ -210,12 +250,10 @@ class ScanTabState extends State<ScanTab> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Manual entry
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _isBusy || _selectedCollection == null onPressed: _isBusy || !_canAddToSelectedCollection
? null ? null
: _manualEntry, : _manualEntry,
icon: const Icon(Icons.keyboard), icon: const Icon(Icons.keyboard),
@ -229,13 +267,25 @@ class ScanTabState extends State<ScanTab> {
); );
} }
Future<void> _openScanner() async { void _setSelectedCollection(String? id) {
final hwId = await navigatorKey.currentState!.push<String>( if (id == null) return;
MaterialPageRoute(builder: (_) => const ScannerScreen()), final matching = _collections.where((c) => c.id == id);
); if (matching.isEmpty) return;
if (hwId == null || !mounted) return; setState(() => _selectedCollection = matching.first);
await _processHwId(hwId); }
Future<void> _openScanner() async {
await navigatorKey.currentState!.push<void>(
MaterialPageRoute(
builder: (_) => ScannerScreen(
collections: _collections,
activeCollectionId: _selectedCollection?.id,
onCollectionChanged: _setSelectedCollection,
onDetected: (hwId) => _processHwId(hwId),
),
),
);
} }
Future<void> _manualEntry() async { Future<void> _manualEntry() async {
@ -274,34 +324,39 @@ class ScanTabState extends State<ScanTab> {
await _processHwId(result); await _processHwId(result);
} }
Future<void> _processHwId(String hwId) async { Future<bool> _processHwId(String hwId) async {
final now = DateTime.now();
if (_lastProcessedHwId == hwId &&
_lastProcessedAt != null &&
now.difference(_lastProcessedAt!) < _duplicateCooldown) {
return true;
}
_lastProcessedHwId = hwId;
_lastProcessedAt = now;
final collection = _selectedCollection; final collection = _selectedCollection;
if (collection == null) return; if (collection == null) return false;
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
// Check if this hw_id already exists in the selected collection. final existing = await supabase
final data = await supabase
.from('hotwheels') .from('hotwheels')
.select() .select('id')
.eq('hw_id', hwId) .eq('hw_id', hwId)
.eq('collection_id', collection.id) .eq('collection_id', collection.id)
.maybeSingle(); .maybeSingle();
if (!mounted) return; if (existing != null) {
setState(() => _isBusy = false); if (!mounted) return false;
setState(() => _isBusy = false);
if (data != null) {
// Already in collection
await showDialog( await showDialog(
context: context, context: navigatorKey.currentContext ?? context,
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
icon: const Icon(Icons.check_circle, icon: const Icon(Icons.check_circle,
color: AppColors.success, size: 48), color: AppColors.success, size: 48),
title: const Text('Already in Collection!'), title: const Text('Already in Collection!'),
content: content: Text('$hwId is already in "${collection.name}".'),
Text('$hwId is already in "${collection.name}".'),
actions: [ actions: [
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -310,49 +365,257 @@ class ScanTabState extends State<ScanTab> {
], ],
), ),
); );
return true;
}
final globalCar = await supabase
.from('global_cars')
.select('hw_id, name, series, year, color, is_verified, confirmation_count')
.eq('hw_id', hwId)
.maybeSingle();
if (!mounted) return false;
setState(() => _isBusy = false);
if (globalCar != null) {
final addConfirmed = await showModalBottomSheet<bool>(
context: navigatorKey.currentContext ?? context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => _FoundCarSheet(
collectionName: collection.name,
car: globalCar,
),
);
if (addConfirmed == true) {
await _addToCollection(collection.id, hwId);
await _ensureValidationVote(hwId);
if (!mounted) return false;
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
}
} else { } else {
// New offer to add final discovery = await showModalBottomSheet<_NewDiscoveryData>(
final added = await showDialog<bool>( context: navigatorKey.currentContext ?? context,
context: context, isScrollControlled: true,
builder: (_) => _AddCarDialog( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => _NewDiscoverySheet(
hwId: hwId, hwId: hwId,
collectionId: collection.id,
collectionName: collection.name, collectionName: collection.name,
), ),
); );
if (added == true) {
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉'); if (discovery != null) {
await _createGlobalCarAndVote(
hwId: hwId,
name: discovery.name,
series: discovery.series,
year: discovery.year,
);
await _addToCollection(collection.id, hwId, notes: discovery.notes);
if (!mounted) return false;
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
} }
} }
return true;
} catch (e) { } catch (e) {
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
showGlobalSnackBar('DB error: $e', isError: true); showGlobalError(
e,
fallback: 'Could not save this car right now. Please try again.',
);
return true;
} }
} }
Future<void> _addToCollection(
String collectionId,
String hwId, {
String? notes,
}) async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw const AuthRequiredException('You must be signed in to add cars.');
}
await supabase.from('hotwheels').insert({
'hw_id': hwId,
'user_id': userId,
'collection_id': collectionId,
if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(),
});
}
Future<void> _createGlobalCarAndVote({
required String hwId,
required String name,
String? series,
int? year,
}) async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw const AuthRequiredException(
'You must be signed in to create catalog entries.',
);
}
final cleanedSeries = series?.trim();
final payload = <String, dynamic>{
'hw_id': hwId,
'name': name,
'series': (cleanedSeries?.isNotEmpty ?? false) ? cleanedSeries : null,
'year': year,
}..removeWhere((key, value) => value == null);
await supabase.from('global_cars').insert(payload);
await supabase.from('car_votes').insert({
'hw_id': hwId,
'user_id': userId,
});
}
Future<void> _ensureValidationVote(String hwId) async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw const AuthRequiredException(
'You must be signed in to validate entries.',
);
}
final existingVote = await supabase
.from('car_votes')
.select('id')
.eq('hw_id', hwId)
.eq('user_id', userId)
.maybeSingle();
if (existingVote != null) return;
await supabase.from('car_votes').insert({
'hw_id': hwId,
'user_id': userId,
});
}
} }
// Add Car Dialog (inline, styled) class _FoundCarSheet extends StatelessWidget {
class _AddCarDialog extends StatefulWidget {
final String hwId;
final String collectionId;
final String collectionName; final String collectionName;
const _AddCarDialog({ final Map<String, dynamic> car;
const _FoundCarSheet({required this.collectionName, required this.car});
@override
Widget build(BuildContext context) {
final hwId = car['hw_id'] as String? ?? '???';
final name = car['name'] as String? ?? 'Unknown model';
final series = car['series'] as String?;
final year = car['year'];
final verified = car['is_verified'] == true;
final confirmations = (car['confirmation_count'] as num?)?.toInt() ?? 0;
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Found in Catalog',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text('$name ($hwId)', style: const TextStyle(fontWeight: FontWeight.w600)),
if (series != null && series.isNotEmpty) ...[
const SizedBox(height: 4),
Text('Series: $series'),
],
if (year != null) ...[
const SizedBox(height: 4),
Text('Year: $year'),
],
const SizedBox(height: 6),
Text(
verified ? 'Verified by community' : 'Unverified catalog entry',
style: TextStyle(
color: verified ? AppColors.success : AppColors.textSecondary,
fontSize: 12,
),
),
const SizedBox(height: 4),
Text(
'$confirmations confirmation${confirmations == 1 ? '' : 's'}',
style: const TextStyle(
color: AppColors.textHint,
fontSize: 12,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => Navigator.pop(context, true),
icon: const Icon(Icons.add),
label: Text('Add to "$collectionName"'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
),
],
),
);
}
}
class _NewDiscoveryData {
final String name;
final String? series;
final int? year;
final String? notes;
const _NewDiscoveryData({
required this.name,
this.series,
this.year,
this.notes,
});
}
class _NewDiscoverySheet extends StatefulWidget {
final String hwId;
final String collectionName;
const _NewDiscoverySheet({
required this.hwId, required this.hwId,
required this.collectionId,
required this.collectionName, required this.collectionName,
}); });
@override @override
State<_AddCarDialog> createState() => _AddCarDialogState(); State<_NewDiscoverySheet> createState() => _NewDiscoverySheetState();
} }
class _AddCarDialogState extends State<_AddCarDialog> { class _NewDiscoverySheetState extends State<_NewDiscoverySheet> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _seriesController = TextEditingController(); final _seriesController = TextEditingController();
final _yearController = TextEditingController(); final _yearController = TextEditingController();
final _notesController = TextEditingController(); final _notesController = TextEditingController();
bool _isAdding = false; bool _isSaving = false;
File? _pickedImage;
@override @override
void dispose() { void dispose() {
@ -363,208 +626,95 @@ class _AddCarDialogState extends State<_AddCarDialog> {
super.dispose(); super.dispose();
} }
Future<void> _pickImage() async { void _save() {
final picker = ImagePicker(); final name = _nameController.text.trim();
final xFile = await picker.pickImage( if (name.isEmpty) {
source: ImageSource.camera, showGlobalSnackBar('Name is required for a new discovery.', isError: true);
maxWidth: 800, return;
maxHeight: 800, }
imageQuality: 60,
setState(() => _isSaving = true);
Navigator.pop(
context,
_NewDiscoveryData(
name: name,
series: _seriesController.text.trim().isEmpty
? null
: _seriesController.text.trim(),
year: int.tryParse(_yearController.text.trim()),
notes: _notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
),
); );
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,
'collection_id': widget.collectionId,
});
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,
'collection_id': widget.collectionId,
};
// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return Padding(
icon: Container( padding: EdgeInsets.only(
padding: const EdgeInsets.all(12), left: 20,
decoration: const BoxDecoration( right: 20,
gradient: AppColors.brandGradient, top: 20,
shape: BoxShape.circle, bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child:
const Icon(Icons.add, color: Colors.white, size: 28),
), ),
title: Text('Add ${widget.hwId}'), child: Column(
content: SingleChildScrollView( mainAxisSize: MainAxisSize.min,
child: Column( children: [
mainAxisSize: MainAxisSize.min, Text(
children: [ 'New Discovery: ${widget.hwId}',
// Photo picker style: Theme.of(context).textTheme.titleLarge?.copyWith(
GestureDetector( fontWeight: FontWeight.w700,
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( const SizedBox(height: 14),
mainAxisAlignment: MainAxisAlignment.center, TextField(
children: [ controller: _nameController,
Icon(Icons.add_a_photo, autofocus: true,
size: 36, decoration: const InputDecoration(
color: AppColors.orange.withValues(alpha: 0.6)), labelText: 'Name *',
const SizedBox(height: 8), hintText: "e.g. '70 Dodge Charger",
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( const SizedBox(height: 10),
controller: _nameController, TextField(
decoration: const InputDecoration( controller: _seriesController,
labelText: 'Car Name', decoration: const InputDecoration(labelText: 'Series'),
hintText: "e.g. '70 Dodge Charger", ),
), const SizedBox(height: 10),
TextField(
controller: _yearController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Year'),
),
const SizedBox(height: 10),
TextField(
controller: _notesController,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Notes (for your garage entry)',
), ),
const SizedBox(height: 12), ),
TextField( const SizedBox(height: 16),
controller: _seriesController, SizedBox(
decoration: const InputDecoration( width: double.infinity,
labelText: 'Series', child: ElevatedButton.icon(
hintText: 'e.g. HW Flames', onPressed: _isSaving ? null : _save,
), icon: const Icon(Icons.save_outlined),
label: Text('Save & Add to "${widget.collectionName}"'),
), ),
const SizedBox(height: 12), ),
TextField( const SizedBox(height: 8),
controller: _yearController, SizedBox(
keyboardType: TextInputType.number, width: double.infinity,
decoration: const InputDecoration( child: TextButton(
labelText: 'Year', onPressed: _isSaving ? null : () => Navigator.pop(context),
hintText: 'e.g. 2025', child: const Text('Cancel'),
),
), ),
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'),
),
],
); );
} }
} }

View file

@ -1,5 +1,6 @@
//import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter/foundation.dart';
import '../main.dart'; import '../main.dart';
import '../utils/error_utils.dart';
/// Data model for a collection. /// Data model for a collection.
class Collection { class Collection {
@ -24,6 +25,9 @@ class Collection {
}); });
bool get isOwner => role == 'owner'; bool get isOwner => role == 'owner';
bool get isMember => role == 'member';
bool get isViewer => role == 'viewer';
bool get canModifyCars => isOwner || isMember;
} }
/// Member of a collection. /// Member of a collection.
@ -43,16 +47,65 @@ class CollectionMember {
}); });
bool get isOwner => role == 'owner'; bool get isOwner => role == 'owner';
bool get isViewer => role == 'viewer';
}
/// Typed error for collection service operations.
class CollectionServiceException extends AppException {
const CollectionServiceException(super.message);
} }
/// Service for managing collections and membership. /// Service for managing collections and membership.
class CollectionService { class CollectionService {
CollectionService._(); CollectionService._();
static Never _fail(String message) => throw CollectionServiceException(message);
@visibleForTesting
static String normalizeRole(String role) => role.trim().toLowerCase();
@visibleForTesting
static bool isSupportedInviteRole(String role) {
final normalized = normalizeRole(role);
return normalized == 'member' || normalized == 'viewer';
}
static String _requireUserId() {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
_fail('You must be signed in to perform this action.');
}
return userId;
}
/// Ensures the current user has at least one collection membership.
/// Creates a default collection on first login.
static Future<String?> ensureDefaultCollection() async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) return null;
final existing = await supabase
.from('collection_members')
.select('collection_id')
.eq('user_id', userId)
.limit(1);
if (existing.isNotEmpty) {
return existing.first['collection_id'] as String;
}
final created = await create(
name: 'My Garage',
description: 'Your default collection',
);
return created.id;
}
/// Fetch all collections the current user is a member of, /// Fetch all collections the current user is a member of,
/// including item count and member count. /// including item count and member count.
static Future<List<Collection>> getMyCollections() async { static Future<List<Collection>> getMyCollections() async {
final userId = supabase.auth.currentUser!.id; final userId = _requireUserId();
// Get memberships with collection data. // Get memberships with collection data.
final memberships = await supabase final memberships = await supabase
@ -75,25 +128,14 @@ class CollectionService {
final collectionIdList = collectionIds.toList(); final collectionIdList = collectionIds.toList();
// Fetch all items for these collections in a single query and count them in memory. final itemCounts = await getCollectionItemCounts(collectionIdList);
final items = await supabase
.from('hotwheels')
.select('id, collection_id')
.inFilter('collection_id', collectionIdList);
final itemCounts = <String, int>{};
for (final item in items) {
final collectionId = item['collection_id'] as String;
itemCounts[collectionId] = (itemCounts[collectionId] ?? 0) + 1;
}
// Fetch all members for these collections in a single query and count them in memory.
final members = await supabase
.from('collection_members')
.select('id, collection_id')
.inFilter('collection_id', collectionIdList);
final memberCounts = <String, int>{}; final memberCounts = <String, int>{};
final members = await supabase
.from('collection_members')
.select('collection_id')
.inFilter('collection_id', collectionIdList);
for (final member in members) { for (final member in members) {
final collectionId = member['collection_id'] as String; final collectionId = member['collection_id'] as String;
memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1; memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1;
@ -127,30 +169,171 @@ class CollectionService {
return collections; return collections;
} }
static Future<Map<String, int>> getCollectionItemCounts(
List<String> collectionIds) async {
if (collectionIds.isEmpty) return <String, int>{};
final itemCounts = <String, int>{};
try {
final rows = await supabase.rpc('get_collection_counts', params: {
'p_collection_ids': collectionIds,
});
for (final row in rows as List) {
final collectionId = row['collection_id'] as String?;
if (collectionId == null) continue;
itemCounts[collectionId] = (row['total_count'] as num?)?.toInt() ?? 0;
}
} catch (e) {
debugPrint('CollectionService.getCollectionItemCounts RPC error: $e');
}
final unresolvedIds = collectionIds
.where((collectionId) => !itemCounts.containsKey(collectionId))
.toList(growable: false);
if (unresolvedIds.isNotEmpty) {
await Future.wait(unresolvedIds.map((collectionId) async {
itemCounts[collectionId] = await _countCarsPaginated(collectionId);
}));
}
return itemCounts;
}
static Future<({int total, int recent})> getCollectionStats(
String collectionId) async {
try {
final rows = await supabase.rpc('get_collection_counts', params: {
'p_collection_ids': [collectionId],
});
if (rows is List && rows.isNotEmpty) {
final first = rows.first as Map<String, dynamic>;
return (
total: (first['total_count'] as num?)?.toInt() ?? 0,
recent: (first['recent_count'] as num?)?.toInt() ?? 0,
);
}
} catch (e) {
debugPrint('CollectionService.getCollectionStats RPC error: $e');
}
final weekAgoIso = DateTime.now()
.subtract(const Duration(days: 7))
.toUtc()
.toIso8601String();
final total = await _countCarsPaginated(collectionId);
final recent = await _countCarsPaginated(collectionId, sinceIso: weekAgoIso);
return (total: total, recent: recent);
}
static Future<int> _countCarsPaginated(String collectionId,
{String? sinceIso}) async {
const pageSize = 1000;
var from = 0;
var total = 0;
while (true) {
dynamic query = supabase
.from('hotwheels')
.select('id')
.eq('collection_id', collectionId)
.range(from, from + pageSize - 1);
if (sinceIso != null) {
query = query.gte('created_at', sinceIso);
}
final rows = await query;
final count = (rows as List).length;
total += count;
if (count < pageSize) break;
from += pageSize;
}
return total;
}
/// Create a new collection. The caller is automatically added as owner. /// Create a new collection. The caller is automatically added as owner.
static Future<Collection> create({ static Future<Collection> create({
required String name, required String name,
String? description, String? description,
}) async { }) async {
final userId = supabase.auth.currentUser!.id; final userId = _requireUserId();
final trimmedDescription = description?.trim();
final normalizedDescription =
(trimmedDescription != null && trimmedDescription.isNotEmpty)
? trimmedDescription
: null;
try {
final rpcResult = await supabase.rpc(
'create_collection_with_owner',
params: {
'p_name': name,
'p_description': normalizedDescription,
},
);
final row = rpcResult is List
? (rpcResult.isNotEmpty
? rpcResult.first as Map<String, dynamic>
: <String, dynamic>{})
: rpcResult as Map<String, dynamic>;
if (row.isNotEmpty) {
return Collection(
id: row['id'] as String,
name: row['name'] as String,
description: row['description'] as String?,
ownerId: row['owner_id'] as String,
createdAt: DateTime.parse(row['created_at'] as String),
role: 'owner',
itemCount: 0,
memberCount: 1,
);
}
} catch (e) {
final message = e.toString();
final rpcUnavailable = message.contains('create_collection_with_owner') &&
(message.contains('not found') ||
message.contains('does not exist') ||
message.contains('PGRST202'));
if (!rpcUnavailable) {
rethrow;
}
debugPrint('CollectionService.create RPC unavailable, using fallback: $e');
}
final row = await supabase final row = await supabase
.from('collections') .from('collections')
.insert({ .insert({
'name': name, 'name': name,
'owner_id': userId, 'owner_id': userId,
if (description != null && description.isNotEmpty) 'description': normalizedDescription,
'description': description,
}) })
.select() .select()
.single(); .single();
// Add owner as a member. // Add owner as a member.
await supabase.from('collection_members').insert({ try {
'collection_id': row['id'], await supabase.from('collection_members').insert({
'user_id': userId, 'collection_id': row['id'],
'role': 'owner', 'user_id': userId,
}); 'role': 'owner',
});
} catch (e) {
debugPrint('CollectionService.create member insert failed: $e');
try {
await supabase.from('collections').delete().eq('id', row['id']);
} catch (cleanupError) {
debugPrint('CollectionService.create rollback failed: $cleanupError');
}
rethrow;
}
return Collection( return Collection(
id: row['id'] as String, id: row['id'] as String,
@ -170,7 +353,7 @@ class CollectionService {
required String name, required String name,
String? description, String? description,
}) async { }) async {
final userId = supabase.auth.currentUser!.id; final userId = _requireUserId();
final collection = await supabase final collection = await supabase
.from('collections') .from('collections')
@ -179,22 +362,27 @@ class CollectionService {
.maybeSingle(); .maybeSingle();
if (collection == null) { if (collection == null) {
throw Exception('Collection not found.'); _fail('Collection not found.');
} }
if (collection['owner_id'] != userId) { if (collection['owner_id'] != userId) {
throw Exception('Only the collection owner can perform this action.'); _fail('Only the collection owner can perform this action.');
} }
final trimmedDescription = description?.trim();
final normalizedDescription =
(trimmedDescription != null && trimmedDescription.isNotEmpty)
? trimmedDescription
: null;
await supabase.from('collections').update({ await supabase.from('collections').update({
'name': name, 'name': name,
if (description != null && description.isNotEmpty) 'description': normalizedDescription,
'description': description,
}).eq('id', collectionId); }).eq('id', collectionId);
} }
/// Delete a collection. Owner only. Cascade deletes members & items. /// Delete a collection. Owner only. Cascade deletes members & items.
static Future<void> delete(String collectionId) async { static Future<void> delete(String collectionId) async {
final userId = supabase.auth.currentUser!.id; final userId = _requireUserId();
final collection = await supabase final collection = await supabase
.from('collections') .from('collections')
@ -203,10 +391,10 @@ class CollectionService {
.maybeSingle(); .maybeSingle();
if (collection == null) { if (collection == null) {
throw Exception('Collection not found.'); _fail('Collection not found.');
} }
if (collection['owner_id'] != userId) { if (collection['owner_id'] != userId) {
throw Exception('Only the collection owner can perform this action.'); _fail('Only the collection owner can perform this action.');
} }
await supabase.from('collections').delete().eq('id', collectionId); await supabase.from('collections').delete().eq('id', collectionId);
@ -238,19 +426,44 @@ class CollectionService {
static Future<void> inviteByEmail({ static Future<void> inviteByEmail({
required String collectionId, required String collectionId,
required String email, required String email,
String role = 'member',
}) async { }) async {
final normalizedRole = normalizeRole(role);
if (!isSupportedInviteRole(role)) {
_fail('Unsupported role "$role".');
}
final currentUserId = _requireUserId();
final collection = await supabase
.from('collections')
.select('owner_id')
.eq('id', collectionId)
.maybeSingle();
if (collection == null) {
_fail('Collection not found.');
}
if (collection['owner_id'] != currentUserId) {
_fail('Only the collection owner can invite members.');
}
// Call an RPC to look up the user ID by email. // Call an RPC to look up the user ID by email.
final result = await supabase.rpc('get_user_id_by_email', params: { final result = await supabase.rpc('get_user_id_by_email', params: {
'lookup_email': email.trim().toLowerCase(), 'lookup_email': email.trim().toLowerCase(),
}); });
if (result == null || (result is List && result.isEmpty)) { if (result == null || (result is List && result.isEmpty)) {
throw Exception( _fail(
'No user found with that email. They must create an account first.'); 'No user found with that email. They must create an account first.');
} }
final userId = result is List ? result.first['id'] as String : result as String; final userId = result is List ? result.first['id'] as String : result as String;
if (userId == currentUserId) {
_fail('You are already in this collection.');
}
// Check if already a member. // Check if already a member.
final existing = await supabase final existing = await supabase
.from('collection_members') .from('collection_members')
@ -260,30 +473,121 @@ class CollectionService {
.maybeSingle(); .maybeSingle();
if (existing != null) { if (existing != null) {
throw Exception('This user is already a member of this collection.'); _fail('This user is already a member of this collection.');
} }
await supabase.from('collection_members').insert({ try {
'collection_id': collectionId, await supabase.from('collection_members').insert({
'user_id': userId, 'collection_id': collectionId,
'role': 'member', 'user_id': userId,
}); 'role': normalizedRole,
});
} catch (e) {
final message = e.toString();
if (normalizedRole == 'viewer' &&
message.contains('collection_members_role_check')) {
_fail(
'Viewer role is currently unavailable. Please contact the app administrator.',
);
}
rethrow;
}
} }
/// Remove a member from a collection. /// Remove a member from a collection.
static Future<void> removeMember({ static Future<void> removeMember({
required String collectionId, required String collectionId,
required String membershipId, required String memberUserId,
}) async { }) async {
await supabase final currentUserId = _requireUserId();
.from('collection_members')
.delete() final collection = await supabase
.eq('id', membershipId); .from('collections')
.select('owner_id')
.eq('id', collectionId)
.maybeSingle();
if (collection == null) {
_fail('Collection not found.');
}
if (collection['owner_id'] != currentUserId) {
_fail('Only the collection owner can remove members.');
}
if (memberUserId == collection['owner_id']) {
_fail('Collection owner cannot be removed.');
}
final membersBefore = await getMembers(collectionId);
final targetMembership =
membersBefore.where((member) => member.userId == memberUserId);
if (targetMembership.isEmpty) {
_fail('Member not found in this collection.');
}
final target = targetMembership.first;
if (target.role == 'owner') {
_fail('Collection owner cannot be removed.');
}
var deleted = false;
try {
await supabase.rpc('remove_collection_member', params: {
'p_collection_id': collectionId,
'p_member_user_id': memberUserId,
});
deleted = true;
} catch (e) {
final message = e.toString();
final rpcUnavailable = message.contains('remove_collection_member') &&
(message.contains('not found') ||
message.contains('does not exist') ||
message.contains('PGRST202'));
if (!rpcUnavailable) {
rethrow;
}
await supabase
.from('collection_members')
.delete()
.eq('collection_id', collectionId)
.eq('user_id', memberUserId);
deleted = true;
}
if (!deleted) {
_fail('Member removal failed.');
}
final membersAfter = await getMembers(collectionId);
final stillExists =
membersAfter.any((member) => member.userId == memberUserId);
if (stillExists) {
_fail(
'Member could not be removed. Please try again in a moment.',
);
}
} }
/// Leave a collection (for non-owners). /// Leave a collection (for non-owners).
static Future<void> leave(String collectionId) async { static Future<void> leave(String collectionId) async {
final userId = supabase.auth.currentUser!.id; final userId = _requireUserId();
final membership = await supabase
.from('collection_members')
.select('role')
.eq('collection_id', collectionId)
.eq('user_id', userId)
.maybeSingle();
if (membership == null) {
_fail('You are not a member of this collection.');
}
if (membership['role'] == 'owner') {
_fail('Owner cannot leave. Delete the collection instead.');
}
await supabase await supabase
.from('collection_members') .from('collection_members')
.delete() .delete()

View file

@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
class MainCollectionSync {
MainCollectionSync._();
static final ValueNotifier<int> changeToken = ValueNotifier<int>(0);
static void notifyChanged() {
changeToken.value = changeToken.value + 1;
}
}

View file

@ -1,72 +1,173 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../main.dart'; import '../main.dart';
import '../utils/error_utils.dart';
/// Handles uploading / deleting car images in Supabase Storage. /// Handles uploading / deleting car images in Supabase Storage.
/// ///
/// Bucket: `car-images` (public, but URLs are unguessable) /// Bucket: `car-images` (private)
/// Path: `cars/{uuid}.jpg` random UUID per image. /// Path: `{auth.uid()}/{entry.id}.jpg`
///
/// Shared garage any authenticated user can upload / replace / delete.
class StorageService { class StorageService {
StorageService._(); StorageService._();
static const _bucket = 'car-images'; static const _bucket = 'car-images';
static const _uuid = Uuid(); static const int _maxImageBytes = 500 * 1024;
static const int _maxWidth = 1080;
static const int _signedUrlExpirySeconds = 3600;
static const int _maxCompressionAttempts = 5;
static const _signedUrlRefreshBuffer = Duration(minutes: 3);
static const _maxSignedUrlCacheEntries = 500;
static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {};
/// Upload a photo from [file]. /// Upload a car image for a specific garage entry.
/// /// Returns the storage path on success (e.g. `uid/123.jpg`).
/// If [oldImageUrl] is provided the previous file is deleted first. static Future<String> uploadCarImage({
/// Returns the public URL on success, or `null` on failure.
static Future<String?> uploadCarImage({
required File file, required File file,
String? oldImageUrl, required int entryId,
String? oldPath,
}) async { }) async {
try { final user = supabase.auth.currentUser;
// Clean up old image if re-uploading. if (user == null) {
if (oldImageUrl != null) { throw const AuthRequiredException(
await _deleteByUrl(oldImageUrl); 'You must be signed in to upload images.',
);
}
final userId = user.id;
final path = '$userId/$entryId.jpg';
final compressed = await _compressImage(file);
await supabase.storage.from(_bucket).uploadBinary(
path,
compressed,
fileOptions: const FileOptions(
upsert: true,
contentType: 'image/jpeg',
),
);
_signedUrlCache.remove(path);
if (oldPath != null && oldPath.isNotEmpty && oldPath != path) {
try {
await deleteCarImage(oldPath);
} catch (e) {
debugPrint('StorageService.uploadCarImage cleanup error: $e');
} }
}
final path = 'cars/${_uuid.v4()}.jpg'; return path;
}
await supabase.storage.from(_bucket).upload( /// Generates a temporary signed URL for a private image path.
path, static Future<String?> createSignedUrl(String? path) async {
file, if (path == null || path.isEmpty) return null;
fileOptions: const FileOptions(
contentType: 'image/jpeg',
),
);
// Return the public URL. final now = DateTime.now();
final url = supabase.storage.from(_bucket).getPublicUrl(path); final cached = _signedUrlCache[path];
return url; if (cached != null &&
now.isBefore(cached.expiresAt.subtract(_signedUrlRefreshBuffer))) {
return cached.url;
}
try {
final signed = await supabase.storage
.from(_bucket)
.createSignedUrl(path, _signedUrlExpirySeconds);
_signedUrlCache[path] = _SignedUrlCacheEntry(
url: signed,
expiresAt: now.add(const Duration(seconds: _signedUrlExpirySeconds)),
);
_pruneSignedUrlCache(now);
return signed;
} catch (e) { } catch (e) {
debugPrint('StorageService.uploadCarImage error: $e'); debugPrint('StorageService.createSignedUrl error: $e');
return null; return null;
} }
} }
/// Delete the image at the given public [imageUrl]. /// Deletes an image using its storage path.
static Future<void> deleteCarImage(String? imageUrl) async { static Future<void> deleteCarImage(String? path) async {
if (imageUrl == null || imageUrl.isEmpty) return; if (path == null || path.isEmpty) return;
await _deleteByUrl(imageUrl); _signedUrlCache.remove(path);
}
/// Extract the storage path from a public URL and remove the file.
static Future<void> _deleteByUrl(String imageUrl) async {
try { 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]); await supabase.storage.from(_bucket).remove([path]);
} catch (e) { } catch (e) {
debugPrint('StorageService._deleteByUrl error: $e'); debugPrint('StorageService.deleteCarImage error: $e');
} }
} }
static void invalidateSignedUrl(String? path) {
if (path == null || path.isEmpty) return;
_signedUrlCache.remove(path);
}
static void _pruneSignedUrlCache(DateTime now) {
_signedUrlCache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
if (_signedUrlCache.length <= _maxSignedUrlCacheEntries) return;
final overflow = _signedUrlCache.length - _maxSignedUrlCacheEntries;
final keys = _signedUrlCache.keys.take(overflow).toList();
for (final key in keys) {
_signedUrlCache.remove(key);
}
}
static Future<Uint8List> _compressImage(File source) async {
final bytes = await source.readAsBytes();
return compute(_compressImageBytes, bytes);
}
/// Performs CPU-intensive image decoding, resizing, and JPEG encoding.
/// Runs in a background isolate via [compute] to avoid blocking the UI thread.
static Uint8List _compressImageBytes(Uint8List bytes) {
final decoded = img.decodeImage(bytes);
if (decoded == null) {
throw const ValidationException('Invalid image file.');
}
img.Image working = decoded.width > _maxWidth
? img.copyResize(decoded, width: _maxWidth)
: decoded;
var quality = 85;
Uint8List out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
// First pass: reduce JPEG quality.
var qualityAttempts = 0;
while (out.lengthInBytes > _maxImageBytes &&
quality > 35 &&
qualityAttempts < _maxCompressionAttempts) {
quality -= 5;
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
qualityAttempts += 1;
}
// Second pass: reduce dimensions progressively if still above the limit.
var dimensionAttempts = 0;
while (out.lengthInBytes > _maxImageBytes &&
working.width > 320 &&
dimensionAttempts < _maxCompressionAttempts) {
final nextWidth = (working.width * 0.85).round();
working = img.copyResize(working, width: nextWidth);
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
dimensionAttempts += 1;
}
if (out.lengthInBytes > _maxImageBytes) {
throw ValidationException(
'Image is too large after compression (${out.lengthInBytes} bytes).',
);
}
return out;
}
}
class _SignedUrlCacheEntry {
final String url;
final DateTime expiresAt;
const _SignedUrlCacheEntry({required this.url, required this.expiresAt});
} }

View file

@ -1,31 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Brand colours pulled from the HW Collector Hub email template. /// Brand colours aligned with https://www.car64.app.
class AppColors { class AppColors {
AppColors._(); AppColors._();
// Primary gradient (header) // Brand (website)
static const Color orange = Color(0xFFF9A11B); static const Color orange = Color(0xFF0EA5E9); // keep name for compatibility
static const Color red = Color(0xFFD40000); static const Color red = Color(0xFF38BDF8); // secondary neon tint
// Accent / CTA // Accent / CTA
static const Color navy = Color(0xFF003D7A); static const Color navy = Color(0xFF0F172A);
static const Color navyLight = Color(0xFF0A5BA8); static const Color navyLight = Color(0xFF1E293B);
// Surfaces // Surfaces
static const Color backgroundLight = Color(0xFFF4F4F4); static const Color backgroundLight = Color(0xFFF8FAFC);
static const Color cardLight = Color(0xFFFFFFFF); static const Color cardLight = Color(0xFFFFFFFF);
static const Color footerGrey = Color(0xFFEEEEEE); static const Color footerGrey = Color(0xFFE2E8F0);
// Text // Text
static const Color textPrimary = Color(0xFF333333); static const Color textPrimary = Color(0xFF0F172A);
static const Color textSecondary = Color(0xFF777777); static const Color textSecondary = Color(0xFF475569);
static const Color textHint = Color(0xFF999999); static const Color textHint = Color(0xFF64748B);
// Dark mode surfaces // Dark mode surfaces
static const Color backgroundDark = Color(0xFF1A1A2E); static const Color backgroundDark = Color(0xFF0F172A);
static const Color cardDark = Color(0xFF24243E); static const Color cardDark = Color(0xFF111827);
static const Color surfaceDark = Color(0xFF2D2D48); static const Color surfaceDark = Color(0xFF1E293B);
// Utility // Utility
static const Color success = Color(0xFF2ECC71); static const Color success = Color(0xFF2ECC71);
@ -40,7 +40,7 @@ class AppColors {
/// A subtler version for cards / chips. /// A subtler version for cards / chips.
static const LinearGradient brandGradientSoft = LinearGradient( static const LinearGradient brandGradientSoft = LinearGradient(
colors: [Color(0xFFFFF3E0), Color(0xFFFFEBEE)], colors: [Color(0xFFE0F2FE), Color(0xFFF0F9FF)],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
); );

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_colors.dart'; import 'app_colors.dart';
/// Central theme definition for HW Collector Hub. /// Central theme definition for car64.
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();

View file

@ -0,0 +1,67 @@
import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Base app-level exception for expected, user-facing failures.
class AppException implements Exception {
final String message;
const AppException(this.message);
@override
String toString() => message;
}
class AuthRequiredException extends AppException {
const AuthRequiredException(super.message);
}
class PermissionDeniedException extends AppException {
const PermissionDeniedException(super.message);
}
class NotFoundException extends AppException {
const NotFoundException(super.message);
}
class ValidationException extends AppException {
const ValidationException(super.message);
}
String userMessageForError(
Object error, {
String fallback = 'Something went wrong. Please try again.',
}) {
if (error is AppException) {
return error.message;
}
if (error is AuthException) {
return error.message;
}
final raw = error.toString();
final normalized = raw.toLowerCase();
if (normalized.contains('socket') ||
normalized.contains('network') ||
normalized.contains('timeout')) {
return 'Network issue. Please check your connection and try again.';
}
if (normalized.contains('permission') || normalized.contains('not allowed')) {
return 'You do not have permission for this action.';
}
if (normalized.contains('signed in')) {
return 'Please sign in again and retry.';
}
return fallback;
}
void logError(String scope, Object error, [StackTrace? stackTrace]) {
debugPrint('[$scope] $error');
if (stackTrace != null) {
debugPrint('$stackTrace');
}
}

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