fix(ux): show errors above modals and handle duplicate owner moves cleanly
This commit is contained in:
parent
eab9000247
commit
a2a8917506
2 changed files with 126 additions and 2 deletions
|
|
@ -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<NavigatorState>();
|
||||
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -479,10 +479,49 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
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());
|
||||
.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<GarageScreen> {
|
|||
if (targetId == null) return;
|
||||
|
||||
if (widget.isOwner) {
|
||||
final existing = await supabase
|
||||
.from('hotwheels')
|
||||
.select('id')
|
||||
.eq('collection_id', targetId)
|
||||
.eq('hw_id', car['hw_id'])
|
||||
.maybeSingle();
|
||||
|
||||
if (existing != null) {
|
||||
showGlobalSnackBar(
|
||||
'${car['hw_id']} is already in the target collection.',
|
||||
isError: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('hotwheels')
|
||||
.update({'collection_id': targetId})
|
||||
|
|
|
|||
Loading…
Reference in a new issue