import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tatlock_ui/core/auth/auth_provider.dart'; import 'package:tatlock_ui/core/config/app_config.dart'; import 'package:tatlock_ui/routing/room_registry.dart'; import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart'; /// Main application scaffold with top header navigation. /// /// Handles authentication automatically: /// - If not authenticated, auto-initiates OIDC flow /// - Shows loading state during authentication /// - Shows error state if auth fails (with retry) /// /// Layout structure per UI_LAYOUT.md: /// ``` /// ┌─────────────────────────────────────────────────────────────────┐ /// │ HEADER: [Logo] [Room Tabs] [Profile] │ /// ├─────────────────────────────────────────────────────────────────┤ /// │ BODY: Room page content (may include room-specific sidebar) │ /// └─────────────────────────────────────────────────────────────────┘ /// ``` class AppScaffold extends ConsumerStatefulWidget { const AppScaffold({super.key, required this.child}); final Widget child; @override ConsumerState createState() => _AppScaffoldState(); } class _AppScaffoldState extends ConsumerState { // Header height must match TopHeaderBar._headerHeight static const double _headerHeight = 56.0; bool _authInitiated = false; @override Widget build(BuildContext context) { // No auth required in LAN mode - show content directly if (!AppConfig.requiresAuth) { return _buildScaffold(context); } // Watch auth state (works for both web and mobile) final authAsync = ref.watch(authProvider); return authAsync.when( data: (authState) { if (authState.isAuthenticated) { // Authenticated - show the app _authInitiated = false; // Reset for next time return _buildScaffold(context); } // On web, NPM handles auth - if we're here without auth, something is wrong // (NPM should have redirected to Authentik before we loaded) if (kIsWeb) { return _buildAuthErrorScreen(context, 'Authentication required'); } // Mobile: Not authenticated - auto-initiate OIDC if (!_authInitiated) { _authInitiated = true; WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(authProvider.notifier).signIn(); }); } // Show loading while redirecting to Authentik return _buildAuthLoadingScreen(context, 'Redirecting to sign in...'); }, loading: () => _buildAuthLoadingScreen(context, 'Loading user info...'), error: (error, _) => _buildAuthErrorScreen(context, error), ); } Widget _buildScaffold(BuildContext context) { return Scaffold( body: Stack( children: [ // Main content area with top padding for header Positioned.fill( child: Padding( padding: const EdgeInsets.only(top: _headerHeight), child: widget.child, ), ), // Top header overlays content (bulge extends into content area) Positioned( top: 0, left: 0, right: 0, child: TopHeaderBar( selectedIndex: _selectedIndex(context), onRoomSelected: (index) => _onNavSelected(context, index), ), ), ], ), ); } Widget _buildAuthLoadingScreen(BuildContext context, String message) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, 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: 32), const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(height: 16), Text( message, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), ), ); } Widget _buildAuthErrorScreen(BuildContext context, Object error) { 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.error_outline, size: 64, color: colorScheme.error, ), const SizedBox(height: 24), Text( 'Authentication Failed', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(8), ), child: Text( _formatError(error), style: TextStyle(color: colorScheme.onErrorContainer), textAlign: TextAlign.center, ), ), const SizedBox(height: 24), FilledButton.icon( onPressed: () { _authInitiated = false; ref.read(authProvider.notifier).signIn(); }, icon: const Icon(Icons.refresh), label: const Text('Try again'), style: FilledButton.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.'; } int _selectedIndex(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; return roomRegistry.indexOfRoute(location); } void _onNavSelected(BuildContext context, int index) { final rooms = roomRegistry.all; if (index >= 0 && index < rooms.length) { context.go(rooms[index].defaultRoute); } } }