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.
509 lines
15 KiB
Dart
509 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||
import 'scanner_screen.dart';
|
||
|
||
// ── Supabase credentials ──────────────────────────────────────────────
|
||
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
|
||
const _supabaseAnonKey = 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL';
|
||
|
||
Future<void> main() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
|
||
await Supabase.initialize(
|
||
url: _supabaseUrl,
|
||
anonKey: _supabaseAnonKey,
|
||
);
|
||
|
||
runApp(const HWHubApp());
|
||
}
|
||
|
||
/// 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});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return MaterialApp(
|
||
title: 'HW Hub',
|
||
debugShowCheckedModeBanner: false,
|
||
navigatorKey: navigatorKey,
|
||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||
theme: ThemeData(
|
||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||
useMaterial3: true,
|
||
),
|
||
home: const AuthGate(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Auth Gate ─────────────────────────────────────────────────────────
|
||
// Listens to Supabase auth state changes (including deep-link callbacks
|
||
// for password resets) and routes to Login or Home.
|
||
class AuthGate extends StatefulWidget {
|
||
const AuthGate({super.key});
|
||
|
||
@override
|
||
State<AuthGate> createState() => _AuthGateState();
|
||
}
|
||
|
||
class _AuthGateState extends State<AuthGate> {
|
||
bool _isLoading = true;
|
||
Session? _session;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
// 1. Check for an existing session on cold start.
|
||
_session = supabase.auth.currentSession;
|
||
|
||
// 2. Listen for auth state changes — but only rebuild when login
|
||
// status actually changes (signed-in ↔ signed-out), NOT on
|
||
// every token refresh, to avoid tearing down open dialogs.
|
||
supabase.auth.onAuthStateChange.listen(
|
||
(AuthState authState) {
|
||
if (!mounted) return;
|
||
|
||
final wasLoggedIn = _session != null;
|
||
final isLoggedIn = authState.session != null;
|
||
_session = authState.session;
|
||
|
||
// Only rebuild the tree when login state actually flips.
|
||
if (wasLoggedIn != isLoggedIn) {
|
||
setState(() {});
|
||
}
|
||
|
||
// If the user just clicked a password-reset link from their email,
|
||
// Supabase fires a PASSWORD_RECOVERY event.
|
||
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
||
_showResetPasswordDialog();
|
||
}
|
||
},
|
||
onError: (error) {
|
||
showGlobalSnackBar('Auth error: $error', isError: true);
|
||
},
|
||
);
|
||
|
||
// Done checking – remove the loading state.
|
||
setState(() => _isLoading = false);
|
||
}
|
||
|
||
/// Shows a dialog so the user can type a new password after clicking
|
||
/// the "Reset Password" link from their email.
|
||
Future<void> _showResetPasswordDialog() async {
|
||
await showDialog<void>(
|
||
context: navigatorKey.currentContext!,
|
||
barrierDismissible: false,
|
||
builder: (_) => const _ResetPasswordDialog(),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_isLoading) {
|
||
return const Scaffold(
|
||
body: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
|
||
if (_session != null) {
|
||
return const HomeScreen();
|
||
}
|
||
|
||
return const LoginScreen();
|
||
}
|
||
}
|
||
|
||
// ── Reset Password Dialog ─────────────────────────────────────────────
|
||
class _ResetPasswordDialog extends StatefulWidget {
|
||
const _ResetPasswordDialog();
|
||
|
||
@override
|
||
State<_ResetPasswordDialog> createState() => _ResetPasswordDialogState();
|
||
}
|
||
|
||
class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
|
||
final _passwordController = TextEditingController();
|
||
bool _isSaving = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_passwordController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
final newPassword = _passwordController.text.trim();
|
||
if (newPassword.length < 6) {
|
||
showGlobalSnackBar('Password must be at least 6 characters.');
|
||
return;
|
||
}
|
||
|
||
setState(() => _isSaving = true);
|
||
|
||
try {
|
||
await supabase.auth.updateUser(
|
||
UserAttributes(password: newPassword),
|
||
);
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop();
|
||
showGlobalSnackBar('Password updated successfully!');
|
||
} on AuthException catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _isSaving = false);
|
||
showGlobalSnackBar(e.message, isError: true);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: const Text('Set New Password'),
|
||
content: TextField(
|
||
controller: _passwordController,
|
||
obscureText: true,
|
||
decoration: const InputDecoration(
|
||
labelText: 'New password',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
onSubmitted: (_) => _save(),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: _isSaving ? null : _save,
|
||
child: _isSaving
|
||
? const SizedBox(
|
||
height: 18,
|
||
width: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Text('Save'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Login Screen ──────────────────────────────────────────────────────
|
||
class LoginScreen extends StatefulWidget {
|
||
const LoginScreen({super.key});
|
||
|
||
@override
|
||
State<LoginScreen> createState() => _LoginScreenState();
|
||
}
|
||
|
||
class _LoginScreenState extends State<LoginScreen> {
|
||
final _emailController = TextEditingController();
|
||
final _passwordController = TextEditingController();
|
||
bool _isLoading = false;
|
||
|
||
/// Sign in with email + password.
|
||
Future<void> _signIn() async {
|
||
final email = _emailController.text.trim();
|
||
final password = _passwordController.text;
|
||
if (email.isEmpty || password.isEmpty) return;
|
||
|
||
setState(() => _isLoading = true);
|
||
|
||
try {
|
||
await supabase.auth.signInWithPassword(
|
||
email: email,
|
||
password: password,
|
||
);
|
||
// AuthGate listener will pick up the new session automatically.
|
||
} on AuthException catch (e) {
|
||
if (!mounted) return;
|
||
showGlobalSnackBar(e.message, isError: true);
|
||
} finally {
|
||
if (mounted) setState(() => _isLoading = false);
|
||
}
|
||
}
|
||
|
||
/// Send a password-reset email. The link in the email uses the
|
||
/// hwcollector://login deep link so it opens back in the app,
|
||
/// which triggers the PASSWORD_RECOVERY event in AuthGate.
|
||
Future<void> _forgotPassword() async {
|
||
final email = _emailController.text.trim();
|
||
if (email.isEmpty) {
|
||
showGlobalSnackBar('Enter your email first.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await supabase.auth.resetPasswordForEmail(
|
||
email,
|
||
redirectTo: 'hwcollector://login',
|
||
);
|
||
showGlobalSnackBar('Password reset email sent! Check your inbox.');
|
||
} on AuthException catch (e) {
|
||
showGlobalSnackBar(e.message, isError: true);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_emailController.dispose();
|
||
_passwordController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
body: Center(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.directions_car, size: 72, color: Colors.deepPurple),
|
||
const SizedBox(height: 16),
|
||
const Text(
|
||
'Hot Wheels Tracker',
|
||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 32),
|
||
TextField(
|
||
controller: _emailController,
|
||
keyboardType: TextInputType.emailAddress,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Email',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _passwordController,
|
||
obscureText: true,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Password',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
onSubmitted: (_) => _signIn(),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: TextButton(
|
||
onPressed: _forgotPassword,
|
||
child: const Text('Forgot Password?'),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: _isLoading ? null : _signIn,
|
||
child: _isLoading
|
||
? const SizedBox(
|
||
height: 20,
|
||
width: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Text('Sign In'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Home Screen ───────────────────────────────────────────────────────
|
||
class HomeScreen extends StatefulWidget {
|
||
const HomeScreen({super.key});
|
||
|
||
@override
|
||
State<HomeScreen> createState() => _HomeScreenState();
|
||
}
|
||
|
||
class _HomeScreenState extends State<HomeScreen> {
|
||
bool _isBusy = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final user = supabase.auth.currentUser;
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Garage is Online! 🏎️'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.logout),
|
||
onPressed: () async {
|
||
await supabase.auth.signOut();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
body: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'Signed in as ${user?.email ?? 'unknown'}',
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
const SizedBox(height: 32),
|
||
const Icon(Icons.directions_car, size: 80, color: Colors.deepPurple),
|
||
const SizedBox(height: 16),
|
||
const Text(
|
||
'Scan or enter a Hot Wheels ID\nto check your collection.',
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
floatingActionButton: FloatingActionButton.extended(
|
||
icon: const Icon(Icons.camera_alt),
|
||
label: const Text('Scan'),
|
||
onPressed: _isBusy ? null : _openScanner,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Opens the scanner, gets the hw_id, then queries the DB.
|
||
Future<void> _openScanner() async {
|
||
final hwId = await navigatorKey.currentState!.push<String>(
|
||
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
||
);
|
||
|
||
if (hwId == null) return;
|
||
|
||
setState(() => _isBusy = true);
|
||
|
||
try {
|
||
final data = await supabase
|
||
.from('hotwheels')
|
||
.select()
|
||
.eq('hw_id', hwId)
|
||
.maybeSingle();
|
||
|
||
if (mounted) setState(() => _isBusy = false);
|
||
|
||
if (data != null) {
|
||
await showGlobalDialog(
|
||
builder: (_) => _AlreadyExistsDialog(hwId: hwId),
|
||
);
|
||
} else {
|
||
final added = await showGlobalDialog<bool>(
|
||
builder: (_) => _AddCarDialog(hwId: hwId),
|
||
);
|
||
if (added == true) {
|
||
showGlobalSnackBar('$hwId added to your collection! 🎉');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (mounted) setState(() => _isBusy = false);
|
||
showGlobalSnackBar('DB error: $e', isError: true);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── "Already in Collection" Dialog ────────────────────────────────────
|
||
class _AlreadyExistsDialog extends StatelessWidget {
|
||
final String hwId;
|
||
const _AlreadyExistsDialog({required this.hwId});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
icon: const Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||
title: const Text('Already in Collection!'),
|
||
content: Text('$hwId is already in your shared garage.'),
|
||
actions: [
|
||
ElevatedButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Got it'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── "Add to Collection" Dialog ────────────────────────────────────────
|
||
class _AddCarDialog extends StatefulWidget {
|
||
final String hwId;
|
||
const _AddCarDialog({required this.hwId});
|
||
|
||
@override
|
||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
||
}
|
||
|
||
class _AddCarDialogState extends State<_AddCarDialog> {
|
||
bool _isAdding = false;
|
||
|
||
Future<void> _addCar() async {
|
||
setState(() => _isAdding = true);
|
||
|
||
try {
|
||
await supabase.from('hotwheels').insert({
|
||
'hw_id': widget.hwId,
|
||
'user_id': supabase.auth.currentUser!.id,
|
||
});
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop(true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _isAdding = false);
|
||
showGlobalSnackBar('Failed to add: $e', isError: true);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
icon: const Icon(Icons.add_circle_outline, color: Colors.deepPurple, size: 48),
|
||
title: const Text('New Car Found!'),
|
||
content: Text('${widget.hwId} is not in your collection yet.\nAdd it now?'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: _isAdding ? null : () => Navigator.of(context).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: _isAdding ? null : _addCar,
|
||
child: _isAdding
|
||
? const SizedBox(
|
||
height: 18,
|
||
width: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Text('Add to Collection'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|