From f41708c662fa349570a87d0bfafd18763c7fd532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:37:09 +0100 Subject: [PATCH 01/22] fix(config): use dart-define supabase settings and default to PKCE auth flow --- README.md | 9 ++++++++- lib/main.dart | 20 ++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c1f6360..345818e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,14 @@ car64 is a Flutter + Supabase Hot Wheels collector app with a fast scanning work ## Local Development 1. Install Flutter (stable) and run `flutter doctor`. -2. Configure Supabase keys in app configuration. +2. Configure Supabase values via build-time variables (`--dart-define`), for example: + + ```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 + ``` 3. Install dependencies: ```bash diff --git a/lib/main.dart b/lib/main.dart index 050b0ba..bf926dc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,8 +12,18 @@ 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: 'https://yaopcyubateifnicpywp.supabase.co', +); +const _supabaseAnonKey = String.fromEnvironment( + 'SUPABASE_ANON_KEY', + defaultValue: 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL', +); +const _usePkceAuthFlow = bool.fromEnvironment( + 'SUPABASE_USE_PKCE', + defaultValue: true, +); Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -21,8 +31,10 @@ Future main() async { await Supabase.initialize( url: _supabaseUrl, anonKey: _supabaseAnonKey, - authOptions: const FlutterAuthClientOptions( - authFlowType: AuthFlowType.implicit, + authOptions: FlutterAuthClientOptions( + authFlowType: _usePkceAuthFlow + ? AuthFlowType.pkce + : AuthFlowType.implicit, ), ); From 947992e8b9a721e554ff50fd89efc4f0aa12b712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:39:00 +0100 Subject: [PATCH 02/22] fix(stability): remove auth/context force unwraps and add guarded null-safe paths --- lib/main.dart | 14 ++++++++++++-- lib/screens/garage_screen.dart | 14 ++++++++++++-- lib/screens/scan_tab.dart | 19 ++++++++++++++++--- lib/services/collection_service.dart | 22 +++++++++++++++------- lib/services/storage_service.dart | 6 +++++- 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index bf926dc..c041243 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -68,8 +68,12 @@ void showGlobalSnackBar(String message, {bool isError = false}) { /// 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, ); } @@ -201,8 +205,14 @@ class _AuthGateState extends State { } 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(), ); diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 659d808..cb59788 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -483,6 +483,11 @@ class GarageScreenState extends State { .update({'collection_id': targetId}) .inFilter('id', _selectedIds.toList()); } 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); @@ -492,7 +497,7 @@ class GarageScreenState extends State { final imagePath = car['user_image_url'] as String?; return { '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) @@ -1028,11 +1033,16 @@ class GarageScreenState extends State { .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 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) diff --git a/lib/screens/scan_tab.dart b/lib/screens/scan_tab.dart index 778d243..90c2f30 100644 --- a/lib/screens/scan_tab.dart +++ b/lib/screens/scan_tab.dart @@ -419,9 +419,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 +438,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 +455,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..088b914 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 @@ -249,7 +257,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 +295,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 +319,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 +370,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 +436,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 +509,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..c437afc 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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); From 1dc440cd25f19b642373cb1bdef993fe04f23ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:40:37 +0100 Subject: [PATCH 03/22] fix(errors): sanitize user-facing failures and centralize safe error messaging --- lib/main.dart | 22 ++++++++- lib/scanner_screen.dart | 31 +++++++++++-- lib/screens/collections_screen.dart | 12 ++++- lib/screens/garage_screen.dart | 54 ++++++++++++++++------- lib/screens/manage_collection_screen.dart | 30 ++++++++++--- lib/screens/my_reports_screen.dart | 7 ++- lib/screens/scan_tab.dart | 10 ++++- lib/utils/error_utils.dart | 37 ++++++++++++++++ 8 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 lib/utils/error_utils.dart diff --git a/lib/main.dart b/lib/main.dart index c041243..24e299e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ 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'; // Re-export so other files can `import '../main.dart'` for these. export 'package:supabase_flutter/supabase_flutter.dart' @@ -66,6 +67,17 @@ void showGlobalSnackBar(String message, {bool isError = false}) { ); } +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; @@ -140,7 +152,10 @@ class _AuthGateState extends State { } }, onError: (error) { - showGlobalSnackBar('Auth error: $error', isError: true); + showGlobalError( + error, + fallback: 'Authentication error. Please sign in again.', + ); }, ); @@ -156,7 +171,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.', + ); } } diff --git a/lib/scanner_screen.dart b/lib/scanner_screen.dart index e42e89c..00e9ad9 100644 --- a/lib/scanner_screen.dart +++ b/lib/scanner_screen.dart @@ -6,6 +6,7 @@ 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 @@ -126,9 +127,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, ), ); @@ -211,8 +218,17 @@ class _ScannerScreenState extends State } catch (e) { _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 +274,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/collections_screen.dart b/lib/screens/collections_screen.dart index 6e387aa..276c902 100644 --- a/lib/screens/collections_screen.dart +++ b/lib/screens/collections_screen.dart @@ -4,6 +4,7 @@ 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 'garage_screen.dart'; import 'manage_collection_screen.dart'; @@ -103,9 +104,13 @@ 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); } } @@ -185,7 +190,10 @@ class CollectionsScreenState extends State showGlobalSnackBar('Collection created!'); _load(); } catch (e) { - showGlobalSnackBar('Failed: $e', isError: true); + showGlobalError( + e, + fallback: 'Could not create collection. Please try again.', + ); } } diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index cb59788..55a186f 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -7,6 +7,7 @@ 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. @@ -130,10 +131,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); } } @@ -519,9 +524,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.', ); } } @@ -833,9 +840,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; } @@ -850,7 +857,10 @@ class GarageScreenState extends State { 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.', + ); } } @@ -878,7 +888,10 @@ class GarageScreenState extends State { 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.', + ); } } @@ -908,7 +921,10 @@ class GarageScreenState extends State { 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.', + ); } } @@ -958,7 +974,10 @@ class GarageScreenState extends State { showGlobalSnackBar('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.', + ); } } @@ -1057,9 +1076,11 @@ class GarageScreenState extends State { : '${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.', ); } } @@ -1110,7 +1131,10 @@ class GarageScreenState extends State { showGlobalSnackBar('${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.', + ); } } } diff --git a/lib/screens/manage_collection_screen.dart b/lib/screens/manage_collection_screen.dart index cc3923b..2e519a7 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.', + ); } } @@ -124,7 +127,10 @@ class _ManageCollectionScreenState extends State { }); showGlobalSnackBar('Collection renamed!'); } catch (e) { - showGlobalSnackBar('Failed: $e', isError: true); + showGlobalError( + e, + fallback: 'Could not rename collection. Please try again.', + ); } } @@ -243,7 +249,10 @@ class _ManageCollectionScreenState extends State { ); await _loadMembers(); } catch (e) { - showGlobalSnackBar('$e', isError: true); + showGlobalError( + e, + fallback: 'Could not send invitation. Please try again.', + ); } finally { if (mounted) setState(() => _isInviting = false); } @@ -280,7 +289,10 @@ class _ManageCollectionScreenState extends State { showGlobalSnackBar('Member removed.'); await _loadMembers(); } catch (e) { - showGlobalSnackBar('Failed: $e', isError: true); + showGlobalError( + e, + fallback: 'Could not remove member. Please try again.', + ); } } @@ -313,7 +325,10 @@ class _ManageCollectionScreenState extends State { showGlobalSnackBar('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.', + ); } } @@ -346,7 +361,10 @@ class _ManageCollectionScreenState extends State { showGlobalSnackBar('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..61025b0 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 { @@ -45,9 +46,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/scan_tab.dart b/lib/screens/scan_tab.dart index 90c2f30..70efc99 100644 --- a/lib/screens/scan_tab.dart +++ b/lib/screens/scan_tab.dart @@ -76,7 +76,10 @@ class ScanTabState extends State { } 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.', + ); } } @@ -409,7 +412,10 @@ class ScanTabState extends State { 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; } } 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'); + } +} From c2d8e754a25ecdfcbf6d9682c4bdd784b6f66270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:43:01 +0100 Subject: [PATCH 04/22] perf(collections): remove N+1 member count RPCs with single batched query --- lib/services/collection_service.dart | 31 +++++++--------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart index 088b914..f2fee28 100644 --- a/lib/services/collection_service.dart +++ b/lib/services/collection_service.dart @@ -114,31 +114,14 @@ class CollectionService { final itemCounts = await getCollectionItemCounts(collectionIdList); final memberCounts = {}; - await Future.wait(collectionIdList.map((collectionId) async { - try { - final rows = await supabase.rpc('get_collection_members', params: { - 'p_collection_id': collectionId, - }); - memberCounts[collectionId] = (rows as List).length; - } catch (_) { - // Keep fallback below when RPC fails. - } - })); + final members = await supabase + .from('collection_members') + .select('collection_id') + .inFilter('collection_id', collectionIdList); - // Fallback member count for collections where RPC did not return data. - final unresolvedIds = collectionIdList - .where((id) => !memberCounts.containsKey(id)) - .toList(growable: false); - if (unresolvedIds.isNotEmpty) { - final members = await supabase - .from('collection_members') - .select('id, collection_id') - .inFilter('collection_id', unresolvedIds); - - for (final member in members) { - final collectionId = member['collection_id'] as String; - memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1; - } + for (final member in members) { + final collectionId = member['collection_id'] as String; + memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1; } final collections = []; From 7c25ab7c6f034d727abe7865772dbba2d91a49d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:44:36 +0100 Subject: [PATCH 05/22] perf(images): lazy-load signed storage URLs for garage cards --- lib/screens/garage_screen.dart | 46 +++++++++++++++++++++------------- lib/widgets/car_card.dart | 32 ++++++++++++++++++++--- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 55a186f..0a0e52e 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -109,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; @@ -334,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), ); @@ -557,7 +552,7 @@ class GarageScreenState extends State { } } - void _showCarDetails(Map car) { + Future _showCarDetails(Map car) async { if (_selectionMode) { _toggleCarSelection(car); return; @@ -571,7 +566,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, diff --git a/lib/widgets/car_card.dart b/lib/widgets/car_card.dart index 97941e4..62caed0 100644 --- a/lib/widgets/car_card.dart +++ b/lib/widgets/car_card.dart @@ -1,5 +1,6 @@ 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. @@ -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), ), ), From edacf7ad1a3ecff481c5e174fabbf1f21ba157d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:45:00 +0100 Subject: [PATCH 06/22] perf(scanner): add adaptive cooldown backoff for repeated OCR misses --- lib/scanner_screen.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/scanner_screen.dart b/lib/scanner_screen.dart index 00e9ad9..fe18aa3 100644 --- a/lib/scanner_screen.dart +++ b/lib/scanner_screen.dart @@ -37,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; @@ -52,6 +53,7 @@ class _ScannerScreenState extends State DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0); bool _isInitializingCamera = false; String? _cameraError; + int _consecutiveMisses = 0; @override void initState() { @@ -202,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; @@ -216,6 +228,7 @@ class _ScannerScreenState extends State }); } } catch (e) { + _consecutiveMisses += 1; _nextScanAllowedAt = DateTime.now().add(_scanCooldownError); if (!mounted) return; logError('scanner.capture', e); From 404ac270f7cea2ad022380c0d11748ba7e9eb69c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:46:12 +0100 Subject: [PATCH 07/22] fix(state): scope active collection preference by user with legacy key migration --- lib/main.dart | 13 +++++---- lib/screens/collections_screen.dart | 28 +++++++++++++++---- lib/screens/scan_tab.dart | 15 ++++++++-- lib/utils/preferences_utils.dart | 43 +++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 lib/utils/preferences_utils.dart diff --git a/lib/main.dart b/lib/main.dart index 24e299e..0d723c0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ 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' @@ -118,8 +119,6 @@ class AuthGate extends StatefulWidget { } class _AuthGateState extends State { - static const _activeCollectionPrefKey = 'active_collection_id'; - bool _isLoading = true; bool _isInPasswordRecoveryFlow = false; Session? _session; @@ -183,7 +182,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 @@ -214,11 +213,15 @@ 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(); } diff --git a/lib/screens/collections_screen.dart b/lib/screens/collections_screen.dart index 276c902..c00e3f0 100644 --- a/lib/screens/collections_screen.dart +++ b/lib/screens/collections_screen.dart @@ -5,6 +5,7 @@ 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'; @@ -18,8 +19,6 @@ class CollectionsScreen extends StatefulWidget { class CollectionsScreenState extends State with WidgetsBindingObserver { - static const _activeCollectionPrefKey = 'active_collection_id'; - List _collections = []; bool _isLoading = true; String? _error; @@ -74,8 +73,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) { @@ -96,7 +100,11 @@ class CollectionsScreenState extends State }); if (activeId != null) { - await prefs.setString(_activeCollectionPrefKey, activeId); + await writeActiveCollectionId( + prefs, + userId: userId, + collectionId: activeId, + ); if (shouldNotifySync) { MainCollectionSync.notifyChanged(); } @@ -218,8 +226,18 @@ 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(); diff --git a/lib/screens/scan_tab.dart b/lib/screens/scan_tab.dart index 70efc99..8a6d06c 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,7 +76,11 @@ 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; 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); +} From 7e798cd7e61846fe6f650708d9a551bbbc6f0904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:46:42 +0100 Subject: [PATCH 08/22] fix(reports): avoid stuck loading state when session is missing --- lib/screens/my_reports_screen.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/screens/my_reports_screen.dart b/lib/screens/my_reports_screen.dart index 61025b0..6e40050 100644 --- a/lib/screens/my_reports_screen.dart +++ b/lib/screens/my_reports_screen.dart @@ -24,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; From ff9aa57548fa8ab4ae7787da0a728f4829c882b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 14:47:02 +0100 Subject: [PATCH 09/22] perf(collections): coalesce duplicate refresh triggers during in-flight loads --- lib/screens/collections_screen.dart | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/screens/collections_screen.dart b/lib/screens/collections_screen.dart index c00e3f0..e1a88a4 100644 --- a/lib/screens/collections_screen.dart +++ b/lib/screens/collections_screen.dart @@ -21,6 +21,8 @@ class CollectionsScreenState extends State with WidgetsBindingObserver { 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(); @@ -119,6 +130,12 @@ class CollectionsScreenState extends State _isLoading = false; }); logError('collections.load', e); + } finally { + _isLoadInFlight = false; + if (_reloadQueued) { + _reloadQueued = false; + Future.microtask(() => _load(showLoading: false)); + } } } From eab9000247a44cf0642a5298d0dd00e0200382ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 15:10:32 +0100 Subject: [PATCH 10/22] fix(regressions): restore member counts and improve duplicate-copy feedback --- lib/screens/garage_screen.dart | 54 ++++++++++++++++++++++++++-- lib/services/collection_service.dart | 29 +++++++++++---- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 0a0e52e..df9d5d0 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -476,11 +476,12 @@ class GarageScreenState extends State { ); if (confirmed != true || targetId == null) return; + final targetCollectionId = targetId!; if (isOwner) { await supabase .from('hotwheels') - .update({'collection_id': targetId}) + .update({'collection_id': targetCollectionId}) .inFilter('id', _selectedIds.toList()); } else { final userId = supabase.auth.currentUser?.id; @@ -492,22 +493,54 @@ class GarageScreenState extends State { .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': userId, - 'collection_id': targetId, + '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; @@ -1069,6 +1102,21 @@ class GarageScreenState extends State { 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({ diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart index f2fee28..023a52d 100644 --- a/lib/services/collection_service.dart +++ b/lib/services/collection_service.dart @@ -114,14 +114,29 @@ class CollectionService { final itemCounts = await getCollectionItemCounts(collectionIdList); final memberCounts = {}; - final members = await supabase - .from('collection_members') - .select('collection_id') - .inFilter('collection_id', collectionIdList); + await Future.wait(collectionIdList.map((collectionId) async { + try { + final rows = await supabase.rpc('get_collection_members', params: { + 'p_collection_id': collectionId, + }); + memberCounts[collectionId] = (rows as List).length; + } catch (_) { + } + })); - for (final member in members) { - final collectionId = member['collection_id'] as String; - memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1; + final unresolvedIds = collectionIdList + .where((id) => !memberCounts.containsKey(id)) + .toList(growable: false); + if (unresolvedIds.isNotEmpty) { + final members = await supabase + .from('collection_members') + .select('id, collection_id') + .inFilter('collection_id', unresolvedIds); + + for (final member in members) { + final collectionId = member['collection_id'] as String; + memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1; + } } final collections = []; From a2a89175063be5ae0eaee19b9203e77524758527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 15:16:10 +0100 Subject: [PATCH 11/22] fix(ux): show errors above modals and handle duplicate owner moves cleanly --- lib/main.dart | 70 ++++++++++++++++++++++++++++++++++ lib/screens/garage_screen.dart | 58 +++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0d723c0..5d50253 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'; @@ -49,9 +51,16 @@ final supabase = Supabase.instance.client; /// Global keys so dialogs & snackbars survive widget-tree rebuilds. final navigatorKey = GlobalKey(); final scaffoldMessengerKey = GlobalKey(); +OverlayEntry? _activeErrorOverlay; +Timer? _activeErrorOverlayTimer; /// Show a snackbar safely through the global key. void showGlobalSnackBar(String message, {bool isError = false}) { + if (isError) { + _showGlobalErrorOverlay(message); + return; + } + final messenger = scaffoldMessengerKey.currentState; if (messenger == null) return; @@ -68,6 +77,67 @@ void showGlobalSnackBar(String message, {bool isError = false}) { ); } +void _showGlobalErrorOverlay(String message) { + final overlay = navigatorKey.currentState?.overlay; + if (overlay == null) return; + + _activeErrorOverlayTimer?.cancel(); + _activeErrorOverlay?.remove(); + + _activeErrorOverlay = 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: Colors.red, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 8, + offset: Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + const Icon(Icons.error_outline, 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(_activeErrorOverlay!); + + _activeErrorOverlayTimer = Timer(const Duration(seconds: 4), () { + _activeErrorOverlay?.remove(); + _activeErrorOverlay = null; + _activeErrorOverlayTimer = null; + }); +} + void showGlobalError( Object error, { String fallback = 'Something went wrong. Please try again.', diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index df9d5d0..fb29b0f 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -479,10 +479,49 @@ class GarageScreenState extends State { final targetCollectionId = targetId!; if (isOwner) { + 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', _selectedIds.toList()); + .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) { @@ -1092,6 +1131,21 @@ 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}) From fded06c85aab76190440e43a286a99f1b5c2e2dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 18:37:47 +0100 Subject: [PATCH 12/22] fix(ux): show all global messages in front overlay for consistency --- lib/main.dart | 49 +++++++++++++++++-------------------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 5d50253..33e28ef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,40 +51,25 @@ final supabase = Supabase.instance.client; /// Global keys so dialogs & snackbars survive widget-tree rebuilds. final navigatorKey = GlobalKey(); final scaffoldMessengerKey = GlobalKey(); -OverlayEntry? _activeErrorOverlay; -Timer? _activeErrorOverlayTimer; +OverlayEntry? _activeMessageOverlay; +Timer? _activeMessageOverlayTimer; /// Show a snackbar safely through the global key. void showGlobalSnackBar(String message, {bool isError = false}) { - if (isError) { - _showGlobalErrorOverlay(message); - return; - } - - final messenger = scaffoldMessengerKey.currentState; - if (messenger == null) return; - - 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), - ), - ); + _showGlobalMessageOverlay(message, isError: isError); } -void _showGlobalErrorOverlay(String message) { +void _showGlobalMessageOverlay(String message, {required bool isError}) { final overlay = navigatorKey.currentState?.overlay; if (overlay == null) return; - _activeErrorOverlayTimer?.cancel(); - _activeErrorOverlay?.remove(); + _activeMessageOverlayTimer?.cancel(); + _activeMessageOverlay?.remove(); - _activeErrorOverlay = OverlayEntry( + final backgroundColor = isError ? Colors.red : const Color(0xFF1F2937); + final leadingIcon = isError ? Icons.error_outline : Icons.info_outline; + + _activeMessageOverlay = OverlayEntry( builder: (context) { final topPadding = MediaQuery.of(context).padding.top; return Positioned( @@ -98,7 +83,7 @@ void _showGlobalErrorOverlay(String message) { child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( - color: Colors.red, + color: backgroundColor, borderRadius: BorderRadius.circular(12), boxShadow: const [ BoxShadow( @@ -110,7 +95,7 @@ void _showGlobalErrorOverlay(String message) { ), child: Row( children: [ - const Icon(Icons.error_outline, color: Colors.white, size: 20), + Icon(leadingIcon, color: Colors.white, size: 20), const SizedBox(width: 8), Expanded( child: Text( @@ -129,12 +114,12 @@ void _showGlobalErrorOverlay(String message) { }, ); - overlay.insert(_activeErrorOverlay!); + overlay.insert(_activeMessageOverlay!); - _activeErrorOverlayTimer = Timer(const Duration(seconds: 4), () { - _activeErrorOverlay?.remove(); - _activeErrorOverlay = null; - _activeErrorOverlayTimer = null; + _activeMessageOverlayTimer = Timer(const Duration(seconds: 4), () { + _activeMessageOverlay?.remove(); + _activeMessageOverlay = null; + _activeMessageOverlayTimer = null; }); } From 450d0a7b3427956bb1530edb365830dcc3501429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 19:03:02 +0100 Subject: [PATCH 13/22] feat(ux): add distinct success/info/error top overlay message styles --- lib/main.dart | 34 +++++++++++++++++++---- lib/screens/collections_screen.dart | 4 +-- lib/screens/garage_screen.dart | 18 ++++++------ lib/screens/login_screen.dart | 2 +- lib/screens/manage_collection_screen.dart | 10 +++---- lib/screens/profile_screen.dart | 2 +- lib/screens/scan_tab.dart | 4 +-- 7 files changed, 49 insertions(+), 25 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 33e28ef..56db27a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -54,20 +54,44 @@ 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}) { - _showGlobalMessageOverlay(message, isError: isError); + _showGlobalMessageOverlay( + message, + type: isError ? GlobalMessageType.error : GlobalMessageType.info, + ); } -void _showGlobalMessageOverlay(String message, {required bool isError}) { +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 = isError ? Colors.red : const Color(0xFF1F2937); - final leadingIcon = isError ? Icons.error_outline : Icons.info_outline; + 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) { @@ -343,7 +367,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/screens/collections_screen.dart b/lib/screens/collections_screen.dart index e1a88a4..cd0fc97 100644 --- a/lib/screens/collections_screen.dart +++ b/lib/screens/collections_screen.dart @@ -212,7 +212,7 @@ class CollectionsScreenState extends State name: nameCtrl.text.trim(), description: descCtrl.text.trim(), ); - showGlobalSnackBar('Collection created!'); + showGlobalSuccess('Collection created!'); _load(); } catch (e) { showGlobalError( @@ -258,7 +258,7 @@ class CollectionsScreenState extends State 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 fb29b0f..0924bd2 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -913,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; @@ -937,7 +937,7 @@ 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) { @@ -968,7 +968,7 @@ 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) { @@ -992,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; } @@ -1001,7 +1001,7 @@ 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) { @@ -1044,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; } @@ -1056,7 +1056,7 @@ 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) { showGlobalError( e, @@ -1185,7 +1185,7 @@ 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); @@ -1242,7 +1242,7 @@ 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) { showGlobalError( 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 2e519a7..ef0ce9c 100644 --- a/lib/screens/manage_collection_screen.dart +++ b/lib/screens/manage_collection_screen.dart @@ -125,7 +125,7 @@ class _ManageCollectionScreenState extends State { memberCount: _collection.memberCount, ); }); - showGlobalSnackBar('Collection renamed!'); + showGlobalSuccess('Collection renamed!'); } catch (e) { showGlobalError( e, @@ -244,7 +244,7 @@ class _ManageCollectionScreenState extends State { email: email, role: inviteRole, ); - showGlobalSnackBar( + showGlobalSuccess( inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!', ); await _loadMembers(); @@ -286,7 +286,7 @@ class _ManageCollectionScreenState extends State { collectionId: _collection.id, memberUserId: member.userId, ); - showGlobalSnackBar('Member removed.'); + showGlobalSuccess('Member removed.'); await _loadMembers(); } catch (e) { showGlobalError( @@ -322,7 +322,7 @@ 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) { showGlobalError( @@ -358,7 +358,7 @@ class _ManageCollectionScreenState extends State { try { await CollectionService.delete(_collection.id); - showGlobalSnackBar('Collection deleted.'); + showGlobalSuccess('Collection deleted.'); if (mounted) Navigator.pop(context); } catch (e) { showGlobalError( 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 8a6d06c..416d6be 100644 --- a/lib/screens/scan_tab.dart +++ b/lib/screens/scan_tab.dart @@ -391,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>( @@ -415,7 +415,7 @@ 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; From 25e0ade7a63458ad2032705259d805c42ab3f698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 19:27:49 +0100 Subject: [PATCH 14/22] fix(config): remove embedded supabase fallback values and require dart-defines --- README.md | 2 +- lib/main.dart | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 345818e..b31088c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ car64 is a Flutter + Supabase Hot Wheels collector app with a fast scanning work ## Local Development 1. Install Flutter (stable) and run `flutter doctor`. -2. Configure Supabase values via build-time variables (`--dart-define`), for example: +2. Configure Supabase values via build-time variables (`--dart-define`) — required (no fallback is embedded), for example: ```bash flutter run \ diff --git a/lib/main.dart b/lib/main.dart index 56db27a..d1a040b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,11 +18,11 @@ export 'package:supabase_flutter/supabase_flutter.dart' // ── Supabase credentials ────────────────────────────────────────────── const _supabaseUrl = String.fromEnvironment( 'SUPABASE_URL', - defaultValue: 'https://yaopcyubateifnicpywp.supabase.co', + defaultValue: '', ); const _supabaseAnonKey = String.fromEnvironment( 'SUPABASE_ANON_KEY', - defaultValue: 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL', + defaultValue: '', ); const _usePkceAuthFlow = bool.fromEnvironment( 'SUPABASE_USE_PKCE', @@ -32,6 +32,13 @@ const _usePkceAuthFlow = bool.fromEnvironment( 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, From 60319e1d3956abf21be1b435dec8b70e0768abee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 19:33:25 +0100 Subject: [PATCH 15/22] chore(dev): add local-only launch/define workflow and ignore secret define file --- .env/flutter_defines.example.json | 5 +++++ .gitignore | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 .env/flutter_defines.example.json 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 From ed992ae14d5dea62777bd654a1aacf626ad409c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 19:37:04 +0100 Subject: [PATCH 16/22] chore(vscode): add one-click release build tasks for apk and appbundle --- .vscode/tasks.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..81519c3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,31 @@ +{ + "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": [] + } + ] +} From 7175fdbb1fe74655d6eecaf3256c7a882805e5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 19:38:19 +0100 Subject: [PATCH 17/22] chore(vscode): add iOS release ipa build task --- .vscode/tasks.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 81519c3..11930d0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -26,6 +26,19 @@ ], "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": [] } ] } From 214285bb685911f5e0d14634089bb3722acad361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 20:14:15 +0100 Subject: [PATCH 18/22] chore(branding): replace Hot Wheels user-facing text with die-cast wording --- README.md | 2 +- lib/scanner_screen.dart | 4 ++-- lib/screens/about_screen.dart | 2 +- lib/screens/collections_screen.dart | 2 +- lib/screens/garage_screen.dart | 4 ++-- lib/screens/scan_tab.dart | 2 +- lib/services/storage_service.dart | 4 ++-- lib/widgets/car_card.dart | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b31088c..285952e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # car64 -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 is a Flutter + Supabase die-cast collector app with a fast scanning workflow, private photo storage, collaborative collections, and community-based catalog validation. ## Core Features diff --git a/lib/scanner_screen.dart b/lib/scanner_screen.dart index fe18aa3..f8b55b6 100644 --- a/lib/scanner_screen.dart +++ b/lib/scanner_screen.dart @@ -9,7 +9,7 @@ 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). @@ -174,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; 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 cd0fc97..3bf5828 100644 --- a/lib/screens/collections_screen.dart +++ b/lib/screens/collections_screen.dart @@ -167,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() ?? ''; diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 0924bd2..0059306 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -10,7 +10,7 @@ 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; @@ -1348,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/scan_tab.dart b/lib/screens/scan_tab.dart index 416d6be..06cff02 100644 --- a/lib/screens/scan_tab.dart +++ b/lib/screens/scan_tab.dart @@ -116,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), diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index c437afc..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, diff --git a/lib/widgets/car_card.dart b/lib/widgets/car_card.dart index 62caed0..a01a0e7 100644 --- a/lib/widgets/car_card.dart +++ b/lib/widgets/car_card.dart @@ -3,7 +3,7 @@ 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; From 7a2a9661287dff73d70252c0294a4dbff67f39b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 21:32:52 +0100 Subject: [PATCH 19/22] docs(readme): redesign with logo, setup, vscode workflows and security notes --- README.md | 143 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 285952e..5b18a57 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,132 @@ -# car64 +

+ car64 logo +

-car64 is a Flutter + Supabase die-cast 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 +## Overview -1. Install Flutter (stable) and run `flutter doctor`. -2. Configure Supabase values via build-time variables (`--dart-define`) — required (no fallback is embedded), for example: +car64 helps collectors scan model IDs, organize personal and shared collections, and keep a clean catalog with community validation/reporting flows. - ```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 - ``` -3. Install dependencies: +## Features - ```bash - flutter pub get - ``` +- 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 -4. Run the app: +## Tech Stack - ```bash - flutter run - ``` +- Flutter (Material 3) +- Supabase (Auth, PostgREST, Storage, RPC) +- Shared Preferences (local settings) +- Google ML Kit Text Recognition (scanner) -## Quality Check +## 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 + +## 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. + +## 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` From e123911d4bd7d23073a38bbd47da78e279e9d4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 22:04:16 +0100 Subject: [PATCH 20/22] docs: expand repository docs with roadmap, troubleshooting, contributing and security --- CONTRIBUTING.md | 52 +++++++++++++++++++++++++++++++++++++++++ README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 31 +++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md 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/README.md b/README.md index 5b18a57..9363f30 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,24 @@ --- +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Tech Stack](#tech-stack) +- [Prerequisites](#prerequisites) +- [Configuration](#configuration) +- [Getting Started](#getting-started) +- [VS Code Workflows](#vs-code-workflows) +- [Project Structure (high level)](#project-structure-high-level) +- [Troubleshooting](#troubleshooting) +- [Release Checklist](#release-checklist) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [Security Notes](#security-notes) +- [License](#license) +- [Reference](#reference) + ## Overview car64 helps collectors scan model IDs, organize personal and shared collections, and keep a clean catalog with community validation/reporting flows. @@ -116,12 +134,55 @@ Use **Terminal → Run Task**: - `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. 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). From 5acb20400bbd961441b70b0708d09f16fb006d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Fri, 6 Mar 2026 06:32:54 +0100 Subject: [PATCH 21/22] chore: add MIT License file to the repository --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8aa2645 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +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. From bbd52ac8dca91bf7ed05a1713f6612fa66e05d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Fri, 6 Mar 2026 06:34:59 +0100 Subject: [PATCH 22/22] chore: update copyright year and author in LICENSE file --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 8aa2645..0c104d9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) [year] [fullname] +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