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: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.',
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue