|
1 | | -import 'dart:async'; |
2 | | -import 'dart:ui'; |
3 | 1 | import 'package:flutter/foundation.dart'; |
4 | 2 | import 'package:flutter/material.dart'; |
5 | | -import 'package:flutter/services.dart'; |
6 | 3 | import 'package:firebase_core/firebase_core.dart'; |
7 | 4 | import 'package:firebase_crashlytics/firebase_crashlytics.dart'; |
8 | | -import 'package:go_router/go_router.dart'; |
9 | 5 | import 'package:provider/provider.dart'; |
10 | 6 | import 'app_theme.dart'; |
11 | 7 | import 'providers/providers.dart'; |
12 | 8 | import 'router.dart'; |
13 | 9 | import 'services/services.dart'; |
14 | | -import 'utils/utils.dart'; |
15 | | -import 'widgets/widgets.dart'; |
16 | 10 | import 'firebase_options.dart'; |
17 | 11 | // Guarded on dart.library.js_interop, not dart.library.html: `dart:html` is |
18 | 12 | // only provided by dart2js, so a `--wasm` build would silently fall through to |
@@ -108,333 +102,3 @@ class BeerFestivalApp extends StatelessWidget { |
108 | 102 | // coverage:ignore-end |
109 | 103 | } |
110 | 104 | } |
111 | | - |
112 | | -/// Widget that initializes the BeerProvider before rendering children |
113 | | -/// This ensures provider is initialized for all routes, including deep links |
114 | | -class ProviderInitializer extends StatefulWidget { |
115 | | - final Widget child; |
116 | | - |
117 | | - const ProviderInitializer({super.key, required this.child}); |
118 | | - |
119 | | - @override |
120 | | - State<ProviderInitializer> createState() => _ProviderInitializerState(); |
121 | | -} |
122 | | - |
123 | | -class _ProviderInitializerState extends State<ProviderInitializer> |
124 | | - with WidgetsBindingObserver { |
125 | | - bool _initialized = false; |
126 | | - |
127 | | - @override |
128 | | - void initState() { |
129 | | - super.initState(); |
130 | | - WidgetsBinding.instance.addObserver(this); |
131 | | - } |
132 | | - |
133 | | - @override |
134 | | - void dispose() { |
135 | | - WidgetsBinding.instance.removeObserver(this); |
136 | | - super.dispose(); |
137 | | - } |
138 | | - |
139 | | - @override |
140 | | - void didChangeAppLifecycleState(AppLifecycleState state) { |
141 | | - super.didChangeAppLifecycleState(state); |
142 | | - |
143 | | - // When app resumes to foreground, refresh data if stale |
144 | | - if (state == AppLifecycleState.resumed) { |
145 | | - unawaited(context.read<BeerProvider>().refreshIfStale()); |
146 | | - } |
147 | | - } |
148 | | - |
149 | | - @override |
150 | | - void didChangeDependencies() { |
151 | | - super.didChangeDependencies(); |
152 | | - if (!_initialized) { |
153 | | - _initialized = true; |
154 | | - // Initialize and load drinks for all routes. initialize() never throws |
155 | | - // (a startup failure surfaces as provider.error), so the redirect below |
156 | | - // always runs and the app never strands on the loading screen. |
157 | | - final provider = context.read<BeerProvider>(); |
158 | | - unawaited( |
159 | | - provider.initialize().then((_) { |
160 | | - unawaited(provider.loadDrinks()); |
161 | | - // After initialization, trigger redirects that were deferred |
162 | | - _handlePostInitRedirect(); |
163 | | - }), |
164 | | - ); |
165 | | - } |
166 | | - } |
167 | | - |
168 | | - /// Handle route redirects after provider initialization |
169 | | - /// |
170 | | - /// CONTEXT: go_router's redirect callbacks run once on initial navigation and |
171 | | - /// don't re-run when provider state changes. This method explicitly handles |
172 | | - /// redirects that were deferred during initialization. |
173 | | - /// |
174 | | - /// KNOWN LIMITATIONS: |
175 | | - /// - Deep links with invalid festival IDs in subpaths are NOT redirected |
176 | | - /// Example: /invalid-fest/drink/abc stays at /invalid-fest/drink/abc |
177 | | - /// Reason: These match route patterns directly (/:festivalId/drink/:id) |
178 | | - /// bypassing the festival home redirect logic |
179 | | - /// Impact: User sees 404 or broken state until they navigate away |
180 | | - /// Fix: Requires adding festival ID validation to ALL route builders |
181 | | - /// |
182 | | - /// - URL fragments are not preserved during redirects |
183 | | - /// Example: /invalid-fest#section → /cbf2025 (loses #section) |
184 | | - /// Impact: Scroll position hints from deep links are lost |
185 | | - /// Fix: Preserve currentUri.fragment in redirect URL construction |
186 | | - void _handlePostInitRedirect() { |
187 | | - if (!mounted) return; |
188 | | - |
189 | | - try { |
190 | | - final router = GoRouter.of(context); |
191 | | - final state = GoRouterState.of(context); |
192 | | - final provider = context.read<BeerProvider>(); |
193 | | - |
194 | | - final currentUri = state.uri; |
195 | | - final currentPath = currentUri.path; |
196 | | - final segments = currentUri.pathSegments; |
197 | | - |
198 | | - // Check if we're on root path - redirect to festival home |
199 | | - if (currentPath == '/') { |
200 | | - router.go('/${provider.currentFestival.id}'); |
201 | | - return; |
202 | | - } |
203 | | - |
204 | | - // Global routes (no festival scope) - do NOT redirect these |
205 | | - // Uses constant from router.dart to avoid duplication |
206 | | - if (globalRoutes.contains(currentPath)) { |
207 | | - return; // Stay on global route |
208 | | - } |
209 | | - |
210 | | - // For festival-scoped routes, validate the festival ID |
211 | | - // Early return: if already on valid festival route, skip expensive checks |
212 | | - if (segments.isNotEmpty && provider.isValidFestivalId(segments.first)) { |
213 | | - // Sync provider when the URL festival differs from the current one. |
214 | | - // This is the primary fix for cold-loading a non-default festival URL |
215 | | - // (browser refresh, shared link opened fresh). |
216 | | - if (segments.first != provider.currentFestival.id) { |
217 | | - final festival = provider.getFestivalById(segments.first); |
218 | | - if (festival != null) { |
219 | | - unawaited(provider.setFestival(festival, persist: false)); |
220 | | - } |
221 | | - } |
222 | | - return; |
223 | | - } |
224 | | - |
225 | | - // Path pattern: /:festivalId or /:festivalId/... |
226 | | - // Extract first path segment as potential festival ID |
227 | | - if (segments.isEmpty) return; |
228 | | - |
229 | | - final firstSegment = segments.first; |
230 | | - |
231 | | - // If first segment is not a valid festival ID, redirect |
232 | | - if (!provider.isValidFestivalId(firstSegment)) { |
233 | | - // Preserve the rest of the path and query parameters |
234 | | - final restOfPath = segments.length > 1 |
235 | | - ? '/${segments.sublist(1).join('/')}' |
236 | | - : ''; |
237 | | - final queryString = currentUri.query.isNotEmpty |
238 | | - ? '?${currentUri.query}' |
239 | | - : ''; |
240 | | - router.go('/${provider.currentFestival.id}$restOfPath$queryString'); |
241 | | - } |
242 | | - } catch (e, stackTrace) { |
243 | | - if (kDebugMode) { |
244 | | - debugPrint('Post-init redirect error: $e'); |
245 | | - debugPrint(stackTrace.toString()); |
246 | | - } else { |
247 | | - // coverage:ignore-start |
248 | | - // In production, log to crashlytics |
249 | | - final provider = context.read<BeerProvider>(); |
250 | | - unawaited( |
251 | | - provider.analyticsService.logError( |
252 | | - e, |
253 | | - stackTrace, |
254 | | - reason: 'Post-initialization redirect failed', |
255 | | - ), |
256 | | - ); |
257 | | - // coverage:ignore-end |
258 | | - } |
259 | | - } |
260 | | - } |
261 | | - |
262 | | - @override |
263 | | - Widget build(BuildContext context) { |
264 | | - final provider = context.watch<BeerProvider>(); |
265 | | - |
266 | | - // Show loading screen until provider is initialized |
267 | | - if (provider.isLoading && provider.allDrinks.isEmpty) { |
268 | | - return const Scaffold( |
269 | | - body: Center( |
270 | | - child: Column( |
271 | | - mainAxisAlignment: MainAxisAlignment.center, |
272 | | - children: [ |
273 | | - CircularProgressIndicator(), |
274 | | - SizedBox(height: 16), |
275 | | - Text('Loading festival data...'), |
276 | | - ], |
277 | | - ), |
278 | | - ), |
279 | | - ); |
280 | | - } |
281 | | - |
282 | | - return widget.child; |
283 | | - } |
284 | | -} |
285 | | - |
286 | | -class BeerFestivalHome extends StatefulWidget { |
287 | | - final Widget child; |
288 | | - |
289 | | - const BeerFestivalHome({super.key, required this.child}); |
290 | | - |
291 | | - @override |
292 | | - State<BeerFestivalHome> createState() => _BeerFestivalHomeState(); |
293 | | -} |
294 | | - |
295 | | -const Duration _exitConfirmationWindow = Duration(seconds: 2); |
296 | | -const String _exitConfirmationMessage = 'Press back again to exit'; |
297 | | - |
298 | | -class _BeerFestivalHomeState extends State<BeerFestivalHome> { |
299 | | - Timer? _exitConfirmationTimer; |
300 | | - |
301 | | - int get _currentIndex { |
302 | | - // Try to get the current location from GoRouter |
303 | | - try { |
304 | | - final location = GoRouterState.of(context).uri.toString(); |
305 | | - if (location.endsWith('/favorites')) return 1; |
306 | | - return 0; |
307 | | - } catch (e) { |
308 | | - // If GoRouter is not available (e.g., in tests), default to 0 |
309 | | - return 0; |
310 | | - } |
311 | | - } |
312 | | - |
313 | | - /// Get festivalId from current route |
314 | | - String? get _festivalId { |
315 | | - try { |
316 | | - final params = GoRouterState.of(context).pathParameters; |
317 | | - return params['festivalId']; |
318 | | - } catch (e) { |
319 | | - return null; |
320 | | - } |
321 | | - } |
322 | | - |
323 | | - void _onDestinationSelected(int index) { |
324 | | - // Try to use GoRouter navigation |
325 | | - try { |
326 | | - // Get festival ID from URL or fall back to provider |
327 | | - final festivalId = |
328 | | - _festivalId ?? context.read<BeerProvider>().currentFestival.id; |
329 | | - |
330 | | - if (index == 0) { |
331 | | - context.go(buildFestivalHome(festivalId)); |
332 | | - } else if (index == 1) { |
333 | | - context.go(buildFavoritesPath(festivalId)); |
334 | | - } |
335 | | - } catch (e) { |
336 | | - // If GoRouter is not available, this is a no-op |
337 | | - // (tests that don't use GoRouter won't navigate) |
338 | | - } |
339 | | - } |
340 | | - |
341 | | - @override |
342 | | - void dispose() { |
343 | | - _exitConfirmationTimer?.cancel(); |
344 | | - super.dispose(); |
345 | | - } |
346 | | - |
347 | | - void _handleExitConfirmation() { |
348 | | - if (!mounted) return; |
349 | | - |
350 | | - if (_exitConfirmationTimer?.isActive ?? false) { |
351 | | - _exitConfirmationTimer!.cancel(); |
352 | | - _exitConfirmationTimer = null; |
353 | | - if (!kIsWeb) { |
354 | | - SystemNavigator.pop(); |
355 | | - } |
356 | | - return; |
357 | | - } |
358 | | - |
359 | | - _exitConfirmationTimer = Timer(_exitConfirmationWindow, () { |
360 | | - _exitConfirmationTimer = null; |
361 | | - }); |
362 | | - |
363 | | - ScaffoldMessenger.of(context) |
364 | | - ..hideCurrentSnackBar() |
365 | | - ..showSnackBar( |
366 | | - const SnackBar( |
367 | | - content: Text(_exitConfirmationMessage), |
368 | | - duration: _exitConfirmationWindow, |
369 | | - ), |
370 | | - ); |
371 | | - } |
372 | | - |
373 | | - @override |
374 | | - Widget build(BuildContext context) { |
375 | | - final hasNavigationHistory = canPopNavigation(context); |
376 | | - |
377 | | - return PopScope( |
378 | | - canPop: kIsWeb || hasNavigationHistory, |
379 | | - onPopInvokedWithResult: (didPop, result) { |
380 | | - final canPopNow = canPopNavigation(context); |
381 | | - if (didPop || canPopNow) { |
382 | | - _exitConfirmationTimer?.cancel(); |
383 | | - _exitConfirmationTimer = null; |
384 | | - return; |
385 | | - } |
386 | | - _handleExitConfirmation(); |
387 | | - }, |
388 | | - child: Scaffold( |
389 | | - body: Stack(children: [widget.child, const EnvironmentBadge()]), |
390 | | - bottomNavigationBar: NavigationBar( |
391 | | - height: 60, |
392 | | - labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, |
393 | | - selectedIndex: _currentIndex, |
394 | | - onDestinationSelected: _onDestinationSelected, |
395 | | - destinations: [ |
396 | | - NavigationDestination( |
397 | | - key: const Key('drinks_tab'), |
398 | | - icon: Semantics( |
399 | | - label: 'Drinks tab, browse all festival drinks', |
400 | | - child: Opacity( |
401 | | - opacity: 0.6, |
402 | | - child: Image.asset( |
403 | | - 'assets/app_icon.png', |
404 | | - width: 24, |
405 | | - height: 24, |
406 | | - ), |
407 | | - ), |
408 | | - ), |
409 | | - selectedIcon: Semantics( |
410 | | - label: 'Drinks tab, browse all festival drinks', |
411 | | - child: Image.asset( |
412 | | - 'assets/app_icon.png', |
413 | | - width: 24, |
414 | | - height: 24, |
415 | | - ), |
416 | | - ), |
417 | | - label: 'Drinks', |
418 | | - ), |
419 | | - NavigationDestination( |
420 | | - key: const Key('favorites_tab'), |
421 | | - icon: Semantics( |
422 | | - label: |
423 | | - 'My Festival tab, view your want-to-try list and' |
424 | | - ' tasting log', |
425 | | - child: const Icon(Icons.bookmark_outline), |
426 | | - ), |
427 | | - selectedIcon: Semantics( |
428 | | - label: |
429 | | - 'My Festival tab, view your want-to-try list and' |
430 | | - ' tasting log', |
431 | | - child: const Icon(Icons.bookmark), |
432 | | - ), |
433 | | - label: 'My Festival', |
434 | | - ), |
435 | | - ], |
436 | | - ), |
437 | | - ), |
438 | | - ); |
439 | | - } |
440 | | -} |
0 commit comments