1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
|
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'app_routes.dart';
import 'navigation_key.dart';
import 'providers/auth_state_provider.dart';
import 'providers/first_run_provider.dart';
import 'screens/audio_player_screen.dart';
import 'screens/bootstrap_screen.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'screens/media_detail_screen.dart';
import 'screens/media_grid_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/share_screen.dart';
import 'screens/video_player_screen.dart';
// Re-export AppRoutes so existing callers that import router.dart for routes
// do not need to change their import path.
export 'app_routes.dart' show AppRoutes;
// ---------------------------------------------------------------------------
// Router provider
// ---------------------------------------------------------------------------
/// Builds the [GoRouter] instance as a Riverpod [Provider] so that:
/// 1. The navigator key is shared with [DioClient] (enabling 401 redirects).
/// 2. The [refreshListenable] is driven by [authStateProvider] changes,
/// which triggers redirect re-evaluation on every auth state transition.
/// 3. The provider is created lazily and disposed with [ProviderScope].
final routerProvider = Provider<GoRouter>((ref) {
// Watch auth state so the router is rebuilt when it changes.
// Using a ChangeNotifier bridge because GoRouter's refreshListenable expects
// a Listenable, while Riverpod exposes streams/notifiers.
final notifier = _RouterRefreshNotifier(ref);
return GoRouter(
// Share the navigator key with DioClient so imperative 401 redirects
// work through go_router rather than the raw Navigator.
navigatorKey: navigatorKey,
// Trigger redirect re-evaluation whenever auth state changes.
refreshListenable: notifier,
// Default entry point before redirect logic resolves.
initialLocation: AppRoutes.home,
redirect: (context, state) {
final authAsync = ref.read(authStateProvider);
// While the initial token check is in-flight, hold the current path.
// The router will re-evaluate once refreshListenable fires.
if (authAsync.isLoading || authAsync.hasError) return null;
final auth = authAsync.requireValue;
final location = state.matchedLocation;
final isLoginRoute = location == AppRoutes.login;
// Bootstrap is a public route (user is unauthenticated by definition).
final isBootstrapRoute = location == AppRoutes.bootstrap;
if (auth.isAuthenticated && (isLoginRoute || isBootstrapRoute)) {
// Prevent already-authenticated users from viewing auth/setup screens.
return AppRoutes.home;
}
if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) {
// Unauthenticated: any route other than /login and /bootstrap is
// protected. This covers /home, /media/:id, /share, /settings, and
// any future authenticated routes added to the route table.
//
// Determine whether this is first-run (no users exist yet) or a normal
// returning-user scenario. firstRunProvider returns true when the
// server reports count == 0.
//
// While the check is loading we stay put; the router re-evaluates when
// firstRunProvider's AsyncValue settles (via refreshListenable).
final firstRunAsync = ref.read(firstRunProvider);
if (firstRunAsync.isLoading) return null;
// On first-run redirect to /bootstrap so the admin account can be set
// up; otherwise send to /login for normal credential entry.
final isFirstRun = firstRunAsync.valueOrNull ?? false;
return isFirstRun ? AppRoutes.bootstrap : AppRoutes.login;
}
// No redirect needed.
return null;
},
routes: [
GoRoute(
path: AppRoutes.bootstrap,
builder: (context, state) => const BootstrapScreen(),
),
GoRoute(
path: AppRoutes.login,
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: AppRoutes.home,
// HomeScreen now hosts SetsListScreen — the real media-library view.
builder: (context, state) => const SetsListScreen(),
),
GoRoute(
path: AppRoutes.mediaGrid,
builder: (context, state) {
// The ':setId' path parameter is guaranteed by the route pattern.
final raw = state.pathParameters['setId']!;
final setId = int.tryParse(raw) ?? 0;
// The set name is optionally passed as a route extra (String) by the
// calling screen (e.g. SetsListScreen) so the app bar can show it
// immediately without an extra API call.
final setName = state.extra is String ? state.extra as String : null;
return MediaGridScreen(setId: setId, setName: setName);
},
),
GoRoute(
path: AppRoutes.mediaDetail,
builder: (context, state) {
// The ':id' path parameter is guaranteed by the route pattern.
final id = state.pathParameters['id']!;
return MediaDetailScreen(mediaId: id);
},
),
GoRoute(
path: AppRoutes.share,
builder: (context, state) => const ShareScreen(),
),
GoRoute(
path: AppRoutes.settings,
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: AppRoutes.videoPlayer,
builder: (context, state) {
// ':mediaId' is guaranteed present by the route pattern.
final mediaId = state.pathParameters['mediaId']!;
// The resolved stream URL is optionally forwarded as a route extra
// (String) by the calling screen (e.g. MediaDetailScreen).
final mediaUrl =
state.extra is String ? state.extra as String : null;
return VideoPlayerScreen(mediaId: mediaId, mediaUrl: mediaUrl);
},
),
GoRoute(
path: AppRoutes.audioPlayer,
builder: (context, state) {
// ':mediaId' is guaranteed present by the route pattern.
final mediaId = state.pathParameters['mediaId']!;
// The resolved stream URL is optionally forwarded as a route extra
// (String) by the calling screen (e.g. MediaDetailScreen).
final mediaUrl =
state.extra is String ? state.extra as String : null;
return AudioPlayerScreen(mediaId: mediaId, mediaUrl: mediaUrl);
},
),
],
);
});
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Bridges Riverpod's auth and first-run providers to [GoRouter.refreshListenable].
///
/// GoRouter expects a [ChangeNotifier] (or any [Listenable]) for its refresh
/// mechanism. This notifier listens to both [authStateProvider] and
/// [firstRunProvider], calling [notifyListeners] on every change so the router
/// re-runs its redirect callback whenever auth state or first-run status settles.
class _RouterRefreshNotifier extends ChangeNotifier {
_RouterRefreshNotifier(Ref ref) {
// Listen to auth state changes (login, logout, token expiry).
_authSubscription = ref.listen<AsyncValue<AuthState>>(
authStateProvider,
(_, __) => notifyListeners(),
);
// Listen to first-run state so the router re-evaluates after the initial
// user-count check resolves from loading to a concrete true/false value.
_firstRunSubscription = ref.listen<AsyncValue<bool>>(
firstRunProvider,
(_, __) => notifyListeners(),
);
}
late final ProviderSubscription<AsyncValue<AuthState>> _authSubscription;
late final ProviderSubscription<AsyncValue<bool>> _firstRunSubscription;
@override
void dispose() {
_authSubscription.close();
_firstRunSubscription.close();
super.dispose();
}
}
|