2025-09-23 13:43:01 +05:30
|
|
|
import 'dart:async';
|
2025-09-22 14:36:43 +05:30
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
|
|
|
|
|
|
import '../providers/app_providers.dart';
|
2025-10-01 23:26:12 +05:30
|
|
|
import '../services/connectivity_service.dart';
|
2025-09-22 14:36:43 +05:30
|
|
|
import '../services/navigation_service.dart';
|
2025-10-14 22:32:09 +05:30
|
|
|
import '../services/persistent_streaming_service.dart';
|
2025-09-22 14:36:43 +05:30
|
|
|
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';
|
2025-10-01 23:26:12 +05:30
|
|
|
import '../../features/auth/views/connection_issue_page.dart';
|
2025-09-22 14:36:43 +05:30
|
|
|
import '../../features/auth/views/server_connection_page.dart';
|
|
|
|
|
import '../../features/chat/views/chat_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<bool>(reviewerModeProvider, _onStateChanged),
|
|
|
|
|
ref.listen<AsyncValue<ServerConfig?>>(
|
|
|
|
|
activeServerProvider,
|
|
|
|
|
_onStateChanged,
|
|
|
|
|
),
|
|
|
|
|
ref.listen<AuthNavigationState>(
|
|
|
|
|
authNavigationStateProvider,
|
|
|
|
|
_onStateChanged,
|
|
|
|
|
),
|
2025-10-09 15:05:34 +05:30
|
|
|
ref.listen<ConnectivityStatus>(
|
|
|
|
|
connectivityStatusProvider,
|
|
|
|
|
_onStateChanged,
|
|
|
|
|
),
|
2025-09-22 14:36:43 +05:30
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final Ref ref;
|
|
|
|
|
late final List<ProviderSubscription<dynamic>> _subscriptions;
|
|
|
|
|
|
|
|
|
|
void _onStateChanged(dynamic previous, dynamic next) {
|
2025-09-23 13:43:01 +05:30
|
|
|
// Debounce router refreshes to avoid thrashing on rapid state changes
|
|
|
|
|
_scheduleRefresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Timer? _refreshDebounce;
|
|
|
|
|
void _scheduleRefresh() {
|
|
|
|
|
_refreshDebounce?.cancel();
|
|
|
|
|
_refreshDebounce = Timer(const Duration(milliseconds: 50), () {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
});
|
2025-09-22 14:36:43 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-09-23 13:43:01 +05:30
|
|
|
// Stay on whatever route if already in chat; otherwise go to chat
|
2025-09-22 14:36:43 +05:30
|
|
|
if (location == Routes.chat) return null;
|
|
|
|
|
return Routes.chat;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (activeServerAsync.isLoading) {
|
2025-09-28 20:41:35 +05:30
|
|
|
// Avoid redirect loops: do not override explicit auth routes while loading
|
|
|
|
|
if (_isAuthLocation(location)) return null;
|
|
|
|
|
// Keep splash during server loading otherwise
|
2025-09-22 14:36:43 +05:30
|
|
|
return location == Routes.splash ? null : Routes.splash;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (activeServerAsync.hasError) {
|
2025-10-01 23:26:12 +05:30
|
|
|
return location == Routes.connectionIssue ? null : Routes.connectionIssue;
|
2025-09-22 14:36:43 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final activeServer = activeServerAsync.asData?.value;
|
2025-10-01 23:26:12 +05:30
|
|
|
final hasActiveServer = activeServer != null;
|
|
|
|
|
if (!hasActiveServer) {
|
2025-09-23 13:43:01 +05:30
|
|
|
// Allow auth-related routes while no server configured
|
2025-09-22 14:36:43 +05:30
|
|
|
if (_isAuthLocation(location)) return null;
|
|
|
|
|
return Routes.serverConnection;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 21:09:01 +05:30
|
|
|
final authState = ref.read(authNavigationStateProvider);
|
2025-10-09 15:47:27 +05:30
|
|
|
final connectivityService = ref.read(connectivityServiceProvider);
|
2025-10-02 21:09:01 +05:30
|
|
|
|
2025-10-10 22:08:23 +05:30
|
|
|
// Allow staying on server connection page
|
2025-10-01 23:26:12 +05:30
|
|
|
if (location == Routes.serverConnection) {
|
2025-10-10 22:08:23 +05:30
|
|
|
// If authenticated but on server connection page, go to chat
|
|
|
|
|
// Otherwise stay on server connection page (for back navigation)
|
2025-10-02 21:09:01 +05:30
|
|
|
return authState == AuthNavigationState.authenticated
|
|
|
|
|
? Routes.chat
|
2025-10-10 22:08:23 +05:30
|
|
|
: null;
|
2025-10-01 23:26:12 +05:30
|
|
|
}
|
|
|
|
|
|
2025-10-09 15:05:34 +05:30
|
|
|
// Check connectivity status to determine if we should show connection issue
|
|
|
|
|
final connectivity = ref.read(connectivityStatusProvider);
|
|
|
|
|
|
|
|
|
|
// Only show connection issue page if:
|
|
|
|
|
// 1. Not in reviewer mode
|
|
|
|
|
// 2. Connectivity is explicitly offline
|
|
|
|
|
// 3. Auth is authenticated (don't interrupt auth flow)
|
2025-10-14 22:32:09 +05:30
|
|
|
// 4. App is in foreground and offline warning isn't suppressed
|
|
|
|
|
// 5. No active streaming is in progress (avoid interrupting token streams)
|
|
|
|
|
final hasActiveStreams = PersistentStreamingService().activeStreamCount > 0;
|
2025-10-01 23:26:12 +05:30
|
|
|
final shouldShowConnectionIssue =
|
|
|
|
|
!reviewerMode &&
|
|
|
|
|
connectivity == ConnectivityStatus.offline &&
|
2025-10-09 15:47:27 +05:30
|
|
|
authState == AuthNavigationState.authenticated &&
|
|
|
|
|
connectivityService.isAppForeground &&
|
2025-10-14 22:32:09 +05:30
|
|
|
!connectivityService.isOfflineSuppressed &&
|
|
|
|
|
!hasActiveStreams;
|
2025-10-01 23:26:12 +05:30
|
|
|
|
|
|
|
|
if (shouldShowConnectionIssue) {
|
|
|
|
|
return location == Routes.connectionIssue ? null : Routes.connectionIssue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-22 14:36:43 +05:30
|
|
|
switch (authState) {
|
|
|
|
|
case AuthNavigationState.loading:
|
2025-09-28 20:41:35 +05:30
|
|
|
// Keep user on auth routes while loading to prevent bounce
|
|
|
|
|
if (_isAuthLocation(location)) return null;
|
|
|
|
|
// Otherwise keep splash during session establishment
|
2025-09-22 14:36:43 +05:30
|
|
|
return location == Routes.splash ? null : Routes.splash;
|
|
|
|
|
case AuthNavigationState.needsLogin:
|
2025-10-01 23:26:12 +05:30
|
|
|
if (location == Routes.connectionIssue) return null;
|
|
|
|
|
return null;
|
2025-09-22 14:36:43 +05:30
|
|
|
case AuthNavigationState.error:
|
2025-10-01 23:26:12 +05:30
|
|
|
if (location == Routes.connectionIssue) return null;
|
|
|
|
|
return null;
|
2025-09-22 14:36:43 +05:30
|
|
|
case AuthNavigationState.authenticated:
|
2025-09-23 13:43:01 +05:30
|
|
|
// Avoid unnecessary redirects if already on a non-auth route
|
2025-10-01 23:26:12 +05:30
|
|
|
if (_isAuthLocation(location) ||
|
|
|
|
|
location == Routes.splash ||
|
|
|
|
|
location == Routes.connectionIssue) {
|
2025-09-22 14:36:43 +05:30
|
|
|
return Routes.chat;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool _isAuthLocation(String location) {
|
|
|
|
|
return location == Routes.serverConnection ||
|
|
|
|
|
location == Routes.login ||
|
2025-10-01 23:26:12 +05:30
|
|
|
location == Routes.authentication ||
|
|
|
|
|
location == Routes.connectionIssue;
|
2025-09-22 14:36:43 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
2025-09-23 13:43:01 +05:30
|
|
|
_refreshDebounce?.cancel();
|
2025-09-22 14:36:43 +05:30
|
|
|
for (final sub in _subscriptions) {
|
|
|
|
|
sub.close();
|
|
|
|
|
}
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final routerNotifierProvider = Provider<RouterNotifier>((ref) {
|
|
|
|
|
final notifier = RouterNotifier(ref);
|
|
|
|
|
ref.onDispose(notifier.dispose);
|
|
|
|
|
return notifier;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final goRouterProvider = Provider<GoRouter>((ref) {
|
|
|
|
|
final notifier = ref.watch(routerNotifierProvider);
|
|
|
|
|
|
|
|
|
|
final routes = <RouteBase>[
|
|
|
|
|
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(),
|
|
|
|
|
),
|
2025-10-01 23:26:12 +05:30
|
|
|
GoRoute(
|
|
|
|
|
path: Routes.connectionIssue,
|
|
|
|
|
name: RouteNames.connectionIssue,
|
|
|
|
|
builder: (context, state) => const ConnectionIssuePage(),
|
|
|
|
|
),
|
2025-09-22 14:36:43 +05:30
|
|
|
GoRoute(
|
|
|
|
|
path: Routes.authentication,
|
|
|
|
|
name: RouteNames.authentication,
|
|
|
|
|
builder: (context, state) {
|
|
|
|
|
final config = state.extra;
|
2025-09-28 20:41:35 +05:30
|
|
|
return AuthenticationPage(
|
|
|
|
|
serverConfig: config is ServerConfig ? config : null,
|
|
|
|
|
);
|
2025-09-22 14:36:43 +05:30
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
GoRoute(
|
|
|
|
|
path: Routes.profile,
|
|
|
|
|
name: RouteNames.profile,
|
|
|
|
|
builder: (context, state) => const ProfilePage(),
|
|
|
|
|
),
|
|
|
|
|
GoRoute(
|
|
|
|
|
path: Routes.appCustomization,
|
|
|
|
|
name: RouteNames.appCustomization,
|
|
|
|
|
builder: (context, state) => const AppCustomizationPage(),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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<dynamic> route, Route<dynamic>? 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<dynamic> route, Route<dynamic>? previousRoute) {
|
|
|
|
|
super.didPop(route, previousRoute);
|
|
|
|
|
DebugLogger.navigation('Popped: ${route.settings.name ?? route.settings}');
|
|
|
|
|
}
|
|
|
|
|
}
|