unrouter 0.8.0 copy "unrouter: ^0.8.0" to clipboard
unrouter: ^0.8.0 copied to clipboard

A URL-first typed router for Flutter with shell navigation and diagnostics.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:unrouter/devtools.dart';
import 'package:unrouter/machine.dart';
import 'package:unrouter/unrouter.dart';

void main() {
  runApp(const UnrouterExampleApp());
}

final DemoSession _session = DemoSession();
final UnrouterRedirectDiagnosticsStore _redirectDiagnostics =
    UnrouterRedirectDiagnosticsStore();
final ValueNotifier<int?> _lastPushResult = ValueNotifier<int?>(null);

final Unrouter<AppRoute> _router = Unrouter<AppRoute>(
  stateTimelineLimit: 128,
  machineTimelineLimit: 512,
  maxRedirectHops: 6,
  redirectLoopPolicy: RedirectLoopPolicy.error,
  onRedirectDiagnostics: _redirectDiagnostics.onDiagnostics,
  loading: (_) => const _BootScreen(),
  routes: <RouteRecord<AppRoute>>[
    route<RootRoute>(
      path: '/',
      parse: (_) => const RootRoute(),
      redirect: (_) => const AppHomeRoute().toUri(),
      builder: (_, _) => const SizedBox.shrink(),
    ),
    route<LegacyRoute>(
      path: '/legacy',
      parse: (_) => const LegacyRoute(),
      redirect: (_) => const AppHomeRoute().toUri(),
      builder: (_, _) => const SizedBox.shrink(),
    ),
    route<LoginRoute>(
      path: '/login',
      parse: (state) => LoginRoute(from: state.queryOrNull('from')),
      builder: (_, route) => LoginScreen(route: route),
    ),
    route<DebugRoute>(
      path: '/debug',
      parse: (_) => const DebugRoute(),
      builder: (_, _) => const DebugCenterScreen(),
    ),
    route<ResultRoute>(
      path: '/result/:id',
      parse: (state) => ResultRoute(id: state.pathInt('id')),
      builder: (_, route) => ResultScreen(route: route),
    ),
    ...shell<AppRoute>(
      name: 'app',
      branches: <ShellBranch<AppRoute>>[
        branch<AppRoute>(
          initialLocation: const AppHomeRoute().toUri(),
          routes: <RouteRecord<AppRoute>>[
            route<AppHomeRoute>(
              path: '/app/home',
              parse: (_) => const AppHomeRoute(),
              builder: (_, _) => const HomeScreen(),
            ),
            route<PostRoute>(
              path: '/app/home/post/:id',
              parse: (state) => PostRoute(
                id: state.pathInt('id'),
                tab: state.queryEnum(
                  'tab',
                  PostTab.values,
                  fallback: PostTab.overview,
                ),
              ),
              transitionDuration: const Duration(milliseconds: 220),
              reverseTransitionDuration: const Duration(milliseconds: 180),
              transitionBuilder: (context, animation, secondary, child) {
                final curved = CurvedAnimation(
                  parent: animation,
                  curve: Curves.easeOutCubic,
                );
                final offset = Tween<Offset>(
                  begin: const Offset(0.03, 0),
                  end: Offset.zero,
                ).animate(curved);
                return FadeTransition(
                  opacity: curved,
                  child: SlideTransition(position: offset, child: child),
                );
              },
              builder: (_, route) => PostScreen(route: route),
            ),
          ],
        ),
        branch<AppRoute>(
          initialLocation: const CatalogRoute().toUri(),
          routes: <RouteRecord<AppRoute>>[
            route<CatalogRoute>(
              path: '/app/catalog',
              parse: (_) => const CatalogRoute(),
              builder: (_, _) => const CatalogScreen(),
            ),
            routeWithLoader<CatalogItemRoute, DemoCatalogItem>(
              path: '/app/catalog/items/:id',
              parse: (state) => CatalogItemRoute(
                id: state.pathInt('id'),
                ref: state.queryOrNull('ref'),
              ),
              loader: (context) async {
                await Future<void>.delayed(const Duration(milliseconds: 280));
                context.signal.throwIfCancelled();
                return DemoCatalogRepository.byId(context.route.id);
              },
              builder: (_, route, item) =>
                  CatalogItemScreen(route: route, item: item),
            ),
          ],
        ),
        branch<AppRoute>(
          initialLocation: const ProfileRoute().toUri(),
          routes: <RouteRecord<AppRoute>>[
            route<ProfileRoute>(
              path: '/app/profile',
              parse: (_) => const ProfileRoute(),
              builder: (_, _) => const ProfileScreen(),
            ),
            route<SecureProfileRoute>(
              path: '/app/profile/secure',
              parse: (_) => const SecureProfileRoute(),
              guards: <RouteGuard<SecureProfileRoute>>[
                (context) {
                  if (_session.isSignedIn) {
                    return RouteGuardResult.allow();
                  }
                  return RouteGuardResult.redirect(
                    LoginRoute(from: context.uri.toString()).toUri(),
                  );
                },
              ],
              builder: (_, _) => const SecureProfileScreen(),
            ),
          ],
        ),
      ],
      builder: (context, shell, child) {
        return AppShellScaffold(shell: shell, child: child);
      },
    ),
  ],
  unknown: (_, uri) => UnknownRouteScreen(uri: uri),
  onError: (_, error, stackTrace) {
    return RouteErrorScreen(error: error, stackTrace: stackTrace);
  },
);

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'unrouter example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1363DF)),
        useMaterial3: true,
      ),
      routerConfig: _router,
    );
  }
}

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

  final ShellState<AppRoute> shell;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final typed = context.unrouterAs<AppRoute>();
    final state = typed.state;
    return AnimatedBuilder(
      animation: _session,
      builder: (context, _) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('unrouter example'),
            actions: <Widget>[
              IconButton(
                key: const Key('shell-open-debug'),
                tooltip: 'Open debug center',
                onPressed: () {
                  context.unrouter.go(const DebugRoute());
                },
                icon: const Icon(Icons.bug_report_outlined),
              ),
              TextButton(
                key: const Key('shell-auth-toggle'),
                onPressed: _session.toggle,
                child: Text(_session.isSignedIn ? 'Sign out' : 'Sign in'),
              ),
            ],
          ),
          body: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Container(
                width: double.infinity,
                padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
                color: const Color(0xFFE8F0FE),
                child: Wrap(
                  spacing: 12,
                  runSpacing: 8,
                  crossAxisAlignment: WrapCrossAlignment.center,
                  children: <Widget>[
                    Text('branch: ${shell.activeBranchIndex}'),
                    Text('routePath: ${state.routePath ?? '-'}'),
                    Text('resolution: ${state.resolution.name}'),
                    Text('canPopBranch: ${shell.canPopBranch}'),
                    OutlinedButton(
                      key: const Key('shell-pop-branch'),
                      onPressed: shell.canPopBranch
                          ? () {
                              shell.popBranch();
                            }
                          : null,
                      child: const Text('Pop branch'),
                    ),
                  ],
                ),
              ),
              Expanded(child: child),
            ],
          ),
          bottomNavigationBar: NavigationBar(
            selectedIndex: shell.activeBranchIndex,
            onDestinationSelected: (index) {
              shell.goBranch(index);
            },
            destinations: const <NavigationDestination>[
              NavigationDestination(
                icon: Icon(Icons.home_outlined),
                label: 'Home',
              ),
              NavigationDestination(
                icon: Icon(Icons.inventory_2_outlined),
                label: 'Catalog',
              ),
              NavigationDestination(
                icon: Icon(Icons.person_outline),
                label: 'Profile',
              ),
            ],
          ),
        );
      },
    );
  }
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  String _lastEnvelope = '-';

  Future<void> _openPostAndWaitResult(BuildContext context) async {
    final result = await context.unrouter.push<int>(const ResultRoute(id: 101));
    _lastPushResult.value = result;
  }

  void _dispatchEnvelopeDemo(BuildContext context) {
    final machine = context.unrouterMachineAs<AppRoute>();
    final envelope = machine.dispatchActionEnvelope<bool>(
      UnrouterMachineAction.switchBranch(99),
    );
    setState(() {
      _lastEnvelope =
          '${envelope.state.name}'
          '/${envelope.failure?.code.name ?? 'none'}';
    });
  }

  @override
  Widget build(BuildContext context) {
    final typed = context.unrouterAs<AppRoute>();
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        Text('Home center', style: Theme.of(context).textTheme.titleLarge),
        const SizedBox(height: 8),
        Text('uri: ${typed.uri}'),
        Text('historyIndex: ${typed.state.historyIndex ?? '-'}'),
        Text('machineTimeline: ${typed.machine.timeline.length}'),
        ValueListenableBuilder<int?>(
          valueListenable: _lastPushResult,
          builder: (context, value, child) {
            return Text('lastPostResult: ${value ?? '-'}');
          },
        ),
        Text('lastEnvelope: $_lastEnvelope'),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            FilledButton(
              key: const Key('home-open-post'),
              onPressed: () {
                _openPostAndWaitResult(context);
              },
              child: const Text('Push post detail'),
            ),
            FilledButton(
              key: const Key('home-go-catalog-branch'),
              onPressed: () {
                context.unrouter.switchBranch(1);
              },
              child: const Text('Go catalog branch'),
            ),
            FilledButton(
              key: const Key('home-go-profile-branch'),
              onPressed: () {
                context.unrouter.switchBranch(2);
              },
              child: const Text('Go profile branch'),
            ),
            OutlinedButton(
              key: const Key('home-go-legacy'),
              onPressed: () {
                context.unrouter.go(const LegacyRoute());
              },
              child: const Text('Go /legacy (redirect)'),
            ),
            OutlinedButton(
              key: const Key('home-machine-envelope'),
              onPressed: () {
                _dispatchEnvelopeDemo(context);
              },
              child: const Text('Envelope demo'),
            ),
          ],
        ),
        const SizedBox(height: 12),
        Text(
          'href(post/7): '
          '${context.unrouter.href(const PostRoute(id: 7, tab: PostTab.overview))}',
        ),
      ],
    );
  }
}

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

  final PostRoute route;

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        Text(
          'Post ${route.id}',
          key: const Key('post-title'),
          style: Theme.of(context).textTheme.titleLarge,
        ),
        const SizedBox(height: 8),
        Text('tab: ${route.tab.name}'),
        Text('uri: ${context.unrouter.uri}'),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            FilledButton(
              key: const Key('post-pop-result'),
              onPressed: () {
                context.unrouter.replace(
                  const AppHomeRoute(),
                  completePendingResult: true,
                  result: route.id * 10,
                );
              },
              child: const Text('Return with result'),
            ),
            OutlinedButton(
              key: const Key('post-replace-catalog'),
              onPressed: () {
                context.unrouter.replace(
                  const CatalogRoute(),
                  completePendingResult: true,
                  result: route.id * 10,
                );
              },
              child: const Text('Replace -> catalog'),
            ),
            OutlinedButton(
              key: const Key('post-back'),
              onPressed: context.unrouter.back,
              child: const Text('Back'),
            ),
          ],
        ),
      ],
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final typed = context.unrouterAs<AppRoute>();
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        Text('Catalog center', style: Theme.of(context).textTheme.titleLarge),
        const SizedBox(height: 8),
        Text('resolution: ${typed.state.resolution.name}'),
        Text('historyIndex: ${typed.state.historyIndex ?? '-'}'),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            for (final item in DemoCatalogRepository.items)
              FilledButton(
                key: Key('catalog-open-item-${item.id}'),
                onPressed: () {
                  context.unrouter.push(
                    CatalogItemRoute(id: item.id, ref: 'catalog-screen'),
                  );
                },
                child: Text('Open item ${item.id}'),
              ),
            OutlinedButton(
              key: const Key('catalog-go-home-branch'),
              onPressed: () {
                context.unrouter.switchBranch(0);
              },
              child: const Text('Go home branch'),
            ),
            OutlinedButton(
              key: const Key('catalog-go-profile-branch'),
              onPressed: () {
                context.unrouter.switchBranch(2);
              },
              child: const Text('Go profile branch'),
            ),
          ],
        ),
      ],
    );
  }
}

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

  final CatalogItemRoute route;
  final DemoCatalogItem item;

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        Text(
          'Item #${item.id}',
          key: Key('catalog-item-title-${item.id}'),
          style: Theme.of(context).textTheme.titleLarge,
        ),
        const SizedBox(height: 8),
        Text('name: ${item.name}'),
        Text('price: \$${item.price.toStringAsFixed(2)}'),
        Text('ref: ${route.ref ?? '-'}'),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            FilledButton(
              key: const Key('catalog-item-pop'),
              onPressed: () {
                context.unrouter.pop('picked-${item.id}');
              },
              child: const Text('Pop'),
            ),
            OutlinedButton(
              key: const Key('catalog-item-back'),
              onPressed: context.unrouter.back,
              child: const Text('Back'),
            ),
            OutlinedButton(
              key: const Key('catalog-item-debug'),
              onPressed: () {
                context.unrouter.go(const DebugRoute());
              },
              child: const Text('Open debug'),
            ),
          ],
        ),
      ],
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _session,
      builder: (context, _) {
        return ListView(
          padding: const EdgeInsets.all(16),
          children: <Widget>[
            Text(
              'Profile center',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 8),
            Text('signedIn: ${_session.isSignedIn}'),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilledButton(
                  key: const Key('profile-open-secure'),
                  onPressed: () {
                    context.unrouter.go(const SecureProfileRoute());
                  },
                  child: const Text('Open secure page'),
                ),
                OutlinedButton(
                  key: const Key('profile-sign-in'),
                  onPressed: _session.signIn,
                  child: const Text('Sign in'),
                ),
                OutlinedButton(
                  key: const Key('profile-sign-out'),
                  onPressed: _session.signOut,
                  child: const Text('Sign out'),
                ),
                OutlinedButton(
                  key: const Key('profile-go-home-branch'),
                  onPressed: () {
                    context.unrouter.switchBranch(0);
                  },
                  child: const Text('Go home branch'),
                ),
              ],
            ),
          ],
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        Text(
          'Secure profile center',
          key: const Key('secure-profile-title'),
          style: Theme.of(context).textTheme.titleLarge,
        ),
        const SizedBox(height: 8),
        const Text('This route is protected by a guard.'),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            FilledButton(
              onPressed: () {
                context.unrouter.go(const ProfileRoute());
              },
              child: const Text('Back to profile'),
            ),
            OutlinedButton(
              onPressed: () {
                context.unrouter.go(const DebugRoute());
              },
              child: const Text('Open debug center'),
            ),
          ],
        ),
      ],
    );
  }
}

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

  final LoginRoute route;

  void _signInAndContinue(BuildContext context) {
    _session.signIn();
    final target = route.from;
    if (target == null || target.isEmpty) {
      context.unrouter.go(const ProfileRoute());
      return;
    }
    context.unrouter.goUri(Uri.parse(target));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sign in required')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: ListView(
          children: <Widget>[
            Text('redirect target: ${route.from ?? '/app/profile'}'),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilledButton(
                  key: const Key('login-sign-in-continue'),
                  onPressed: () {
                    _signInAndContinue(context);
                  },
                  child: const Text('Sign in and continue'),
                ),
                OutlinedButton(
                  key: const Key('login-go-home'),
                  onPressed: () {
                    context.unrouter.go(const AppHomeRoute());
                  },
                  child: const Text('Back to home'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

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

  final ResultRoute route;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Result demo')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              'Result demo ${route.id}',
              key: const Key('result-title'),
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 8),
            const Text('This page is pushed as a typed result demonstration.'),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilledButton(
                  key: const Key('result-pop-result'),
                  onPressed: () {
                    context.unrouter.pop(route.id * 10);
                  },
                  child: const Text('Pop with result'),
                ),
                OutlinedButton(
                  onPressed: context.unrouter.back,
                  child: const Text('Back'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class DebugCenterScreen extends StatefulWidget {
  const DebugCenterScreen({super.key});

  @override
  State<DebugCenterScreen> createState() => _DebugCenterScreenState();
}

class _DebugCenterScreenState extends State<DebugCenterScreen> {
  UnrouterInspectorBridge<AppRoute>? _bridge;
  UnrouterInspectorPanelAdapter? _panel;
  UnrouterInspectorReplayStore? _replay;
  UnrouterInspectorReplayController? _replayController;
  UnrouterInspectorReplayStore? _baselineReplay;
  UnrouterInspectorReplaySessionDiff? _diff;
  StreamSubscription<UnrouterInspectorEmission>? _bridgeSubscription;
  bool _initialized = false;
  String _status = 'idle';
  String _exportPreview = '-';
  String? _replaySnapshot;
  UnrouterInspectorReplayCompareMode _compareMode =
      UnrouterInspectorReplayCompareMode.sequence;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_initialized) {
      return;
    }
    _initializeDebugInfra();
  }

  void _initializeDebugInfra() {
    final inspector = context.unrouterAs<AppRoute>().inspector;
    final bridge = UnrouterInspectorBridge<AppRoute>(
      inspector: inspector,
      redirectDiagnostics: _redirectDiagnostics,
      config: const UnrouterInspectorBridgeConfig(
        timelineTail: 24,
        redirectTrailTail: 12,
        machineTimelineTail: 80,
      ),
    );
    final panel = UnrouterInspectorPanelAdapter.fromBridge(
      bridge: bridge,
      config: const UnrouterInspectorPanelAdapterConfig(maxEntries: 400),
    );
    final replay = UnrouterInspectorReplayStore.fromBridge(
      bridge: bridge,
      config: const UnrouterInspectorReplayStoreConfig(maxEntries: 1200),
    );
    final replayController = UnrouterInspectorReplayController(
      store: replay,
      config: const UnrouterInspectorReplayControllerConfig(
        step: Duration(milliseconds: 90),
      ),
    );

    _bridgeSubscription = bridge.stream.listen((event) {
      if (!mounted) {
        return;
      }
      setState(() {
        _status = 'event=${event.reason.name} uri=${event.report['uri']}';
      });
    });

    _bridge = bridge;
    _panel = panel;
    _replay = replay;
    _replayController = replayController;
    _status = 'debug infra ready';
    _initialized = true;
  }

  void _emitManual() {
    _bridge?.emit();
    setState(() {
      _status = 'manual emit';
    });
  }

  void _clearPanel() {
    _panel?.clear();
    setState(() {
      _status = 'panel cleared';
    });
  }

  void _exportReplaySnapshot() {
    final replay = _replay;
    if (replay == null) {
      return;
    }
    _replaySnapshot = replay.exportJson(pretty: true);
    setState(() {
      _status = 'replay snapshot exported';
      _exportPreview = _truncate(_replaySnapshot!);
    });
  }

  void _importReplaySnapshot() {
    final replay = _replay;
    final snapshot = _replaySnapshot;
    if (replay == null || snapshot == null) {
      return;
    }
    replay.clear(resetCounters: true);
    replay.importJson(snapshot);
    setState(() {
      _status = 'replay snapshot re-imported';
    });
  }

  void _captureBaseline() {
    final replay = _replay;
    if (replay == null) {
      return;
    }
    final baseline = UnrouterInspectorReplayStore();
    baseline.importJson(replay.exportJson());
    _baselineReplay?.dispose();
    _baselineReplay = baseline;
    _refreshDiff();
    setState(() {
      _status = 'baseline captured';
    });
  }

  void _refreshDiff() {
    final replay = _replay;
    final baseline = _baselineReplay;
    if (replay == null || baseline == null) {
      setState(() {
        _diff = null;
        _status = 'baseline missing';
      });
      return;
    }
    final diff = replay.compareWith(baseline, mode: _compareMode, tail: 200);
    setState(() {
      _diff = diff;
      _status =
          'diff changed=${diff.changedCount} '
          'missingBaseline=${diff.missingBaselineCount} '
          'missingCurrent=${diff.missingCurrentCount}';
    });
  }

  void _validateReplay() {
    final replay = _replay;
    if (replay == null) {
      return;
    }
    final result = replay.validateCompatibility();
    setState(() {
      _status =
          'compatibility issues=${result.issues.length} '
          'errors=${result.errorCount} warnings=${result.warningCount}';
    });
  }

  Future<void> _togglePlayPauseResume() async {
    final replayController = _replayController;
    if (replayController == null) {
      return;
    }
    if (replayController.value.isPlaying) {
      replayController.pause();
      setState(() {
        _status = 'replay paused';
      });
      return;
    }
    if (replayController.value.isPaused) {
      replayController.resume();
      setState(() {
        _status = 'replay resumed';
      });
      return;
    }
    final delivered = await replayController.play();
    if (!mounted) {
      return;
    }
    setState(() {
      _status = 'replay finished delivered=$delivered';
    });
  }

  void _stopReplay() {
    _replayController?.stop();
    setState(() {
      _status = 'replay stopped';
    });
  }

  void _onExportSelected(String payload) {
    setState(() {
      _exportPreview = _truncate(payload);
      _status = 'panel selection exported';
    });
  }

  String _truncate(String value, [int max = 260]) {
    if (value.length <= max) {
      return value;
    }
    return '${value.substring(0, max)}...';
  }

  @override
  void dispose() {
    unawaited(_bridgeSubscription?.cancel());
    _replayController?.dispose();
    _replay?.dispose();
    _baselineReplay?.dispose();
    _panel?.dispose();
    _bridge?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final panel = _panel;
    final replay = _replay;
    final replayController = _replayController;
    if (!_initialized ||
        panel == null ||
        replay == null ||
        replayController == null) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    final inspector = context.unrouterAs<AppRoute>().inspector;
    return AnimatedBuilder(
      animation: Listenable.merge(<Listenable>[
        panel,
        replay,
        replayController,
      ]),
      builder: (context, _) {
        final replayValidation = replay.validateCompatibility();
        final replayState = replay.value;
        final replayControllerState = replayController.value;
        return Scaffold(
          appBar: AppBar(
            title: const Text('Debug Center'),
            actions: <Widget>[
              TextButton(
                key: const Key('debug-go-home'),
                onPressed: () {
                  context.unrouter.go(const AppHomeRoute());
                },
                child: const Text('Back to app'),
              ),
            ],
          ),
          body: SingleChildScrollView(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: <Widget>[
                    FilledButton(
                      key: const Key('debug-manual-emit'),
                      onPressed: _emitManual,
                      child: const Text('Emit now'),
                    ),
                    OutlinedButton(
                      key: const Key('debug-clear-panel'),
                      onPressed: _clearPanel,
                      child: const Text('Clear panel'),
                    ),
                    OutlinedButton(
                      key: const Key('debug-export-replay'),
                      onPressed: _exportReplaySnapshot,
                      child: const Text('Export replay'),
                    ),
                    OutlinedButton(
                      key: const Key('debug-import-replay'),
                      onPressed: _importReplaySnapshot,
                      child: const Text('Import replay'),
                    ),
                    OutlinedButton(
                      key: const Key('debug-capture-baseline'),
                      onPressed: _captureBaseline,
                      child: const Text('Capture baseline'),
                    ),
                    OutlinedButton(
                      key: const Key('debug-run-diff'),
                      onPressed: _refreshDiff,
                      child: const Text('Run diff'),
                    ),
                    OutlinedButton(
                      key: const Key('debug-validate-replay'),
                      onPressed: _validateReplay,
                      child: const Text('Validate replay'),
                    ),
                    OutlinedButton(
                      key: const Key('debug-play-toggle'),
                      onPressed: _togglePlayPauseResume,
                      child: Text(
                        replayControllerState.isPlaying
                            ? 'Pause replay'
                            : replayControllerState.isPaused
                            ? 'Resume replay'
                            : 'Play replay',
                      ),
                    ),
                    OutlinedButton(
                      key: const Key('debug-stop-play'),
                      onPressed: _stopReplay,
                      child: const Text('Stop replay'),
                    ),
                  ],
                ),
                const SizedBox(height: 10),
                SegmentedButton<UnrouterInspectorReplayCompareMode>(
                  segments:
                      const <ButtonSegment<UnrouterInspectorReplayCompareMode>>[
                        ButtonSegment<UnrouterInspectorReplayCompareMode>(
                          value: UnrouterInspectorReplayCompareMode.sequence,
                          label: Text('sequence diff'),
                        ),
                        ButtonSegment<UnrouterInspectorReplayCompareMode>(
                          value: UnrouterInspectorReplayCompareMode.path,
                          label: Text('path diff'),
                        ),
                      ],
                  selected: <UnrouterInspectorReplayCompareMode>{_compareMode},
                  onSelectionChanged: (selection) {
                    setState(() {
                      _compareMode = selection.first;
                    });
                    _refreshDiff();
                  },
                ),
                const SizedBox(height: 10),
                Text('status: $_status'),
                Text(
                  'panel entries=${panel.value.entries.length} '
                  'dropped=${panel.value.droppedCount}',
                ),
                Text(
                  'replay entries=${replayState.entries.length} '
                  'phase=${replayControllerState.phase.name} '
                  'speed=${replayControllerState.speed.label} '
                  'replayed=${replayControllerState.replayedCount}',
                ),
                Text(
                  'compatibility issues=${replayValidation.issues.length} '
                  'errors=${replayValidation.errorCount} '
                  'warnings=${replayValidation.warningCount}',
                ),
                Text(
                  _diff == null
                      ? 'diff: not ready'
                      : 'diff changed=${_diff!.changedCount} '
                            'missingBaseline=${_diff!.missingBaselineCount} '
                            'missingCurrent=${_diff!.missingCurrentCount}',
                ),
                const SizedBox(height: 12),
                UnrouterInspectorWidget<AppRoute>(
                  inspector: inspector,
                  redirectDiagnostics: _redirectDiagnostics,
                  timelineTail: 6,
                  redirectTrailTail: 4,
                  machineTimelineTail: 8,
                  onExport: (payload) {
                    setState(() {
                      _exportPreview = _truncate(payload);
                      _status = 'inspector report exported';
                    });
                  },
                ),
                const SizedBox(height: 12),
                SizedBox(
                  key: const Key('debug-panel'),
                  height: 580,
                  child: UnrouterInspectorPanelWidget(
                    panel: panel,
                    replayController: replayController,
                    replayDiff: _diff,
                    maxVisibleEntries: 120,
                    listHeight: 220,
                    compareListHeight: 120,
                    onClear: _clearPanel,
                    onExportSelected: _onExportSelected,
                    onMachineEventGroupsChanged: (groups) {
                      _bridge?.updateMachineEventGroups(groups);
                      setState(() {
                        _status = 'machine groups synced';
                      });
                    },
                    onMachinePayloadKindsChanged: (kinds) {
                      _bridge?.updateMachinePayloadKinds(kinds);
                      setState(() {
                        _status = 'machine payload kinds synced';
                      });
                    },
                  ),
                ),
                const SizedBox(height: 12),
                SelectableText(
                  'export preview:\n$_exportPreview',
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

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

  final Uri uri;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Unknown route')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text('No route matched: $uri'),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: () {
                context.unrouter.go(const AppHomeRoute());
              },
              child: const Text('Go home'),
            ),
          ],
        ),
      ),
    );
  }
}

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

  final Object error;
  final StackTrace stackTrace;

  @override
  Widget build(BuildContext context) {
    final preview = '$error\n$stackTrace';
    return Scaffold(
      appBar: AppBar(title: const Text('Route error')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: ListView(
          children: <Widget>[
            Text('error: ${error.runtimeType}'),
            const SizedBox(height: 8),
            SelectableText(
              preview.length > 700
                  ? '${preview.substring(0, 700)}...'
                  : preview,
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilledButton(
                  onPressed: () {
                    context.unrouter.go(const AppHomeRoute());
                  },
                  child: const Text('Go home'),
                ),
                OutlinedButton(
                  onPressed: () {
                    context.unrouter.go(const DebugRoute());
                  },
                  child: const Text('Open debug center'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class _BootScreen extends StatelessWidget {
  const _BootScreen();

  @override
  Widget build(BuildContext context) {
    return const Scaffold(body: Center(child: CircularProgressIndicator()));
  }
}

class DemoSession extends ChangeNotifier {
  bool _signedIn = false;

  bool get isSignedIn => _signedIn;

  void signIn() {
    if (_signedIn) {
      return;
    }
    _signedIn = true;
    notifyListeners();
  }

  void signOut() {
    if (!_signedIn) {
      return;
    }
    _signedIn = false;
    notifyListeners();
  }

  void toggle() {
    _signedIn = !_signedIn;
    notifyListeners();
  }
}

class DemoCatalogItem {
  const DemoCatalogItem({
    required this.id,
    required this.name,
    required this.price,
  });

  final int id;
  final String name;
  final double price;
}

class DemoCatalogRepository {
  const DemoCatalogRepository._();

  static const List<DemoCatalogItem> items = <DemoCatalogItem>[
    DemoCatalogItem(id: 1, name: 'Keyboard', price: 79.0),
    DemoCatalogItem(id: 2, name: 'Mouse', price: 39.0),
    DemoCatalogItem(id: 3, name: 'Display', price: 399.0),
  ];

  static DemoCatalogItem byId(int id) {
    for (final item in items) {
      if (item.id == id) {
        return item;
      }
    }
    throw StateError('No catalog item found for id=$id');
  }
}

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

enum PostTab { overview, comments }

final class RootRoute extends AppRoute {
  const RootRoute();

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

final class AppHomeRoute extends AppRoute {
  const AppHomeRoute();

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

final class PostRoute extends AppRoute {
  const PostRoute({required this.id, this.tab = PostTab.overview});

  final int id;
  final PostTab tab;

  @override
  Uri toUri() => Uri(
    path: '/app/home/post/$id',
    queryParameters: <String, String>{'tab': tab.name},
  );
}

final class CatalogRoute extends AppRoute {
  const CatalogRoute();

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

final class CatalogItemRoute extends AppRoute {
  const CatalogItemRoute({required this.id, this.ref});

  final int id;
  final String? ref;

  @override
  Uri toUri() => Uri(
    path: '/app/catalog/items/$id',
    queryParameters: ref == null ? null : <String, String>{'ref': ref!},
  );
}

final class ProfileRoute extends AppRoute {
  const ProfileRoute();

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

final class SecureProfileRoute extends AppRoute {
  const SecureProfileRoute();

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

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

  final String? from;

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

final class DebugRoute extends AppRoute {
  const DebugRoute();

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

final class ResultRoute extends AppRoute {
  const ResultRoute({required this.id});

  final int id;

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

final class LegacyRoute extends AppRoute {
  const LegacyRoute();

  @override
  Uri toUri() => Uri(path: '/legacy');
}
6
likes
0
points
486
downloads

Publisher

verified publishermedz.dev

Weekly Downloads

A URL-first typed router for Flutter with shell navigation and diagnostics.

Repository (GitHub)
View/report issues

Topics

#flutter #router #navigation #routing #web

License

unknown (license)

Dependencies

flutter, roux, unstory

More

Packages that depend on unrouter