fix(ux): show errors above modals and handle duplicate owner moves cleanly

This commit is contained in:
Lukas Müllner 2026-03-05 15:16:10 +01:00
parent eab9000247
commit a2a8917506
2 changed files with 126 additions and 2 deletions

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.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. /// Global keys so dialogs & snackbars survive widget-tree rebuilds.
final navigatorKey = GlobalKey<NavigatorState>(); final navigatorKey = GlobalKey<NavigatorState>();
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
OverlayEntry? _activeErrorOverlay;
Timer? _activeErrorOverlayTimer;
/// Show a snackbar safely through the global key. /// Show a snackbar safely through the global key.
void showGlobalSnackBar(String message, {bool isError = false}) { void showGlobalSnackBar(String message, {bool isError = false}) {
if (isError) {
_showGlobalErrorOverlay(message);
return;
}
final messenger = scaffoldMessengerKey.currentState; final messenger = scaffoldMessengerKey.currentState;
if (messenger == null) return; 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( void showGlobalError(
Object error, { Object error, {
String fallback = 'Something went wrong. Please try again.', String fallback = 'Something went wrong. Please try again.',

View file

@ -479,10 +479,49 @@ class GarageScreenState extends State<GarageScreen> {
final targetCollectionId = targetId!; final targetCollectionId = targetId!;
if (isOwner) { 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 await supabase
.from('hotwheels') .from('hotwheels')
.update({'collection_id': targetCollectionId}) .update({'collection_id': targetCollectionId})
.inFilter('id', _selectedIds.toList()); .inFilter('id', moveableIds);
if (skippedDuplicates > 0) {
showGlobalSnackBar(
'$skippedDuplicates car(s) skipped because they already exist in target collection.',
);
}
} else { } else {
final userId = supabase.auth.currentUser?.id; final userId = supabase.auth.currentUser?.id;
if (userId == null) { if (userId == null) {
@ -1092,6 +1131,21 @@ class GarageScreenState extends State<GarageScreen> {
if (targetId == null) return; if (targetId == null) return;
if (widget.isOwner) { 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 await supabase
.from('hotwheels') .from('hotwheels')
.update({'collection_id': targetId}) .update({'collection_id': targetId})