420 lines
12 KiB
Dart
420 lines
12 KiB
Dart
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<void> 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<NavigatorState>();
|
|
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
|
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<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
|
|
final context = navigatorKey.currentContext;
|
|
if (context == null) {
|
|
return Future<T?>.value(null);
|
|
}
|
|
return showDialog<T>(
|
|
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<AuthGate> createState() => _AuthGateState();
|
|
}
|
|
|
|
class _AuthGateState extends State<AuthGate> {
|
|
bool _isLoading = true;
|
|
bool _isInPasswordRecoveryFlow = false;
|
|
Session? _session;
|
|
String? _lastEnsuredUserId;
|
|
StreamSubscription<AuthState>? _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<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) {
|
|
showGlobalError(
|
|
e,
|
|
fallback: 'Collection setup failed. Please try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _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<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 clearActiveCollectionId(prefs, userId: userId);
|
|
return;
|
|
}
|
|
|
|
await writeActiveCollectionId(
|
|
prefs,
|
|
userId: userId,
|
|
collectionId: nextActiveId,
|
|
);
|
|
MainCollectionSync.notifyChanged();
|
|
}
|
|
|
|
Future<void> _showResetPasswordDialog() async {
|
|
final context = navigatorKey.currentContext;
|
|
if (context == null) {
|
|
_isInPasswordRecoveryFlow = false;
|
|
return;
|
|
}
|
|
|
|
await showDialog<void>(
|
|
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<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();
|
|
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|