diff --git a/.env/flutter_defines.example.json b/.env/flutter_defines.example.json new file mode 100644 index 0000000..4250c9f --- /dev/null +++ b/.env/flutter_defines.example.json @@ -0,0 +1,5 @@ +{ + "SUPABASE_URL": "https://your-project.supabase.co", + "SUPABASE_ANON_KEY": "your_anon_key", + "SUPABASE_USE_PKCE": "true" +} diff --git a/.gitignore b/.gitignore index 2375b23..8695b9b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..11930d0 --- /dev/null +++ b/.vscode/tasks.json @@ -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": [] + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e4c7e38 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing Guide + +Thanks for contributing to car64. + +## Workflow + +- Create a branch from `main`: + - `feature/` for features + - `fix/` for bug fixes + - `docs/` 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` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c104d9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index c1f6360..9363f30 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,193 @@ -# car64 +

+ car64 logo +

-car64 is a Flutter + Supabase Hot Wheels collector app with a fast scanning workflow, private photo storage, collaborative collections, and community-based catalog validation. +

car64

-## Core Features +

+ A modern Flutter + Supabase app for tracking and managing die-cast car collections. +

-- 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) - ```bash - flutter pub get - ``` +## Overview -4. Run the app: +car64 helps collectors scan model IDs, organize personal and shared collections, and keep a clean catalog with community validation/reporting flows. - ```bash - flutter run - ``` +## Features -## Quality Check +- 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 +``` + +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` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..983e897 --- /dev/null +++ b/SECURITY.md @@ -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). diff --git a/lib/main.dart b/lib/main.dart index 050b0ba..d1a040b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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(); final scaffoldMessengerKey = GlobalKey(); +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 showGlobalDialog({required WidgetBuilder builder}) { + final context = navigatorKey.currentContext; + if (context == null) { + return Future.value(null); + } return showDialog( - context: navigatorKey.currentContext!, + context: context, builder: builder, ); } @@ -90,8 +205,6 @@ class AuthGate extends StatefulWidget { } class _AuthGateState extends State { - static const _activeCollectionPrefKey = 'active_collection_id'; - bool _isLoading = true; bool _isInPasswordRecoveryFlow = false; Session? _session; @@ -124,7 +237,10 @@ class _AuthGateState extends State { } }, 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 { 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 { if (userId == null) return; final prefs = await SharedPreferences.getInstance(); - final persisted = prefs.getString(_activeCollectionPrefKey); + final persisted = await readActiveCollectionId(prefs, userId: userId); Future hasMembership(String collectionId) async { final membership = await supabase @@ -180,17 +299,27 @@ class _AuthGateState extends State { } 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 _showResetPasswordDialog() async { + final context = navigatorKey.currentContext; + if (context == null) { + _isInPasswordRecoveryFlow = false; + return; + } + await showDialog( - 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); diff --git a/lib/scanner_screen.dart b/lib/scanner_screen.dart index e42e89c..f8b55b6 100644 --- a/lib/scanner_screen.dart +++ b/lib/scanner_screen.dart @@ -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 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 DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0); bool _isInitializingCamera = false; String? _cameraError; + int _consecutiveMisses = 0; @override void initState() { @@ -126,9 +129,15 @@ class _ScannerScreenState extends State _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 }); } - /// 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 _captureAndScan() async { if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return; if (_cameraController!.value.isTakingPicture) return; @@ -195,13 +204,23 @@ class _ScannerScreenState extends State 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 }); } } 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 } } 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, + ), ); } } diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index c58b73a..7dcaaf6 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -106,7 +106,7 @@ class _AboutScreenState extends State { 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, ), diff --git a/lib/screens/collections_screen.dart b/lib/screens/collections_screen.dart index 6e387aa..3bf5828 100644 --- a/lib/screens/collections_screen.dart +++ b/lib/screens/collections_screen.dart @@ -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 with WidgetsBindingObserver { - static const _activeCollectionPrefKey = 'active_collection_id'; - List _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 } } - Future _load() async { - setState(() { - _isLoading = true; - _error = null; - }); + Future _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 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 }); 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 } 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.microtask(() => _load(showLoading: false)); + } } } @@ -137,7 +167,7 @@ class CollectionsScreenState extends State 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 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 } Future _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 diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 659d808..0059306 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -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 { .range(from, to); final rows = List>.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 { } 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 { 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 { ); 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 { '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 { _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 { } } - void _showCarDetails(Map car) { + Future _showCarDetails(Map car) async { if (_selectionMode) { _toggleCarSelection(car); return; @@ -559,7 +638,24 @@ class GarageScreenState extends State { 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 { ); 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 { 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 { .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 { .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 { .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 { '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 { .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 { '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 { 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 { 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 { 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), ), diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index bf4d457..b9164fd 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -78,7 +78,7 @@ class _LoginScreenState extends State 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); } diff --git a/lib/screens/manage_collection_screen.dart b/lib/screens/manage_collection_screen.dart index cc3923b..ef0ce9c 100644 --- a/lib/screens/manage_collection_screen.dart +++ b/lib/screens/manage_collection_screen.dart @@ -40,7 +40,10 @@ class _ManageCollectionScreenState extends State { } 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 { 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 { 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 { 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 { 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 { 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.', + ); } } diff --git a/lib/screens/my_reports_screen.dart b/lib/screens/my_reports_screen.dart index 171bb5d..6e40050 100644 --- a/lib/screens/my_reports_screen.dart +++ b/lib/screens/my_reports_screen.dart @@ -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 { Future _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 { } 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); } } diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 2d297dd..fbcfae6 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -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); } diff --git a/lib/screens/scan_tab.dart b/lib/screens/scan_tab.dart index 778d243..06cff02 100644 --- a/lib/screens/scan_tab.dart +++ b/lib/screens/scan_tab.dart @@ -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 { - static const _activeCollectionPrefKey = 'active_collection_id'; static const _duplicateCooldown = Duration(seconds: 2); bool _isBusy = false; @@ -50,8 +50,13 @@ class ScanTabState extends State { Future _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 { }); 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 { ), 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 { 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 { ); 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 { 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 { 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 = { 'hw_id': hwId, @@ -445,12 +470,15 @@ class ScanTabState extends State { await supabase.from('car_votes').insert({ 'hw_id': hwId, - 'user_id': supabase.auth.currentUser!.id, + 'user_id': userId, }); } Future _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') diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart index a38105c..023a52d 100644 --- a/lib/services/collection_service.dart +++ b/lib/services/collection_service.dart @@ -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 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> 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 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 leave(String collectionId) async { - final userId = supabase.auth.currentUser!.id; + final userId = _requireUserId(); final membership = await supabase .from('collection_members') diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index e07b9d9..5814152 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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 _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 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); diff --git a/lib/utils/error_utils.dart b/lib/utils/error_utils.dart new file mode 100644 index 0000000..3beee1d --- /dev/null +++ b/lib/utils/error_utils.dart @@ -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'); + } +} diff --git a/lib/utils/preferences_utils.dart b/lib/utils/preferences_utils.dart new file mode 100644 index 0000000..39da613 --- /dev/null +++ b/lib/utils/preferences_utils.dart @@ -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 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 writeActiveCollectionId( + SharedPreferences prefs, { + required String userId, + required String collectionId, +}) { + return prefs.setString(activeCollectionPrefKeyForUser(userId), collectionId); +} + +Future clearActiveCollectionId( + SharedPreferences prefs, { + required String userId, +}) async { + await prefs.remove(activeCollectionPrefKeyForUser(userId)); + await prefs.remove(_legacyActiveCollectionPrefKey); +} diff --git a/lib/widgets/car_card.dart b/lib/widgets/car_card.dart index 97941e4..a01a0e7 100644 --- a/lib/widgets/car_card.dart +++ b/lib/widgets/car_card.dart @@ -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,7 +83,27 @@ class CarCard extends StatelessWidget { return _PlaceholderIcon(isDark: isDark); }, ) - : _PlaceholderIcon(isDark: isDark), + : hasImagePath + ? FutureBuilder( + 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), ), ),