- Add supabase_flutter dependency - Replace default counter template with auth flow (AuthGate, LoginScreen, HomeScreen) - Implement email/password sign-in via signInWithPassword() - Add forgot password flow with resetPasswordForEmail() and hwcollector://login deep link - Handle PASSWORD_RECOVERY event to show set-new-password dialog - Add deep link intent-filter to AndroidManifest for hwcollector://login scheme - Configure Supabase URL and publishable key - Replace outdated widget test with placeholder
329 lines
10 KiB
Dart
329 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||
|
||
// ── Supabase credentials ──────────────────────────────────────────────
|
||
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
|
||
// TODO: Paste your anon key from Supabase Dashboard → Settings → API.
|
||
const _supabaseAnonKey = 'YOUR_ANON_KEY';
|
||
|
||
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 {
|
||
final passwordController = TextEditingController();
|
||
|
||
await showDialog<void>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('Set New Password'),
|
||
content: TextField(
|
||
controller: passwordController,
|
||
obscureText: true,
|
||
decoration: const InputDecoration(
|
||
labelText: 'New password',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () 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;
|
||
}
|
||
try {
|
||
await supabase.auth.updateUser(
|
||
UserAttributes(password: newPassword),
|
||
);
|
||
if (!ctx.mounted) return;
|
||
Navigator.of(ctx).pop();
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Password updated successfully!')),
|
||
);
|
||
} on AuthException catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(e.message), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
},
|
||
child: const Text('Save'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
passwordController.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_isLoading) {
|
||
return const Scaffold(
|
||
body: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
|
||
if (_session != null) {
|
||
return const HomeScreen();
|
||
}
|
||
|
||
return const LoginScreen();
|
||
}
|
||
}
|
||
|
||
// ── 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 (placeholder) ─────────────────────────────────────────
|
||
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: Text(
|
||
'Signed in as ${user?.email ?? 'unknown'}',
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|