From 66a28958edcfc0c691efef15fff0e2e38e15c0ce Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:36:43 +0530 Subject: [PATCH] refactor: migrate to go_router navigation --- lib/core/router/app_router.dart | 194 +++++++++++++ lib/core/services/navigation_service.dart | 186 +++++-------- .../services/user_friendly_error_handler.dart | 3 +- .../auth/views/authentication_page.dart | 6 +- .../auth/views/connect_signin_page.dart | 29 +- .../auth/views/server_connection_page.dart | 173 ++++++------ .../navigation/widgets/chats_drawer.dart | 14 +- lib/features/profile/views/profile_page.dart | 7 +- lib/main.dart | 255 ++++++------------ pubspec.lock | 8 + pubspec.yaml | 3 + 11 files changed, 468 insertions(+), 410 deletions(-) create mode 100644 lib/core/router/app_router.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..eb7b6d0 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../providers/app_providers.dart'; +import '../services/navigation_service.dart'; +import '../utils/debug_logger.dart'; +import '../../features/auth/providers/unified_auth_providers.dart'; +import '../../features/auth/views/authentication_page.dart'; +import '../../features/auth/views/connect_signin_page.dart'; +import '../../features/auth/views/server_connection_page.dart'; +import '../../features/chat/views/chat_page.dart'; +import '../../features/files/views/workspace_page.dart'; +import '../../features/navigation/views/splash_launcher_page.dart'; +import '../../features/profile/views/app_customization_page.dart'; +import '../../features/profile/views/profile_page.dart'; +import '../../l10n/app_localizations.dart'; +import '../models/server_config.dart'; + +class RouterNotifier extends ChangeNotifier { + RouterNotifier(this.ref) { + _subscriptions = [ + ref.listen(reviewerModeProvider, _onStateChanged), + ref.listen>( + activeServerProvider, + _onStateChanged, + ), + ref.listen( + authNavigationStateProvider, + _onStateChanged, + ), + ]; + } + + final Ref ref; + late final List> _subscriptions; + + void _onStateChanged(dynamic previous, dynamic next) { + notifyListeners(); + } + + String? redirect(BuildContext context, GoRouterState state) { + final location = state.uri.path.isEmpty ? Routes.splash : state.uri.path; + final reviewerMode = ref.read(reviewerModeProvider); + final activeServerAsync = ref.read(activeServerProvider); + + if (reviewerMode) { + if (location == Routes.chat) return null; + return Routes.chat; + } + + if (activeServerAsync.isLoading) { + return location == Routes.splash ? null : Routes.splash; + } + + if (activeServerAsync.hasError) { + return location == Routes.serverConnection + ? null + : Routes.serverConnection; + } + + final activeServer = activeServerAsync.asData?.value; + if (activeServer == null) { + if (_isAuthLocation(location)) return null; + return Routes.serverConnection; + } + + final authState = ref.read(authNavigationStateProvider); + switch (authState) { + case AuthNavigationState.loading: + return location == Routes.splash ? null : Routes.splash; + case AuthNavigationState.needsLogin: + case AuthNavigationState.error: + if (_isAuthLocation(location)) return null; + return Routes.serverConnection; + case AuthNavigationState.authenticated: + if (_isAuthLocation(location) || location == Routes.splash) { + return Routes.chat; + } + return null; + } + } + + bool _isAuthLocation(String location) { + return location == Routes.serverConnection || + location == Routes.login || + location == Routes.authentication; + } + + @override + void dispose() { + for (final sub in _subscriptions) { + sub.close(); + } + super.dispose(); + } +} + +final routerNotifierProvider = Provider((ref) { + final notifier = RouterNotifier(ref); + ref.onDispose(notifier.dispose); + return notifier; +}); + +final goRouterProvider = Provider((ref) { + final notifier = ref.watch(routerNotifierProvider); + + final routes = [ + GoRoute( + path: Routes.splash, + name: RouteNames.splash, + builder: (context, state) => const SplashLauncherPage(), + ), + GoRoute( + path: Routes.chat, + name: RouteNames.chat, + builder: (context, state) => const ChatPage(), + ), + GoRoute( + path: Routes.login, + name: RouteNames.login, + builder: (context, state) => const ConnectAndSignInPage(), + ), + GoRoute( + path: Routes.serverConnection, + name: RouteNames.serverConnection, + builder: (context, state) => const ServerConnectionPage(), + ), + GoRoute( + path: Routes.authentication, + name: RouteNames.authentication, + builder: (context, state) { + final config = state.extra; + if (config is! ServerConfig) { + return const ServerConnectionPage(); + } + return AuthenticationPage(serverConfig: config); + }, + ), + GoRoute( + path: Routes.profile, + name: RouteNames.profile, + builder: (context, state) => const ProfilePage(), + ), + GoRoute( + path: Routes.appCustomization, + name: RouteNames.appCustomization, + builder: (context, state) => const AppCustomizationPage(), + ), + GoRoute( + path: Routes.workspace, + name: RouteNames.workspace, + builder: (context, state) => const WorkspacePage(), + ), + ]; + + final router = GoRouter( + navigatorKey: NavigationService.navigatorKey, + initialLocation: Routes.splash, + refreshListenable: notifier, + redirect: notifier.redirect, + routes: routes, + observers: [NavigationLoggingObserver()], + errorBuilder: (context, state) { + final l10n = AppLocalizations.of(context); + final message = + l10n?.routeNotFound(state.uri.path) ?? + 'Route not found: ${state.uri.path}'; + return Scaffold( + body: Center(child: Text(message, textAlign: TextAlign.center)), + ); + }, + ); + + NavigationService.attachRouter(router); + return router; +}); + +class NavigationLoggingObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + final previous = previousRoute?.settings.name ?? previousRoute?.settings; + DebugLogger.navigation( + 'Pushed: ${route.settings.name ?? route.settings} (from ${previous ?? 'root'})', + ); + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + DebugLogger.navigation('Popped: ${route.settings.name ?? route.settings}'); + } +} diff --git a/lib/core/services/navigation_service.dart b/lib/core/services/navigation_service.dart index 34adc55..4f7b6c0 100644 --- a/lib/core/services/navigation_service.dart +++ b/lib/core/services/navigation_service.dart @@ -1,67 +1,70 @@ import 'package:flutter/material.dart'; -// ThemedDialogs handles theming; no direct use of extensions here -import '../../features/auth/views/connect_signin_page.dart'; -import '../../features/chat/views/chat_page.dart'; -import '../../features/files/views/workspace_page.dart'; -import '../../features/profile/views/profile_page.dart'; +import 'package:go_router/go_router.dart'; import '../../shared/widgets/themed_dialogs.dart'; -import 'package:conduit/l10n/app_localizations.dart'; -/// Service for handling navigation throughout the app +/// Service for handling navigation throughout the app. +/// +/// With GoRouter in place, this class mostly provides convenient wrappers +/// around the global router so existing callers can trigger navigation +/// without directly depending on BuildContext. class NavigationService { static final GlobalKey navigatorKey = - GlobalKey(); + GlobalKey(debugLabel: 'rootNavigator'); + + static GoRouter? _router; + + static GoRouter get router { + final router = _router; + if (router == null) { + throw StateError('GoRouter has not been attached to NavigationService.'); + } + return router; + } + + static void attachRouter(GoRouter router) { + _router = router; + } static NavigatorState? get navigator => navigatorKey.currentState; static BuildContext? get context => navigatorKey.currentContext; - static final List _navigationStack = []; - static String? _currentRoute; + /// The current location reported by GoRouter. + static String? get currentRoute { + final router = _router; + if (router == null) return null; + return router.routeInformationProvider.value.uri.toString(); + } - /// Get current route - static String? get currentRoute => _currentRoute; - - /// Get navigation stack - static List get navigationStack => - List.unmodifiable(_navigationStack); - - /// Navigate to a specific route + /// Navigate to a specific route path. static Future navigateTo(String routeName) async { - if (_currentRoute != routeName) { - _navigationStack.add(routeName); - _currentRoute = routeName; - } + final router = _router; + if (router == null) return; + router.go(routeName); } - /// Navigate back with optional result + /// Navigate back with an optional result payload. static void goBack([T? result]) { - if (navigator?.canPop() == true) { - if (_navigationStack.isNotEmpty) { - _navigationStack.removeLast(); - } - _currentRoute = _navigationStack.isNotEmpty - ? _navigationStack.last - : null; - navigator?.pop(result); + final router = _router; + if (router?.canPop() == true) { + router!.pop(result); } } - /// Check if can navigate back - static bool canGoBack() { - return navigator?.canPop() == true; - } + /// Check whether the router can pop the current route. + static bool canGoBack() => _router?.canPop() ?? false; - /// Show confirmation dialog before navigation + /// Show confirmation dialog before navigation. static Future confirmNavigation({ required String title, required String message, String confirmText = 'Continue', String cancelText = 'Cancel', }) async { - if (context == null) return false; + final ctx = context; + if (ctx == null) return false; final result = await ThemedDialogs.confirm( - context!, + ctx, title: title, message: message, confirmText: confirmText, @@ -72,95 +75,40 @@ class NavigationService { return result; } - /// Navigate to chat - static Future navigateToChat() { - return navigateTo(Routes.chat); - } + static Future navigateToChat() => navigateTo(Routes.chat); + static Future navigateToLogin() => navigateTo(Routes.serverConnection); + static Future navigateToProfile() => navigateTo(Routes.profile); + static Future navigateToServerConnection() => + navigateTo(Routes.serverConnection); - /// Navigate to login - static Future navigateToLogin() { - return navigateTo(Routes.login); - } - - /// Navigate to profile - static Future navigateToProfile() { - return navigateTo(Routes.profile); - } - - /// Navigate to server connection - static Future navigateToServerConnection() { - return navigateTo(Routes.serverConnection); - } - - // Chats list is now provided as a left drawer in ChatPage - - /// Clear navigation stack (useful for logout) + /// Clear navigation history. With GoRouter this becomes a simple go call. static void clearNavigationStack() { - _navigationStack.clear(); - _currentRoute = null; - } - - /// Set current route (useful for initial app state) - static void setCurrentRoute(String routeName) { - _currentRoute = routeName; - if (!_navigationStack.contains(routeName)) { - _navigationStack.add(routeName); - } - } - - /// Generate routes - static Route? generateRoute(RouteSettings settings) { - Widget page; - - switch (settings.name) { - // Removed tabbed main navigation - - case Routes.chat: - page = const ChatPage(); - break; - - case Routes.login: - page = const ConnectAndSignInPage(); - break; - - case Routes.profile: - page = const ProfilePage(); - break; - - case Routes.serverConnection: - page = const ConnectAndSignInPage(); - break; - - case Routes.workspace: - page = const WorkspacePage(); - break; - - // chats list route removed (replaced by drawer) - - // Removed navigation drawer route - - default: - page = Builder( - builder: (context) => Scaffold( - body: Center( - child: Text( - AppLocalizations.of(context)! - .routeNotFound(settings.name ?? ''), - ), - ), - ), - ); - } - - return MaterialPageRoute(builder: (_) => page, settings: settings); + final router = _router; + if (router == null) return; + router.go(Routes.serverConnection); } } -/// Route names +/// Route path definitions used across the app. class Routes { + static const String splash = '/splash'; static const String chat = '/chat'; static const String login = '/login'; - static const String profile = '/profile'; static const String serverConnection = '/server-connection'; + static const String authentication = '/authentication'; + static const String profile = '/profile'; + static const String appCustomization = '/profile/customization'; static const String workspace = '/workspace'; } + +/// Friendly names for GoRouter routes to support context.pushNamed. +class RouteNames { + static const String splash = 'splash'; + static const String chat = 'chat'; + static const String login = 'login'; + static const String serverConnection = 'server-connection'; + static const String authentication = 'authentication'; + static const String profile = 'profile'; + static const String appCustomization = 'app-customization'; + static const String workspace = 'workspace'; +} diff --git a/lib/core/services/user_friendly_error_handler.dart b/lib/core/services/user_friendly_error_handler.dart index 3297799..a0f3afa 100644 --- a/lib/core/services/user_friendly_error_handler.dart +++ b/lib/core/services/user_friendly_error_handler.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../shared/theme/theme_extensions.dart'; +import 'navigation_service.dart'; /// User-friendly error messages and recovery actions class UserFriendlyErrorHandler { @@ -483,7 +484,7 @@ class ErrorCard extends StatelessWidget { break; case ErrorActionType.signIn: // Navigate to sign in page - Navigator.of(context).pushReplacementNamed('/login'); + NavigationService.navigateToServerConnection(); break; case ErrorActionType.openSettings: // Open app settings - would need platform-specific implementation diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index cde14e5..5253e6b 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../core/models/server_config.dart'; import '../../../core/providers/app_providers.dart'; @@ -136,10 +137,7 @@ class _AuthenticationPageState extends ConsumerState { DebugLogger.auth('Navigating to chat page'); // Navigate directly to chat page on successful authentication - Navigator.of(context).pushNamedAndRemoveUntil( - Routes.chat, - (route) => false, // Remove all previous routes - ); + context.go(Routes.chat); } }); diff --git a/lib/features/auth/views/connect_signin_page.dart b/lib/features/auth/views/connect_signin_page.dart index 71d10a8..c6ecfc7 100644 --- a/lib/features/auth/views/connect_signin_page.dart +++ b/lib/features/auth/views/connect_signin_page.dart @@ -1,38 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/widgets/error_boundary.dart'; -import '../../../shared/theme/theme_extensions.dart'; -import '../../../shared/widgets/conduit_components.dart'; -import 'package:conduit/l10n/app_localizations.dart'; import 'server_connection_page.dart'; -/// Entry point for the connection and sign-in flow -/// Redirects to the mobile-first two-step process +/// Entry point for the connection and sign-in flow. +/// We now forward directly to the server connection experience. class ConnectAndSignInPage extends ConsumerWidget { const ConnectAndSignInPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - // Directly navigate to the new mobile-first server connection page - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => const ServerConnectionPage(), - ), - ); - }); - - // Show a simple loading state while transitioning - return ErrorBoundary( - child: Scaffold( - backgroundColor: context.conduitTheme.surfaceBackground, - body: Center( - child: ConduitLoadingIndicator( - message: AppLocalizations.of(context)!.loadingContent, - ), - ), - ), - ); + return const ServerConnectionPage(); } } diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index e6f27c6..b8e2b32 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:flutter/services.dart'; import 'package:uuid/uuid.dart'; import 'package:conduit/l10n/app_localizations.dart'; @@ -12,12 +13,11 @@ import '../../../core/models/server_config.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/api_service.dart'; import '../../../core/services/input_validation_service.dart'; +import '../../../core/services/navigation_service.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/services/brand_service.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/conduit_components.dart'; -import '../../chat/views/chat_page.dart'; -import 'authentication_page.dart'; class ServerConnectionPage extends ConsumerStatefulWidget { const ServerConnectionPage({super.key}); @@ -33,7 +33,7 @@ class _ServerConnectionPageState extends ConsumerState { final Map _customHeaders = {}; final TextEditingController _headerKeyController = TextEditingController(); final TextEditingController _headerValueController = TextEditingController(); - + String? _connectionError; bool _isConnecting = false; bool _showAdvancedSettings = false; @@ -85,14 +85,10 @@ class _ServerConnectionPageState extends ConsumerState { } await _saveServerConfig(tempConfig); - + // Navigate to authentication page if (mounted) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => AuthenticationPage(serverConfig: tempConfig), - ), - ); + context.pushNamed(RouteNames.authentication, extra: tempConfig); } } catch (e) { setState(() { @@ -122,12 +118,12 @@ class _ServerConnectionPageState extends ConsumerState { // Clean up the input String url = input.trim(); - + // Add protocol if missing if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'http://$url'; } - + // Remove trailing slash if (url.endsWith('/')) { url = url.substring(0, url.length - 1); @@ -171,7 +167,7 @@ class _ServerConnectionPageState extends ConsumerState { bool _isValidIPAddress(String ip) { final parts = ip.split('.'); if (parts.length != 4) return false; - + for (final part in parts) { final num = int.tryParse(part); if (num == null || num < 0 || num > 255) return false; @@ -190,7 +186,7 @@ class _ServerConnectionPageState extends ConsumerState { String _formatConnectionError(String error) { // Clean up the error message String cleanError = error.replaceFirst('Exception: ', ''); - + // Handle specific error types if (error.contains('SocketException')) { return AppLocalizations.of(context)!.weCouldntReachServer; @@ -208,10 +204,12 @@ class _ServerConnectionPageState extends ConsumerState { return cleanError; } else if (error.contains('Invalid IP address format')) { return cleanError; - } else if (error.contains('This does not appear to be an Open-WebUI server')) { + } else if (error.contains( + 'This does not appear to be an Open-WebUI server', + )) { return AppLocalizations.of(context)!.serverNotOpenWebUI; } - + return AppLocalizations.of(context)!.couldNotConnectGeneric; } @@ -232,9 +230,9 @@ class _ServerConnectionPageState extends ConsumerState { children: [ // Header with progress indicator _buildHeader(), - + const SizedBox(height: Spacing.extraLarge), - + // Main content Expanded( child: SingleChildScrollView( @@ -269,7 +267,7 @@ class _ServerConnectionPageState extends ConsumerState { ), ), ), - + // Bottom action button _buildConnectButton(), ], @@ -340,8 +338,12 @@ class _ServerConnectionPageState extends ConsumerState { shape: BoxShape.circle, gradient: RadialGradient( colors: [ - context.conduitTheme.buttonPrimary.withValues(alpha: 0.12), - context.conduitTheme.buttonPrimary.withValues(alpha: 0.06), + context.conduitTheme.buttonPrimary.withValues( + alpha: 0.12, + ), + context.conduitTheme.buttonPrimary.withValues( + alpha: 0.06, + ), Colors.transparent, ], stops: const [0.0, 0.7, 1.0], @@ -361,7 +363,9 @@ class _ServerConnectionPageState extends ConsumerState { bottom: 0, child: ConduitBadge( text: AppLocalizations.of(context)!.demoBadge, - backgroundColor: context.conduitTheme.warning.withValues(alpha: 0.15), + backgroundColor: context.conduitTheme.warning.withValues( + alpha: 0.15, + ), textColor: context.conduitTheme.warning, isCompact: true, ), @@ -448,11 +452,7 @@ class _ServerConnectionPageState extends ConsumerState { text: AppLocalizations.of(context)!.enterDemo, icon: Platform.isIOS ? CupertinoIcons.play_fill : Icons.play_arrow, onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ChatPage(), - ), - ); + context.go(Routes.chat); }, isSecondary: true, isFullWidth: true, @@ -475,7 +475,8 @@ class _ServerConnectionPageState extends ConsumerState { controller: _urlController, validator: InputValidationService.combine([ InputValidationService.validateRequired, - (value) => InputValidationService.validateUrl(value, required: true), + (value) => + InputValidationService.validateUrl(value, required: true), ]), keyboardType: TextInputType.url, semanticLabel: AppLocalizations.of(context)!.enterServerUrlSemantic, @@ -492,7 +493,7 @@ class _ServerConnectionPageState extends ConsumerState { delay: AnimationDuration.microInteraction, curve: Curves.easeOutCubic, ), - + if (_connectionError != null) ...[ const SizedBox(height: Spacing.md), _buildErrorMessage(_connectionError!), @@ -516,7 +517,9 @@ class _ServerConnectionPageState extends ConsumerState { child: Column( children: [ InkWell( - onTap: () => setState(() => _showAdvancedSettings = !_showAdvancedSettings), + onTap: () => setState( + () => _showAdvancedSettings = !_showAdvancedSettings, + ), borderRadius: BorderRadius.circular(AppBorderRadius.button), child: Padding( padding: const EdgeInsets.symmetric(vertical: Spacing.sm), @@ -552,7 +555,9 @@ class _ServerConnectionPageState extends ConsumerState { duration: AnimationDuration.microInteraction, turns: _showAdvancedSettings ? 0.5 : 0, child: Icon( - Platform.isIOS ? CupertinoIcons.chevron_down : Icons.expand_more, + Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.expand_more, color: context.conduitTheme.iconSecondary, ), ), @@ -563,7 +568,9 @@ class _ServerConnectionPageState extends ConsumerState { AnimatedSize( duration: AnimationDuration.microInteraction, curve: Curves.easeInOutCubic, - child: _showAdvancedSettings ? _buildAdvancedSettingsContent() : const SizedBox.shrink(), + child: _showAdvancedSettings + ? _buildAdvancedSettingsContent() + : const SizedBox.shrink(), ), ], ), @@ -591,8 +598,8 @@ class _ServerConnectionPageState extends ConsumerState { Text( '${_customHeaders.length}/10', style: context.conduitTheme.bodySmall?.copyWith( - color: _customHeaders.length >= 10 - ? context.conduitTheme.error + color: _customHeaders.length >= 10 + ? context.conduitTheme.error : context.conduitTheme.textSecondary, ), ), @@ -638,14 +645,14 @@ class _ServerConnectionPageState extends ConsumerState { ConduitIconButton( icon: Platform.isIOS ? CupertinoIcons.plus : Icons.add, onPressed: _customHeaders.length >= 10 ? null : _addCustomHeader, - tooltip: _customHeaders.length >= 10 - ? AppLocalizations.of(context)!.maximumHeadersReached + tooltip: _customHeaders.length >= 10 + ? AppLocalizations.of(context)!.maximumHeadersReached : AppLocalizations.of(context)!.addHeader, - backgroundColor: _customHeaders.length >= 10 - ? context.conduitTheme.surfaceContainer + backgroundColor: _customHeaders.length >= 10 + ? context.conduitTheme.surfaceContainer : context.conduitTheme.buttonPrimary, - iconColor: _customHeaders.length >= 10 - ? context.conduitTheme.textDisabled + iconColor: _customHeaders.length >= 10 + ? context.conduitTheme.textDisabled : context.conduitTheme.buttonPrimaryText, ), ], @@ -679,8 +686,8 @@ class _ServerConnectionPageState extends ConsumerState { fit: FlexFit.loose, child: ConduitBadge( text: entry.key, - backgroundColor: - context.conduitTheme.buttonPrimary.withValues(alpha: 0.1), + backgroundColor: context.conduitTheme.buttonPrimary + .withValues(alpha: 0.1), textColor: context.conduitTheme.buttonPrimary, isCompact: true, maxLines: 1, @@ -704,7 +711,9 @@ class _ServerConnectionPageState extends ConsumerState { icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close, onPressed: () => _removeCustomHeader(entry.key), tooltip: AppLocalizations.of(context)!.removeHeader, - backgroundColor: context.conduitTheme.error.withValues(alpha: 0.1), + backgroundColor: context.conduitTheme.error.withValues( + alpha: 0.1, + ), iconColor: context.conduitTheme.error, isCompact: true, ), @@ -718,20 +727,23 @@ class _ServerConnectionPageState extends ConsumerState { Widget _buildConnectButton() { return Padding( padding: const EdgeInsets.only(top: Spacing.lg), - child: ConduitButton( - text: _isConnecting - ? AppLocalizations.of(context)!.connecting - : AppLocalizations.of(context)!.connectToServerButton, - icon: _isConnecting - ? null - : (Platform.isIOS ? CupertinoIcons.arrow_right : Icons.arrow_forward), - onPressed: _isConnecting ? null : _connectToServer, - isLoading: _isConnecting, - isFullWidth: true, - ).animate().fadeIn( - duration: AnimationDuration.pageTransition, - delay: AnimationDuration.fast, - ), + child: + ConduitButton( + text: _isConnecting + ? AppLocalizations.of(context)!.connecting + : AppLocalizations.of(context)!.connectToServerButton, + icon: _isConnecting + ? null + : (Platform.isIOS + ? CupertinoIcons.arrow_right + : Icons.arrow_forward), + onPressed: _isConnecting ? null : _connectToServer, + isLoading: _isConnecting, + isFullWidth: true, + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.fast, + ), ); } @@ -749,8 +761,8 @@ class _ServerConnectionPageState extends ConsumerState { child: Row( children: [ Icon( - Platform.isIOS - ? CupertinoIcons.exclamationmark_circle_fill + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle_fill : Icons.error_outline, color: context.conduitTheme.error, size: IconSize.medium, @@ -776,35 +788,35 @@ class _ServerConnectionPageState extends ConsumerState { void _addCustomHeader() { final key = _headerKeyController.text.trim(); final value = _headerValueController.text.trim(); - + if (key.isEmpty || value.isEmpty) return; - + // Validate header name final keyValidation = _validateHeaderKey(key); if (keyValidation != null) { _showHeaderError(keyValidation); return; } - + // Validate header value final valueValidation = _validateHeaderValue(value); if (valueValidation != null) { _showHeaderError(valueValidation); return; } - + // Check for duplicates if (_customHeaders.containsKey(key)) { _showHeaderError(AppLocalizations.of(context)!.headerAlreadyExists(key)); return; } - + // Check header count limit if (_customHeaders.length >= 10) { _showHeaderError(AppLocalizations.of(context)!.maxHeadersReachedDetail); return; } - + setState(() { _customHeaders[key] = value; _headerKeyController.clear(); @@ -817,31 +829,42 @@ class _ServerConnectionPageState extends ConsumerState { // RFC 7230 compliant header name validation if (key.isEmpty) return AppLocalizations.of(context)!.headerNameEmpty; if (key.length > 64) return AppLocalizations.of(context)!.headerNameTooLong; - + // Check for valid characters (RFC 7230: token characters) if (!RegExp(r'^[a-zA-Z0-9!#$&\-^_`|~]+$').hasMatch(key)) { return AppLocalizations.of(context)!.headerNameInvalidChars; } - + // Check for reserved headers that should not be overridden final lowerKey = key.toLowerCase(); final reservedHeaders = { - 'authorization', 'content-type', 'content-length', 'host', - 'user-agent', 'accept', 'accept-encoding', 'connection', - 'transfer-encoding', 'upgrade', 'via', 'warning' + 'authorization', + 'content-type', + 'content-length', + 'host', + 'user-agent', + 'accept', + 'accept-encoding', + 'connection', + 'transfer-encoding', + 'upgrade', + 'via', + 'warning', }; - + if (reservedHeaders.contains(lowerKey)) { return AppLocalizations.of(context)!.headerNameReserved(key); } - + return null; } String? _validateHeaderValue(String value) { if (value.isEmpty) return AppLocalizations.of(context)!.headerValueEmpty; - if (value.length > 1024) return AppLocalizations.of(context)!.headerValueTooLong; - + if (value.length > 1024) { + return AppLocalizations.of(context)!.headerValueTooLong; + } + // Check for valid characters (no control characters except tab) for (int i = 0; i < value.length; i++) { final char = value.codeUnitAt(i); @@ -850,14 +873,14 @@ class _ServerConnectionPageState extends ConsumerState { return AppLocalizations.of(context)!.headerValueInvalidChars; } } - + // Check for security-sensitive patterns if (value.toLowerCase().contains('script') || value.contains('<') || value.contains('>')) { return AppLocalizations.of(context)!.headerValueUnsafe; } - + return null; } diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 6dd9d74..54c8ce0 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -5,13 +5,14 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../core/providers/app_providers.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../chat/providers/chat_providers.dart' as chat; // import '../../files/views/files_page.dart'; -import '../../profile/views/profile_page.dart'; import '../../../shared/utils/ui_utils.dart'; +import '../../../core/services/navigation_service.dart'; import '../../../shared/widgets/themed_dialogs.dart'; import '../../../core/auth/auth_state_manager.dart'; import 'package:conduit/l10n/app_localizations.dart'; @@ -1190,11 +1191,12 @@ class _ChatsDrawerState extends ConsumerState { container.read(activeConversationProvider.notifier).set(full); } else { // Fallback: use the lightweight item to update the active conversation - container.read(activeConversationProvider.notifier).set( + container + .read(activeConversationProvider.notifier) + .set( (await container.read( conversationsProvider.future, - )) - .firstWhere((c) => c.id == id), + )).firstWhere((c) => c.id == id), ); } @@ -1292,9 +1294,7 @@ class _ChatsDrawerState extends ConsumerState { tooltip: AppLocalizations.of(context)!.manage, onPressed: () { Navigator.of(context).maybePop(); - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ProfilePage()), - ); + context.pushNamed(RouteNames.profile); }, icon: Icon( Platform.isIOS diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index d559178..d7a047f 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -4,6 +4,7 @@ import '../../../shared/theme/theme_extensions.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:conduit/l10n/app_localizations.dart'; @@ -15,6 +16,7 @@ import '../../../shared/widgets/themed_dialogs.dart'; import '../../../shared/widgets/sheet_handle.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../../core/providers/app_providers.dart'; +import '../../../core/services/navigation_service.dart'; import '../../auth/providers/unified_auth_providers.dart'; import '../../../core/services/settings_service.dart'; import '../../../core/models/model.dart'; @@ -23,7 +25,6 @@ import '../../../core/models/user.dart' as models; import 'dart:async'; import 'dart:io'; import '../../chat/views/chat_page_helpers.dart'; -import 'app_customization_page.dart'; import '../../../shared/widgets/modal_safe_area.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../core/utils/user_avatar_utils.dart'; @@ -338,9 +339,7 @@ class ProfilePage extends ConsumerWidget { title: AppLocalizations.of(context)!.appCustomization, subtitle: AppLocalizations.of(context)!.appCustomizationSubtitle, onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AppCustomizationPage()), - ); + context.pushNamed(RouteNames.appCustomization); }, ), _buildAboutTile(context), diff --git a/lib/main.dart b/lib/main.dart index d719313..2589d15 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,25 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'core/services/navigation_service.dart'; import 'core/widgets/error_boundary.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'core/providers/app_providers.dart'; +import 'core/router/app_router.dart'; import 'shared/theme/app_theme.dart'; import 'shared/theme/theme_extensions.dart'; import 'shared/widgets/offline_indicator.dart'; -import 'features/auth/views/connect_signin_page.dart'; import 'features/auth/providers/unified_auth_providers.dart'; import 'core/auth/auth_state_manager.dart'; import 'core/utils/debug_logger.dart'; import 'package:conduit/l10n/app_localizations.dart'; -import 'features/chat/views/chat_page.dart'; -import 'features/navigation/views/splash_launcher_page.dart'; import 'core/services/share_receiver_service.dart'; import 'core/providers/app_startup_providers.dart'; +import 'core/models/server_config.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -64,46 +62,97 @@ class ConduitApp extends ConsumerStatefulWidget { class _ConduitAppState extends ConsumerState { bool _attemptedSilentAutoLogin = false; + ProviderSubscription? _authNavSubscription; + ProviderSubscription>? _activeServerSubscription; @override void initState() { super.initState(); // Defer heavy provider initialization to after first frame to render UI sooner WidgetsBinding.instance.addPostFrameCallback((_) => _initializeAppState()); - } - Widget _buildInitialLoadingSkeleton(BuildContext context) { - // Replace skeleton with branded splash during initialization - return const SplashLauncherPage(); + _authNavSubscription = ref.listenManual( + authNavigationStateProvider, + (previous, next) { + if (next == AuthNavigationState.needsLogin) { + _maybeAttemptSilentLogin(); + } else { + _attemptedSilentAutoLogin = false; + } + }, + ); + + _activeServerSubscription = ref.listenManual>( + activeServerProvider, + (previous, next) { + next.when( + data: (server) { + if (server != null) { + _maybeAttemptSilentLogin(); + } + }, + loading: () {}, + error: (error, stackTrace) {}, + ); + }, + ); + + Future.microtask(_maybeAttemptSilentLogin); } void _initializeAppState() { - // Initialize unified auth state manager and API integration synchronously - // This ensures auth state is loaded before first widget build DebugLogger.auth('Initializing unified auth system'); - // Initialize auth state manager (will handle token validation automatically) ref.read(authStateManagerProvider); - - // Ensure API service auth integration is active ref.read(authApiIntegrationProvider); - - // Initialize auto-selection listener for default model changes in settings ref.read(defaultModelAutoSelectionProvider); - - // Initialize OS share receiver so users can share text/files to Conduit ref.read(shareReceiverInitializerProvider); } + @override + void dispose() { + _authNavSubscription?.close(); + _activeServerSubscription?.close(); + super.dispose(); + } + + void _maybeAttemptSilentLogin() { + if (_attemptedSilentAutoLogin) return; + + final authState = ref.read(authNavigationStateProvider); + if (authState != AuthNavigationState.needsLogin) { + return; + } + + final activeServerAsync = ref.read(activeServerProvider); + final hasActiveServer = activeServerAsync.maybeWhen( + data: (server) => server != null, + orElse: () => false, + ); + + if (!hasActiveServer) { + return; + } + + _attemptedSilentAutoLogin = true; + + Future.microtask(() async { + try { + final hasCreds = await ref.read(hasSavedCredentialsProvider2.future); + if (hasCreds) { + await ref.read(authActionsProvider).silentLogin(); + } + } catch (_) { + // Ignore silent login errors; fall back to manual login. + } + }); + } + @override Widget build(BuildContext context) { - // Use select to watch only the specific themeMode property to reduce rebuilds final themeMode = ref.watch(themeModeProvider.select((mode) => mode)); + final router = ref.watch(goRouterProvider); + ref.watch(appStartupFlowProvider); - // Reduced debug noise - only log when necessary - // debugPrint('DEBUG: Building app'); - - // Determine the current theme based on themeMode - // Default to Conduit brand theme globally final currentTheme = themeMode == ThemeMode.dark ? AppTheme.conduitDarkTheme : themeMode == ThemeMode.light @@ -118,18 +167,18 @@ class _ConduitAppState extends ConsumerState { theme: currentTheme, duration: AnimationDuration.medium, child: ErrorBoundary( - child: MaterialApp( + child: MaterialApp.router( + routerConfig: router, onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle, theme: AppTheme.conduitLightTheme, darkTheme: AppTheme.conduitDarkTheme, themeMode: themeMode, debugShowCheckedModeBanner: false, - navigatorKey: NavigationService.navigatorKey, locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, localeListResolutionCallback: (deviceLocales, supported) { - if (locale != null) return locale; // User override wins + if (locale != null) return locale; if (deviceLocales == null || deviceLocales.isEmpty) { return supported.first; } @@ -141,161 +190,19 @@ class _ConduitAppState extends ConsumerState { return supported.first; }, builder: (context, child) { - // Apply edge-to-edge inset handling and responsive design + final mediaQuery = MediaQuery.of(context); return MediaQuery( - data: MediaQuery.of(context).copyWith( - // Ensure proper text scaling for edge-to-edge - textScaler: MediaQuery.of( - context, - ).textScaler.clamp(minScaleFactor: 0.8, maxScaleFactor: 1.3), + data: mediaQuery.copyWith( + textScaler: mediaQuery.textScaler.clamp( + minScaleFactor: 0.8, + maxScaleFactor: 1.3, + ), ), child: OfflineIndicator(child: child ?? const SizedBox.shrink()), ); }, - home: _getInitialPageWithReactiveState(), - onGenerateRoute: NavigationService.generateRoute, - navigatorObservers: [_NavigationObserver()], - ), - ), - ); - } - - Widget _getInitialPageWithReactiveState() { - return Consumer( - builder: (context, ref, child) { - // Watch for server connection state changes - final activeServerAsync = ref.watch(activeServerProvider); - final reviewerMode = ref.watch(reviewerModeProvider); - - if (reviewerMode) { - // In reviewer mode, skip server/auth flows and go to chat - NavigationService.setCurrentRoute(Routes.chat); - return const ChatPage(); - } - - return activeServerAsync.when( - data: (activeServer) { - if (activeServer == null) { - return const ConnectAndSignInPage(); - } - - // Server is connected, now check authentication reactively - final authNavState = ref.watch(authNavigationStateProvider); - - if (authNavState == AuthNavigationState.needsLogin) { - // Try one-shot silent login if credentials are saved - if (!_attemptedSilentAutoLogin) { - _attemptedSilentAutoLogin = true; - Future.microtask(() async { - try { - final hasCreds = await ref.read( - hasSavedCredentialsProvider2.future, - ); - if (hasCreds) { - await ref.read(authActionsProvider).silentLogin(); - } - } catch (_) { - // Ignore errors, fallback to showing unified page - } - }); - } - return const ConnectAndSignInPage(); - } - - if (authNavState == AuthNavigationState.loading) { - return _buildInitialLoadingSkeleton(context); - } - - if (authNavState == AuthNavigationState.error) { - return _buildErrorState( - ref.watch(authErrorProvider3) ?? - AppLocalizations.of(context)!.errorMessage, - ); - } - - // User is authenticated, navigate directly to chat page - // Kick off and keep app startup/background task flow alive - ref.watch(appStartupFlowProvider); - - // Set the current route for navigation tracking - NavigationService.setCurrentRoute(Routes.chat); - - return const ChatPage(); - }, - loading: () => _buildInitialLoadingSkeleton(context), - error: (error, stackTrace) { - DebugLogger.error('Server provider error', error); - return _buildErrorState( - AppLocalizations.of(context)!.unableToConnectServer, - ); - }, - ); - }, - ); - } - - // Background initialization moved to app-based task flow provider - - Widget _buildErrorState(String error) { - return Scaffold( - backgroundColor: context.conduitTheme.surfaceBackground, - body: Center( - child: Padding( - padding: const EdgeInsets.all(Spacing.lg), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: IconSize.xxl + Spacing.md, - color: context.conduitTheme.error, - ), - const SizedBox(height: Spacing.md), - Text( - AppLocalizations.of(context)!.initializationFailed, - style: TextStyle( - fontSize: AppTypography.headlineLarge, - fontWeight: FontWeight.bold, - color: context.conduitTheme.textPrimary, - ), - ), - const SizedBox(height: Spacing.sm), - Text( - error, - style: TextStyle(color: context.conduitTheme.textSecondary), - textAlign: TextAlign.center, - ), - const SizedBox(height: Spacing.lg), - ElevatedButton( - onPressed: () { - // Restart the app - WidgetsBinding.instance.reassembleApplication(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: context.conduitTheme.buttonPrimary, - foregroundColor: context.conduitTheme.buttonPrimaryText, - ), - child: Text(AppLocalizations.of(context)!.retry), - ), - ], - ), ), ), ); } } - -class _NavigationObserver extends NavigatorObserver { - @override - void didPush(Route route, Route? previousRoute) { - super.didPush(route, previousRoute); - // Log navigation for debugging and analytics - DebugLogger.navigation('Pushed: ${route.settings.name}'); - } - - @override - void didPop(Route route, Route? previousRoute) { - super.didPop(route, previousRoute); - DebugLogger.navigation('Popped: ${route.settings.name}'); - } -} diff --git a/pubspec.lock b/pubspec.lock index 4ef45f7..0221aa9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -517,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" graphs: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e14c824..062c7c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,9 @@ dependencies: # State Management flutter_riverpod: ^3.0.0 + # Navigation + go_router: ^14.2.0 + # Network & API dio: ^5.9.0 http_parser: ^4.0.2