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