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] 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})