Skip to main content

Navigation

BookWish uses GoRouter for declarative routing with authentication guards and deep linking support.

Router Configuration

Located in /lib/config/routes.dart:

final routerProvider = Provider<GoRouter>((ref) {
final authChangeNotifier = ref.watch(authChangeNotifierProvider);

return GoRouter(
initialLocation: '/onboarding',
refreshListenable: authChangeNotifier,
observers: kIsWeb ? [] : [ref.watch(analyticsObserverProvider)],
redirect: (context, state) {
// Auth-based redirects
},
routes: [
// Route definitions
],
);
});

Route Structure

Public Routes

Routes accessible without authentication:

GoRoute(
path: '/onboarding',
builder: (context, state) =>
kIsWeb ? const LandingPage() : const OnboardingPage(),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/signup',
builder: (context, state) => const SignupPage(),
),
GoRoute(
path: '/terms',
builder: (context, state) => const TermsPage(),
),
GoRoute(
path: '/privacy',
builder: (context, state) => const PrivacyPage(),
),
GoRoute(
path: '/about',
builder: (context, state) => const AboutPage(),
),

Shell Routes

Main app uses ShellRoute for persistent bottom navigation:

ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/home',
redirect: (context, state) => '/home/share',
),
// Main tabs
GoRoute(
path: '/home/share',
builder: (context, state) => const SharePage(),
),
GoRoute(
path: '/home/wish',
builder: (context, state) => const WishPage(),
),
GoRoute(
path: '/home/read',
builder: (context, state) => const ReadPage(),
),
GoRoute(
path: '/home/shop',
builder: (context, state) => const ShopPage(),
),
// Detail pages within shell
GoRoute(
path: '/wishlist/:id',
builder: (context, state) => WishlistDetailPage(
wishlistId: state.pathParameters['id']!,
),
),
GoRoute(
path: '/cart',
builder: (context, state) => const CartPage(),
),
GoRoute(
path: '/checkout',
builder: (context, state) => const CheckoutPage(),
),
],
),

Route Parameters

Access path parameters via state.pathParameters:

GoRoute(
path: '/club/:id',
builder: (context, state) => ClubDetailPage(
clubId: state.pathParameters['id']!,
),
),
GoRoute(
path: '/challenge/:id',
builder: (context, state) => ChallengeDetailPage(
challengeId: state.pathParameters['id']!,
),
),

Query Parameters

Access query parameters via state.uri.queryParameters:

GoRoute(
path: '/upgrade/success',
builder: (context, state) => UpgradeSuccessPage(
sessionId: state.uri.queryParameters['session_id'],
),
),

Authentication Guards

Auth Change Notifier

Listens to auth state and triggers router refresh:

class AuthChangeNotifier extends ChangeNotifier {
AuthChangeNotifier(this._ref) {
_ref.listen(authNotifierProvider, (_, __) {
notifyListeners();
});
}

final Ref _ref;
}

Redirect Logic

Protects authenticated routes and prevents authenticated users from accessing auth pages:

redirect: (context, state) {
final authState = ref.read(authNotifierProvider);

// Don't redirect during loading states
if (authState.isLoading) {
return null;
}

final isAuthenticated = authState.value?.isAuthenticated ?? false;
final isGuest = authState.value?.isGuest ?? false;
final isOnboarding = state.matchedLocation == '/onboarding';
final isLogin = state.matchedLocation == '/login';
final isSignup = state.matchedLocation == '/signup';
final isLegalPage = state.matchedLocation == '/terms' ||
state.matchedLocation == '/privacy' ||
state.matchedLocation == '/about';

// Allow access to public pages without auth
if (!isAuthenticated && !isOnboarding && !isLogin && !isSignup && !isLegalPage) {
return '/onboarding';
}

// Allow guest users to access signup to convert to full account
// Only redirect fully authenticated (non-guest) users away from auth pages
if (isAuthenticated && !isGuest && (isOnboarding || isLogin || isSignup)) {
return '/home';
}

return null;
},

Declarative Navigation

context.go()

Replace current route (no back button):

context.go('/home/wish');
context.go('/wishlist/$wishlistId');

context.push()

Push new route (adds to stack):

context.push('/cart');
context.push('/club/$clubId');

context.pop()

Pop current route:

Navigator.of(context).pop();
// or
context.pop();

Convenience Redirects

Top-level paths redirect to shell routes:

GoRoute(
path: '/wish',
redirect: (context, state) => '/home/wish',
),
GoRoute(
path: '/share',
redirect: (context, state) => '/home/share',
),
GoRoute(
path: '/read',
redirect: (context, state) => '/home/read',
),
GoRoute(
path: '/shop',
redirect: (context, state) => '/home/shop',
),

Deep Linking

iOS Configuration

In ios/Runner/Info.plist:

<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>bookwish</string>
</array>
</dict>
</array>

Android Configuration

In android/app/src/main/AndroidManifest.xml:

<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="bookwish.app" />
<data android:scheme="bookwish" />
</intent-filter>
  • bookwish://wishlist/:id - Open wishlist
  • bookwish://club/:id - Open club
  • bookwish://challenge/:id - Open challenge
  • bookwish://upgrade - Open upgrade page
  • https://bookwish.app/* - Universal links

App Shell Navigation

Bottom navigation managed in /lib/ui/app_shell.dart:

class _AppShellState extends ConsumerState<AppShell> {
void _onTapNavigation(int index, bool showShopTab) {
switch (index) {
case 0:
context.go('/home/share');
break;
case 1:
context.go('/home/wish');
break;
case 2:
context.go('/home/read');
break;
case 3:
if (showShopTab) {
context.go('/home/shop');
}
break;
}
}

int _getCurrentIndex(String location) {
if (location.contains('/home/share')) return 0;
if (location.contains('/home/wish')) return 1;
if (location.contains('/home/read')) return 2;
if (location.contains('/home/shop')) return 3;
return 0;
}
}

Conditional Tabs

Shop tab only shown to bookstore owners:

final authState = ref.watch(authNotifierProvider);
final user = authState.value?.user;
final showShopTab = user?.isBookstoreOwner ?? false;

Analytics Tracking

Navigation events tracked via observer:

observers: kIsWeb ? [] : [ref.watch(analyticsObserverProvider)],

The analytics observer automatically logs screen views using Firebase Analytics.

Best Practices

  1. Use context.go() for tab navigation - Prevents route stack buildup
  2. Use context.push() for detail pages - Maintains back navigation
  3. Always check auth state - Use redirect for protected routes
  4. Handle loading states - Don't redirect during auth initialization
  5. Use path parameters for IDs - More RESTful and deep-link friendly
  6. Close overlays before navigation - Use Navigator.pop() first
  7. Use Future.microtask for post-overlay navigation - Prevents navigation during build
  8. Avoid nested navigation - Keep routing flat and predictable

Common Patterns

// Capture router before closing overlay
final router = GoRouter.of(context);

// Close overlay
Navigator.of(context).pop();

// Navigate after overlay fully dismissed
Future.microtask(() {
router.go('/signup');
});
// Path parameters
context.push('/wishlist/$wishlistId');
context.push('/club/$clubId');

// Query parameters
context.push('/upgrade/success?session_id=$sessionId');

Conditional Navigation

// Navigate based on auth state
if (isAuthenticated) {
context.go('/home/share');
} else {
context.go('/login');
}

Replace vs Push

// Replace current route (can't go back)
context.go('/home');

// Push new route (can go back)
context.push('/settings');