hwhub/lib/main.dart
Lukas Müllner 8c453e3f13 fix: refactor password reset dialog to its own StatefulWidget
Fixes red screen crash ('_dependents.isEmpty is not true') caused by
auth state change rebuilding the widget tree while the dialog was
still open and sharing the parent context across an async gap.
2026-02-23 08:38:05 +01:00

473 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// ── Root App Widget ───────────────────────────────────────────────────
class HWHubApp extends StatelessWidget {
const HWHubApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'HW Hub',
debugShowCheckedModeBanner: false,
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 (sign-in, sign-out,
// password-recovery deep link, token refresh, etc.)
supabase.auth.onAuthStateChange.listen(
(AuthState authState) {
if (!mounted) return;
setState(() {
_session = authState.session;
});
// If the user just clicked a password-reset link from their email,
// Supabase fires a PASSWORD_RECOVERY event. We can navigate them
// to a "set new password" screen here.
if (authState.event == AuthChangeEvent.passwordRecovery) {
_showResetPasswordDialog();
}
},
onError: (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Auth error: $error'), backgroundColor: Colors.red),
);
},
);
// 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 {
if (!mounted) return;
await showDialog<void>(
context: context,
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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('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();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('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),
);
}
}
@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;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: Colors.red),
);
} 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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter your email first.')),
);
return;
}
try {
await supabase.auth.resetPasswordForEmail(
email,
redirectTo: 'hwcollector://login',
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('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),
);
}
}
@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 StatelessWidget {
const HomeScreen({super.key});
@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: () => _openScanner(context),
),
);
}
/// Opens the scanner, gets the hw_id, then queries the DB.
Future<void> _openScanner(BuildContext context) async {
// 1. Navigate to the scanner screen and wait for the result.
final hwId = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => const ScannerScreen()),
);
// User cancelled / went back without selecting an ID.
if (hwId == null || !context.mounted) return;
// 2. Query the database.
try {
final data = await supabase
.from('hotwheels')
.select()
.eq('hw_id', hwId)
.maybeSingle();
if (!context.mounted) return;
if (data != null) {
// ── Car already exists ──
_showAlreadyExistsDialog(context, hwId);
} else {
// ── Car is new — offer to add it ──
_showAddDialog(context, hwId);
}
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('DB error: $e'), backgroundColor: Colors.red),
);
}
}
/// Shows an alert: this car is already in the collection.
void _showAlreadyExistsDialog(BuildContext context, String hwId) {
showDialog<void>(
context: context,
builder: (ctx) => 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(ctx).pop(),
child: const Text('Got it'),
),
],
),
);
}
/// Shows a confirmation dialog to add a new car.
void _showAddDialog(BuildContext context, String hwId) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.add_circle_outline, color: Colors.deepPurple, size: 48),
title: const Text('New Car Found!'),
content: Text('$hwId is not in your collection yet.\nAdd it now?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
try {
await supabase.from('hotwheels').insert({
'hw_id': hwId,
'user_id': supabase.auth.currentUser!.id,
});
if (!ctx.mounted) return;
Navigator.of(ctx).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$hwId added to your collection! 🎉')),
);
} catch (e) {
if (!ctx.mounted) return;
Navigator.of(ctx).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add: $e'), backgroundColor: Colors.red),
);
}
},
child: const Text('Add to Collection'),
),
],
),
);
}
}