fix: use global navigator/scaffold keys for all dialogs and snackbars

Eliminates all _dependents.isEmpty and Duplicate GlobalKeys crashes by
routing every dialog through a global NavigatorState key and every
snackbar through a global ScaffoldMessengerState key. These keys live
on MaterialApp and are never disposed, so async operations can safely
show UI feedback regardless of widget tree rebuilds from auth events.
This commit is contained in:
Lukas Müllner 2026-02-23 09:04:00 +01:00
parent c104ec16ed
commit 024c05734b

View file

@ -20,6 +20,28 @@ Future<void> main() async {
/// Convenience accessor used throughout the app.
final supabase = Supabase.instance.client;
/// Global keys so dialogs & snackbars survive widget-tree rebuilds.
final navigatorKey = GlobalKey<NavigatorState>();
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
/// Show a snackbar safely through the global key.
void showGlobalSnackBar(String message, {bool isError = false}) {
scaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : null,
),
);
}
/// Show a dialog safely through the global navigator key.
Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
return showDialog<T>(
context: navigatorKey.currentContext!,
builder: builder,
);
}
// Root App Widget
class HWHubApp extends StatelessWidget {
const HWHubApp({super.key});
@ -29,6 +51,8 @@ class HWHubApp extends StatelessWidget {
return MaterialApp(
title: 'HW Hub',
debugShowCheckedModeBanner: false,
navigatorKey: navigatorKey,
scaffoldMessengerKey: scaffoldMessengerKey,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
@ -82,10 +106,7 @@ class _AuthGateState extends State<AuthGate> {
}
},
onError: (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Auth error: $error'), backgroundColor: Colors.red),
);
showGlobalSnackBar('Auth error: $error', isError: true);
},
);
@ -96,10 +117,8 @@ class _AuthGateState extends State<AuthGate> {
/// Shows a dialog so the user can type a new password after clicking
/// the "Reset Password" link from their email.
Future<void> _showResetPasswordDialog() async {
if (!mounted) return;
await showDialog<void>(
context: context,
context: navigatorKey.currentContext!,
barrierDismissible: false,
builder: (_) => const _ResetPasswordDialog(),
);
@ -142,9 +161,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
Future<void> _save() async {
final newPassword = _passwordController.text.trim();
if (newPassword.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Password must be at least 6 characters.')),
);
showGlobalSnackBar('Password must be at least 6 characters.');
return;
}
@ -156,15 +173,11 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
);
if (!mounted) return;
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Password updated successfully!')),
);
showGlobalSnackBar('Password updated successfully!');
} on AuthException catch (e) {
if (!mounted) return;
setState(() => _isSaving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: Colors.red),
);
showGlobalSnackBar(e.message, isError: true);
}
}
@ -230,9 +243,7 @@ class _LoginScreenState extends State<LoginScreen> {
// AuthGate listener will pick up the new session automatically.
} on AuthException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: Colors.red),
);
showGlobalSnackBar(e.message, isError: true);
} finally {
if (mounted) setState(() => _isLoading = false);
}
@ -244,9 +255,7 @@ class _LoginScreenState extends State<LoginScreen> {
Future<void> _forgotPassword() async {
final email = _emailController.text.trim();
if (email.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter your email first.')),
);
showGlobalSnackBar('Enter your email first.');
return;
}
@ -255,15 +264,9 @@ class _LoginScreenState extends State<LoginScreen> {
email,
redirectTo: 'hwcollector://login',
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Password reset email sent! Check your inbox.')),
);
showGlobalSnackBar('Password reset email sent! Check your inbox.');
} on AuthException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: Colors.red),
);
showGlobalSnackBar(e.message, isError: true);
}
}
@ -393,11 +396,11 @@ class _HomeScreenState extends State<HomeScreen> {
/// Opens the scanner, gets the hw_id, then queries the DB.
Future<void> _openScanner() async {
final hwId = await Navigator.of(context).push<String>(
final hwId = await navigatorKey.currentState!.push<String>(
MaterialPageRoute(builder: (_) => const ScannerScreen()),
);
if (hwId == null || !mounted) return;
if (hwId == null) return;
setState(() => _isBusy = true);
@ -408,32 +411,23 @@ class _HomeScreenState extends State<HomeScreen> {
.eq('hw_id', hwId)
.maybeSingle();
if (!mounted) return;
setState(() => _isBusy = false);
if (mounted) setState(() => _isBusy = false);
if (data != null) {
await showDialog<void>(
context: context,
await showGlobalDialog(
builder: (_) => _AlreadyExistsDialog(hwId: hwId),
);
} else {
// Dialog returns true if the car was added successfully.
final added = await showDialog<bool>(
context: context,
final added = await showGlobalDialog<bool>(
builder: (_) => _AddCarDialog(hwId: hwId),
);
if (added == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$hwId added to your collection! 🎉')),
);
if (added == true) {
showGlobalSnackBar('$hwId added to your collection! 🎉');
}
}
} catch (e) {
if (!mounted) return;
setState(() => _isBusy = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('DB error: $e'), backgroundColor: Colors.red),
);
if (mounted) setState(() => _isBusy = false);
showGlobalSnackBar('DB error: $e', isError: true);
}
}
}
@ -480,14 +474,11 @@ class _AddCarDialogState extends State<_AddCarDialog> {
'user_id': supabase.auth.currentUser!.id,
});
if (!mounted) return;
// Return true to signal success snackbar shown by HomeScreen.
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
setState(() => _isAdding = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add: $e'), backgroundColor: Colors.red),
);
showGlobalSnackBar('Failed to add: $e', isError: true);
}
}