Merge branch 'fix/flutter-code-review-findings-2026-03-05' into v3.0

This commit is contained in:
Lukas Müllner 2026-03-06 06:37:09 +01:00
commit e4f0f73fe8
22 changed files with 1009 additions and 164 deletions

View file

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

4
.gitignore vendored
View file

@ -48,6 +48,10 @@ app.*.map.json
/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

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": []
}
]
}

52
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,52 @@
# 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
- `fix(scanner): prevent duplicate processing on repeated detections`
- `perf(collections): coalesce overlapping reload requests`
- `docs(readme): add release workflow section`

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.

197
README.md
View file

@ -1,38 +1,193 @@
# car64
<p align="center">
<img src="assets/icon/app_icon.png" alt="car64 logo" width="120" />
</p>
car64 is a Flutter + Supabase Hot Wheels collector app with a fast scanning workflow, private photo storage, collaborative collections, and community-based catalog validation.
<h1 align="center">car64</h1>
## Core Features
<p align="center">
A modern Flutter + Supabase app for tracking and managing die-cast car collections.
</p>
- Lightning add flow with barcode/OCR scanning and active collection selection.
- Global catalog (`global_cars`) + personal entries (`hotwheels`) architecture.
- Private storage (`car-images`) with signed URL rendering for collection members.
- In-app photo compression pipeline (target max 1080px and <500 KB uploads).
- Collaborative garages via `collections` + `collection_members`.
- Community validation with `car_votes`, `confirmation_count`, and verification state.
---
## Local Development
## Table of Contents
1. Install Flutter (stable) and run `flutter doctor`.
2. Configure Supabase keys in app configuration.
3. Install dependencies:
- [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` (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
1. Install dependencies
```bash
flutter pub get
```
4. Run the app:
```bash
flutter run
```
## Quality Check
2. Run analyze
```bash
flutter analyze
```
3. Launch app
```bash
flutter run
```
## VS Code Workflows
### Run / Debug (`launch.json`)
Use **Run and Debug** with:
- `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)`
## 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` for branching, commit style, 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`.
## License
License file is not included in this repository yet.
Add your preferred license in the GitHub repo when ready.
## Reference
- Product and backend requirements: `TPB.md`
- Product/backend notes: `TPB.md`

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,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@ -6,23 +8,44 @@ import 'services/collection_service.dart';
import 'services/main_collection_sync.dart';
import 'screens/login_screen.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.
export 'package:supabase_flutter/supabase_flutter.dart'
show AuthException, UserAttributes;
// Supabase credentials
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
const _supabaseAnonKey = 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL';
const _supabaseUrl = String.fromEnvironment(
'SUPABASE_URL',
defaultValue: '',
);
const _supabaseAnonKey = String.fromEnvironment(
'SUPABASE_ANON_KEY',
defaultValue: '',
);
const _usePkceAuthFlow = bool.fromEnvironment(
'SUPABASE_USE_PKCE',
defaultValue: true,
);
Future<void> main() async {
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(
url: _supabaseUrl,
anonKey: _supabaseAnonKey,
authOptions: const FlutterAuthClientOptions(
authFlowType: AuthFlowType.implicit,
authOptions: FlutterAuthClientOptions(
authFlowType: _usePkceAuthFlow
? AuthFlowType.pkce
: AuthFlowType.implicit,
),
);
@ -35,29 +58,121 @@ final supabase = Supabase.instance.client;
/// Global keys so dialogs & snackbars survive widget-tree rebuilds.
final navigatorKey = GlobalKey<NavigatorState>();
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
OverlayEntry? _activeMessageOverlay;
Timer? _activeMessageOverlayTimer;
enum GlobalMessageType { info, success, error }
/// Show a snackbar safely through the global key.
void showGlobalSnackBar(String message, {bool isError = false}) {
final messenger = scaffoldMessengerKey.currentState;
if (messenger == null) return;
_showGlobalMessageOverlay(
message,
type: isError ? GlobalMessageType.error : GlobalMessageType.info,
);
}
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : null,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 96),
duration: const Duration(seconds: 3),
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.
Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
final context = navigatorKey.currentContext;
if (context == null) {
return Future<T?>.value(null);
}
return showDialog<T>(
context: navigatorKey.currentContext!,
context: context,
builder: builder,
);
}
@ -90,8 +205,6 @@ class AuthGate extends StatefulWidget {
}
class _AuthGateState extends State<AuthGate> {
static const _activeCollectionPrefKey = 'active_collection_id';
bool _isLoading = true;
bool _isInPasswordRecoveryFlow = false;
Session? _session;
@ -124,7 +237,10 @@ class _AuthGateState extends State<AuthGate> {
}
},
onError: (error) {
showGlobalSnackBar('Auth error: $error', isError: true);
showGlobalError(
error,
fallback: 'Authentication error. Please sign in again.',
);
},
);
@ -140,7 +256,10 @@ class _AuthGateState extends State<AuthGate> {
final defaultCollectionId = await CollectionService.ensureDefaultCollection();
await _ensureMainCollectionPreference(defaultCollectionId);
} catch (e) {
showGlobalSnackBar('Collection setup failed: $e', isError: true);
showGlobalError(
e,
fallback: 'Collection setup failed. Please try again.',
);
}
}
@ -149,7 +268,7 @@ class _AuthGateState extends State<AuthGate> {
if (userId == null) return;
final prefs = await SharedPreferences.getInstance();
final persisted = prefs.getString(_activeCollectionPrefKey);
final persisted = await readActiveCollectionId(prefs, userId: userId);
Future<bool> hasMembership(String collectionId) async {
final membership = await supabase
@ -180,17 +299,27 @@ class _AuthGateState extends State<AuthGate> {
}
if (nextActiveId == null) {
await prefs.remove(_activeCollectionPrefKey);
await clearActiveCollectionId(prefs, userId: userId);
return;
}
await prefs.setString(_activeCollectionPrefKey, nextActiveId);
await writeActiveCollectionId(
prefs,
userId: userId,
collectionId: nextActiveId,
);
MainCollectionSync.notifyChanged();
}
Future<void> _showResetPasswordDialog() async {
final context = navigatorKey.currentContext;
if (context == null) {
_isInPasswordRecoveryFlow = false;
return;
}
await showDialog<void>(
context: navigatorKey.currentContext!,
context: context,
barrierDismissible: false,
builder: (_) => const _ResetPasswordDialog(),
);
@ -245,7 +374,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
);
if (!mounted) return;
Navigator.of(context).pop();
showGlobalSnackBar('Password updated successfully!');
showGlobalSuccess('Password updated successfully!');
} on AuthException catch (e) {
if (!mounted) return;
setState(() => _isSaving = false);

View file

@ -6,9 +6,10 @@ import 'package:camera/camera.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'services/collection_service.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").
///
/// The detected ID is returned via Navigator.pop(context, hwId).
@ -36,6 +37,7 @@ class _ScannerScreenState extends State<ScannerScreen>
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;
late final TextRecognizer _textRecognizer;
@ -51,6 +53,7 @@ class _ScannerScreenState extends State<ScannerScreen>
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
bool _isInitializingCamera = false;
String? _cameraError;
int _consecutiveMisses = 0;
@override
void initState() {
@ -126,9 +129,15 @@ class _ScannerScreenState extends State<ScannerScreen>
_cameraError = 'Camera unavailable. Please retry.';
_statusText = 'Camera unavailable';
});
logError('scanner.initCamera', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Camera init failed: $e'),
content: Text(
userMessageForError(
e,
fallback: 'Failed to start camera. Please try again.',
),
),
backgroundColor: Colors.red,
),
);
@ -165,7 +174,7 @@ class _ScannerScreenState extends State<ScannerScreen>
});
}
/// Capture a photo, run OCR, and look for a Hot Wheels ID.
/// Capture a photo, run OCR, and look for a die-cast model ID.
Future<void> _captureAndScan() async {
if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return;
if (_cameraController!.value.isTakingPicture) return;
@ -195,13 +204,23 @@ class _ScannerScreenState extends State<ScannerScreen>
if (!mounted) return;
if (found != null) {
_consecutiveMisses = 0;
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
setState(() => _lastDetected = found);
if (widget.onDetected != null) {
await _submitDetected(found);
}
} else {
_nextScanAllowedAt = DateTime.now().add(_scanCooldownNoMatch);
_consecutiveMisses += 1;
final missBackoffMs = (_scanCooldownNoMatch.inMilliseconds +
(_consecutiveMisses * 300))
.clamp(
_scanCooldownNoMatch.inMilliseconds,
_scanCooldownNoMatchMax.inMilliseconds,
);
_nextScanAllowedAt = DateTime.now().add(
Duration(milliseconds: missBackoffMs),
);
setState(() {
_scanAccepted = false;
_scanNotFound = true;
@ -209,10 +228,20 @@ class _ScannerScreenState extends State<ScannerScreen>
});
}
} catch (e) {
_consecutiveMisses += 1;
_nextScanAllowedAt = DateTime.now().add(_scanCooldownError);
if (!mounted) return;
logError('scanner.capture', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scan error: $e'), backgroundColor: Colors.red),
SnackBar(
content: Text(
userMessageForError(
e,
fallback: 'Scan failed. Please try again.',
),
),
backgroundColor: Colors.red,
),
);
setState(() => _statusText = 'Scan failed, try again');
} finally {
@ -258,8 +287,17 @@ class _ScannerScreenState extends State<ScannerScreen>
}
} catch (e) {
if (!mounted) return;
logError('scanner.submitDetected', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Process error: $e'), backgroundColor: Colors.red),
SnackBar(
content: Text(
userMessageForError(
e,
fallback: 'Could not process this scan. Please try again.',
),
),
backgroundColor: Colors.red,
),
);
}
}

View file

@ -106,7 +106,7 @@ class _AboutScreenState extends State<AboutScreen> {
const SizedBox(height: 8),
Center(
child: Text(
'Track and manage your Hot Wheels collection.',
'Track and manage your die-cast car collection.',
style: theme.textTheme.bodyMedium?.copyWith(
color: AppColors.textHint,
),

View file

@ -4,6 +4,8 @@ import '../main.dart';
import '../services/collection_service.dart';
import '../services/main_collection_sync.dart';
import '../theme/app_colors.dart';
import '../utils/error_utils.dart';
import '../utils/preferences_utils.dart';
import 'garage_screen.dart';
import 'manage_collection_screen.dart';
@ -17,10 +19,10 @@ class CollectionsScreen extends StatefulWidget {
class CollectionsScreenState extends State<CollectionsScreen>
with WidgetsBindingObserver {
static const _activeCollectionPrefKey = 'active_collection_id';
List<Collection> _collections = [];
bool _isLoading = true;
bool _isLoadInFlight = false;
bool _reloadQueued = false;
String? _error;
String? _activeCollectionId;
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
@ -61,11 +63,20 @@ class CollectionsScreenState extends State<CollectionsScreen>
}
}
Future<void> _load() async {
Future<void> _load({bool showLoading = true}) async {
if (_isLoadInFlight) {
_reloadQueued = true;
return;
}
_isLoadInFlight = true;
if (showLoading) {
setState(() {
_isLoading = true;
_error = null;
});
}
try {
var list = await CollectionService.getMyCollections();
@ -73,8 +84,13 @@ class CollectionsScreenState extends State<CollectionsScreen>
await CollectionService.ensureDefaultCollection();
list = await CollectionService.getMyCollections();
}
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('You must be signed in to load collections.');
}
final prefs = await SharedPreferences.getInstance();
final persisted = prefs.getString(_activeCollectionPrefKey);
final persisted = await readActiveCollectionId(prefs, userId: userId);
String? activeId = persisted;
if (activeId == null && list.isNotEmpty) {
@ -95,7 +111,11 @@ class CollectionsScreenState extends State<CollectionsScreen>
});
if (activeId != null) {
await prefs.setString(_activeCollectionPrefKey, activeId);
await writeActiveCollectionId(
prefs,
userId: userId,
collectionId: activeId,
);
if (shouldNotifySync) {
MainCollectionSync.notifyChanged();
}
@ -103,9 +123,19 @@ class CollectionsScreenState extends State<CollectionsScreen>
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_error = userMessageForError(
e,
fallback: 'Failed to load collections. Please try again.',
);
_isLoading = false;
});
logError('collections.load', e);
} finally {
_isLoadInFlight = false;
if (_reloadQueued) {
_reloadQueued = false;
Future<void>.microtask(() => _load(showLoading: false));
}
}
}
@ -137,7 +167,7 @@ class CollectionsScreenState extends State<CollectionsScreen>
maxLength: 50,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'e.g. Hot Wheels, Matchbox…',
hintText: 'e.g. Die-Cast Cars, Matchbox…',
),
validator: (value) {
final trimmed = value?.trim() ?? '';
@ -182,10 +212,13 @@ class CollectionsScreenState extends State<CollectionsScreen>
name: nameCtrl.text.trim(),
description: descCtrl.text.trim(),
);
showGlobalSnackBar('Collection created!');
showGlobalSuccess('Collection created!');
_load();
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
showGlobalError(
e,
fallback: 'Could not create collection. Please try again.',
);
}
}
@ -210,12 +243,22 @@ class CollectionsScreenState extends State<CollectionsScreen>
}
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 prefs.setString(_activeCollectionPrefKey, collectionId);
await writeActiveCollectionId(
prefs,
userId: userId,
collectionId: collectionId,
);
if (!mounted) return;
setState(() => _activeCollectionId = collectionId);
MainCollectionSync.notifyChanged();
showGlobalSnackBar('Main collection set for scanning.');
showGlobalInfo('Main collection set for scanning.');
}
@override

View file

@ -7,9 +7,10 @@ import '../main.dart';
import '../services/collection_service.dart';
import '../services/storage_service.dart';
import '../theme/app_colors.dart';
import '../utils/error_utils.dart';
import '../widgets/car_card.dart';
/// The "My Garage" screen shows a collection's cars in a grid.
/// The "My Garage" screen shows a collection's die-cast cars in a grid.
class GarageScreen extends StatefulWidget {
final String collectionId;
final String collectionName;
@ -108,21 +109,11 @@ class GarageScreenState extends State<GarageScreen> {
.range(from, to);
final rows = List<Map<String, dynamic>>.from(data);
final withSignedUrls = await Future.wait(
rows.map((row) async {
final path = row['user_image_url'] as String?;
final signed = await StorageService.createSignedUrl(path);
return {
...row,
'signed_image_url': signed,
};
}),
);
if (!mounted) return;
setState(() {
_cars = reset ? withSignedUrls : [..._cars, ...withSignedUrls];
_hasMore = withSignedUrls.length == _pageSize;
_cars = reset ? rows : [..._cars, ...rows];
_hasMore = rows.length == _pageSize;
if (_hasMore) _page += 1;
_isLoading = false;
_isLoadingMore = false;
@ -130,10 +121,14 @@ class GarageScreenState extends State<GarageScreen> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_error = userMessageForError(
e,
fallback: 'Failed to load cars. Please try again.',
);
_isLoading = false;
_isLoadingMore = false;
});
logError('garage.loadCars', e);
}
}
@ -329,14 +324,19 @@ class GarageScreenState extends State<GarageScreen> {
color: global?['color'] as String?,
isVerified: global?['is_verified'] == true,
imageUrl: car['signed_image_url'] as String?,
imagePath: car['user_image_url'] as String?,
isSelected: _selectedIds.contains(carId),
addedAt: car['created_at'] != null
? DateTime.tryParse(car['created_at'])
: null,
onImageError: () => _refreshSignedUrlForCar(carId),
onTap: () => _selectionMode
? _toggleCarSelection(car)
: _showCarDetails(car),
onTap: () {
if (_selectionMode) {
_toggleCarSelection(car);
} else {
_showCarDetails(car);
}
},
onLongPress:
widget.isViewer ? null : () => _toggleCarSelection(car),
);
@ -476,33 +476,110 @@ class GarageScreenState extends State<GarageScreen> {
);
if (confirmed != true || targetId == null) return;
final targetCollectionId = targetId!;
if (isOwner) {
await supabase
.from('hotwheels')
.update({'collection_id': targetId})
.inFilter('id', _selectedIds.toList());
} else {
final sourceCars = _cars
.where((car) => _selectedIds.contains(car['id'] as int))
.toList(growable: false);
final hwIds = sourceCars
.map((car) => car['hw_id'] as String)
.toSet()
.toList(growable: false);
final existing = await supabase
.from('hotwheels')
.select('hw_id')
.eq('collection_id', targetCollectionId)
.inFilter('hw_id', hwIds);
final existingHwIds = (existing as List)
.map((row) => row['hw_id'] as String)
.toSet();
final moveableIds = sourceCars
.where((car) => !existingHwIds.contains(car['hw_id'] as String))
.map((car) => car['id'] as int)
.toList(growable: false);
final skippedDuplicates = sourceCars.length - moveableIds.length;
if (moveableIds.isEmpty) {
showGlobalSnackBar(
'All selected cars are already in the target collection.',
isError: true,
);
return;
}
await supabase
.from('hotwheels')
.update({'collection_id': targetCollectionId})
.inFilter('id', moveableIds);
if (skippedDuplicates > 0) {
showGlobalSnackBar(
'$skippedDuplicates car(s) skipped because they already exist in target collection.',
);
}
} else {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('You must be signed in to copy cars.');
}
final sourceCars = _cars
.where((car) => _selectedIds.contains(car['id'] as int))
.toList(growable: false);
final hwIds = sourceCars
.map((car) => car['hw_id'] as String)
.toSet()
.toList(growable: false);
final existing = await supabase
.from('hotwheels')
.select('hw_id')
.eq('collection_id', targetCollectionId)
.inFilter('hw_id', hwIds);
final existingHwIds = (existing as List)
.map((row) => row['hw_id'] as String)
.toSet();
final insertRows = sourceCars.map((car) {
final notes = car['notes'] as String?;
final imagePath = car['user_image_url'] as String?;
return <String, dynamic>{
'hw_id': car['hw_id'] as String,
'user_id': supabase.auth.currentUser!.id,
'collection_id': targetId,
'user_id': userId,
'collection_id': targetCollectionId,
if (notes != null && notes.trim().isNotEmpty) 'notes': notes,
if (imagePath != null && imagePath.isNotEmpty)
'user_image_url': imagePath,
};
}).toList(growable: false);
}).where((row) => !existingHwIds.contains(row['hw_id'] as String)).toList(
growable: false,
);
final skippedDuplicates = sourceCars.length - insertRows.length;
if (insertRows.isEmpty) {
showGlobalSnackBar(
'All selected cars are already in the target collection.',
isError: true,
);
return;
}
if (insertRows.isNotEmpty) {
await supabase.from('hotwheels').insert(insertRows);
}
if (skippedDuplicates > 0) {
showGlobalSnackBar(
'$skippedDuplicates car(s) skipped because they already exist in target collection.',
);
}
}
if (!mounted) return;
@ -514,9 +591,11 @@ class GarageScreenState extends State<GarageScreen> {
_toggleSelectionMode(false);
await _loadCars(reset: true);
} catch (e) {
showGlobalSnackBar(
widget.isOwner ? 'Failed to move cars: $e' : 'Failed to copy cars: $e',
isError: true,
showGlobalError(
e,
fallback: widget.isOwner
? 'Failed to move cars. Please try again.'
: 'Failed to copy cars. Please try again.',
);
}
}
@ -545,7 +624,7 @@ class GarageScreenState extends State<GarageScreen> {
}
}
void _showCarDetails(Map<String, dynamic> car) {
Future<void> _showCarDetails(Map<String, dynamic> car) async {
if (_selectionMode) {
_toggleCarSelection(car);
return;
@ -559,7 +638,24 @@ class GarageScreenState extends State<GarageScreen> {
final verified = global?['is_verified'] == true;
final confirmations = (global?['confirmation_count'] as num?)?.toInt() ?? 0;
final notes = car['notes'] as String?;
final imageUrl = car['signed_image_url'] as String?;
String? imageUrl = car['signed_image_url'] as String?;
if (imageUrl == null || imageUrl.isEmpty) {
final path = car['user_image_url'] as String?;
final signed = await StorageService.createSignedUrl(path);
if (!mounted) return;
if (signed != null && signed.isNotEmpty) {
imageUrl = signed;
final index = _cars.indexWhere((c) => c['id'] == car['id']);
if (index != -1) {
setState(() {
_cars[index] = {
..._cars[index],
'signed_image_url': signed,
};
});
}
}
}
showModalBottomSheet(
context: context,
@ -817,7 +913,7 @@ class GarageScreenState extends State<GarageScreen> {
);
if (xFile == null) return;
showGlobalSnackBar('Uploading photo…');
showGlobalInfo('Uploading photo…');
final oldPath = car['user_image_url'] as String?;
String newPath;
@ -828,9 +924,9 @@ class GarageScreenState extends State<GarageScreen> {
oldPath: oldPath,
);
} catch (e) {
showGlobalSnackBar(
'Failed to upload photo. Please try a smaller/clearer image. ($e)',
isError: true,
showGlobalError(
e,
fallback: 'Failed to upload photo. Please try a smaller image.',
);
return;
}
@ -841,11 +937,14 @@ class GarageScreenState extends State<GarageScreen> {
.update({'user_image_url': newPath})
.eq('id', car['id']);
showGlobalSnackBar('Photo updated!');
showGlobalSuccess('Photo updated!');
if (sheetContext.mounted) Navigator.pop(sheetContext);
_loadCars(reset: true); // refresh grid
} catch (e) {
showGlobalSnackBar('Failed to save: $e', isError: true);
showGlobalError(
e,
fallback: 'Failed to save photo. Please try again.',
);
}
}
@ -869,11 +968,14 @@ class GarageScreenState extends State<GarageScreen> {
.update(updated)
.eq('id', car['id']);
showGlobalSnackBar('Car updated!');
showGlobalSuccess('Car updated!');
if (sheetContext.mounted) Navigator.pop(sheetContext);
_loadCars(reset: true);
} catch (e) {
showGlobalSnackBar('Failed to update: $e', isError: true);
showGlobalError(
e,
fallback: 'Failed to update car. Please try again.',
);
}
}
@ -890,7 +992,7 @@ class GarageScreenState extends State<GarageScreen> {
.maybeSingle();
if (existingVote != null) {
showGlobalSnackBar('You already confirmed this catalog entry.');
showGlobalInfo('You already confirmed this catalog entry.');
return;
}
@ -899,11 +1001,14 @@ class GarageScreenState extends State<GarageScreen> {
'user_id': user.id,
});
showGlobalSnackBar('Thanks! Your validation vote was recorded.');
showGlobalSuccess('Thanks! Your validation vote was recorded.');
if (sheetContext.mounted) Navigator.pop(sheetContext);
_loadCars(reset: true);
} catch (e) {
showGlobalSnackBar('Failed to submit validation vote: $e', isError: true);
showGlobalError(
e,
fallback: 'Failed to submit validation vote. Please try again.',
);
}
}
@ -939,7 +1044,7 @@ class GarageScreenState extends State<GarageScreen> {
.maybeSingle();
if (existingOpen != null) {
showGlobalSnackBar('You already have an open report for this car.');
showGlobalInfo('You already have an open report for this car.');
return;
}
@ -951,9 +1056,12 @@ class GarageScreenState extends State<GarageScreen> {
'note': payload.note,
});
showGlobalSnackBar('Thanks for reporting. We will review this entry.');
showGlobalSuccess('Thanks for reporting. We will review this entry.');
} catch (e) {
showGlobalSnackBar('Failed to submit report: $e', isError: true);
showGlobalError(
e,
fallback: 'Failed to submit report. Please try again.',
);
}
}
@ -1023,16 +1131,51 @@ class GarageScreenState extends State<GarageScreen> {
if (targetId == null) return;
if (widget.isOwner) {
final existing = await supabase
.from('hotwheels')
.select('id')
.eq('collection_id', targetId)
.eq('hw_id', car['hw_id'])
.maybeSingle();
if (existing != null) {
showGlobalSnackBar(
'${car['hw_id']} is already in the target collection.',
isError: true,
);
return;
}
await supabase
.from('hotwheels')
.update({'collection_id': targetId})
.eq('id', car['id']);
} else {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('You must be signed in to copy cars.');
}
final existing = await supabase
.from('hotwheels')
.select('id')
.eq('collection_id', targetId)
.eq('hw_id', car['hw_id'])
.maybeSingle();
if (existing != null) {
showGlobalSnackBar(
'${car['hw_id']} is already in the target collection.',
isError: true,
);
return;
}
final notes = car['notes'] as String?;
final imagePath = car['user_image_url'] as String?;
await supabase.from('hotwheels').insert({
'hw_id': car['hw_id'] as String,
'user_id': supabase.auth.currentUser!.id,
'user_id': userId,
'collection_id': targetId,
if (notes != null && notes.trim().isNotEmpty) 'notes': notes,
if (imagePath != null && imagePath.isNotEmpty)
@ -1042,14 +1185,16 @@ class GarageScreenState extends State<GarageScreen> {
if (!mounted) return;
if (sheetContext.mounted) Navigator.pop(sheetContext);
showGlobalSnackBar(widget.isOwner
showGlobalSuccess(widget.isOwner
? '${car['hw_id']} moved to another collection.'
: '${car['hw_id']} copied to another collection.');
await _loadCars(reset: true);
} catch (e) {
showGlobalSnackBar(
widget.isOwner ? 'Failed to move car: $e' : 'Failed to copy car: $e',
isError: true,
showGlobalError(
e,
fallback: widget.isOwner
? 'Failed to move car. Please try again.'
: 'Failed to copy car. Please try again.',
);
}
}
@ -1097,10 +1242,13 @@ class GarageScreenState extends State<GarageScreen> {
if (sheetContext.mounted) {
Navigator.pop(sheetContext); // close bottom sheet
}
showGlobalSnackBar('${car['hw_id']} removed from your garage.');
showGlobalSuccess('${car['hw_id']} removed from your garage.');
_loadCars(reset: true);
} catch (e) {
showGlobalSnackBar('Failed to remove: $e', isError: true);
showGlobalError(
e,
fallback: 'Failed to remove car. Please try again.',
);
}
}
}
@ -1200,7 +1348,7 @@ class _EmptyGarage extends StatelessWidget {
Text(
hasSearch
? 'Try a different search term'
: 'Scan your first Hot Wheels car to get started!',
: 'Scan your first die-cast car to get started!',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textHint),
),

View file

@ -78,7 +78,7 @@ class _LoginScreenState extends State<LoginScreen>
email,
redirectTo: 'hwcollector://login/recovery',
);
showGlobalSnackBar('Password reset email sent! Check your inbox.');
showGlobalSuccess('Password reset email sent! Check your inbox.');
} on AuthException catch (e) {
showGlobalSnackBar(e.message, isError: true);
}

View file

@ -40,7 +40,10 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
showGlobalSnackBar('Failed to load members: $e', isError: true);
showGlobalError(
e,
fallback: 'Failed to load members. Please try again.',
);
}
}
@ -122,9 +125,12 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
memberCount: _collection.memberCount,
);
});
showGlobalSnackBar('Collection renamed!');
showGlobalSuccess('Collection renamed!');
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
showGlobalError(
e,
fallback: 'Could not rename collection. Please try again.',
);
}
}
@ -238,12 +244,15 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
email: email,
role: inviteRole,
);
showGlobalSnackBar(
showGlobalSuccess(
inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!',
);
await _loadMembers();
} catch (e) {
showGlobalSnackBar('$e', isError: true);
showGlobalError(
e,
fallback: 'Could not send invitation. Please try again.',
);
} finally {
if (mounted) setState(() => _isInviting = false);
}
@ -277,10 +286,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
collectionId: _collection.id,
memberUserId: member.userId,
);
showGlobalSnackBar('Member removed.');
showGlobalSuccess('Member removed.');
await _loadMembers();
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
showGlobalError(
e,
fallback: 'Could not remove member. Please try again.',
);
}
}
@ -310,10 +322,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
try {
await CollectionService.leave(_collection.id);
showGlobalSnackBar('Left "${_collection.name}".');
showGlobalSuccess('Left "${_collection.name}".');
if (mounted) Navigator.pop(context);
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
showGlobalError(
e,
fallback: 'Could not leave collection. Please try again.',
);
}
}
@ -343,10 +358,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
try {
await CollectionService.delete(_collection.id);
showGlobalSnackBar('Collection deleted.');
showGlobalSuccess('Collection deleted.');
if (mounted) Navigator.pop(context);
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
showGlobalError(
e,
fallback: 'Could not delete collection. Please try again.',
);
}
}

View file

@ -1,6 +1,7 @@
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 {
@ -23,7 +24,15 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
Future<void> _loadReports() async {
final user = supabase.auth.currentUser;
if (user == null) return;
if (user == null) {
if (!mounted) return;
setState(() {
_reports = [];
_error = 'Please sign in to view reports.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
@ -45,9 +54,13 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_error = userMessageForError(
e,
fallback: 'Failed to load reports. Please try again.',
);
_isLoading = false;
});
logError('reports.load', e);
}
}

View file

@ -208,7 +208,7 @@ class ProfileScreen extends StatelessWidget {
UserAttributes(password: pw),
);
if (context.mounted) Navigator.pop(context);
showGlobalSnackBar('Password updated!');
showGlobalSuccess('Password updated!');
} on AuthException catch (e) {
showGlobalSnackBar(e.message, isError: true);
}

View file

@ -5,6 +5,7 @@ import '../scanner_screen.dart';
import '../services/collection_service.dart';
import '../services/main_collection_sync.dart';
import '../theme/app_colors.dart';
import '../utils/preferences_utils.dart';
class ScanTab extends StatefulWidget {
const ScanTab({super.key});
@ -14,7 +15,6 @@ class ScanTab extends StatefulWidget {
}
class ScanTabState extends State<ScanTab> {
static const _activeCollectionPrefKey = 'active_collection_id';
static const _duplicateCooldown = Duration(seconds: 2);
bool _isBusy = false;
@ -50,8 +50,13 @@ class ScanTabState extends State<ScanTab> {
Future<void> _loadCollections() async {
try {
final list = await CollectionService.getMyCollections();
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('You must be signed in to load collections.');
}
final prefs = await SharedPreferences.getInstance();
final persistedId = prefs.getString(_activeCollectionPrefKey);
final persistedId = await readActiveCollectionId(prefs, userId: userId);
Collection? selected;
if (persistedId != null) {
@ -71,12 +76,19 @@ class ScanTabState extends State<ScanTab> {
});
if (selected != null) {
await prefs.setString(_activeCollectionPrefKey, selected.id);
await writeActiveCollectionId(
prefs,
userId: userId,
collectionId: selected.id,
);
}
} catch (e) {
if (!mounted) return;
setState(() => _loadingCollections = false);
showGlobalSnackBar('Failed to load collections: $e', isError: true);
showGlobalError(
e,
fallback: 'Failed to load collections. Please try again.',
);
}
}
@ -104,7 +116,7 @@ class ScanTabState extends State<ScanTab> {
),
const SizedBox(height: 28),
const Text(
'Scan a Hot Wheels Car',
'Scan a Die-Cast Car',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
@ -379,7 +391,7 @@ class ScanTabState extends State<ScanTab> {
await _addToCollection(collection.id, hwId);
await _ensureValidationVote(hwId);
if (!mounted) return false;
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
}
} else {
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
@ -403,13 +415,16 @@ class ScanTabState extends State<ScanTab> {
);
await _addToCollection(collection.id, hwId, notes: discovery.notes);
if (!mounted) return false;
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
}
}
return true;
} catch (e) {
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;
}
}
@ -419,9 +434,14 @@ class ScanTabState extends State<ScanTab> {
String hwId, {
String? notes,
}) async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('You must be signed in to add cars.');
}
await supabase.from('hotwheels').insert({
'hw_id': hwId,
'user_id': supabase.auth.currentUser!.id,
'user_id': userId,
'collection_id': collectionId,
if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(),
});
@ -433,6 +453,11 @@ class ScanTabState extends State<ScanTab> {
String? series,
int? year,
}) async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('You must be signed in to create catalog entries.');
}
final cleanedSeries = series?.trim();
final payload = <String, dynamic>{
'hw_id': hwId,
@ -445,12 +470,15 @@ class ScanTabState extends State<ScanTab> {
await supabase.from('car_votes').insert({
'hw_id': hwId,
'user_id': supabase.auth.currentUser!.id,
'user_id': userId,
});
}
Future<void> _ensureValidationVote(String hwId) async {
final userId = supabase.auth.currentUser!.id;
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('You must be signed in to validate entries.');
}
final existingVote = await supabase
.from('car_votes')
.select('id')

View file

@ -53,6 +53,14 @@ class CollectionMember {
class CollectionService {
CollectionService._();
static String _requireUserId() {
final userId = supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('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 {
@ -80,7 +88,7 @@ class CollectionService {
/// Fetch all collections the current user is a member of,
/// including item count and member count.
static Future<List<Collection>> getMyCollections() async {
final userId = supabase.auth.currentUser!.id;
final userId = _requireUserId();
// Get memberships with collection data.
final memberships = await supabase
@ -113,11 +121,9 @@ class CollectionService {
});
memberCounts[collectionId] = (rows as List).length;
} catch (_) {
// Keep fallback below when RPC fails.
}
}));
// Fallback member count for collections where RPC did not return data.
final unresolvedIds = collectionIdList
.where((id) => !memberCounts.containsKey(id))
.toList(growable: false);
@ -249,7 +255,7 @@ class CollectionService {
required String name,
String? description,
}) async {
final userId = supabase.auth.currentUser!.id;
final userId = _requireUserId();
final row = await supabase
.from('collections')
@ -287,7 +293,7 @@ class CollectionService {
required String name,
String? description,
}) async {
final userId = supabase.auth.currentUser!.id;
final userId = _requireUserId();
final collection = await supabase
.from('collections')
@ -311,7 +317,7 @@ class CollectionService {
/// Delete a collection. Owner only. Cascade deletes members & items.
static Future<void> delete(String collectionId) async {
final userId = supabase.auth.currentUser!.id;
final userId = _requireUserId();
final collection = await supabase
.from('collections')
@ -362,7 +368,7 @@ class CollectionService {
throw Exception('Unsupported role "$role".');
}
final currentUserId = supabase.auth.currentUser!.id;
final currentUserId = _requireUserId();
final collection = await supabase
.from('collections')
@ -428,7 +434,7 @@ class CollectionService {
required String collectionId,
required String memberUserId,
}) async {
final currentUserId = supabase.auth.currentUser!.id;
final currentUserId = _requireUserId();
final collection = await supabase
.from('collections')
@ -501,7 +507,7 @@ class CollectionService {
/// Leave a collection (for non-owners).
static Future<void> leave(String collectionId) async {
final userId = supabase.auth.currentUser!.id;
final userId = _requireUserId();
final membership = await supabase
.from('collection_members')

View file

@ -7,7 +7,7 @@ import '../main.dart';
/// Handles uploading / deleting car images in Supabase Storage.
///
/// Bucket: `car-images` (private)
/// Path: `{auth.uid()}/{hotwheels.id}.jpg`
/// Path: `{auth.uid()}/{entry.id}.jpg`
class StorageService {
StorageService._();
@ -20,7 +20,7 @@ class StorageService {
static const _maxSignedUrlCacheEntries = 500;
static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {};
/// Upload a car image for a specific hotwheels entry.
/// Upload a car image for a specific garage entry.
/// Returns the storage path on success (e.g. `uid/123.jpg`).
static Future<String> uploadCarImage({
required File file,
@ -31,7 +31,11 @@ class StorageService {
await deleteCarImage(oldPath);
}
final userId = supabase.auth.currentUser!.id;
final user = supabase.auth.currentUser;
if (user == null) {
throw Exception('You must be signed in to upload images.');
}
final userId = user.id;
final path = '$userId/$entryId.jpg';
final compressed = await _compressImage(file);

View file

@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
String userMessageForError(
Object error, {
String fallback = 'Something went wrong. Please try again.',
}) {
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');
}
}

View file

@ -0,0 +1,43 @@
import 'package:shared_preferences/shared_preferences.dart';
const _legacyActiveCollectionPrefKey = 'active_collection_id';
String activeCollectionPrefKeyForUser(String userId) {
return 'active_collection_id_$userId';
}
Future<String?> readActiveCollectionId(
SharedPreferences prefs, {
required String userId,
}) async {
final scopedKey = activeCollectionPrefKeyForUser(userId);
final scopedValue = prefs.getString(scopedKey);
if (scopedValue != null && scopedValue.isNotEmpty) {
return scopedValue;
}
final legacyValue = prefs.getString(_legacyActiveCollectionPrefKey);
if (legacyValue == null || legacyValue.isEmpty) {
return null;
}
await prefs.setString(scopedKey, legacyValue);
await prefs.remove(_legacyActiveCollectionPrefKey);
return legacyValue;
}
Future<void> writeActiveCollectionId(
SharedPreferences prefs, {
required String userId,
required String collectionId,
}) {
return prefs.setString(activeCollectionPrefKeyForUser(userId), collectionId);
}
Future<void> clearActiveCollectionId(
SharedPreferences prefs, {
required String userId,
}) async {
await prefs.remove(activeCollectionPrefKeyForUser(userId));
await prefs.remove(_legacyActiveCollectionPrefKey);
}

View file

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../services/storage_service.dart';
import '../theme/app_colors.dart';
/// A styled card for displaying a single Hot Wheels car in the garage.
/// A styled card for displaying a single die-cast car in the garage.
class CarCard extends StatelessWidget {
final String hwId;
final String? name;
@ -10,6 +11,7 @@ class CarCard extends StatelessWidget {
final int? year;
final String? color;
final String? imageUrl;
final String? imagePath;
final bool isVerified;
final DateTime? addedAt;
final VoidCallback? onTap;
@ -25,6 +27,7 @@ class CarCard extends StatelessWidget {
this.year,
this.color,
this.imageUrl,
this.imagePath,
this.isVerified = false,
this.addedAt,
this.onTap,
@ -37,6 +40,9 @@ class CarCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final resolvedImageUrl = imageUrl;
final hasResolvedUrl = resolvedImageUrl != null && resolvedImageUrl.isNotEmpty;
final hasImagePath = imagePath != null && imagePath!.isNotEmpty;
return Card(
clipBehavior: Clip.antiAlias,
@ -66,9 +72,9 @@ class CarCard extends StatelessWidget {
)
: AppColors.brandGradientSoft,
),
child: imageUrl != null && imageUrl!.isNotEmpty
child: hasResolvedUrl
? CachedNetworkImage(
imageUrl: imageUrl!,
imageUrl: resolvedImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
@ -77,6 +83,26 @@ class CarCard extends StatelessWidget {
return _PlaceholderIcon(isDark: isDark);
},
)
: hasImagePath
? FutureBuilder<String?>(
future: StorageService.createSignedUrl(imagePath),
builder: (context, snapshot) {
final signedUrl = snapshot.data;
if (signedUrl == null || signedUrl.isEmpty) {
return _PlaceholderIcon(isDark: isDark);
}
return CachedNetworkImage(
imageUrl: signedUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (context, url, error) {
onImageError?.call();
return _PlaceholderIcon(isDark: isDark);
},
);
},
)
: _PlaceholderIcon(isDark: isDark),
),
),