Skip to content

Commit 75f376d

Browse files
committed
refactor(main): break the main.dart / router.dart import cycle
router.dart imported main.dart for ProviderInitializer and BeerFestivalHome while main.dart imported router.dart for appRouter, so main.dart was not an entry point but a widget library that happened to contain main(), and neither file could be read or tested without the other. Move both widgets to lib/widgets/ and export them from the barrel. They are copied verbatim — the only edit is one comment pointer. globalRoutes moves from router.dart to utils/navigation_helpers.dart rather than staying put as the issue suggested: the redirect handler that reads it now lives under lib/widgets/, and router.dart imports the widgets barrel, so leaving it in router.dart would simply re-form the cycle one file over. navigation_helpers.dart has no project imports of its own and already owns the route-path builders, so it is the natural cycle-free home. main.dart is now main() + isTransientFontLoadError + BeerFestivalApp, 104 lines instead of 440. Nothing under lib/ imports it any more. Removing the moved code left six dead imports in main.dart, which the newly-fatal analyzer from #524 caught immediately. Follow-up not done here, to keep this a pure move: the BeerFestivalHome and ProviderInitializer tests still live in test/main_test.dart rather than mirroring the new lib/widgets/ layout. Fixes #527
1 parent 9914662 commit 75f376d

7 files changed

Lines changed: 362 additions & 343 deletions

File tree

lib/main.dart

Lines changed: 0 additions & 336 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
import 'dart:async';
2-
import 'dart:ui';
31
import 'package:flutter/foundation.dart';
42
import 'package:flutter/material.dart';
5-
import 'package:flutter/services.dart';
63
import 'package:firebase_core/firebase_core.dart';
74
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
8-
import 'package:go_router/go_router.dart';
95
import 'package:provider/provider.dart';
106
import 'app_theme.dart';
117
import 'providers/providers.dart';
128
import 'router.dart';
139
import 'services/services.dart';
14-
import 'utils/utils.dart';
15-
import 'widgets/widgets.dart';
1610
import 'firebase_options.dart';
1711
// Guarded on dart.library.js_interop, not dart.library.html: `dart:html` is
1812
// only provided by dart2js, so a `--wasm` build would silently fall through to
@@ -108,333 +102,3 @@ class BeerFestivalApp extends StatelessWidget {
108102
// coverage:ignore-end
109103
}
110104
}
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-
}

lib/router.dart

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ import 'package:go_router/go_router.dart';
66
import 'package:provider/provider.dart';
77
import 'providers/beer_provider.dart';
88
import 'screens/screens.dart';
9-
import 'main.dart';
10-
11-
/// Global routes that exist outside festival scope
12-
/// IMPORTANT: Keep in sync with _handlePostInitRedirect in main.dart
13-
const List<String> globalRoutes = ['/about'];
9+
import 'widgets/widgets.dart';
1410

1511
/// Rebuilds [state]'s location with the leading festival segment replaced by
1612
/// [currentFestivalId], preserving everything else about the URL.
@@ -120,8 +116,8 @@ GoRouter _buildRouter() {
120116
// it mounts a Navigator with an empty `pages` list and no
121117
// `onGenerateRoute`, which crashes with "Null check operator used
122118
// on a null value" in release builds (issue #386). Once
123-
// initialization completes, _handlePostInitRedirect in main.dart
124-
// navigates to the current festival.
119+
// initialization completes, _handlePostInitRedirect in
120+
// widgets/provider_initializer.dart navigates to the current festival.
125121
builder: (context, state) => const Scaffold(
126122
body: Center(child: CircularProgressIndicator()),
127123
),

lib/utils/navigation_helpers.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ library;
1010
import 'package:flutter/material.dart';
1111
import 'package:go_router/go_router.dart';
1212

13+
/// Routes that exist outside festival scope and must never be rewritten to a
14+
/// festival-scoped path.
15+
///
16+
/// IMPORTANT: keep in sync with the route table in `router.dart` and with
17+
/// `_handlePostInitRedirect` in `widgets/provider_initializer.dart` — both
18+
/// consume this list. It lives here, alongside the path builders, rather than
19+
/// in `router.dart` so that the redirect handler can read it without importing
20+
/// the router (which would reintroduce the import cycle removed in #527).
21+
const List<String> globalRoutes = ['/about'];
22+
1323
/// Builds a festival-scoped URL path.
1424
///
1525
/// The [festivalId] and [path] must not be empty.

0 commit comments

Comments
 (0)