- Replace noisy no-id snackbars with in-screen red status feedback - Keep success state green and reduce interruption during scan flow - Make scan-tab collection switching session-only (does not overwrite main) - Keep main collection preselected from collections tab preference - Apply recent UX text/layout polish for scan and detail actions
235 lines
6.9 KiB
Dart
235 lines
6.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'theme/app_theme.dart';
|
|
import 'services/collection_service.dart';
|
|
import 'screens/login_screen.dart';
|
|
import 'screens/home_shell.dart';
|
|
|
|
// Re-export so other files can `import '../main.dart'` for these.
|
|
export 'package:supabase_flutter/supabase_flutter.dart'
|
|
show AuthException, UserAttributes;
|
|
|
|
// ── 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,
|
|
authOptions: const FlutterAuthClientOptions(
|
|
authFlowType: AuthFlowType.implicit,
|
|
),
|
|
);
|
|
|
|
runApp(const Car64App());
|
|
}
|
|
|
|
/// 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}) {
|
|
final messenger = scaffoldMessengerKey.currentState;
|
|
if (messenger == null) return;
|
|
|
|
messenger
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
backgroundColor: isError ? Colors.red : null,
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 96),
|
|
duration: const Duration(seconds: 3),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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 Car64App extends StatelessWidget {
|
|
const Car64App({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'car64',
|
|
debugShowCheckedModeBanner: false,
|
|
navigatorKey: navigatorKey,
|
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
|
theme: AppTheme.light,
|
|
darkTheme: AppTheme.dark,
|
|
themeMode: ThemeMode.system,
|
|
home: const AuthGate(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Auth Gate ─────────────────────────────────────────────────────────
|
|
class AuthGate extends StatefulWidget {
|
|
const AuthGate({super.key});
|
|
|
|
@override
|
|
State<AuthGate> createState() => _AuthGateState();
|
|
}
|
|
|
|
class _AuthGateState extends State<AuthGate> {
|
|
bool _isLoading = true;
|
|
bool _isInPasswordRecoveryFlow = false;
|
|
Session? _session;
|
|
String? _lastEnsuredUserId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_session = supabase.auth.currentSession;
|
|
_ensureDefaultCollectionIfNeeded();
|
|
|
|
supabase.auth.onAuthStateChange.listen(
|
|
(AuthState authState) {
|
|
if (!mounted) return;
|
|
|
|
final wasLoggedIn = _session != null;
|
|
final isLoggedIn = authState.session != null;
|
|
_session = authState.session;
|
|
|
|
if (wasLoggedIn != isLoggedIn) {
|
|
setState(() {});
|
|
}
|
|
|
|
_ensureDefaultCollectionIfNeeded();
|
|
|
|
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
|
setState(() => _isInPasswordRecoveryFlow = true);
|
|
_showResetPasswordDialog();
|
|
}
|
|
},
|
|
onError: (error) {
|
|
showGlobalSnackBar('Auth error: $error', isError: true);
|
|
},
|
|
);
|
|
|
|
setState(() => _isLoading = false);
|
|
}
|
|
|
|
Future<void> _ensureDefaultCollectionIfNeeded() async {
|
|
final userId = _session?.user.id;
|
|
if (userId == null || userId == _lastEnsuredUserId) return;
|
|
|
|
_lastEnsuredUserId = userId;
|
|
try {
|
|
await CollectionService.ensureDefaultCollection();
|
|
} catch (e) {
|
|
showGlobalSnackBar('Collection setup failed: $e', isError: true);
|
|
}
|
|
}
|
|
|
|
Future<void> _showResetPasswordDialog() async {
|
|
await showDialog<void>(
|
|
context: navigatorKey.currentContext!,
|
|
barrierDismissible: false,
|
|
builder: (_) => const _ResetPasswordDialog(),
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() => _isInPasswordRecoveryFlow = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_isLoading) {
|
|
return const Scaffold(
|
|
body: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
if (_isInPasswordRecoveryFlow) return const LoginScreen();
|
|
return _session != null ? const HomeShell() : 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'),
|
|
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|