import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'theme/app_theme.dart'; import 'services/collection_service.dart'; import 'services/main_collection_sync.dart'; import 'screens/login_screen.dart'; import 'screens/home_shell.dart'; import 'utils/error_utils.dart'; import 'utils/preferences_utils.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 = String.fromEnvironment( 'SUPABASE_URL', defaultValue: '', ); const _supabaseAnonKey = String.fromEnvironment( 'SUPABASE_ANON_KEY', defaultValue: '', ); const _usePkceAuthFlow = bool.fromEnvironment( 'SUPABASE_USE_PKCE', defaultValue: true, ); Future main() async { WidgetsFlutterBinding.ensureInitialized(); if (_supabaseUrl.trim().isEmpty || _supabaseAnonKey.trim().isEmpty) { throw StateError( 'Missing Supabase configuration. Provide --dart-define=SUPABASE_URL and ' '--dart-define=SUPABASE_ANON_KEY.', ); } await Supabase.initialize( url: _supabaseUrl, anonKey: _supabaseAnonKey, authOptions: FlutterAuthClientOptions( authFlowType: _usePkceAuthFlow ? AuthFlowType.pkce : 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(); final scaffoldMessengerKey = GlobalKey(); OverlayEntry? _activeMessageOverlay; Timer? _activeMessageOverlayTimer; enum GlobalMessageType { info, success, error } /// Show a snackbar safely through the global key. void showGlobalSnackBar(String message, {bool isError = false}) { _showGlobalMessageOverlay( message, type: isError ? GlobalMessageType.error : GlobalMessageType.info, ); } void showGlobalSuccess(String message) { _showGlobalMessageOverlay(message, type: GlobalMessageType.success); } void showGlobalInfo(String message) { _showGlobalMessageOverlay(message, type: GlobalMessageType.info); } void _showGlobalMessageOverlay( String message, { required GlobalMessageType type, }) { final overlay = navigatorKey.currentState?.overlay; if (overlay == null) return; _activeMessageOverlayTimer?.cancel(); _activeMessageOverlay?.remove(); final backgroundColor = switch (type) { GlobalMessageType.error => Colors.red, GlobalMessageType.success => Colors.green, GlobalMessageType.info => const Color(0xFF1F2937), }; final leadingIcon = switch (type) { GlobalMessageType.error => Icons.error_outline, GlobalMessageType.success => Icons.check_circle_outline, GlobalMessageType.info => Icons.info_outline, }; _activeMessageOverlay = OverlayEntry( builder: (context) { final topPadding = MediaQuery.of(context).padding.top; return Positioned( top: topPadding + 12, left: 12, right: 12, child: Material( color: Colors.transparent, child: IgnorePointer( ignoring: true, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(12), boxShadow: const [ BoxShadow( color: Colors.black26, blurRadius: 8, offset: Offset(0, 3), ), ], ), child: Row( children: [ Icon(leadingIcon, color: Colors.white, size: 20), const SizedBox(width: 8), Expanded( child: Text( message, style: const TextStyle(color: Colors.white), maxLines: 3, overflow: TextOverflow.ellipsis, ), ), ], ), ), ), ), ); }, ); overlay.insert(_activeMessageOverlay!); _activeMessageOverlayTimer = Timer(const Duration(seconds: 4), () { _activeMessageOverlay?.remove(); _activeMessageOverlay = null; _activeMessageOverlayTimer = null; }); } void showGlobalError( Object error, { String fallback = 'Something went wrong. Please try again.', }) { logError('ui', error); showGlobalSnackBar( userMessageForError(error, fallback: fallback), isError: true, ); } /// Show a dialog safely through the global navigator key. Future showGlobalDialog({required WidgetBuilder builder}) { final context = navigatorKey.currentContext; if (context == null) { return Future.value(null); } return showDialog( context: context, 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 createState() => _AuthGateState(); } class _AuthGateState extends State { bool _isLoading = true; bool _isInPasswordRecoveryFlow = false; Session? _session; String? _lastEnsuredUserId; StreamSubscription? _authStateSubscription; @override void initState() { super.initState(); _session = supabase.auth.currentSession; _ensureDefaultCollectionIfNeeded(); _authStateSubscription = 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) { showGlobalError( error, fallback: 'Authentication error. Please sign in again.', ); }, ); setState(() => _isLoading = false); } @override void dispose() { _authStateSubscription?.cancel(); super.dispose(); } Future _ensureDefaultCollectionIfNeeded() async { final userId = _session?.user.id; if (userId == null || userId == _lastEnsuredUserId) return; _lastEnsuredUserId = userId; try { final defaultCollectionId = await CollectionService.ensureDefaultCollection(); await _ensureMainCollectionPreference(defaultCollectionId); } catch (e) { showGlobalError( e, fallback: 'Collection setup failed. Please try again.', ); } } Future _ensureMainCollectionPreference(String? defaultCollectionId) async { final userId = _session?.user.id; if (userId == null) return; final prefs = await SharedPreferences.getInstance(); final persisted = await readActiveCollectionId(prefs, userId: userId); Future hasMembership(String collectionId) async { final membership = await supabase .from('collection_members') .select('id') .eq('user_id', userId) .eq('collection_id', collectionId) .maybeSingle(); return membership != null; } if (persisted != null && await hasMembership(persisted)) { return; } String? nextActiveId; if (defaultCollectionId != null && await hasMembership(defaultCollectionId)) { nextActiveId = defaultCollectionId; } else { final firstMembership = await supabase .from('collection_members') .select('collection_id') .eq('user_id', userId) .limit(1); if (firstMembership.isNotEmpty) { nextActiveId = firstMembership.first['collection_id'] as String; } } if (nextActiveId == null) { await clearActiveCollectionId(prefs, userId: userId); return; } await writeActiveCollectionId( prefs, userId: userId, collectionId: nextActiveId, ); MainCollectionSync.notifyChanged(); } Future _showResetPasswordDialog() async { final context = navigatorKey.currentContext; if (context == null) { _isInPasswordRecoveryFlow = false; return; } await showDialog( context: context, 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 _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(); showGlobalSuccess('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'), ), ], ); } }