hwhub/lib/main.dart

296 lines
8.7 KiB
Dart

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';
// 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: 'https://yaopcyubateifnicpywp.supabase.co',
);
const _supabaseAnonKey = String.fromEnvironment(
'SUPABASE_ANON_KEY',
defaultValue: 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL',
);
const _usePkceAuthFlow = bool.fromEnvironment(
'SUPABASE_USE_PKCE',
defaultValue: true,
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
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<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> {
static const _activeCollectionPrefKey = 'active_collection_id';
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 {
final defaultCollectionId = await CollectionService.ensureDefaultCollection();
await _ensureMainCollectionPreference(defaultCollectionId);
} catch (e) {
showGlobalSnackBar('Collection setup failed: $e', isError: true);
}
}
Future<void> _ensureMainCollectionPreference(String? defaultCollectionId) async {
final userId = _session?.user.id;
if (userId == null) return;
final prefs = await SharedPreferences.getInstance();
final persisted = prefs.getString(_activeCollectionPrefKey);
Future<bool> 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 prefs.remove(_activeCollectionPrefKey);
return;
}
await prefs.setString(_activeCollectionPrefKey, nextActiveId);
MainCollectionSync.notifyChanged();
}
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'),
),
],
);
}
}