On web, NPM forward auth handles authentication at the proxy level. By the time the Flutter app loads, the user is already authenticated. Skip the redundant Flutter OIDC flow that was causing Riverpod "Ref disposed" errors from conflicting auth state updates. Mobile still uses Flutter's OIDC flow as before. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
240 lines
8.2 KiB
Dart
240 lines
8.2 KiB
Dart
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/features/control_room/router.dart';
|
|
import 'package:tatlock_ui/features/security/router.dart';
|
|
import 'package:tatlock_ui/routing/app_router.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<AppScaffold> createState() => _AppScaffoldState();
|
|
}
|
|
|
|
class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
|
// 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);
|
|
}
|
|
|
|
// On web, NPM forward auth handles authentication at the proxy level.
|
|
// If we reach this point, the user is already authenticated by NPM.
|
|
// No need for Flutter's OIDC flow - just show the app.
|
|
if (kIsWeb) {
|
|
return _buildScaffold(context);
|
|
}
|
|
|
|
// Mobile: Use Flutter's OIDC flow
|
|
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);
|
|
}
|
|
|
|
// 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, 'Checking authentication...'),
|
|
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;
|
|
|
|
if (location.startsWith(ControlRoomRoutes.base)) return 1;
|
|
if (location.startsWith(SecurityRoutes.base)) return 2;
|
|
if (location.startsWith(AppRoutes.parlor)) return 3;
|
|
// Settings is no longer in main nav (accessed via Profile dropdown)
|
|
return 0; // Front Hall
|
|
}
|
|
|
|
void _onNavSelected(BuildContext context, int index) {
|
|
final route = switch (index) {
|
|
0 => AppRoutes.frontHall,
|
|
1 => ControlRoomRoutes.containers,
|
|
2 => SecurityRoutes.users,
|
|
3 => AppRoutes.parlor,
|
|
_ => AppRoutes.frontHall,
|
|
};
|
|
context.go(route);
|
|
}
|
|
}
|