import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:tatlock_ui/core/auth/auth_provider.dart'; import 'package:tatlock_ui/core/config/app_config.dart'; import 'package:tatlock_ui/features/control_room/router.dart'; import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart'; import 'package:tatlock_ui/features/security/router.dart'; import 'package:tatlock_ui/shared/layouts/app_scaffold.dart'; part 'app_router.g.dart'; /// Route paths as constants. abstract class AppRoutes { static const frontHall = '/'; static const parlor = '/parlor'; static const settings = '/settings'; static const login = '/login'; static const callback = '/callback'; } /// Provides the GoRouter instance. @riverpod GoRouter appRouter(Ref ref) { final authState = ref.watch(authProvider); return GoRouter( initialLocation: AppRoutes.frontHall, debugLogDiagnostics: true, redirect: (context, state) { // No auth required in LAN mode if (!AppConfig.requiresAuth) { return null; } // Allow callback route through without auth check (must be checked FIRST!) if (state.matchedLocation == AppRoutes.callback) { return null; } final isAuthenticated = authState.value?.isAuthenticated ?? false; final isLoginRoute = state.matchedLocation == AppRoutes.login; // If not authenticated, redirect to login (except if already on login) if (!isAuthenticated && !isLoginRoute) { return AppRoutes.login; } // If authenticated and on login page, redirect to home if (isAuthenticated && isLoginRoute) { return AppRoutes.frontHall; } return null; }, routes: [ // Login route (outside shell - no app scaffold) GoRoute( path: AppRoutes.login, name: 'login', builder: (context, state) => const _LoginPage(), ), // OIDC callback route (handles auth code exchange) GoRoute( path: AppRoutes.callback, name: 'callback', builder: (context, state) => _OidcCallbackPage( code: state.uri.queryParameters['code'], callbackState: state.uri.queryParameters['state'], error: state.uri.queryParameters['error'], errorDescription: state.uri.queryParameters['error_description'], ), ), // Main app routes (inside shell with app scaffold) ShellRoute( builder: (context, state, child) => AppScaffold(child: child), routes: [ GoRoute( path: AppRoutes.frontHall, name: 'frontHall', builder: (context, state) => const FrontHallPage(), ), ...controlRoomRoutes(), ...securityRoutes(), GoRoute( path: AppRoutes.parlor, name: 'parlor', builder: (context, state) => const _PlaceholderPage(title: 'Parlor'), ), GoRoute( path: AppRoutes.settings, name: 'settings', builder: (context, state) => const _PlaceholderPage(title: 'Settings'), ), ], ), ], ); } /// Placeholder page for routes not yet implemented. class _PlaceholderPage extends StatelessWidget { const _PlaceholderPage({required this.title}); final String title; @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.construction, size: 64, color: Theme.of(context).colorScheme.outline, ), const SizedBox(height: 16), Text( title, style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 8), Text( 'Coming soon', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context).colorScheme.outline, ), ), ], ), ); } } /// Login page displayed when user is not authenticated. class _LoginPage extends ConsumerWidget { const _LoginPage(); @override Widget build(BuildContext context, WidgetRef ref) { final authAsync = ref.watch(authProvider); final colorScheme = Theme.of(context).colorScheme; return Scaffold( body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), child: Card( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.home_work_outlined, size: 64, color: colorScheme.primary, ), const SizedBox(height: 24), Text( 'Tatlock Estate', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Text( 'Sign in to access the estate management system', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), const SizedBox(height: 32), authAsync.when( data: (_) => _buildSignInContent(context, ref), loading: () => const Column( children: [ SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(height: 16), Text('Checking authentication...'), ], ), error: (error, _) => _buildErrorContent(context, ref, error), ), ], ), ), ), ), ), ); } Widget _buildSignInContent(BuildContext context, WidgetRef ref) { // Same sign in button for both web and mobile return FilledButton.icon( onPressed: () => ref.read(authProvider.notifier).signIn(), icon: const Icon(Icons.login), label: const Text('Sign in with Authentik'), style: FilledButton.styleFrom( minimumSize: const Size(double.infinity, 48), ), ); } Widget _buildErrorContent(BuildContext context, WidgetRef ref, Object error) { final colorScheme = Theme.of(context).colorScheme; return Column( children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Icon(Icons.error_outline, color: colorScheme.onErrorContainer), const SizedBox(width: 12), Expanded( child: Text( _formatError(error), style: TextStyle(color: colorScheme.onErrorContainer), ), ), ], ), ), const SizedBox(height: 16), OutlinedButton.icon( onPressed: () => ref.read(authProvider.notifier).signIn(), icon: const Icon(Icons.refresh), label: const Text('Try again'), style: OutlinedButton.styleFrom( minimumSize: const Size(double.infinity, 48), ), ), ], ); } String _formatError(Object error) { final message = error.toString(); if (message.contains('user_cancelled')) { return 'Sign in was cancelled'; } if (message.contains('network')) { return 'Network error. Please check your connection.'; } return 'Authentication failed. Please try again.'; } } /// OIDC callback page that handles the authorization code exchange. class _OidcCallbackPage extends ConsumerStatefulWidget { const _OidcCallbackPage({ this.code, this.callbackState, this.error, this.errorDescription, }); final String? code; final String? callbackState; final String? error; final String? errorDescription; @override ConsumerState<_OidcCallbackPage> createState() => _OidcCallbackPageState(); } class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> { bool _isProcessing = true; String? _error; @override void initState() { super.initState(); // Defer callback processing to avoid Riverpod state modification during build WidgetsBinding.instance.addPostFrameCallback((_) { _processCallback(); }); } Future _processCallback() async { // Check for error from Authentik if (widget.error != null) { setState(() { _isProcessing = false; _error = widget.errorDescription ?? widget.error; }); return; } // Check for required parameters if (widget.code == null || widget.callbackState == null) { setState(() { _isProcessing = false; _error = 'Invalid callback - missing code or state parameter'; }); return; } // Exchange code for tokens try { await ref.read(authProvider.notifier).handleOidcCallback( widget.code!, widget.callbackState!, ); // Navigate to home on success if (mounted) { context.go(AppRoutes.frontHall); } } catch (e) { if (mounted) { setState(() { _isProcessing = false; _error = e.toString(); }); } } } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), child: Card( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( _error != null ? Icons.error_outline : Icons.home_work_outlined, size: 64, color: _error != null ? colorScheme.error : colorScheme.primary, ), const SizedBox(height: 24), Text( _error != null ? 'Authentication Failed' : 'Signing in...', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 16), if (_isProcessing) const CircularProgressIndicator() else if (_error != null) ...[ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(8), ), child: Text( _error!, style: TextStyle(color: colorScheme.onErrorContainer), textAlign: TextAlign.center, ), ), const SizedBox(height: 16), OutlinedButton.icon( onPressed: () => context.go(AppRoutes.login), icon: const Icon(Icons.arrow_back), label: const Text('Back to Login'), style: OutlinedButton.styleFrom( minimumSize: const Size(double.infinity, 48), ), ), ], ], ), ), ), ), ), ); } }