flutter_unrouter 0.1.0 copy "flutter_unrouter: ^0.1.0" to clipboard
flutter_unrouter: ^0.1.0 copied to clipboard

Flutter adapter for the unrouter typed routing core.

example/lib/main.dart

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter_unrouter/flutter_unrouter.dart';
import 'package:google_fonts/google_fonts.dart';

void main() {
  runApp(AtelierExampleApp());
}

final StoreSession _session = StoreSession();
final ValueNotifier<String> _eventFeed = ValueNotifier<String>('Ready');

Unrouter<AppRoute> _createRouter() {
  return Unrouter<AppRoute>(
    routes: <RouteRecord<AppRoute>>[
      ...shell<AppRoute>(
        branches: <ShellBranch<AppRoute>>[
          branch<AppRoute>(
            initialLocation: Uri(path: '/'),
            routes: <RouteRecord<AppRoute>>[
              route<DashboardRoute>(
                path: '/',
                parse: (_) => const DashboardRoute(),
                builder: (_, _) => const DashboardScreen(),
              ),
              route<CatalogRoute>(
                path: '/catalog',
                parse: (state) {
                  final tab = state.query.containsKey('tab')
                      ? state.query.$enum('tab', CatalogTab.values)
                      : CatalogTab.featured;
                  return CatalogRoute(tab: tab);
                },
                builder: (_, route) => CatalogScreen(route: route),
              ),
              dataRoute<ProductRoute, ProductDetails>(
                path: '/products/:id',
                parse: (state) {
                  final panel = state.query.containsKey('panel')
                      ? state.query.$enum('panel', ProductPanel.values)
                      : ProductPanel.overview;
                  return ProductRoute(
                    id: state.params.$int('id'),
                    panel: panel,
                  );
                },
                loader: (context) => _loadProduct(context.route),
                builder: (_, route, data) =>
                    ProductScreen(route: route, data: data),
              ),
            ],
          ),
          branch<AppRoute>(
            initialLocation: Uri(path: '/cart'),
            routes: <RouteRecord<AppRoute>>[
              dataRoute<CartRoute, CartSummary>(
                path: '/cart',
                parse: (_) => const CartRoute(),
                loader: (_) => _loadCartSummary(),
                builder: (_, route, summary) =>
                    CartScreen(route: route, summary: summary),
              ),
              route<ProfileRoute>(
                path: '/profile',
                parse: (_) => const ProfileRoute(),
                builder: (_, _) => const ProfileScreen(),
              ),
              dataRoute<CheckoutRoute, CheckoutSummary>(
                path: '/checkout',
                parse: (_) => const CheckoutRoute(),
                guards: <RouteGuard<CheckoutRoute>>[
                  (context) {
                    if (!_session.isSignedIn) {
                      return RouteGuardResult.redirect(
                        route: LoginRoute(from: context.uri.toString()),
                      );
                    }
                    if (_session.itemCount == 0) {
                      return const RouteGuardResult.block();
                    }
                    return const RouteGuardResult.allow();
                  },
                ],
                loader: (_) => _loadCheckoutSummary(),
                builder: (_, _, summary) => CheckoutScreen(summary: summary),
              ),
            ],
          ),
        ],
        builder: (_, shellState, child) {
          return StudioShell(shellState: shellState, child: child);
        },
      ),
      route<LoginRoute>(
        path: '/login',
        parse: (state) => LoginRoute(from: state.query['from']),
        builder: (_, route) => LoginScreen(route: route),
      ),
    ],
    unknown: (_, uri) => UnknownRouteScreen(uri: uri),
    blocked: (_, uri) => BlockedRouteScreen(uri: uri),
    loading: (_, uri) => LoadingRouteScreen(uri: uri),
    onError: (_, error, stackTrace) {
      return ErrorRouteScreen(error: error, stackTrace: stackTrace);
    },
  );
}

class AtelierExampleApp extends StatelessWidget {
  AtelierExampleApp({super.key}) : _router = _createRouter() {
    _session.reset();
    _eventFeed.value = 'Ready';
  }

  final Unrouter<AppRoute> _router;

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Atelier Storefront',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.light,
        textTheme: GoogleFonts.spaceGroteskTextTheme(),
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF1157D8),
          brightness: Brightness.light,
        ),
        scaffoldBackgroundColor: const Color(0xFFF5F1E8),
      ),
      routerConfig: _router,
    );
  }
}

class StudioShell extends StatelessWidget {
  const StudioShell({super.key, required this.shellState, required this.child});

  final ShellState<AppRoute> shellState;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final controller = context.unrouterAs<AppRoute>();
    return AnimatedBuilder(
      animation: _session,
      builder: (context, _) {
        return Scaffold(
          appBar: AppBar(
            toolbarHeight: 76,
            elevation: 0,
            flexibleSpace: DecoratedBox(
              decoration: const BoxDecoration(
                gradient: LinearGradient(
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                  colors: <Color>[Color(0xFF0C1F3D), Color(0xFF1446A0)],
                ),
              ),
              child: SafeArea(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 20),
                  child: Row(
                    children: <Widget>[
                      Container(
                        width: 38,
                        height: 38,
                        decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(12),
                          color: const Color(
                            0xFF89A6FF,
                          ).withValues(alpha: 0.22),
                        ),
                        child: const Icon(
                          Icons.auto_awesome,
                          color: Colors.white,
                        ),
                      ),
                      const SizedBox(width: 12),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: <Widget>[
                            Text(
                              'Atelier Storefront',
                              style: Theme.of(context).textTheme.titleLarge
                                  ?.copyWith(
                                    color: Colors.white,
                                    fontWeight: FontWeight.w700,
                                  ),
                            ),
                            Text(
                              controller.state.uri.toString(),
                              style: Theme.of(context).textTheme.bodySmall
                                  ?.copyWith(
                                    color: const Color(0xFFBFD5FF),
                                    letterSpacing: 0.3,
                                  ),
                            ),
                          ],
                        ),
                      ),
                      TextButton(
                        key: const Key('shell-go-cart'),
                        onPressed: () {
                          shellState.goBranch(1);
                        },
                        child: const Text('Wallet'),
                      ),
                      const SizedBox(width: 8),
                      OutlinedButton(
                        key: const Key('shell-auth-toggle'),
                        style: OutlinedButton.styleFrom(
                          foregroundColor: Colors.white,
                          side: const BorderSide(color: Color(0xFF8EB1FF)),
                        ),
                        onPressed: () {
                          _session.toggleAuth();
                          _eventFeed.value = _session.isSignedIn
                              ? 'Session opened'
                              : 'Session closed';
                        },
                        child: Text(
                          _session.isSignedIn ? 'Sign out' : 'Sign in',
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
          body: Container(
            decoration: const BoxDecoration(
              gradient: LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: <Color>[Color(0xFFF7F2E9), Color(0xFFECE7DD)],
              ),
            ),
            child: Column(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.fromLTRB(20, 14, 20, 8),
                  child: Row(
                    children: <Widget>[
                      Container(
                        key: const Key('shell-cart-count'),
                        padding: const EdgeInsets.symmetric(
                          horizontal: 12,
                          vertical: 8,
                        ),
                        decoration: BoxDecoration(
                          color: const Color(0xFFE6DBCA),
                          borderRadius: BorderRadius.circular(999),
                        ),
                        child: Text(
                          'Cart ${_session.itemCount}',
                          style: Theme.of(context).textTheme.labelLarge,
                        ),
                      ),
                      const SizedBox(width: 10),
                      Container(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 12,
                          vertical: 8,
                        ),
                        decoration: BoxDecoration(
                          color: _session.isSignedIn
                              ? const Color(0xFFD4F4E6)
                              : const Color(0xFFFFE2D6),
                          borderRadius: BorderRadius.circular(999),
                        ),
                        child: Text(
                          _session.isSignedIn ? 'Signed in' : 'Guest mode',
                          style: Theme.of(context).textTheme.labelLarge,
                        ),
                      ),
                      const Spacer(),
                      Flexible(
                        child: ValueListenableBuilder<String>(
                          valueListenable: _eventFeed,
                          builder: (context, value, _) {
                            return Text(
                              value,
                              textAlign: TextAlign.right,
                              style: Theme.of(context).textTheme.bodySmall,
                            );
                          },
                        ),
                      ),
                    ],
                  ),
                ),
                Expanded(
                  child: AnimatedSwitcher(
                    duration: const Duration(milliseconds: 280),
                    switchInCurve: Curves.easeOutCubic,
                    switchOutCurve: Curves.easeInCubic,
                    child: KeyedSubtree(
                      key: ValueKey<String>(controller.state.uri.toString()),
                      child: child,
                    ),
                  ),
                ),
              ],
            ),
          ),
          bottomNavigationBar: NavigationBar(
            selectedIndex: shellState.activeBranchIndex,
            onDestinationSelected: (index) {
              shellState.goBranch(index);
            },
            destinations: const <NavigationDestination>[
              NavigationDestination(
                icon: Icon(Icons.explore_outlined),
                selectedIcon: Icon(Icons.explore),
                label: 'Explore',
              ),
              NavigationDestination(
                icon: Icon(Icons.account_balance_wallet_outlined),
                selectedIcon: Icon(Icons.account_balance_wallet),
                label: 'Wallet',
              ),
            ],
          ),
        );
      },
    );
  }
}

class DashboardScreen extends StatelessWidget {
  const DashboardScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final controller = context.unrouterAs<AppRoute>();

    return ListView(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
      children: <Widget>[
        TweenAnimationBuilder<double>(
          tween: Tween<double>(begin: 0.94, end: 1),
          duration: const Duration(milliseconds: 500),
          curve: Curves.easeOutBack,
          builder: (context, value, child) {
            return Transform.scale(scale: value, child: child);
          },
          child: _HeroBanner(controller: controller),
        ),
        const SizedBox(height: 18),
        _SectionCard(
          title: 'Quick routes',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: <Widget>[
              FilledButton.tonal(
                key: const Key('dashboard-go-catalog'),
                onPressed: () {
                  controller.go(const CatalogRoute(tab: CatalogTab.featured));
                  _eventFeed.value = 'Opened featured catalog';
                },
                child: const Text('Open catalog'),
              ),
              FilledButton(
                key: const Key('dashboard-push-highlight'),
                onPressed: () async {
                  final qty = await controller.push<int>(
                    const ProductRoute(id: 42, panel: ProductPanel.overview),
                  );
                  if (qty != null && qty > 0) {
                    _session.addToCart(42, qty);
                    _eventFeed.value = 'Added Nebula 75 x$qty';
                  }
                },
                child: const Text('Inspect Nebula 75'),
              ),
              OutlinedButton(
                onPressed: () {
                  controller.go(const CheckoutRoute());
                },
                child: const Text('Jump to checkout'),
              ),
            ],
          ),
        ),
        const SizedBox(height: 18),
        _SectionCard(
          title: 'State monitor',
          child: Text(
            'resolution: ${controller.state.resolution.name}\n'
            'route: ${controller.state.routePath ?? '-'}\n'
            'action: ${controller.state.lastAction.name}',
            style: Theme.of(context).textTheme.bodyMedium,
          ),
        ),
      ],
    );
  }
}

class _HeroBanner extends StatelessWidget {
  const _HeroBanner({required this.controller});

  final UnrouterController<AppRoute> controller;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(28),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            Color(0xFF0A2448),
            Color(0xFF1360E2),
            Color(0xFF4AB0FF),
          ],
        ),
      ),
      padding: const EdgeInsets.fromLTRB(22, 24, 22, 22),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            'Designed for calm, built for speed.',
            style: Theme.of(context).textTheme.headlineSmall?.copyWith(
              color: Colors.white,
              fontWeight: FontWeight.w700,
            ),
          ),
          const SizedBox(height: 10),
          Text(
            'Use typed routes, branch navigation, and guard-driven flows '
            'without losing visual polish.',
            style: Theme.of(context).textTheme.bodyLarge?.copyWith(
              color: const Color(0xFFEAF3FF),
              height: 1.4,
            ),
          ),
          const SizedBox(height: 18),
          Text(
            'href preview: ${controller.href(const ProductRoute(id: 11, panel: ProductPanel.specs))}',
            style: Theme.of(
              context,
            ).textTheme.labelLarge?.copyWith(color: const Color(0xFFD5E6FF)),
          ),
        ],
      ),
    );
  }
}

class CatalogScreen extends StatelessWidget {
  const CatalogScreen({super.key, required this.route});

  final CatalogRoute route;

  @override
  Widget build(BuildContext context) {
    final controller = context.unrouterAs<AppRoute>();
    final products = _productsForTab(route.tab);

    return ListView(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
      children: <Widget>[
        _SectionCard(
          title: 'Catalog lanes',
          child: Wrap(
            spacing: 8,
            runSpacing: 8,
            children: CatalogTab.values.map((tab) {
              final selected = tab == route.tab;
              return ChoiceChip(
                label: Text(_catalogLabel(tab)),
                selected: selected,
                onSelected: (_) {
                  controller.go(CatalogRoute(tab: tab));
                },
              );
            }).toList(),
          ),
        ),
        const SizedBox(height: 14),
        for (final product in products)
          Padding(
            padding: const EdgeInsets.only(bottom: 12),
            child: _ProductTile(
              product: product,
              onOpen: () async {
                final qty = await controller.push<int>(
                  ProductRoute(id: product.id, panel: ProductPanel.overview),
                );
                if (qty != null && qty > 0) {
                  _session.addToCart(product.id, qty);
                  _eventFeed.value = 'Added ${product.name} x$qty';
                }
              },
            ),
          ),
      ],
    );
  }
}

class _ProductTile extends StatelessWidget {
  const _ProductTile({required this.product, required this.onOpen});

  final ProductSeed product;
  final Future<void> Function() onOpen;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(20),
        boxShadow: const <BoxShadow>[
          BoxShadow(
            color: Color(0x22000000),
            blurRadius: 20,
            offset: Offset(0, 8),
          ),
        ],
      ),
      padding: const EdgeInsets.all(16),
      child: Row(
        children: <Widget>[
          Container(
            width: 58,
            height: 58,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(16),
              gradient: LinearGradient(
                colors: <Color>[
                  product.tint.withValues(alpha: 0.86),
                  product.tint.withValues(alpha: 0.46),
                ],
              ),
            ),
            child: const Icon(Icons.keyboard_alt_outlined, color: Colors.white),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  product.name,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 4),
                Text(
                  product.tagline,
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ],
            ),
          ),
          const SizedBox(width: 10),
          Column(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: <Widget>[
              Text(
                _usd(product.priceCents),
                style: Theme.of(
                  context,
                ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
              ),
              const SizedBox(height: 6),
              FilledButton.tonal(
                key: Key('catalog-open-${product.id}'),
                onPressed: () {
                  onOpen();
                },
                child: const Text('Open'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class ProductScreen extends StatefulWidget {
  const ProductScreen({super.key, required this.route, required this.data});

  final ProductRoute route;
  final ProductDetails data;

  @override
  State<ProductScreen> createState() => _ProductScreenState();
}

class _ProductScreenState extends State<ProductScreen> {
  int _qty = 1;

  @override
  Widget build(BuildContext context) {
    final controller = context.unrouterAs<AppRoute>();

    return ListView(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
      children: <Widget>[
        _SectionCard(
          title: widget.data.name,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(widget.data.story),
              const SizedBox(height: 10),
              Text(
                '${_usd(widget.data.priceCents)}  •  ${widget.data.rating.toStringAsFixed(1)}★',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 14),
              SegmentedButton<ProductPanel>(
                segments: ProductPanel.values
                    .map(
                      (panel) => ButtonSegment<ProductPanel>(
                        value: panel,
                        label: Text(panel.name),
                      ),
                    )
                    .toList(),
                selected: <ProductPanel>{widget.route.panel},
                onSelectionChanged: (selection) {
                  controller.go(
                    ProductRoute(id: widget.route.id, panel: selection.first),
                  );
                },
              ),
              const SizedBox(height: 14),
              AnimatedSwitcher(
                duration: const Duration(milliseconds: 240),
                child: Text(
                  _panelCopy(widget.route.panel, widget.data),
                  key: ValueKey<ProductPanel>(widget.route.panel),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 14),
        _SectionCard(
          title: 'Quantity',
          child: Row(
            children: <Widget>[
              IconButton(
                key: const Key('product-qty-minus'),
                onPressed: () {
                  setState(() {
                    _qty = math.max(1, _qty - 1);
                  });
                },
                icon: const Icon(Icons.remove_circle_outline),
              ),
              Text('$_qty', style: Theme.of(context).textTheme.titleLarge),
              IconButton(
                key: const Key('product-qty-plus'),
                onPressed: () {
                  setState(() {
                    _qty += 1;
                  });
                },
                icon: const Icon(Icons.add_circle_outline),
              ),
              const Spacer(),
              OutlinedButton(
                onPressed: () => context.unrouter.back(),
                child: const Text('Back'),
              ),
              const SizedBox(width: 10),
              FilledButton(
                key: const Key('product-add-return'),
                onPressed: () {
                  context.unrouter.pop<int>(_qty);
                },
                child: const Text('Add & return'),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class CartScreen extends StatelessWidget {
  const CartScreen({super.key, required this.route, required this.summary});

  final CartRoute route;
  final CartSummary summary;

  @override
  Widget build(BuildContext context) {
    final controller = context.unrouterAs<AppRoute>();

    return ListView(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
      children: <Widget>[
        _SectionCard(
          title: 'Cart composition',
          child: summary.entries.isEmpty
              ? const Text('No items yet. Add one from the catalog.')
              : Column(
                  children: summary.entries.map((entry) {
                    return Padding(
                      padding: const EdgeInsets.only(bottom: 10),
                      child: Row(
                        children: <Widget>[
                          Expanded(child: Text(entry.name)),
                          Text('x${entry.quantity}'),
                          const SizedBox(width: 14),
                          Text(_usd(entry.subtotalCents)),
                        ],
                      ),
                    );
                  }).toList(),
                ),
        ),
        const SizedBox(height: 14),
        _SectionCard(
          title: 'Summary',
          child: Row(
            children: <Widget>[
              Expanded(
                child: Text(
                  'Items: ${summary.itemCount}\nTotal: ${_usd(summary.totalCents)}',
                ),
              ),
              FilledButton(
                key: const Key('cart-go-checkout'),
                onPressed: () {
                  controller.go(const CheckoutRoute());
                },
                child: const Text('Checkout'),
              ),
              const SizedBox(width: 8),
              OutlinedButton(
                key: const Key('cart-clear'),
                onPressed: () {
                  _session.clearCart();
                  _eventFeed.value = 'Cart cleared';
                  controller.go(const CartRoute());
                },
                child: const Text('Clear'),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final controller = context.unrouterAs<AppRoute>();

    return ListView(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
      children: <Widget>[
        _SectionCard(
          title: 'Profile lane',
          child: AnimatedBuilder(
            animation: _session,
            builder: (context, _) {
              return Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    _session.isSignedIn
                        ? 'Welcome back. Concierge mode enabled.'
                        : 'Guest mode. Sign in for checkout.',
                  ),
                  const SizedBox(height: 12),
                  FilledButton(
                    key: const Key('profile-toggle-auth'),
                    onPressed: () {
                      _session.toggleAuth();
                    },
                    child: Text(_session.isSignedIn ? 'Sign out' : 'Sign in'),
                  ),
                  const SizedBox(height: 8),
                  OutlinedButton(
                    onPressed: () {
                      controller.go(const DashboardRoute());
                    },
                    child: const Text('Back to explore'),
                  ),
                ],
              );
            },
          ),
        ),
      ],
    );
  }
}

class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key, required this.route});

  final LoginRoute route;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Container(
          constraints: const BoxConstraints(maxWidth: 560),
          margin: const EdgeInsets.all(20),
          padding: const EdgeInsets.all(24),
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(24),
            boxShadow: const <BoxShadow>[
              BoxShadow(
                color: Color(0x26000000),
                blurRadius: 24,
                offset: Offset(0, 10),
              ),
            ],
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                'Authentication required',
                style: Theme.of(context).textTheme.headlineSmall,
              ),
              const SizedBox(height: 12),
              Text('After sign-in, you will continue to: ${route.from ?? '/'}'),
              const SizedBox(height: 20),
              FilledButton(
                key: const Key('login-sign-in-continue'),
                onPressed: () {
                  _session.signIn();
                  _eventFeed.value = 'Session opened from login';
                  final target = Uri.tryParse(route.from ?? '');
                  if (target == null) {
                    context.unrouter.go(const DashboardRoute());
                    return;
                  }
                  if (target.path == '/checkout' && _session.itemCount == 0) {
                    _eventFeed.value = 'Checkout requires cart items first';
                    context.unrouter.go(const CartRoute());
                    return;
                  }
                  context.unrouter.goUri(target);
                },
                child: const Text('Sign in and continue'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class CheckoutScreen extends StatelessWidget {
  const CheckoutScreen({super.key, required this.summary});

  final CheckoutSummary summary;

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
      children: <Widget>[
        _SectionCard(
          title: 'Checkout ready',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                'Subtotal: ${_usd(summary.subtotalCents)}',
                key: const Key('checkout-title'),
              ),
              Text('Shipping: ${_usd(summary.shippingCents)}'),
              Text('Tax: ${_usd(summary.taxCents)}'),
              const SizedBox(height: 8),
              Text(
                'Total: ${_usd(summary.totalCents)}',
                style: Theme.of(context).textTheme.titleLarge,
              ),
              const SizedBox(height: 14),
              FilledButton(
                onPressed: () {
                  _session.clearCart();
                  _eventFeed.value = 'Order confirmed';
                  context.unrouter.go(const DashboardRoute());
                },
                child: const Text('Place order'),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class UnknownRouteScreen extends StatelessWidget {
  const UnknownRouteScreen({super.key, required this.uri});

  final Uri uri;

  @override
  Widget build(BuildContext context) {
    return _StatusScaffold(
      title: 'Route not found',
      message: 'No screen is mapped to ${uri.path}.',
      actionLabel: 'Back to dashboard',
      onAction: () => context.unrouter.go(const DashboardRoute()),
    );
  }
}

class BlockedRouteScreen extends StatelessWidget {
  const BlockedRouteScreen({super.key, required this.uri});

  final Uri uri;

  @override
  Widget build(BuildContext context) {
    return _StatusScaffold(
      title: 'Unable to continue',
      message: 'Your cart is empty. Add products before opening ${uri.path}.',
      messageKey: const Key('blocked-message'),
      actionLabel: 'Open catalog',
      onAction: () =>
          context.unrouter.go(const CatalogRoute(tab: CatalogTab.featured)),
    );
  }
}

class LoadingRouteScreen extends StatelessWidget {
  const LoadingRouteScreen({super.key, required this.uri});

  final Uri uri;

  @override
  Widget build(BuildContext context) {
    return _StatusScaffold(
      title: 'Resolving route',
      message: 'Loading ${uri.path}...',
      loading: true,
      actionLabel: 'Cancel',
      onAction: () => context.unrouter.back(),
    );
  }
}

class ErrorRouteScreen extends StatelessWidget {
  const ErrorRouteScreen({
    super.key,
    required this.error,
    required this.stackTrace,
  });

  final Object error;
  final StackTrace stackTrace;

  @override
  Widget build(BuildContext context) {
    return _StatusScaffold(
      title: 'Route error',
      message: '$error\n\n$stackTrace',
      actionLabel: 'Back to dashboard',
      onAction: () => context.unrouter.go(const DashboardRoute()),
    );
  }
}

class _StatusScaffold extends StatelessWidget {
  const _StatusScaffold({
    required this.title,
    required this.message,
    required this.actionLabel,
    required this.onAction,
    this.loading = false,
    this.messageKey,
  });

  final String title;
  final String message;
  final String actionLabel;
  final VoidCallback onAction;
  final bool loading;
  final Key? messageKey;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Container(
          width: 560,
          margin: const EdgeInsets.all(20),
          padding: const EdgeInsets.all(24),
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(24),
            boxShadow: const <BoxShadow>[
              BoxShadow(
                color: Color(0x26000000),
                blurRadius: 24,
                offset: Offset(0, 10),
              ),
            ],
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              if (loading)
                const Padding(
                  padding: EdgeInsets.only(bottom: 12),
                  child: LinearProgressIndicator(minHeight: 5),
                ),
              Text(title, style: Theme.of(context).textTheme.headlineSmall),
              const SizedBox(height: 10),
              Text(message, key: messageKey),
              const SizedBox(height: 18),
              FilledButton(onPressed: onAction, child: Text(actionLabel)),
            ],
          ),
        ),
      ),
    );
  }
}

class _SectionCard extends StatelessWidget {
  const _SectionCard({required this.title, required this.child});

  final String title;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(22),
        boxShadow: const <BoxShadow>[
          BoxShadow(
            color: Color(0x17000000),
            blurRadius: 20,
            offset: Offset(0, 6),
          ),
        ],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            title,
            style: Theme.of(
              context,
            ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
          ),
          const SizedBox(height: 10),
          child,
        ],
      ),
    );
  }
}

Future<ProductDetails> _loadProduct(ProductRoute route) async {
  await Future<void>.delayed(const Duration(milliseconds: 120));
  final seed = _catalogById[route.id];
  if (seed == null) {
    throw StateError('Unknown product id ${route.id}.');
  }
  return ProductDetails(
    id: seed.id,
    name: seed.name,
    story: seed.description,
    priceCents: seed.priceCents,
    rating: seed.rating,
  );
}

Future<CartSummary> _loadCartSummary() async {
  await Future<void>.delayed(const Duration(milliseconds: 70));
  return _session.toCartSummary();
}

Future<CheckoutSummary> _loadCheckoutSummary() async {
  await Future<void>.delayed(const Duration(milliseconds: 90));
  final cart = _session.toCartSummary();
  final shipping = cart.itemCount == 0 ? 0 : 1200;
  final tax = (cart.totalCents * 0.08).round();
  return CheckoutSummary(
    subtotalCents: cart.totalCents,
    shippingCents: shipping,
    taxCents: tax,
    totalCents: cart.totalCents + shipping + tax,
  );
}

List<ProductSeed> _productsForTab(CatalogTab tab) {
  if (tab == CatalogTab.featured) {
    return _products;
  }
  return _products.where((product) => product.tab == tab).toList();
}

String _catalogLabel(CatalogTab tab) {
  return switch (tab) {
    CatalogTab.featured => 'Featured',
    CatalogTab.keyboards => 'Keyboards',
    CatalogTab.accessories => 'Accessories',
  };
}

String _panelCopy(ProductPanel panel, ProductDetails data) {
  return switch (panel) {
    ProductPanel.overview => data.story,
    ProductPanel.specs =>
      'CNC frame, hot-swappable PCB, gasket mount, and tuned stabilizers.',
    ProductPanel.reviews =>
      '“Incredibly balanced acoustics and a refined typing feel.”',
  };
}

String _usd(int cents) {
  final dollars = cents ~/ 100;
  final remains = (cents % 100).toString().padLeft(2, '0');
  return '\$$dollars.$remains';
}

sealed class AppRoute implements RouteData {
  const AppRoute();
}

final class DashboardRoute extends AppRoute {
  const DashboardRoute();

  @override
  Uri toUri() => Uri(path: '/');
}

enum CatalogTab { featured, keyboards, accessories }

final class CatalogRoute extends AppRoute {
  const CatalogRoute({this.tab = CatalogTab.featured});

  final CatalogTab tab;

  @override
  Uri toUri() =>
      Uri(path: '/catalog', queryParameters: <String, String>{'tab': tab.name});
}

enum ProductPanel { overview, specs, reviews }

final class ProductRoute extends AppRoute {
  const ProductRoute({required this.id, this.panel = ProductPanel.overview});

  final int id;
  final ProductPanel panel;

  @override
  Uri toUri() {
    return Uri(
      path: '/products/$id',
      queryParameters: <String, String>{'panel': panel.name},
    );
  }
}

final class CartRoute extends AppRoute {
  const CartRoute();

  @override
  Uri toUri() => Uri(path: '/cart');
}

final class ProfileRoute extends AppRoute {
  const ProfileRoute();

  @override
  Uri toUri() => Uri(path: '/profile');
}

final class CheckoutRoute extends AppRoute {
  const CheckoutRoute();

  @override
  Uri toUri() => Uri(path: '/checkout');
}

final class LoginRoute extends AppRoute {
  const LoginRoute({this.from});

  final String? from;

  @override
  Uri toUri() {
    return Uri(
      path: '/login',
      queryParameters: from == null ? null : <String, String>{'from': from!},
    );
  }
}

final class ProductSeed {
  const ProductSeed({
    required this.id,
    required this.name,
    required this.tagline,
    required this.description,
    required this.priceCents,
    required this.rating,
    required this.tab,
    required this.tint,
  });

  final int id;
  final String name;
  final String tagline;
  final String description;
  final int priceCents;
  final double rating;
  final CatalogTab tab;
  final Color tint;
}

final List<ProductSeed> _products = <ProductSeed>[
  const ProductSeed(
    id: 11,
    name: 'Aster 68',
    tagline: 'Compact board with luminous underglow.',
    description:
        'Aster 68 blends a dense aluminum chassis with a bright but calm profile.',
    priceCents: 11900,
    rating: 4.7,
    tab: CatalogTab.keyboards,
    tint: Color(0xFF7A87FF),
  ),
  const ProductSeed(
    id: 42,
    name: 'Nebula 75',
    tagline: 'Gasket-mounted daily driver with sculpted acoustics.',
    description:
        'Nebula 75 is tuned for long sessions with a soft, confident key feel.',
    priceCents: 14900,
    rating: 4.9,
    tab: CatalogTab.keyboards,
    tint: Color(0xFF1380EF),
  ),
  const ProductSeed(
    id: 73,
    name: 'Silk Wrist Rest',
    tagline: 'Layered resin support with subtle texture.',
    description: 'Silk Wrist Rest adds comfort without stealing desk space.',
    priceCents: 3900,
    rating: 4.6,
    tab: CatalogTab.accessories,
    tint: Color(0xFF5FAE87),
  ),
];

final Map<int, ProductSeed> _catalogById = <int, ProductSeed>{
  for (final product in _products) product.id: product,
};

final class ProductDetails {
  const ProductDetails({
    required this.id,
    required this.name,
    required this.story,
    required this.priceCents,
    required this.rating,
  });

  final int id;
  final String name;
  final String story;
  final int priceCents;
  final double rating;
}

final class CartEntry {
  const CartEntry({
    required this.id,
    required this.name,
    required this.quantity,
    required this.subtotalCents,
  });

  final int id;
  final String name;
  final int quantity;
  final int subtotalCents;
}

final class CartSummary {
  const CartSummary({
    required this.entries,
    required this.itemCount,
    required this.totalCents,
  });

  final List<CartEntry> entries;
  final int itemCount;
  final int totalCents;
}

final class CheckoutSummary {
  const CheckoutSummary({
    required this.subtotalCents,
    required this.shippingCents,
    required this.taxCents,
    required this.totalCents,
  });

  final int subtotalCents;
  final int shippingCents;
  final int taxCents;
  final int totalCents;
}

class StoreSession extends ChangeNotifier {
  bool _isSignedIn = false;
  final Map<int, int> _cart = <int, int>{};

  bool get isSignedIn => _isSignedIn;
  int get itemCount => _cart.values.fold<int>(0, (sum, qty) => sum + qty);

  void signIn() {
    _isSignedIn = true;
    notifyListeners();
  }

  void signOut() {
    _isSignedIn = false;
    notifyListeners();
  }

  void toggleAuth() {
    if (_isSignedIn) {
      signOut();
      return;
    }
    signIn();
  }

  void addToCart(int productId, int quantity) {
    if (quantity <= 0) {
      return;
    }
    _cart.update(
      productId,
      (value) => value + quantity,
      ifAbsent: () => quantity,
    );
    notifyListeners();
  }

  void clearCart() {
    _cart.clear();
    notifyListeners();
  }

  CartSummary toCartSummary() {
    final entries = <CartEntry>[];
    var total = 0;
    var count = 0;

    for (final entry in _cart.entries) {
      final product = _catalogById[entry.key];
      if (product == null) {
        continue;
      }
      final subtotal = product.priceCents * entry.value;
      entries.add(
        CartEntry(
          id: product.id,
          name: product.name,
          quantity: entry.value,
          subtotalCents: subtotal,
        ),
      );
      total += subtotal;
      count += entry.value;
    }

    entries.sort((a, b) => a.name.compareTo(b.name));
    return CartSummary(entries: entries, itemCount: count, totalCents: total);
  }

  void reset() {
    _isSignedIn = false;
    _cart.clear();
    notifyListeners();
  }
}
1
likes
150
points
26
downloads

Documentation

Documentation
API reference

Publisher

verified publishermedz.dev

Weekly Downloads

Flutter adapter for the unrouter typed routing core.

Repository (GitHub)
View/report issues

Topics

#flutter #router #navigation #routing #typed

License

MIT (license)

Dependencies

flutter, roux, unrouter, unstory

More

Packages that depend on flutter_unrouter