use_request 0.6.2 copy "use_request: ^0.6.2" to clipboard
use_request: ^0.6.2 copied to clipboard

A Flutter useRequest-style async request management library based on dio, flutter_hooks, and Riverpod.

example/lib/main.dart

import 'dart:async';
import 'dart:math';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:use_request/use_request.dart';

void main() {
  runApp(const ProviderScope(child: UseRequestShowcaseApp()));
}

const String _githubBaseUrl = 'https://api.github.com';
const Map<String, dynamic> _githubHeaders = {
  'Accept': 'application/vnd.github+json',
  'X-GitHub-Api-Version': '2022-11-28',
  'User-Agent': 'use-request-showcase',
};

/// Claude 暖色 + Zed 线稿风格调色板。
class _Ink {
  static const paper = Color(0xFFF3EFE7); // 米白背景
  static const panel = Color(0xFFFAF8F2); // 卡片填充
  static const grid = Color(0x14000000); // 线稿网格
  static const gridStrong = Color(0x24000000); // 角标十字
  static const hairline = Color(0xFFDED8CB); // 卡片描边
  static const ink = Color(0xFF201D18); // 主文字
  static const muted = Color(0xFF6B6459); // 次文字
  static const clay = Color(0xFFD97757); // Claude 主强调色
  static const clayDeep = Color(0xFFB0512B); // 深强调(文字)
  static const clayTint = Color(0x1AD97757); // 强调浅底
}

/// useRequest 渐进式展示站点。
///
/// 页面目标:
/// 1. 所有示例都使用 GitHub 公共 API。
/// 2. 示例从简单到复杂,覆盖常见到高级能力。
/// 3. 每个示例支持直接看到效果,并可展开源码。
/// 4. 提供可交互的 Options 控制面板,便于试参。
class UseRequestShowcaseApp extends StatelessWidget {
  const UseRequestShowcaseApp({super.key});

  @override
  Widget build(BuildContext context) {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: const Color(0xFFD97757),
      brightness: Brightness.light,
    );

    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'useRequest Progressive GitHub Showcase',
      theme: ThemeData(
        colorScheme: colorScheme,
        useMaterial3: true,
        scaffoldBackgroundColor: const Color(0xFFF3EFE7),
        fontFamily: 'Avenir',
        cardTheme: CardThemeData(
          elevation: 0,
          margin: EdgeInsets.zero,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(10),
          ),
        ),
      ),
      home: const ProgressiveExamplePage(),
    );
  }
}

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

  @override
  State<ProgressiveExamplePage> createState() => _ProgressiveExamplePageState();
}

class _ProgressiveExamplePageState extends State<ProgressiveExamplePage> {
  final ScrollController _scrollController = ScrollController();
  int _selectedIndex = 0;

  late final List<_DemoSection> _sections = [
    _DemoSection(
      key: GlobalKey(),
      level: 'Level 1',
      title: '基础自动请求 + 状态三态',
      subtitle: 'manual=false + ready + refreshDeps + loadingDelay',
      description: '默认自动请求 GitHub 用户资料,展示 loading/error/data 的最小闭环。',
      tags: const ['manual', 'ready', 'defaultParams', 'refreshDeps'],
      initialSourceCode: _sourceLevel1,
      demoBuilder: (onSourceChanged) =>
          _BasicAutoRequestDemo(onSourceChanged: onSourceChanged),
    ),
    _DemoSection(
      key: GlobalKey(),
      level: 'Level 2',
      title: '搜索频率控制(防抖 / 节流)',
      subtitle: 'debounce / throttle + keepPreviousData + loadingDelay',
      description: '输入仓库关键字后自动搜索,并用开关实时调节请求节奏。',
      tags: const ['debounceInterval', 'throttleInterval', 'keepPreviousData'],
      initialSourceCode: _sourceLevel2,
      demoBuilder: (onSourceChanged) =>
          _SearchRateControlDemo(onSourceChanged: onSourceChanged),
    ),
    _DemoSection(
      key: GlobalKey(),
      level: 'Level 3',
      title: '轮询 + 重试 + 超时 + 取消',
      subtitle: 'polling / retry / timeout / cancel 全链路演示',
      description: '持续观察仓库指标,支持错误重试、超时控制和手动暂停/恢复轮询。',
      tags: const [
        'pollingInterval',
        'retryCount',
        'connectTimeout',
        'cancelToken',
      ],
      initialSourceCode: _sourceLevel3,
      demoBuilder: (onSourceChanged) =>
          _PollingRetryDemo(onSourceChanged: onSourceChanged),
    ),
    _DemoSection(
      key: GlobalKey(),
      level: 'Level 4',
      title: 'Options 全配置实验台',
      subtitle: '按功能分组覆盖几乎全部 UseRequestOptions',
      description: '通过分组开关与滑杆统一调参,验证从基础配置到并发加载更多的组合行为。',
      tags: const ['cache', 'fetchKey', 'loadMoreParams', 'callbacks'],
      initialSourceCode: _sourceLevel4,
      demoBuilder: (onSourceChanged) =>
          _OptionsWorkbenchDemo(onSourceChanged: onSourceChanged),
    ),
    _DemoSection(
      key: GlobalKey(),
      level: 'Level 5',
      title: '闭包模式 vs refreshDeps 陷阱',
      subtitle: 'useRequestFn 闭包驱动 · 对照参数模式复用旧参数的坑',
      description:
          '切换排序 tab,观察接口实际发送的 sort 参数。参数模式下切 tab 参数不变(经典 bug),闭包模式始终带最新条件。',
      tags: const ['useRequestFn', 'refreshDeps', 'refreshDepsAction'],
      initialSourceCode: _sourceLevel5,
      demoBuilder: (onSourceChanged) =>
          _ClosureVsParamsDemo(onSourceChanged: onSourceChanged),
    ),
  ];

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _scrollToSection(int index) {
    setState(() {
      _selectedIndex = index;
    });
    final context = _sections[index].key.currentContext;
    if (context != null) {
      Scrollable.ensureVisible(
        context,
        duration: const Duration(milliseconds: 460),
        curve: Curves.easeOutCubic,
        alignment: 0.08,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    final isDesktop = width >= 1080;

    return Scaffold(
      body: Stack(
        children: [
          const _DecorativeBackdrop(),
          SafeArea(
            child: Row(
              children: [
                if (isDesktop)
                  SizedBox(
                    width: 292,
                    child: Padding(
                      padding: const EdgeInsets.fromLTRB(18, 18, 8, 18),
                      child: _GlassPanel(
                        padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            _buildSidebarHeader(context),
                            const SizedBox(height: 16),
                            Expanded(
                              child: ListView.builder(
                                itemCount: _sections.length,
                                itemBuilder: (context, index) {
                                  final section = _sections[index];
                                  final selected = _selectedIndex == index;
                                  return Padding(
                                    padding: const EdgeInsets.only(bottom: 8),
                                    child: InkWell(
                                      borderRadius: BorderRadius.circular(14),
                                      onTap: () => _scrollToSection(index),
                                      child: AnimatedContainer(
                                        duration: const Duration(
                                          milliseconds: 220,
                                        ),
                                        padding: const EdgeInsets.all(12),
                                        decoration: BoxDecoration(
                                          borderRadius: BorderRadius.circular(
                                            14,
                                          ),
                                          color: selected
                                              ? const Color(0x26D97757)
                                              : Colors.transparent,
                                          border: Border.all(
                                            color: selected
                                                ? const Color(0xFFCF7550)
                                                : const Color(0x33FFFFFF),
                                          ),
                                        ),
                                        child: Column(
                                          crossAxisAlignment:
                                              CrossAxisAlignment.start,
                                          children: [
                                            Text(
                                              section.level,
                                              style: TextStyle(
                                                fontSize: 11,
                                                color: selected
                                                    ? const Color(0xFFB0512B)
                                                    : const Color(0xFF64748B),
                                              ),
                                            ),
                                            const SizedBox(height: 4),
                                            Text(
                                              section.title,
                                              style: TextStyle(
                                                fontWeight: FontWeight.w700,
                                                fontSize: 14,
                                                color: selected
                                                    ? const Color(0xFF7A3B22)
                                                    : const Color(0xFF1E293B),
                                              ),
                                            ),
                                          ],
                                        ),
                                      ),
                                    ),
                                  );
                                },
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                  ),
                Expanded(
                  child: Scrollbar(
                    controller: _scrollController,
                    child: SingleChildScrollView(
                      controller: _scrollController,
                      padding: EdgeInsets.fromLTRB(
                        isDesktop ? 14 : 16,
                        18,
                        isDesktop ? 22 : 16,
                        26,
                      ),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          _buildHero(context),
                          if (!isDesktop) ...[
                            const SizedBox(height: 14),
                            _buildTopNav(),
                          ],
                          const SizedBox(height: 16),
                          ..._sections.asMap().entries.map((entry) {
                            final index = entry.key;
                            final section = entry.value;
                            return Padding(
                              key: section.key,
                              padding: const EdgeInsets.only(bottom: 16),
                              child: _DemoSectionCard(
                                section: section,
                                index: index,
                                isSelected: _selectedIndex == index,
                                onActivate: () => _scrollToSection(index),
                              ),
                            );
                          }),
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSidebarHeader(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Wrap(
          spacing: 8,
          runSpacing: 8,
          crossAxisAlignment: WrapCrossAlignment.center,
          children: [
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(12),
                gradient: const LinearGradient(
                  colors: [Color(0xFFD97757), Color(0xFFEBA986)],
                ),
              ),
              child: const Text(
                'useRequest',
                style: TextStyle(
                  fontWeight: FontWeight.w800,
                  color: Colors.white,
                  letterSpacing: 0.2,
                ),
              ),
            ),
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(999),
                color: const Color(0x26D97757),
              ),
              child: const Text('GitHub API', style: TextStyle(fontSize: 11)),
            ),
          ],
        ),
        const SizedBox(height: 10),
        Text(
          '渐进式示例导航',
          style: Theme.of(
            context,
          ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
        ),
        const SizedBox(height: 4),
      ],
    );
  }

  Widget _buildHero(BuildContext context) {
    return _GlassPanel(
      padding: const EdgeInsets.all(20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          const Text(
            'useRequest Progressive Demo · GitHub Public API',
            style: TextStyle(
              fontSize: 13,
              fontWeight: FontWeight.w700,
              color: Color(0xFFB0512B),
              letterSpacing: 0.1,
            ),
          ),
          const SizedBox(height: 8),
          Text(
            '从基础请求到全量 Options 实验台',
            style: Theme.of(context).textTheme.headlineSmall?.copyWith(
              fontWeight: FontWeight.w800,
              color: const Color(0xFF102A43),
            ),
          ),
          const SizedBox(height: 8),
          const Text(
            '每个示例默认先展示运行效果,底部面板可展开查看源码(含关键字高亮)。\n右侧示例均为真实 GitHub REST API 请求。',
            style: TextStyle(
              height: 1.42,
              color: Color(0xFF475569),
              fontSize: 13,
            ),
          ),
          const SizedBox(height: 14),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: const [
              _TopBadge(label: '0.6.1 · useRequestFn'),
              _TopBadge(label: '自动/手动请求'),
              _TopBadge(label: '防抖/节流'),
              _TopBadge(label: '轮询/重试/超时'),
              _TopBadge(label: '缓存/并发/加载更多'),
            ],
          ),
          const SizedBox(height: 14),
          Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              FilledButton.tonalIcon(
                onPressed: () => _scrollToSection(4),
                icon: const Icon(Icons.new_releases_outlined),
                label: const Text('先看最新:useRequestFn'),
              ),
              OutlinedButton.icon(
                onPressed: () => _scrollToSection(3),
                icon: const Icon(Icons.tune),
                label: const Text('查看全量 Options 实验台'),
              ),
            ],
          ),
          const SizedBox(height: 10),
          const Text(
            'GitHub Pages 预览当前只挂载正在查看的示例,未选中的卡片不会启动 Hook、请求和源码高亮。这样能减少首屏请求数,也能缓解 Flutter Web 上的滚动和输入卡顿。',
            style: TextStyle(
              height: 1.42,
              color: Color(0xFF64748B),
              fontSize: 12.5,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildTopNav() {
    return _GlassPanel(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
      child: SingleChildScrollView(
        scrollDirection: Axis.horizontal,
        child: Row(
          children: _sections.asMap().entries.map((entry) {
            final index = entry.key;
            final section = entry.value;
            final selected = _selectedIndex == index;
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 4),
              child: FilledButton.tonal(
                style: FilledButton.styleFrom(
                  backgroundColor: selected
                      ? const Color(0xFFD97757)
                      : const Color(0x33FFFFFF),
                  foregroundColor: selected
                      ? Colors.white
                      : const Color(0xFF0F172A),
                ),
                onPressed: () => _scrollToSection(index),
                child: Text('${section.level} · ${section.title}'),
              ),
            );
          }).toList(),
        ),
      ),
    );
  }
}

class _DemoSection {
  const _DemoSection({
    required this.key,
    required this.level,
    required this.title,
    required this.subtitle,
    required this.description,
    required this.tags,
    required this.initialSourceCode,
    required this.demoBuilder,
  });

  final GlobalKey key;
  final String level;
  final String title;
  final String subtitle;
  final String description;
  final List<String> tags;
  final String initialSourceCode;
  final Widget Function(ValueChanged<String> onSourceChanged) demoBuilder;
}

class _DemoSectionCard extends StatefulWidget {
  const _DemoSectionCard({
    required this.section,
    required this.index,
    required this.isSelected,
    required this.onActivate,
  });

  final _DemoSection section;
  final int index;
  final bool isSelected;
  final VoidCallback onActivate;

  @override
  State<_DemoSectionCard> createState() => _DemoSectionCardState();
}

class _DemoSectionCardState extends State<_DemoSectionCard> {
  late String _sourceCode = widget.section.initialSourceCode;
  String? _queuedSourceCode;
  bool _sourceFlushScheduled = false;

  void _handleSourceChanged(String code) {
    if (_sourceCode == code || _queuedSourceCode == code) return;
    _queuedSourceCode = code;
    if (_sourceFlushScheduled) return;

    _sourceFlushScheduled = true;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _sourceFlushScheduled = false;
      final nextCode = _queuedSourceCode;
      _queuedSourceCode = null;
      if (!mounted || nextCode == null || nextCode == _sourceCode) return;
      scheduleMicrotask(() {
        if (!mounted || nextCode == _sourceCode) return;
        setState(() {
          _sourceCode = nextCode;
        });
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return _GlassPanel(
      padding: EdgeInsets.zero,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: double.infinity,
            padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
            decoration: BoxDecoration(
              borderRadius: const BorderRadius.vertical(
                top: Radius.circular(20),
              ),
              gradient: LinearGradient(
                colors: [
                  widget.index.isEven
                      ? const Color(0x26D97757)
                      : const Color(0x2699897E),
                  const Color(0x19FFFFFF),
                ],
              ),
              border: Border(
                bottom: BorderSide(
                  color: widget.isSelected
                      ? const Color(0xFFCF7550)
                      : const Color(0x33A7B2C5),
                ),
              ),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Wrap(
                  spacing: 8,
                  runSpacing: 4,
                  crossAxisAlignment: WrapCrossAlignment.center,
                  children: [
                    _TopBadge(label: widget.section.level),
                    Text(
                      widget.section.subtitle,
                      style: const TextStyle(
                        fontSize: 12,
                        color: Color(0xFF526175),
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                Text(
                  widget.section.title,
                  style: Theme.of(context).textTheme.titleLarge?.copyWith(
                    fontWeight: FontWeight.w800,
                    color: const Color(0xFF0F172A),
                  ),
                ),
                const SizedBox(height: 6),
                Text(
                  widget.section.description,
                  style: const TextStyle(
                    fontSize: 13,
                    color: Color(0xFF475569),
                  ),
                ),
                const SizedBox(height: 12),
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: widget.section.tags
                      .map((tag) => _OptionTag(label: tag))
                      .toList(),
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
            child: AnimatedSwitcher(
              duration: const Duration(milliseconds: 180),
              switchInCurve: Curves.easeOutCubic,
              switchOutCurve: Curves.easeInCubic,
              // GitHub Pages 的 Web 预览不再同时挂载所有 demo,
              // 避免首屏并发请求和多组 Hook 一起运行造成卡顿。
              child: widget.isSelected
                  ? Column(
                      key: const ValueKey('active-demo'),
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        widget.section.demoBuilder(_handleSourceChanged),
                        const SizedBox(height: 14),
                        _SourceCodePanel(code: _sourceCode),
                      ],
                    )
                  : _InactiveDemoPlaceholder(
                      key: const ValueKey('inactive-demo'),
                      title: widget.section.title,
                      onActivate: widget.onActivate,
                    ),
            ),
          ),
        ],
      ),
    );
  }
}

class _InactiveDemoPlaceholder extends StatelessWidget {
  const _InactiveDemoPlaceholder({
    super.key,
    required this.title,
    required this.onActivate,
  });

  final String title;
  final VoidCallback onActivate;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(14),
        color: Colors.white.withValues(alpha: 0.6),
        border: Border.all(color: const Color(0x337D8FA8)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            '当前未挂载此示例',
            style: TextStyle(
              fontWeight: FontWeight.w800,
              fontSize: 14,
              color: Color(0xFF7A3B22),
            ),
          ),
          const SizedBox(height: 6),
          Text(
            '为了降低 GitHub Pages 上的 Flutter Web 卡顿,未选中的卡片不会启动真实请求、Hook 逻辑和源码高亮。点击下面按钮后,再加载“$title”的运行效果。',
            style: const TextStyle(
              fontSize: 12.5,
              height: 1.45,
              color: Color(0xFF516176),
            ),
          ),
          const SizedBox(height: 12),
          FilledButton.tonalIcon(
            onPressed: onActivate,
            icon: const Icon(Icons.play_circle_outline),
            label: const Text('加载此示例'),
          ),
        ],
      ),
    );
  }
}

/// Level 1:自动请求与状态三态。
///
/// 这是最小的 useRequest 闭环:
/// - `manual: false` 自动请求
/// - `ready` 控制是否放行请求
/// - `refreshDeps` 监听用户名变化自动刷新
class _BasicAutoRequestDemo extends HookWidget {
  const _BasicAutoRequestDemo({required this.onSourceChanged});

  final ValueChanged<String> onSourceChanged;

  @override
  Widget build(BuildContext context) {
    final username = useState('octocat');
    final ready = useState(true);
    final loadingDelayMs = useState(120.0);
    final logs = useState<List<String>>(<String>[]);

    useEffect(() {
      onSourceChanged(
        _buildLevel1Source(
          username: username.value,
          ready: ready.value,
          loadingDelayMs: loadingDelayMs.value.toInt(),
        ),
      );
      return null;
    }, [username.value, ready.value, loadingDelayMs.value.toInt()]);

    void pushLog(String text) {
      logs.value = <String>[
        '${_clockNow()}  $text',
        ...logs.value,
      ].take(6).toList();
    }

    final adapter = useMemoized(
      () => DioHttpAdapter.withBaseUrl(_githubBaseUrl),
    );
    final service = useMemoized(
      () => createDioService<Map<String, dynamic>>(
        adapter,
        transformer: (response) {
          final raw = response.data;
          if (raw is Map) {
            return raw.cast<String, dynamic>();
          }
          return <String, dynamic>{};
        },
      ),
      [adapter],
    );
    final defaultProfileParams = useMemoized(
      () => _searchConfig(query: 'user:${username.value}', page: 1, perPage: 1),
      [username.value],
    );

    final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
      service,
      options: UseRequestOptions(
        manual: false,
        ready: ready.value,
        defaultParams: defaultProfileParams,
        refreshDeps: [username.value],
        loadingDelay: Duration(milliseconds: loadingDelayMs.value.toInt()),
        onBefore: (_) => pushLog('onBefore search user:${username.value}'),
        onSuccess: (data, _) =>
            pushLog('onSuccess items=${(data['items'] as List?)?.length ?? 0}'),
        onError: (error, _) => pushLog('onError $error'),
      ),
    );

    final profile = _toProfileRecord(request.data);
    final avatar = profile.avatarUrl.trim();

    void handleMutate() {
      request.mutate((old) {
        if (old == null) return old;
        final items = old['items'];
        if (items is! List || items.isEmpty || items.first is! Map) {
          return old;
        }
        final first = Map<String, dynamic>.from(items.first as Map);
        final currentCount = (old['_mutateCount'] as num?)?.toInt() ?? 0;
        final nextCount = currentCount + 1;
        final originalDescription = (first['description'] ?? '')
            .toString()
            .replaceFirst(RegExp(r'^\[mutate(?: x\d+)?\]\s*'), '');

        // 演示更接近真实业务的乐观更新:
        // 1) 文案只保留一个 mutate 前缀,并展示累计次数
        // 2) 同步给 stars 做本地 +1,便于观察 mutate 的可见效果
        first['description'] = '[mutate x$nextCount] $originalDescription';
        first['stargazers_count'] =
            ((first['stargazers_count'] as num?)?.toInt() ?? 0) + 1;
        return <String, dynamic>{
          ...old,
          '_mutateCount': nextCount,
          'items': <dynamic>[first, ...items.skip(1)],
        };
      });
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Wrap(
          spacing: 12,
          runSpacing: 8,
          crossAxisAlignment: WrapCrossAlignment.center,
          children: [
            SizedBox(
              width: 200,
              child: DropdownButtonFormField<String>(
                key: ValueKey(username.value),
                initialValue: username.value,
                decoration: const InputDecoration(
                  labelText: 'GitHub 用户',
                  border: OutlineInputBorder(),
                ),
                items: const [
                  DropdownMenuItem(value: 'octocat', child: Text('octocat')),
                  DropdownMenuItem(value: 'torvalds', child: Text('torvalds')),
                  DropdownMenuItem(value: 'gaearon', child: Text('gaearon')),
                  DropdownMenuItem(value: 'flutter', child: Text('flutter')),
                ],
                onChanged: (v) {
                  if (v != null) username.value = v;
                },
              ),
            ),
            _SwitchOption(
              label: 'ready',
              description: 'false 时阻止自动请求',
              value: ready.value,
              onChanged: (v) => ready.value = v,
            ),
            SizedBox(
              width: 280,
              child: _SliderOption(
                label: 'loadingDelay',
                valueLabel: '${loadingDelayMs.value.toInt()}ms',
                value: loadingDelayMs.value,
                min: 0,
                max: 600,
                divisions: 12,
                onChanged: (v) => loadingDelayMs.value = v,
              ),
            ),
            SizedBox(
              width: 420,
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Column(
                    // 在 Row 的非弹性子项中,横向约束可能是无界的。
                    // 这里如果用 stretch,按钮列会尝试在无界宽度下拉伸,进而触发布局断言。
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      FilledButton.tonalIcon(
                        onPressed: request.refresh,
                        icon: const Icon(Icons.refresh),
                        label: const Text('refresh()'),
                      ),
                      const SizedBox(height: 8),
                      OutlinedButton.icon(
                        onPressed: handleMutate,
                        icon: const Icon(Icons.auto_fix_high),
                        label: const Text('mutate()'),
                      ),
                    ],
                  ),
                  const SizedBox(width: 12),
                  const Expanded(
                    child: Padding(
                      padding: EdgeInsets.only(top: 4),
                      child: Text(
                        'refresh() 会重新请求 GitHub 并覆盖本地改动;mutate() 只改本地状态:stars +1,描述更新为 [mutate xN]。',
                        style: TextStyle(
                          fontSize: 12,
                          height: 1.35,
                          color: Color(0xFF64748B),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        if (!ready.value)
          const _InfoBanner(
            color: Color(0xFF92400E),
            message: 'ready=false:当前不会发起自动请求。',
          ),
        if (request.loading)
          const Padding(
            padding: EdgeInsets.only(top: 8),
            child: LinearProgressIndicator(minHeight: 4),
          ),
        if (request.error != null)
          Padding(
            padding: const EdgeInsets.only(top: 12),
            child: _InfoBanner(
              color: const Color(0xFFB91C1C),
              message: '请求失败:${request.error}',
            ),
          ),
        if (request.data != null)
          Padding(
            padding: const EdgeInsets.only(top: 12),
            child: _GlassPanel(
              padding: const EdgeInsets.all(14),
              blur: 8,
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  CircleAvatar(
                    radius: 28,
                    backgroundColor: const Color(0xFFE2E8F0),
                    backgroundImage: avatar.isNotEmpty
                        ? NetworkImage(avatar)
                        : null,
                    child: avatar.isEmpty
                        ? const Icon(Icons.person_outline)
                        : const SizedBox.shrink(),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          '@${profile.login}',
                          style: const TextStyle(
                            fontSize: 16,
                            fontWeight: FontWeight.w800,
                          ),
                        ),
                        const SizedBox(height: 4),
                        Text(
                          'Stars: ${profile.stars} · Forks: ${profile.forks}',
                          style: const TextStyle(color: Color(0xFF475569)),
                        ),
                        const SizedBox(height: 6),
                        Text(
                          profile.bio,
                          maxLines: 3,
                          overflow: TextOverflow.ellipsis,
                          style: const TextStyle(height: 1.4),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ),
        const SizedBox(height: 10),
        _EventLog(logs: logs.value),
      ],
    );
  }
}

/// Level 2:通过切换防抖/节流模式,观察请求节奏和 UI 稳定性。
class _SearchRateControlDemo extends HookWidget {
  const _SearchRateControlDemo({required this.onSourceChanged});

  final ValueChanged<String> onSourceChanged;

  @override
  Widget build(BuildContext context) {
    final controller = useTextEditingController(text: 'flutter hooks');
    final query = useState(controller.text);
    final mode = useState(_RateMode.debounce);
    final intervalMs = useState(360.0);
    final maxWaitMs = useState(1000.0);
    final useMaxWait = useState(false);
    final leading = useState(false);
    final trailing = useState(true);
    final keepPrevious = useState(true);
    final loadingDelay = useState(120.0);
    final perPage = useState(6.0);

    useEffect(
      () {
        onSourceChanged(
          _buildLevel2Source(
            query: query.value,
            perPage: perPage.value.toInt(),
            mode: mode.value,
            intervalMs: intervalMs.value.toInt(),
            leading: leading.value,
            trailing: trailing.value,
            useMaxWait: useMaxWait.value,
            maxWaitMs: maxWaitMs.value.toInt(),
            keepPrevious: keepPrevious.value,
            loadingDelay: loadingDelay.value.toInt(),
          ),
        );
        return null;
      },
      [
        query.value,
        perPage.value.toInt(),
        mode.value,
        intervalMs.value.toInt(),
        leading.value,
        trailing.value,
        useMaxWait.value,
        maxWaitMs.value.toInt(),
        keepPrevious.value,
        loadingDelay.value.toInt(),
      ],
    );

    useEffect(() {
      void listener() {
        final text = controller.text.trim();
        query.value = text.isEmpty ? 'flutter' : text;
      }

      controller.addListener(listener);
      return () => controller.removeListener(listener);
    }, [controller]);

    final adapter = useMemoized(
      () => DioHttpAdapter.withBaseUrl(_githubBaseUrl),
    );
    final service = useMemoized(
      () => createDioService<Map<String, dynamic>>(
        adapter,
        transformer: (response) {
          final raw = response.data;
          return raw is Map ? raw.cast<String, dynamic>() : <String, dynamic>{};
        },
      ),
      [adapter],
    );

    final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
      service,
      options: UseRequestOptions(
        manual: false,
        defaultParams: useMemoized(
          () => _searchConfig(
            query: query.value,
            page: 1,
            perPage: perPage.value.toInt(),
          ),
          [query.value, perPage.value.toInt()],
        ),
        refreshDeps: [
          query.value,
          perPage.value.toInt(),
          mode.value,
          intervalMs.value.toInt(),
          leading.value,
          trailing.value,
          useMaxWait.value,
          maxWaitMs.value.toInt(),
        ],
        keepPreviousData: keepPrevious.value,
        debounceInterval: mode.value == _RateMode.debounce
            ? Duration(milliseconds: intervalMs.value.toInt())
            : null,
        debounceLeading: leading.value,
        debounceTrailing: trailing.value,
        debounceMaxWait: mode.value == _RateMode.debounce && useMaxWait.value
            ? Duration(milliseconds: maxWaitMs.value.toInt())
            : null,
        throttleInterval: mode.value == _RateMode.throttle
            ? Duration(milliseconds: intervalMs.value.toInt())
            : null,
        throttleLeading: leading.value,
        throttleTrailing: trailing.value,
        loadingDelay: Duration(milliseconds: loadingDelay.value.toInt()),
      ),
    );

    final repos = _toRepoItems(request.data);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          controller: controller,
          decoration: const InputDecoration(
            labelText: '搜索仓库关键字',
            hintText: '例如 flutter hooks / riverpod',
            border: OutlineInputBorder(),
            prefixIcon: Icon(Icons.search),
          ),
        ),
        const SizedBox(height: 10),
        Wrap(
          spacing: 10,
          runSpacing: 10,
          children: [
            SizedBox(
              width: 320,
              child: SegmentedButton<_RateMode>(
                segments: const [
                  ButtonSegment(
                    value: _RateMode.debounce,
                    label: Text('Debounce'),
                    icon: Icon(Icons.timer_outlined),
                  ),
                  ButtonSegment(
                    value: _RateMode.throttle,
                    label: Text('Throttle'),
                    icon: Icon(Icons.speed),
                  ),
                ],
                selected: {mode.value},
                onSelectionChanged: (value) {
                  mode.value = value.first;
                },
              ),
            ),
            _SwitchOption(
              label: 'keepPreviousData',
              description: '切参时保留旧列表',
              value: keepPrevious.value,
              onChanged: (v) => keepPrevious.value = v,
            ),
            _SwitchOption(
              label: 'leading',
              description: '首次变更立即触发',
              value: leading.value,
              onChanged: (v) => leading.value = v,
            ),
            _SwitchOption(
              label: 'trailing',
              description: '窗口末尾触发',
              value: trailing.value,
              onChanged: (v) => trailing.value = v,
            ),
            if (mode.value == _RateMode.debounce)
              _SwitchOption(
                label: 'debounceMaxWait',
                description: '限制最长等待时间',
                value: useMaxWait.value,
                onChanged: (v) => useMaxWait.value = v,
              ),
            SizedBox(
              width: 260,
              child: _SliderOption(
                label: mode.value == _RateMode.debounce
                    ? 'debounceInterval'
                    : 'throttleInterval',
                valueLabel: '${intervalMs.value.toInt()}ms',
                value: intervalMs.value,
                min: 80,
                max: 1200,
                divisions: 14,
                onChanged: (v) => intervalMs.value = v,
              ),
            ),
            if (mode.value == _RateMode.debounce && useMaxWait.value)
              SizedBox(
                width: 260,
                child: _SliderOption(
                  label: 'maxWait',
                  valueLabel: '${maxWaitMs.value.toInt()}ms',
                  value: maxWaitMs.value,
                  min: 500,
                  max: 3000,
                  divisions: 10,
                  onChanged: (v) => maxWaitMs.value = v,
                ),
              ),
            SizedBox(
              width: 260,
              child: _SliderOption(
                label: 'loadingDelay',
                valueLabel: '${loadingDelay.value.toInt()}ms',
                value: loadingDelay.value,
                min: 0,
                max: 600,
                divisions: 12,
                onChanged: (v) => loadingDelay.value = v,
              ),
            ),
            SizedBox(
              width: 260,
              child: _SliderOption(
                label: 'per_page',
                valueLabel: perPage.value.toInt().toString(),
                value: perPage.value,
                min: 3,
                max: 10,
                divisions: 7,
                onChanged: (v) => perPage.value = v,
              ),
            ),
          ],
        ),
        const SizedBox(height: 10),
        if (request.loading) const LinearProgressIndicator(minHeight: 4),
        if (request.error != null)
          Padding(
            padding: const EdgeInsets.only(top: 10),
            child: _InfoBanner(
              color: const Color(0xFFB91C1C),
              message: '查询失败:${request.error}',
            ),
          ),
        const SizedBox(height: 8),
        ...repos.map((repo) {
          return ListTile(
            contentPadding: EdgeInsets.zero,
            title: Text(repo.fullName),
            subtitle: Text(
              '${repo.language} · ${repo.description}',
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
            trailing: Text('★ ${repo.stars}'),
          );
        }),
      ],
    );
  }
}

/// Level 3:演示轮询、失败重试、超时与取消。
class _PollingRetryDemo extends HookWidget {
  const _PollingRetryDemo({required this.onSourceChanged});

  final ValueChanged<String> onSourceChanged;

  @override
  Widget build(BuildContext context) {
    final repo = useState('flutter/flutter');
    final pollingEnabled = useState(false);
    final pollingInterval = useState(8.0);
    final pollingWhenHidden = useState(true);
    final pausePollingOnError = useState(true);
    final pollingRetryInterval = useState(4.0);
    final retryCount = useState(2.0);
    final retryInterval = useState(1000.0);
    final retryExponential = useState(true);
    final timeoutSec = useState(3.0);
    final forceError = useState(false);
    final logs = useState<List<String>>(<String>[]);

    useEffect(
      () {
        onSourceChanged(
          _buildLevel3Source(
            repo: repo.value,
            pollingEnabled: pollingEnabled.value,
            pollingInterval: pollingInterval.value.toInt(),
            pollingWhenHidden: pollingWhenHidden.value,
            pausePollingOnError: pausePollingOnError.value,
            pollingRetryInterval: pollingRetryInterval.value.toInt(),
            retryCount: retryCount.value.toInt(),
            retryInterval: retryInterval.value.toInt(),
            retryExponential: retryExponential.value,
            timeoutSec: timeoutSec.value.toInt(),
            forceError: forceError.value,
          ),
        );
        return null;
      },
      [
        repo.value,
        pollingEnabled.value,
        pollingInterval.value.toInt(),
        pollingWhenHidden.value,
        pausePollingOnError.value,
        pollingRetryInterval.value.toInt(),
        retryCount.value.toInt(),
        retryInterval.value.toInt(),
        retryExponential.value,
        timeoutSec.value.toInt(),
        forceError.value,
      ],
    );

    void pushLog(String text) {
      logs.value = <String>[
        '${_clockNow()}  $text',
        ...logs.value,
      ].take(8).toList();
    }

    final adapter = useMemoized(
      () => DioHttpAdapter.withBaseUrl(_githubBaseUrl),
    );
    final service = useMemoized(
      () => createDioService<Map<String, dynamic>>(
        adapter,
        transformer: (response) {
          final raw = response.data;
          return raw is Map ? raw.cast<String, dynamic>() : <String, dynamic>{};
        },
      ),
      [adapter],
    );

    final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
      service,
      options: UseRequestOptions(
        manual: false,
        defaultParams: useMemoized(
          () => _repoConfig(repo.value, forceError: forceError.value),
          [repo.value, forceError.value],
        ),
        refreshDeps: [repo.value, forceError.value],
        pollingInterval: pollingEnabled.value
            ? Duration(seconds: pollingInterval.value.toInt())
            : null,
        pollingWhenHidden: pollingWhenHidden.value,
        pausePollingOnError: pausePollingOnError.value,
        pollingRetryInterval: Duration(
          seconds: pollingRetryInterval.value.toInt(),
        ),
        retryCount: retryCount.value.toInt(),
        retryInterval: Duration(milliseconds: retryInterval.value.toInt()),
        retryExponential: retryExponential.value,
        connectTimeout: Duration(seconds: timeoutSec.value.toInt()),
        receiveTimeout: Duration(seconds: timeoutSec.value.toInt()),
        sendTimeout: Duration(seconds: timeoutSec.value.toInt()),
        onRetryAttempt: (attempt, error) {
          pushLog('onRetryAttempt #$attempt -> $error');
        },
        onSuccess: (data, _) {
          pushLog('onSuccess repo=${repo.value}');
        },
        onError: (error, _) {
          pushLog('onError $error');
        },
      ),
    );

    final repoInfo = _toRepoMetricsRecord(request.data);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Wrap(
          spacing: 10,
          runSpacing: 10,
          children: [
            SizedBox(
              width: 300,
              child: DropdownButtonFormField<String>(
                key: ValueKey(repo.value),
                initialValue: repo.value,
                isExpanded: true,
                decoration: const InputDecoration(
                  labelText: '仓库',
                  border: OutlineInputBorder(),
                ),
                items: const [
                  DropdownMenuItem(
                    value: 'flutter/flutter',
                    child: Text('flutter/flutter'),
                  ),
                  DropdownMenuItem(
                    value: 'dart-lang/sdk',
                    child: Text('dart-lang/sdk'),
                  ),
                  DropdownMenuItem(
                    value: 'vercel/next.js',
                    child: Text('vercel/next.js'),
                  ),
                ],
                onChanged: (v) {
                  if (v != null) repo.value = v;
                },
              ),
            ),
            _SwitchOption(
              label: 'pollingEnabled',
              description: '是否开启轮询',
              value: pollingEnabled.value,
              onChanged: (v) => pollingEnabled.value = v,
            ),
            _SwitchOption(
              label: 'pollingWhenHidden',
              description: '后台是否继续轮询',
              value: pollingWhenHidden.value,
              onChanged: (v) => pollingWhenHidden.value = v,
            ),
            _SwitchOption(
              label: 'pausePollingOnError',
              description: '出错后暂停轮询',
              value: pausePollingOnError.value,
              onChanged: (v) => pausePollingOnError.value = v,
            ),
            _SwitchOption(
              label: 'retryExponential',
              description: '指数退避',
              value: retryExponential.value,
              onChanged: (v) => retryExponential.value = v,
            ),
            _SwitchOption(
              label: 'forceError',
              description: '模拟错误路径',
              value: forceError.value,
              onChanged: (v) => forceError.value = v,
            ),
          ],
        ),
        const SizedBox(height: 10),
        Wrap(
          spacing: 10,
          runSpacing: 10,
          children: [
            SizedBox(
              width: 250,
              child: _SliderOption(
                label: 'pollingInterval',
                valueLabel: '${pollingInterval.value.toInt()}s',
                value: pollingInterval.value,
                min: 2,
                max: 20,
                divisions: 9,
                onChanged: (v) => pollingInterval.value = v,
              ),
            ),
            SizedBox(
              width: 250,
              child: _SliderOption(
                label: 'pollingRetryInterval',
                valueLabel: '${pollingRetryInterval.value.toInt()}s',
                value: pollingRetryInterval.value,
                min: 1,
                max: 12,
                divisions: 11,
                onChanged: (v) => pollingRetryInterval.value = v,
              ),
            ),
            SizedBox(
              width: 250,
              child: _SliderOption(
                label: 'retryCount',
                valueLabel: retryCount.value.toInt().toString(),
                value: retryCount.value,
                min: 0,
                max: 5,
                divisions: 5,
                onChanged: (v) => retryCount.value = v,
              ),
            ),
            SizedBox(
              width: 250,
              child: _SliderOption(
                label: 'retryInterval',
                valueLabel: '${retryInterval.value.toInt()}ms',
                value: retryInterval.value,
                min: 300,
                max: 4000,
                divisions: 12,
                onChanged: (v) => retryInterval.value = v,
              ),
            ),
            SizedBox(
              width: 250,
              child: _SliderOption(
                label: 'connect/receive/send timeout',
                valueLabel: '${timeoutSec.value.toInt()}s',
                value: timeoutSec.value,
                min: 1,
                max: 10,
                divisions: 9,
                onChanged: (v) => timeoutSec.value = v,
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 10,
          runSpacing: 10,
          children: [
            FilledButton.tonalIcon(
              onPressed: request.refresh,
              icon: const Icon(Icons.refresh),
              label: const Text('refresh()'),
            ),
            OutlinedButton.icon(
              onPressed: request.pausePolling,
              icon: const Icon(Icons.pause_circle_outline),
              label: const Text('pausePolling()'),
            ),
            OutlinedButton.icon(
              onPressed: request.resumePolling,
              icon: const Icon(Icons.play_circle_outline),
              label: const Text('resumePolling()'),
            ),
            OutlinedButton.icon(
              onPressed: request.cancel,
              icon: const Icon(Icons.cancel_outlined),
              label: const Text('cancel()'),
            ),
            _StatusDot(
              label: request.isPolling ? 'Polling ON' : 'Polling OFF',
              active: request.isPolling,
            ),
          ],
        ),
        const SizedBox(height: 10),
        if (request.loading) const LinearProgressIndicator(minHeight: 4),
        if (request.error != null)
          Padding(
            padding: const EdgeInsets.only(top: 10),
            child: _InfoBanner(
              color: const Color(0xFFB91C1C),
              message: '请求失败:${request.error}',
            ),
          ),
        if (request.data != null)
          Padding(
            padding: const EdgeInsets.only(top: 12),
            child: _GlassPanel(
              padding: const EdgeInsets.all(14),
              blur: 8,
              child: Wrap(
                spacing: 16,
                runSpacing: 8,
                children: [
                  Text('仓库: ${repoInfo.fullName}'),
                  Text('★ ${repoInfo.stars}'),
                  Text('Forks: ${repoInfo.forks}'),
                  Text('Issues: ${repoInfo.openIssues}'),
                  Text('Updated: ${repoInfo.pushedAt}'),
                ],
              ),
            ),
          ),
        const SizedBox(height: 10),
        _EventLog(logs: logs.value),
      ],
    );
  }
}

/// Level 4:按分组控制几乎全部 UseRequestOptions,便于统一试参。
///
/// 这个实验台使用 GitHub 搜索接口,并把高频组合能力都放到一个页面:
/// - 基础配置、依赖刷新、轮询、频率控制
/// - 重试、超时、缓存、并发隔离
/// - 加载更多、回调、取消令牌、刷新策略
class _OptionsWorkbenchDemo extends HookWidget {
  const _OptionsWorkbenchDemo({required this.onSourceChanged});

  final ValueChanged<String> onSourceChanged;

  @override
  Widget build(BuildContext context) {
    final queryController = useTextEditingController(text: 'flutter');
    final query = useState(queryController.text);
    final sort = useState('stars');
    final order = useState('desc');
    final perPage = useState(6.0);

    // 基础配置
    final manual = useState(false);
    final ready = useState(true);
    final useInitialData = useState(true);
    final keepPreviousData = useState(true);

    // 依赖刷新
    final refreshDepsEnabled = useState(true);
    final customRefreshDepsAction = useState(false);
    final depsVersion = useState(0);

    // 轮询配置
    final pollingEnabled = useState(false);
    final pollingInterval = useState(10.0);
    final pollingWhenHidden = useState(true);
    final pausePollingOnError = useState(false);
    final pollingRetryInterval = useState(4.0);

    // 频率控制
    final rateMode = useState(_RateMode.debounce);
    final rateInterval = useState(450.0);
    final debounceLeading = useState(false);
    final debounceTrailing = useState(true);
    final debounceMaxWaitEnabled = useState(false);
    final debounceMaxWait = useState(1200.0);
    final throttleLeading = useState(true);
    final throttleTrailing = useState(true);

    // 重试配置
    final retryEnabled = useState(true);
    final retryCount = useState(2.0);
    final retryInterval = useState(900.0);
    final retryExponential = useState(true);

    // 超时配置
    final connectTimeout = useState(5.0);
    final receiveTimeout = useState(6.0);
    final sendTimeout = useState(5.0);

    // 加载与刷新
    final loadingDelay = useState(120.0);
    final refreshOnFocus = useState(false);
    final refreshOnReconnect = useState(false);

    // 缓存与并发
    final cacheEnabled = useState(true);
    final cacheTime = useState(40.0);
    final staleTime = useState(12.0);
    final fetchKeyByQuery = useState(true);

    // 加载更多与控制
    final loadMoreEnabled = useState(true);
    final externalCancelEnabled = useState(false);
    final callbacksEnabled = useState(true);
    final forceError = useState(false);

    useEffect(
      () {
        onSourceChanged(
          _buildLevel4Source(
            query: query.value,
            sort: sort.value,
            order: order.value,
            perPage: perPage.value.toInt(),
            manual: manual.value,
            ready: ready.value,
            useInitialData: useInitialData.value,
            keepPreviousData: keepPreviousData.value,
            refreshDepsEnabled: refreshDepsEnabled.value,
            customRefreshDepsAction: customRefreshDepsAction.value,
            depsVersion: depsVersion.value,
            pollingEnabled: pollingEnabled.value,
            pollingInterval: pollingInterval.value.toInt(),
            pollingWhenHidden: pollingWhenHidden.value,
            pausePollingOnError: pausePollingOnError.value,
            pollingRetryInterval: pollingRetryInterval.value.toInt(),
            rateMode: rateMode.value,
            rateInterval: rateInterval.value.toInt(),
            debounceLeading: debounceLeading.value,
            debounceTrailing: debounceTrailing.value,
            debounceMaxWaitEnabled: debounceMaxWaitEnabled.value,
            debounceMaxWait: debounceMaxWait.value.toInt(),
            throttleLeading: throttleLeading.value,
            throttleTrailing: throttleTrailing.value,
            retryEnabled: retryEnabled.value,
            retryCount: retryCount.value.toInt(),
            retryInterval: retryInterval.value.toInt(),
            retryExponential: retryExponential.value,
            connectTimeout: connectTimeout.value.toInt(),
            receiveTimeout: receiveTimeout.value.toInt(),
            sendTimeout: sendTimeout.value.toInt(),
            loadingDelay: loadingDelay.value.toInt(),
            refreshOnFocus: refreshOnFocus.value,
            refreshOnReconnect: refreshOnReconnect.value,
            cacheEnabled: cacheEnabled.value,
            cacheTime: cacheTime.value.toInt(),
            staleTime: staleTime.value.toInt(),
            fetchKeyByQuery: fetchKeyByQuery.value,
            loadMoreEnabled: loadMoreEnabled.value,
            externalCancelEnabled: externalCancelEnabled.value,
            callbacksEnabled: callbacksEnabled.value,
            forceError: forceError.value,
          ),
        );
        return null;
      },
      [
        query.value,
        sort.value,
        order.value,
        perPage.value.toInt(),
        manual.value,
        ready.value,
        useInitialData.value,
        keepPreviousData.value,
        refreshDepsEnabled.value,
        customRefreshDepsAction.value,
        depsVersion.value,
        pollingEnabled.value,
        pollingInterval.value.toInt(),
        pollingWhenHidden.value,
        pausePollingOnError.value,
        pollingRetryInterval.value.toInt(),
        rateMode.value,
        rateInterval.value.toInt(),
        debounceLeading.value,
        debounceTrailing.value,
        debounceMaxWaitEnabled.value,
        debounceMaxWait.value.toInt(),
        throttleLeading.value,
        throttleTrailing.value,
        retryEnabled.value,
        retryCount.value.toInt(),
        retryInterval.value.toInt(),
        retryExponential.value,
        connectTimeout.value.toInt(),
        receiveTimeout.value.toInt(),
        sendTimeout.value.toInt(),
        loadingDelay.value.toInt(),
        refreshOnFocus.value,
        refreshOnReconnect.value,
        cacheEnabled.value,
        cacheTime.value.toInt(),
        staleTime.value.toInt(),
        fetchKeyByQuery.value,
        loadMoreEnabled.value,
        externalCancelEnabled.value,
        callbacksEnabled.value,
        forceError.value,
      ],
    );

    final reconnectController = useMemoized(
      () => StreamController<bool>.broadcast(),
    );
    useEffect(() {
      return () {
        unawaited(reconnectController.close());
      };
    }, [reconnectController]);

    final cancelEpoch = useState(0);
    final externalToken = useMemoized(() => CancelToken(), [cancelEpoch.value]);
    final logs = useState<List<String>>(<String>[]);
    final requestRef =
        useRef<UseRequestResult<Map<String, dynamic>, HttpRequestConfig>?>(
          null,
        );

    void pushLog(String text) {
      logs.value = <String>[
        '${_clockNow()}  $text',
        ...logs.value,
      ].take(12).toList();
    }

    useEffect(() {
      void listener() {
        final trimmed = queryController.text.trim();
        query.value = trimmed.isEmpty ? 'flutter' : trimmed;
      }

      queryController.addListener(listener);
      return () => queryController.removeListener(listener);
    }, [queryController]);

    final adapter = useMemoized(
      () => DioHttpAdapter.withBaseUrl(_githubBaseUrl),
    );
    final service =
        useMemoized<Service<Map<String, dynamic>, HttpRequestConfig>>(() {
          return (config) async {
            final response = await adapter.request<Map<String, dynamic>>(
              config,
            );
            final data = response.data;
            final map = data is Map
                ? Map<String, dynamic>.from(data as Map<dynamic, dynamic>)
                : <String, dynamic>{};
            return <String, dynamic>{
              ...map,
              '_fetchedAt': DateTime.now().toIso8601String(),
            };
          };
        }, [adapter]);

    HttpRequestConfig buildSearchParams({required int page}) {
      return HttpRequestConfig.get(
        forceError.value
            ? '/search/repositories-invalid'
            : '/search/repositories',
        headers: _githubHeaders,
        queryParameters: {
          'q': query.value,
          'sort': sort.value,
          'order': order.value,
          'per_page': perPage.value.toInt(),
          'page': page,
        },
      );
    }

    final options = UseRequestOptions<Map<String, dynamic>, HttpRequestConfig>(
      manual: manual.value,
      ready: ready.value,
      defaultParams: useMemoized(() => buildSearchParams(page: 1), [
        query.value,
        sort.value,
        order.value,
        perPage.value.toInt(),
        forceError.value,
      ]),
      initialData: useInitialData.value
          ? <String, dynamic>{
              'total_count': 0,
              'items': const <dynamic>[],
              '_fetchedAt': 'initialData',
            }
          : null,
      keepPreviousData: keepPreviousData.value,
      refreshDeps: refreshDepsEnabled.value
          ? [
              query.value,
              sort.value,
              order.value,
              perPage.value.toInt(),
              depsVersion.value,
            ]
          : null,
      refreshDepsAction: customRefreshDepsAction.value
          ? () {
              pushLog('refreshDepsAction -> run(page=1)');
              requestRef.value?.run(buildSearchParams(page: 1));
            }
          : null,
      pollingInterval: pollingEnabled.value
          ? Duration(seconds: pollingInterval.value.toInt())
          : null,
      pollingWhenHidden: pollingWhenHidden.value,
      pausePollingOnError: pausePollingOnError.value,
      pollingRetryInterval: Duration(
        seconds: pollingRetryInterval.value.toInt(),
      ),
      debounceInterval: rateMode.value == _RateMode.debounce
          ? Duration(milliseconds: rateInterval.value.toInt())
          : null,
      debounceLeading: debounceLeading.value,
      debounceTrailing: debounceTrailing.value,
      debounceMaxWait:
          rateMode.value == _RateMode.debounce && debounceMaxWaitEnabled.value
          ? Duration(milliseconds: debounceMaxWait.value.toInt())
          : null,
      throttleInterval: rateMode.value == _RateMode.throttle
          ? Duration(milliseconds: rateInterval.value.toInt())
          : null,
      throttleLeading: throttleLeading.value,
      throttleTrailing: throttleTrailing.value,
      retryCount: retryEnabled.value ? retryCount.value.toInt() : null,
      retryInterval: retryEnabled.value
          ? Duration(milliseconds: retryInterval.value.toInt())
          : null,
      retryExponential: retryExponential.value,
      onRetryAttempt: retryEnabled.value && callbacksEnabled.value
          ? (attempt, error) => pushLog('onRetryAttempt #$attempt -> $error')
          : null,
      connectTimeout: Duration(seconds: connectTimeout.value.toInt()),
      receiveTimeout: Duration(seconds: receiveTimeout.value.toInt()),
      sendTimeout: Duration(seconds: sendTimeout.value.toInt()),
      loadingDelay: Duration(milliseconds: loadingDelay.value.toInt()),
      refreshOnFocus: refreshOnFocus.value,
      refreshOnReconnect: refreshOnReconnect.value,
      reconnectStream: reconnectController.stream,
      cacheKey: cacheEnabled.value
          ? (params) {
              final qp = params.queryParameters ?? const <String, dynamic>{};
              return 'gh:${params.path}:${qp['q']}:${qp['page']}:${qp['per_page']}';
            }
          : null,
      cacheTime: cacheEnabled.value
          ? Duration(seconds: cacheTime.value.toInt())
          : null,
      staleTime: cacheEnabled.value
          ? Duration(seconds: staleTime.value.toInt())
          : null,
      fetchKey: fetchKeyByQuery.value
          ? (params) => 'q:${params.queryParameters?['q'] ?? ''}'
          : null,
      loadMoreParams: loadMoreEnabled.value
          ? (lastParams, _) {
              final currentPage =
                  (lastParams.queryParameters?['page'] as num?)?.toInt() ?? 1;
              return lastParams.copyWith(
                queryParameters: {
                  ...?lastParams.queryParameters,
                  'page': currentPage + 1,
                },
              );
            }
          : null,
      dataMerger: loadMoreEnabled.value ? _mergeSearchResult : null,
      hasMore: loadMoreEnabled.value ? _hasMoreSearchResult : null,
      cancelToken: externalCancelEnabled.value ? externalToken : null,
      onBefore: callbacksEnabled.value
          ? (params) {
              final page =
                  (params.queryParameters?['page'] as num?)?.toInt() ?? 1;
              pushLog('onBefore page=$page');
            }
          : null,
      onSuccess: callbacksEnabled.value
          ? (data, params) {
              final page =
                  (params.queryParameters?['page'] as num?)?.toInt() ?? 1;
              final count = (data['items'] as List?)?.length ?? 0;
              pushLog('onSuccess page=$page items=$count');
            }
          : null,
      onError: callbacksEnabled.value
          ? (error, _) => pushLog('onError $error')
          : null,
      onFinally: callbacksEnabled.value
          ? (params, _, error) {
              final page =
                  (params.queryParameters?['page'] as num?)?.toInt() ?? 1;
              pushLog('onFinally page=$page error=${error != null}');
            }
          : null,
    );

    final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
      service,
      options: options,
    );
    requestRef.value = request;

    final repos = _toRepoItems(request.data);
    final fetchedAt = (request.data?['_fetchedAt'] ?? '-').toString();

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _OptionGroup(
          title: '基础配置',
          subtitle:
              'manual / ready / defaultParams / initialData / keepPreviousData',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              SizedBox(
                width: 280,
                child: TextField(
                  controller: queryController,
                  decoration: const InputDecoration(
                    labelText: 'q',
                    border: OutlineInputBorder(),
                    prefixIcon: Icon(Icons.search),
                  ),
                ),
              ),
              SizedBox(
                width: 170,
                child: DropdownButtonFormField<String>(
                  key: ValueKey(sort.value),
                  initialValue: sort.value,
                  isExpanded: true,
                  decoration: const InputDecoration(
                    labelText: 'sort',
                    border: OutlineInputBorder(),
                  ),
                  items: const [
                    DropdownMenuItem(value: 'stars', child: Text('stars')),
                    DropdownMenuItem(value: 'updated', child: Text('updated')),
                    DropdownMenuItem(value: 'forks', child: Text('forks')),
                  ],
                  onChanged: (v) {
                    if (v != null) sort.value = v;
                  },
                ),
              ),
              SizedBox(
                width: 150,
                child: DropdownButtonFormField<String>(
                  key: ValueKey(order.value),
                  initialValue: order.value,
                  isExpanded: true,
                  decoration: const InputDecoration(
                    labelText: 'order',
                    border: OutlineInputBorder(),
                  ),
                  items: const [
                    DropdownMenuItem(value: 'desc', child: Text('desc')),
                    DropdownMenuItem(value: 'asc', child: Text('asc')),
                  ],
                  onChanged: (v) {
                    if (v != null) order.value = v;
                  },
                ),
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'per_page',
                  valueLabel: perPage.value.toInt().toString(),
                  value: perPage.value,
                  min: 3,
                  max: 15,
                  divisions: 12,
                  onChanged: (v) => perPage.value = v,
                ),
              ),
              _SwitchOption(
                label: 'manual',
                description: '手动触发 run()',
                value: manual.value,
                onChanged: (v) => manual.value = v,
              ),
              _SwitchOption(
                label: 'ready',
                description: 'false 时禁止自动请求',
                value: ready.value,
                onChanged: (v) => ready.value = v,
              ),
              _SwitchOption(
                label: 'initialData',
                description: '首帧占位数据',
                value: useInitialData.value,
                onChanged: (v) => useInitialData.value = v,
              ),
              _SwitchOption(
                label: 'keepPreviousData',
                description: '切参时保留旧数据',
                value: keepPreviousData.value,
                onChanged: (v) => keepPreviousData.value = v,
              ),
              _SwitchOption(
                label: 'forceError',
                description: '模拟错误路径',
                value: forceError.value,
                onChanged: (v) => forceError.value = v,
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _OptionGroup(
          title: '依赖刷新',
          subtitle: 'refreshDeps / refreshDepsAction',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              _SwitchOption(
                label: 'refreshDeps',
                description: '依赖变化自动刷新',
                value: refreshDepsEnabled.value,
                onChanged: (v) => refreshDepsEnabled.value = v,
              ),
              _SwitchOption(
                label: 'refreshDepsAction',
                description: '自定义依赖变化动作',
                value: customRefreshDepsAction.value,
                onChanged: (v) => customRefreshDepsAction.value = v,
              ),
              FilledButton.tonalIcon(
                onPressed: () => depsVersion.value += 1,
                icon: const Icon(Icons.track_changes_outlined),
                label: Text('deps +1 (${depsVersion.value})'),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _OptionGroup(
          title: '轮询配置',
          subtitle:
              'pollingInterval / pollingWhenHidden / pausePollingOnError / pollingRetryInterval',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              _SwitchOption(
                label: 'pollingInterval',
                description: '是否开启轮询',
                value: pollingEnabled.value,
                onChanged: (v) => pollingEnabled.value = v,
              ),
              _SwitchOption(
                label: 'pollingWhenHidden',
                description: '后台继续轮询',
                value: pollingWhenHidden.value,
                onChanged: (v) => pollingWhenHidden.value = v,
              ),
              _SwitchOption(
                label: 'pausePollingOnError',
                description: '出错暂停轮询',
                value: pausePollingOnError.value,
                onChanged: (v) => pausePollingOnError.value = v,
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'pollingInterval',
                  valueLabel: '${pollingInterval.value.toInt()}s',
                  value: pollingInterval.value,
                  min: 2,
                  max: 30,
                  divisions: 14,
                  onChanged: (v) => pollingInterval.value = v,
                ),
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'pollingRetryInterval',
                  valueLabel: '${pollingRetryInterval.value.toInt()}s',
                  value: pollingRetryInterval.value,
                  min: 1,
                  max: 12,
                  divisions: 11,
                  onChanged: (v) => pollingRetryInterval.value = v,
                ),
              ),
              OutlinedButton.icon(
                onPressed: request.pausePolling,
                icon: const Icon(Icons.pause_circle_outline),
                label: const Text('pausePolling'),
              ),
              OutlinedButton.icon(
                onPressed: request.resumePolling,
                icon: const Icon(Icons.play_circle_outline),
                label: const Text('resumePolling'),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _OptionGroup(
          title: '频率控制(防抖 / 节流)',
          subtitle:
              'debounceInterval / debounceLeading / debounceTrailing / debounceMaxWait / throttleInterval',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              SizedBox(
                width: 360,
                child: SegmentedButton<_RateMode>(
                  segments: const [
                    ButtonSegment(
                      value: _RateMode.debounce,
                      label: Text('Debounce'),
                    ),
                    ButtonSegment(
                      value: _RateMode.throttle,
                      label: Text('Throttle'),
                    ),
                  ],
                  selected: {rateMode.value},
                  onSelectionChanged: (value) {
                    rateMode.value = value.first;
                  },
                ),
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: rateMode.value == _RateMode.debounce
                      ? 'debounceInterval'
                      : 'throttleInterval',
                  valueLabel: '${rateInterval.value.toInt()}ms',
                  value: rateInterval.value,
                  min: 100,
                  max: 1500,
                  divisions: 14,
                  onChanged: (v) => rateInterval.value = v,
                ),
              ),
              _SwitchOption(
                label: 'debounceLeading',
                description: '防抖首次触发',
                value: debounceLeading.value,
                onChanged: (v) => debounceLeading.value = v,
              ),
              _SwitchOption(
                label: 'debounceTrailing',
                description: '防抖末尾触发',
                value: debounceTrailing.value,
                onChanged: (v) => debounceTrailing.value = v,
              ),
              _SwitchOption(
                label: 'debounceMaxWait',
                description: '防抖最大等待',
                value: debounceMaxWaitEnabled.value,
                onChanged: (v) => debounceMaxWaitEnabled.value = v,
              ),
              if (debounceMaxWaitEnabled.value)
                SizedBox(
                  width: 240,
                  child: _SliderOption(
                    label: 'maxWait',
                    valueLabel: '${debounceMaxWait.value.toInt()}ms',
                    value: debounceMaxWait.value,
                    min: 400,
                    max: 3000,
                    divisions: 13,
                    onChanged: (v) => debounceMaxWait.value = v,
                  ),
                ),
              _SwitchOption(
                label: 'throttleLeading',
                description: '节流首次触发',
                value: throttleLeading.value,
                onChanged: (v) => throttleLeading.value = v,
              ),
              _SwitchOption(
                label: 'throttleTrailing',
                description: '节流末尾触发',
                value: throttleTrailing.value,
                onChanged: (v) => throttleTrailing.value = v,
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _OptionGroup(
          title: '重试与超时',
          subtitle:
              'retryCount / retryInterval / retryExponential + connect/receive/send timeout',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              _SwitchOption(
                label: 'retryEnabled',
                description: '是否启用重试',
                value: retryEnabled.value,
                onChanged: (v) => retryEnabled.value = v,
              ),
              _SwitchOption(
                label: 'retryExponential',
                description: '指数退避',
                value: retryExponential.value,
                onChanged: (v) => retryExponential.value = v,
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'retryCount',
                  valueLabel: retryCount.value.toInt().toString(),
                  value: retryCount.value,
                  min: 0,
                  max: 6,
                  divisions: 6,
                  onChanged: (v) => retryCount.value = v,
                ),
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'retryInterval',
                  valueLabel: '${retryInterval.value.toInt()}ms',
                  value: retryInterval.value,
                  min: 300,
                  max: 4000,
                  divisions: 12,
                  onChanged: (v) => retryInterval.value = v,
                ),
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'connectTimeout',
                  valueLabel: '${connectTimeout.value.toInt()}s',
                  value: connectTimeout.value,
                  min: 1,
                  max: 15,
                  divisions: 14,
                  onChanged: (v) => connectTimeout.value = v,
                ),
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'receiveTimeout',
                  valueLabel: '${receiveTimeout.value.toInt()}s',
                  value: receiveTimeout.value,
                  min: 1,
                  max: 15,
                  divisions: 14,
                  onChanged: (v) => receiveTimeout.value = v,
                ),
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'sendTimeout',
                  valueLabel: '${sendTimeout.value.toInt()}s',
                  value: sendTimeout.value,
                  min: 1,
                  max: 15,
                  divisions: 14,
                  onChanged: (v) => sendTimeout.value = v,
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _OptionGroup(
          title: '加载与刷新',
          subtitle:
              'loadingDelay / refreshOnFocus / refreshOnReconnect / reconnectStream',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'loadingDelay',
                  valueLabel: '${loadingDelay.value.toInt()}ms',
                  value: loadingDelay.value,
                  min: 0,
                  max: 800,
                  divisions: 16,
                  onChanged: (v) => loadingDelay.value = v,
                ),
              ),
              _SwitchOption(
                label: 'refreshOnFocus',
                description: '窗口聚焦自动刷新',
                value: refreshOnFocus.value,
                onChanged: (v) => refreshOnFocus.value = v,
              ),
              _SwitchOption(
                label: 'refreshOnReconnect',
                description: '网络恢复自动刷新',
                value: refreshOnReconnect.value,
                onChanged: (v) => refreshOnReconnect.value = v,
              ),
              FilledButton.tonalIcon(
                onPressed: () {
                  reconnectController.add(true);
                  pushLog('reconnectStream -> true');
                },
                icon: const Icon(Icons.wifi_tethering_outlined),
                label: const Text('模拟重连事件'),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _OptionGroup(
          title: '缓存与并发隔离',
          subtitle: 'cacheKey / cacheTime / staleTime / fetchKey',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              _SwitchOption(
                label: 'cacheKey',
                description: '启用缓存键',
                value: cacheEnabled.value,
                onChanged: (v) => cacheEnabled.value = v,
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'cacheTime',
                  valueLabel: '${cacheTime.value.toInt()}s',
                  value: cacheTime.value,
                  min: 10,
                  max: 120,
                  divisions: 11,
                  onChanged: (v) => cacheTime.value = v,
                ),
              ),
              SizedBox(
                width: 240,
                child: _SliderOption(
                  label: 'staleTime',
                  valueLabel: '${staleTime.value.toInt()}s',
                  value: staleTime.value,
                  min: 2,
                  max: 60,
                  divisions: 14,
                  onChanged: (v) => staleTime.value = v,
                ),
              ),
              _SwitchOption(
                label: 'fetchKey',
                description: '按 query 隔离并发',
                value: fetchKeyByQuery.value,
                onChanged: (v) => fetchKeyByQuery.value = v,
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _OptionGroup(
          title: '加载更多、取消与回调',
          subtitle:
              'loadMoreParams / dataMerger / hasMore / cancelToken / onBefore/onSuccess/onError/onFinally',
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              _SwitchOption(
                label: 'loadMoreParams',
                description: '启用分页加载更多',
                value: loadMoreEnabled.value,
                onChanged: (v) => loadMoreEnabled.value = v,
              ),
              _SwitchOption(
                label: 'cancelToken',
                description: '使用外部 cancel token',
                value: externalCancelEnabled.value,
                onChanged: (v) => externalCancelEnabled.value = v,
              ),
              _SwitchOption(
                label: 'callbacks',
                description: '启用生命周期回调日志',
                value: callbacksEnabled.value,
                onChanged: (v) => callbacksEnabled.value = v,
              ),
              FilledButton.tonalIcon(
                onPressed: () => request.run(buildSearchParams(page: 1)),
                icon: const Icon(Icons.play_arrow_outlined),
                label: const Text('run(page=1)'),
              ),
              OutlinedButton.icon(
                onPressed: request.refresh,
                icon: const Icon(Icons.refresh),
                label: const Text('refresh()'),
              ),
              OutlinedButton.icon(
                onPressed: loadMoreEnabled.value ? request.loadMore : null,
                icon: const Icon(Icons.expand_more),
                label: Text(
                  request.loadingMore ? 'loadingMore...' : 'loadMore()',
                ),
              ),
              OutlinedButton.icon(
                onPressed: () {
                  request.mutate((old) {
                    if (old == null) return old;
                    final oldItems = old['items'] as List? ?? const [];
                    if (oldItems.isEmpty) return old;
                    final first = oldItems.first;
                    if (first is! Map) return old;
                    final patchedFirst = Map<String, dynamic>.from(first)
                      ..update(
                        'full_name',
                        (value) => '[mutated] $value',
                        ifAbsent: () => '[mutated] unknown',
                      );
                    return <String, dynamic>{
                      ...old,
                      'items': <dynamic>[patchedFirst, ...oldItems.skip(1)],
                    };
                  });
                  pushLog('mutate -> patch first item');
                },
                icon: const Icon(Icons.auto_fix_high_outlined),
                label: const Text('mutate()'),
              ),
              OutlinedButton.icon(
                onPressed: request.cancel,
                icon: const Icon(Icons.cancel_outlined),
                label: const Text('cancel()'),
              ),
              OutlinedButton.icon(
                onPressed: externalCancelEnabled.value
                    ? () {
                        externalToken.cancel('cancel from external token');
                        cancelEpoch.value += 1;
                        pushLog('external cancelToken.cancel()');
                      }
                    : null,
                icon: const Icon(Icons.block_outlined),
                label: const Text('external cancelToken.cancel()'),
              ),
            ],
          ),
        ),
        const SizedBox(height: 14),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: [
            _StatusDot(
              label: request.loading ? 'loading' : 'idle',
              active: request.loading,
            ),
            _StatusDot(
              label: request.loadingMore ? 'loadingMore' : 'not loadingMore',
              active: request.loadingMore,
            ),
            _StatusDot(
              label: request.isPolling ? 'polling' : 'not polling',
              active: request.isPolling,
            ),
            _StatusDot(
              label: 'hasMore=${request.hasMore}',
              active: request.hasMore ?? false,
            ),
            _StatusDot(label: 'fetchedAt=$fetchedAt', active: false),
          ],
        ),
        if (request.error != null)
          Padding(
            padding: const EdgeInsets.only(top: 10),
            child: _InfoBanner(
              color: const Color(0xFFB91C1C),
              message: '请求失败:${request.error}',
            ),
          ),
        if (request.loading)
          const Padding(
            padding: EdgeInsets.only(top: 10),
            child: LinearProgressIndicator(minHeight: 4),
          ),
        const SizedBox(height: 8),
        ...repos.map((repo) {
          return ListTile(
            contentPadding: EdgeInsets.zero,
            title: Text(repo.fullName),
            subtitle: Text(
              '${repo.language} · ${repo.description}',
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
            trailing: Text('★ ${repo.stars}'),
          );
        }),
        const SizedBox(height: 6),
        _EventLog(logs: logs.value),
      ],
    );
  }
}

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

  final String title;
  final String subtitle;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return _GlassPanel(
      blur: 6,
      padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            title,
            style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
          ),
          const SizedBox(height: 2),
          Text(
            subtitle,
            style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
          ),
          const SizedBox(height: 8),
          child,
        ],
      ),
    );
  }
}

/// Level 5:闭包模式(`useRequestFn`)对照参数模式 + refreshDeps 的经典坑。
///
/// 切换排序 tab 时,直接展示"接口实际发送的 sort 参数":
/// - 闭包模式:sort 始终跟随当前 tab。
/// - 参数模式:`refreshDeps` 触发的 `refresh()` 复用上一次参数,sort 卡在旧值。
class _ClosureVsParamsDemo extends HookWidget {
  const _ClosureVsParamsDemo({required this.onSourceChanged});

  final ValueChanged<String> onSourceChanged;

  static const _sortValues = ['stars', 'forks', 'updated'];
  static const _sortLabels = ['按 Star', '按 Fork', '最近更新'];

  @override
  Widget build(BuildContext context) {
    final controller = useTextEditingController(text: 'flutter');
    final keyword = useState('flutter');
    final sortTab = useState(0);
    final isClosure = useState(true);
    final sentSort = useState('—');

    useEffect(() {
      onSourceChanged(_sourceLevel5);
      return null;
    }, const []);

    useEffect(() {
      void listener() {
        final t = controller.text.trim();
        keyword.value = t.isEmpty ? 'flutter' : t;
      }

      controller.addListener(listener);
      return () => controller.removeListener(listener);
    }, [controller]);

    final adapter = useMemoized(
      () => DioHttpAdapter.withBaseUrl(_githubBaseUrl),
    );

    Future<List<String>> runSearch(HttpRequestConfig cfg) async {
      // 记录接口真正带出去的 sort,供 UI 展示是否跟上了当前 tab。
      sentSort.value = cfg.queryParameters?['sort']?.toString() ?? '?';
      final res = await adapter.request<Map<String, dynamic>>(cfg);
      final items = (res.data?['items'] as List?) ?? const [];
      return items
          .take(6)
          .map((e) => (e as Map)['full_name'].toString())
          .toList();
    }

    // ✅ 闭包模式:条件从闭包读,refreshDeps 只当触发器。
    final closureReq = useRequestFn<List<String>>(
      () => runSearch(
        _searchConfig(
          query: keyword.value,
          page: 1,
          perPage: 6,
          sort: _sortValues[sortTab.value],
        ),
      ),
      options: UseRequestOptions(
        ready: isClosure.value,
        refreshDeps: [keyword.value, sortTab.value, isClosure.value],
        debounceInterval: const Duration(milliseconds: 300),
        keepPreviousData: true,
      ),
    );

    // ❌ 参数模式 + refreshDeps:切 tab 时 refresh() 复用旧参数。
    final paramCfg = useMemoized(
      () => _searchConfig(
        query: keyword.value,
        page: 1,
        perPage: 6,
        sort: _sortValues[sortTab.value],
      ),
      [keyword.value, sortTab.value],
    );
    final paramsReq = useRequest<List<String>, HttpRequestConfig>(
      runSearch,
      options: UseRequestOptions(
        ready: !isClosure.value,
        defaultParams: paramCfg,
        refreshDeps: [keyword.value, sortTab.value, isClosure.value],
        debounceInterval: const Duration(milliseconds: 300),
        keepPreviousData: true,
      ),
    );

    final loading = isClosure.value ? closureReq.loading : paramsReq.loading;
    final items =
        (isClosure.value ? closureReq.data : paramsReq.data) ??
        const <String>[];
    final expected = _sortValues[sortTab.value];
    final matched = sentSort.value == expected;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 模式切换
        Row(
          children: [
            Expanded(
              child: _ModeButton(
                label: '闭包模式 useRequestFn',
                active: isClosure.value,
                onTap: () => isClosure.value = true,
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: _ModeButton(
                label: '参数模式 + refreshDeps',
                active: !isClosure.value,
                onTap: () => isClosure.value = false,
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        TextField(
          controller: controller,
          decoration: InputDecoration(
            isDense: true,
            hintText: '搜索关键字(如 flutter)',
            prefixIcon: const Icon(Icons.search, size: 18),
            filled: true,
            fillColor: Colors.white,
            border: OutlineInputBorder(
              borderRadius: BorderRadius.circular(10),
              borderSide: const BorderSide(color: _Ink.hairline),
            ),
            enabledBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(10),
              borderSide: const BorderSide(color: _Ink.hairline),
            ),
          ),
        ),
        const SizedBox(height: 12),
        // 排序筛选 tab
        Wrap(
          spacing: 8,
          children: List.generate(_sortLabels.length, (i) {
            final selected = sortTab.value == i;
            return ChoiceChip(
              label: Text(_sortLabels[i]),
              selected: selected,
              onSelected: (_) => sortTab.value = i,
              selectedColor: _Ink.clayTint,
              backgroundColor: Colors.white,
              side: BorderSide(color: selected ? _Ink.clay : _Ink.hairline),
              labelStyle: TextStyle(
                fontSize: 12.5,
                fontWeight: FontWeight.w700,
                color: selected ? _Ink.clayDeep : _Ink.muted,
              ),
            );
          }),
        ),
        const SizedBox(height: 14),
        // 「接口实际发送」读数:是否跟上当前 tab
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: matched ? _Ink.clayTint : const Color(0x1FB91C1C),
            borderRadius: BorderRadius.circular(10),
            border: Border.all(
              color: matched ? _Ink.clay : const Color(0x55B91C1C),
            ),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                '接口实际发送',
                style: TextStyle(
                  fontSize: 11,
                  fontWeight: FontWeight.w700,
                  color: matched ? _Ink.clayDeep : const Color(0xFFB91C1C),
                ),
              ),
              const SizedBox(height: 4),
              Text(
                'GET /search/repositories?sort=${sentSort.value}',
                style: const TextStyle(
                  fontFamily: 'monospace',
                  fontSize: 13,
                  color: _Ink.ink,
                ),
              ),
              const SizedBox(height: 6),
              Text(
                matched
                    ? '✓ 与当前 tab($expected)一致'
                    : '⚠ 当前 tab 是 $expected,但接口带出去的还是旧值 —— 这正是 refreshDeps 复用上一次参数的坑',
                style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                  color: matched ? _Ink.clayDeep : const Color(0xFFB91C1C),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        if (loading)
          const Padding(
            padding: EdgeInsets.symmetric(vertical: 8),
            child: LinearProgressIndicator(minHeight: 3),
          ),
        _GlassPanel(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              for (final name in items)
                Padding(
                  padding: const EdgeInsets.symmetric(vertical: 3),
                  child: Text(
                    name,
                    style: const TextStyle(fontSize: 13, color: _Ink.ink),
                  ),
                ),
              if (items.isEmpty)
                const Text(
                  '暂无数据',
                  style: TextStyle(fontSize: 12, color: _Ink.muted),
                ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        _InfoBanner(
          message: isClosure.value
              ? '闭包模式:请求条件全部从闭包读取,切 tab 后 sort 始终跟随当前选择。'
              : '参数模式:切 tab 时 refreshDeps 触发 refresh(),复用上一次请求参数,sort 卡在旧值。先切几次排序 tab 看接口发送的 sort。',
          color: isClosure.value ? _Ink.clayDeep : const Color(0xFFB91C1C),
        ),
      ],
    );
  }
}

class _ModeButton extends StatelessWidget {
  const _ModeButton({
    required this.label,
    required this.active,
    required this.onTap,
  });

  final String label;
  final bool active;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(10),
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
        decoration: BoxDecoration(
          color: active ? _Ink.clay : Colors.white,
          borderRadius: BorderRadius.circular(10),
          border: Border.all(color: active ? _Ink.clay : _Ink.hairline),
        ),
        child: Center(
          child: Text(
            label,
            style: TextStyle(
              fontSize: 12.5,
              fontWeight: FontWeight.w700,
              color: active ? Colors.white : _Ink.muted,
            ),
          ),
        ),
      ),
    );
  }
}

/// Zed 风格线稿背景:米白纸面 + 细网格 + 角标十字 + 轻噪点。
class _DecorativeBackdrop extends StatelessWidget {
  const _DecorativeBackdrop();

  @override
  Widget build(BuildContext context) {
    return const IgnorePointer(
      child: SizedBox.expand(child: CustomPaint(painter: _WireframePainter())),
    );
  }
}

class _WireframePainter extends CustomPainter {
  const _WireframePainter();

  static const double _cell = 116; // 网格间距

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Offset.zero & size, Paint()..color = _Ink.paper);

    // 细网格线
    final line = Paint()
      ..color = _Ink.grid
      ..strokeWidth = 1;
    for (double x = _cell; x < size.width; x += _cell) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), line);
    }
    for (double y = _cell; y < size.height; y += _cell) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), line);
    }

    // 网格交点的十字角标(Zed 标志性细节)
    final cross = Paint()
      ..color = _Ink.gridStrong
      ..strokeWidth = 1;
    const double arm = 4;
    for (double x = _cell; x < size.width; x += _cell) {
      for (double y = _cell; y < size.height; y += _cell) {
        canvas.drawLine(Offset(x - arm, y), Offset(x + arm, y), cross);
        canvas.drawLine(Offset(x, y - arm), Offset(x, y + arm), cross);
      }
    }

    // 轻噪点(用固定种子,避免每帧抖动)
    final rand = Random(7);
    final grain = Paint()..color = const Color(0x0A000000);
    final count = ((size.width * size.height) / 900).clamp(0, 4000).toInt();
    for (int i = 0; i < count; i++) {
      canvas.drawRect(
        Rect.fromLTWH(
          rand.nextDouble() * size.width,
          rand.nextDouble() * size.height,
          1,
          1,
        ),
        grain,
      );
    }
  }

  @override
  bool shouldRepaint(covariant _WireframePainter oldDelegate) => false;
}

/// Zed 风格卡片:纸面填充 + 1px 描边,去掉毛玻璃与重阴影。
class _GlassPanel extends StatelessWidget {
  const _GlassPanel({
    required this.child,
    this.padding = const EdgeInsets.all(12),
    this.blur = 12,
  });

  final Widget child;
  final EdgeInsetsGeometry padding;
  final double blur; // 兼容旧调用点,线稿风格下不再使用

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: padding,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(10),
        color: _Ink.panel,
        border: Border.all(color: _Ink.hairline),
        boxShadow: const [
          BoxShadow(
            color: Color(0x0F201D18),
            blurRadius: 10,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: child,
    );
  }
}

class _TopBadge extends StatelessWidget {
  const _TopBadge({required this.label});

  final String label;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
      decoration: BoxDecoration(
        color: const Color(0x26D97757),
        borderRadius: BorderRadius.circular(999),
        border: Border.all(color: const Color(0x55C87A50)),
      ),
      child: Text(
        label,
        style: const TextStyle(
          fontSize: 11,
          color: Color(0xFFB0512B),
          fontWeight: FontWeight.w700,
        ),
      ),
    );
  }
}

class _OptionTag extends StatelessWidget {
  const _OptionTag({required this.label});

  final String label;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: const Color(0x1AD97757),
        borderRadius: BorderRadius.circular(999),
      ),
      child: Text(
        label,
        style: const TextStyle(
          color: Color(0xFFB0512B),
          fontWeight: FontWeight.w600,
          fontSize: 11,
        ),
      ),
    );
  }
}

class _SwitchOption extends StatelessWidget {
  const _SwitchOption({
    required this.label,
    required this.description,
    required this.value,
    required this.onChanged,
  });

  final String label;
  final String description;
  final bool value;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    return Container(
      constraints: const BoxConstraints(minWidth: 200, maxWidth: 260),
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: const Color(0x447D8FA8)),
        color: Colors.white.withValues(alpha: 0.45),
      ),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  label,
                  style: const TextStyle(
                    fontWeight: FontWeight.w700,
                    fontSize: 13,
                  ),
                ),
                Text(
                  description,
                  style: const TextStyle(
                    fontSize: 11,
                    color: Color(0xFF64748B),
                  ),
                ),
              ],
            ),
          ),
          Switch(value: value, onChanged: onChanged),
        ],
      ),
    );
  }
}

class _SliderOption extends StatelessWidget {
  const _SliderOption({
    required this.label,
    required this.valueLabel,
    required this.value,
    required this.min,
    required this.max,
    required this.divisions,
    required this.onChanged,
  });

  final String label;
  final String valueLabel;
  final double value;
  final double min;
  final double max;
  final int divisions;
  final ValueChanged<double> onChanged;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: const Color(0x447D8FA8)),
        color: Colors.white.withValues(alpha: 0.45),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Expanded(
                child: Text(
                  label,
                  style: const TextStyle(
                    fontWeight: FontWeight.w700,
                    fontSize: 13,
                  ),
                ),
              ),
              Text(
                valueLabel,
                style: const TextStyle(
                  fontSize: 12,
                  color: Color(0xFFB0512B),
                  fontWeight: FontWeight.w700,
                ),
              ),
            ],
          ),
          Slider(
            value: value,
            min: min,
            max: max,
            divisions: divisions,
            onChanged: onChanged,
          ),
        ],
      ),
    );
  }
}

class _StatusDot extends StatelessWidget {
  const _StatusDot({required this.label, required this.active});

  final String label;
  final bool active;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(999),
        color: active ? const Color(0x26D97757) : const Color(0x22A7B2C5),
        border: Border.all(
          color: active ? const Color(0xFFD98A63) : const Color(0xFF93A5BE),
        ),
      ),
      child: Text(
        label,
        style: TextStyle(
          fontSize: 11,
          color: active ? const Color(0xFFB0512B) : const Color(0xFF516176),
        ),
      ),
    );
  }
}

class _InfoBanner extends StatelessWidget {
  const _InfoBanner({required this.message, required this.color});

  final String message;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(10),
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.09),
        borderRadius: BorderRadius.circular(10),
        border: Border.all(color: color.withValues(alpha: 0.4)),
      ),
      child: Text(message, style: TextStyle(color: color, fontSize: 12.5)),
    );
  }
}

class _EventLog extends StatelessWidget {
  const _EventLog({required this.logs});

  final List<String> logs;

  @override
  Widget build(BuildContext context) {
    if (logs.isEmpty) {
      return const Text(
        '事件日志:暂无',
        style: TextStyle(color: Color(0xFF64748B), fontSize: 12),
      );
    }

    return _GlassPanel(
      blur: 5,
      padding: const EdgeInsets.all(10),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            '事件日志',
            style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12.5),
          ),
          const SizedBox(height: 4),
          ...logs.map(
            (log) => Padding(
              padding: const EdgeInsets.only(top: 2),
              child: Text(
                '• $log',
                style: const TextStyle(
                  fontFamily: 'Menlo',
                  fontSize: 11.5,
                  color: Color(0xFF45556A),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _SourceCodePanel extends StatefulWidget {
  const _SourceCodePanel({required this.code});

  final String code;

  @override
  State<_SourceCodePanel> createState() => _SourceCodePanelState();
}

class _SourceCodePanelState extends State<_SourceCodePanel> {
  bool _expanded = false;
  TextSpan? _highlightedCode;

  @override
  void didUpdateWidget(covariant _SourceCodePanel oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (_expanded && oldWidget.code != widget.code) {
      _highlightedCode = _buildHighlightedCodeSpan(widget.code);
    }
  }

  void _handleExpansionChanged(bool expanded) {
    if (!expanded) {
      setState(() {
        _expanded = false;
      });
      return;
    }

    setState(() {
      _expanded = true;
      // 代码高亮是纯展示逻辑,展开前不做正则分段,减少 Web 首屏开销。
      _highlightedCode ??= _buildHighlightedCodeSpan(widget.code);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
      child: _GlassPanel(
        blur: 5,
        padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
        child: Material(
          color: Colors.transparent,
          child: ExpansionTile(
            onExpansionChanged: _handleExpansionChanged,
            tilePadding: const EdgeInsets.symmetric(horizontal: 8),
            childrenPadding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
            title: const Text(
              '查看源码(Dart)',
              style: TextStyle(fontWeight: FontWeight.w700, fontSize: 13),
            ),
            children: [
              Container(
                width: double.infinity,
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: const Color(0xFF0F172A),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: SingleChildScrollView(
                  scrollDirection: Axis.horizontal,
                  child: ConstrainedBox(
                    constraints: const BoxConstraints(minWidth: 640),
                    child: SelectableText.rich(
                      _highlightedCode ?? const TextSpan(text: ''),
                      style: const TextStyle(
                        fontFamily: 'Menlo',
                        fontSize: 12,
                        height: 1.45,
                        color: Color(0xFFE2E8F0),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

TextSpan _buildHighlightedCodeSpan(String source) {
  final keywordPattern =
      r'\b(class|const|final|if|else|for|while|switch|case|default|return|void|bool|int|double|String|Future|async|await|var|true|false|null|import|enum|extends|implements|with|this|required|late|try|catch|throw|typedef)\b';
  final pattern = RegExp(
    "(//.*\$|\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|$keywordPattern)",
    multiLine: true,
  );

  final spans = <TextSpan>[];
  var current = 0;

  for (final match in pattern.allMatches(source)) {
    if (match.start > current) {
      spans.add(TextSpan(text: source.substring(current, match.start)));
    }

    final token = match.group(0) ?? '';
    Color color = const Color(0xFFE2E8F0);

    if (token.startsWith('//')) {
      color = const Color(0xFF86EFAC);
    } else if (token.startsWith('"') || token.startsWith('\'')) {
      color = const Color(0xFFFDE68A);
    } else {
      color = const Color(0xFF93C5FD);
    }

    spans.add(
      TextSpan(
        text: token,
        style: TextStyle(color: color),
      ),
    );
    current = match.end;
  }

  if (current < source.length) {
    spans.add(TextSpan(text: source.substring(current)));
  }

  return TextSpan(children: spans);
}

String _clockNow() {
  final now = DateTime.now();
  String two(int value) => value.toString().padLeft(2, '0');
  return '${two(now.hour)}:${two(now.minute)}:${two(now.second)}';
}

enum _RateMode { debounce, throttle }

typedef _ProfileRecord = ({
  String login,
  int stars,
  int forks,
  String avatarUrl,
  String bio,
});

_ProfileRecord _toProfileRecord(Map<String, dynamic>? raw) {
  final first = _firstRepoItem(raw);
  final owner = first?['owner'];
  final ownerMap = owner is Map ? owner.cast<String, dynamic>() : null;

  return (
    login: (ownerMap?['login'] ?? 'unknown').toString(),
    stars: (first?['stargazers_count'] as num?)?.toInt() ?? 0,
    forks: (first?['forks_count'] as num?)?.toInt() ?? 0,
    avatarUrl: (ownerMap?['avatar_url'] ?? '').toString(),
    bio: (first?['description'] ?? 'No description').toString(),
  );
}

typedef _RepoItemRecord = ({
  String fullName,
  String description,
  int stars,
  String language,
});

List<_RepoItemRecord> _toRepoItems(Map<String, dynamic>? raw) {
  final items = raw?['items'];
  if (items is! List) return const [];

  return items.map((item) {
    if (item is! Map) {
      return (fullName: 'unknown', description: '', stars: 0, language: '-');
    }

    final map = item.cast<String, dynamic>();
    return (
      fullName: (map['full_name'] ?? '').toString(),
      description: (map['description'] ?? '').toString(),
      stars: (map['stargazers_count'] as num?)?.toInt() ?? 0,
      language: (map['language'] ?? '-').toString(),
    );
  }).toList();
}

typedef _RepoMetricsRecord = ({
  String fullName,
  int stars,
  int forks,
  int openIssues,
  String pushedAt,
});

_RepoMetricsRecord _toRepoMetricsRecord(Map<String, dynamic>? raw) {
  final first = _firstRepoItem(raw);
  return (
    fullName: (first?['full_name'] ?? '-').toString(),
    stars: (first?['stargazers_count'] as num?)?.toInt() ?? 0,
    forks: (first?['forks_count'] as num?)?.toInt() ?? 0,
    openIssues: (first?['open_issues_count'] as num?)?.toInt() ?? 0,
    pushedAt: (first?['pushed_at'] ?? '-').toString(),
  );
}

HttpRequestConfig _searchConfig({
  required String query,
  required int page,
  required int perPage,
  String sort = 'stars',
}) {
  return HttpRequestConfig.get(
    '/search/repositories',
    headers: _githubHeaders,
    queryParameters: {
      'q': query,
      'sort': sort,
      'order': 'desc',
      'per_page': perPage,
      'page': page,
    },
  );
}

HttpRequestConfig _repoConfig(String fullName, {required bool forceError}) {
  if (forceError) {
    return HttpRequestConfig.get(
      '/search/repositories-invalid',
      headers: _githubHeaders,
    );
  }

  return _searchConfig(query: fullName, page: 1, perPage: 1);
}

Map<String, dynamic>? _firstRepoItem(Map<String, dynamic>? raw) {
  final items = raw?['items'];
  if (items is! List || items.isEmpty || items.first is! Map) {
    return null;
  }
  return (items.first as Map).cast<String, dynamic>();
}

Map<String, dynamic> _mergeSearchResult(
  Map<String, dynamic>? previous,
  Map<String, dynamic> next,
) {
  final previousItems = previous?['items'] as List? ?? const [];
  final nextItems = next['items'] as List? ?? const [];

  return <String, dynamic>{
    ...next,
    'items': <dynamic>[...previousItems, ...nextItems],
  };
}

bool _hasMoreSearchResult(Map<String, dynamic>? data) {
  final totalCount = (data?['total_count'] as num?)?.toInt() ?? 0;
  final currentCount = (data?['items'] as List?)?.length ?? 0;
  return currentCount < totalCount && currentCount < 80;
}

String _boolCode(bool value) => value ? 'true' : 'false';

String _buildLevel1Source({
  required String username,
  required bool ready,
  required int loadingDelayMs,
}) {
  return '''
final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
  profileService,
  options: UseRequestOptions(
    manual: false,
    ready: ${_boolCode(ready)},
    defaultParams: _searchConfig(
      query: 'user:$username',
      page: 1,
      perPage: 1,
    ),
    refreshDeps: ['$username'],
    loadingDelay: Duration(milliseconds: $loadingDelayMs),
    onBefore: (_) => log('onBefore search user:$username'),
    onSuccess: (data, _) => log('onSuccess items=\${(data['items'] as List?)?.length ?? 0}'),
    onError: (error, _) => log('onError: \$error'),
  ),
);
''';
}

String _buildLevel2Source({
  required String query,
  required int perPage,
  required _RateMode mode,
  required int intervalMs,
  required bool leading,
  required bool trailing,
  required bool useMaxWait,
  required int maxWaitMs,
  required bool keepPrevious,
  required int loadingDelay,
}) {
  final debounceLine = mode == _RateMode.debounce
      ? 'Duration(milliseconds: $intervalMs)'
      : 'null';
  final throttleLine = mode == _RateMode.throttle
      ? 'Duration(milliseconds: $intervalMs)'
      : 'null';
  final maxWaitLine = mode == _RateMode.debounce && useMaxWait
      ? 'Duration(milliseconds: $maxWaitMs)'
      : 'null';

  return '''
final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
  searchService,
  options: UseRequestOptions(
    manual: false,
    defaultParams: _searchConfig(
      query: '$query',
      page: 1,
      perPage: $perPage,
    ),
    refreshDeps: ['$query', ${mode == _RateMode.debounce ? "'debounce'" : "'throttle'"}, $intervalMs],
    keepPreviousData: ${_boolCode(keepPrevious)},
    debounceInterval: $debounceLine,
    debounceLeading: ${_boolCode(leading)},
    debounceTrailing: ${_boolCode(trailing)},
    debounceMaxWait: $maxWaitLine,
    throttleInterval: $throttleLine,
    throttleLeading: ${_boolCode(leading)},
    throttleTrailing: ${_boolCode(trailing)},
    loadingDelay: Duration(milliseconds: $loadingDelay),
  ),
);
''';
}

String _buildLevel3Source({
  required String repo,
  required bool pollingEnabled,
  required int pollingInterval,
  required bool pollingWhenHidden,
  required bool pausePollingOnError,
  required int pollingRetryInterval,
  required int retryCount,
  required int retryInterval,
  required bool retryExponential,
  required int timeoutSec,
  required bool forceError,
}) {
  return '''
final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
  repoService,
  options: UseRequestOptions(
    manual: false,
    defaultParams: _repoConfig('$repo', forceError: ${_boolCode(forceError)}),
    refreshDeps: ['$repo', ${_boolCode(forceError)}],
    pollingInterval: ${pollingEnabled ? 'Duration(seconds: $pollingInterval)' : 'null'},
    pollingWhenHidden: ${_boolCode(pollingWhenHidden)},
    pausePollingOnError: ${_boolCode(pausePollingOnError)},
    pollingRetryInterval: Duration(seconds: $pollingRetryInterval),
    retryCount: $retryCount,
    retryInterval: Duration(milliseconds: $retryInterval),
    retryExponential: ${_boolCode(retryExponential)},
    connectTimeout: Duration(seconds: $timeoutSec),
    receiveTimeout: Duration(seconds: $timeoutSec),
    sendTimeout: Duration(seconds: $timeoutSec),
    onRetryAttempt: (attempt, error) => log('retry #\$attempt: \$error'),
  ),
);
''';
}

String _buildLevel4Source({
  required String query,
  required String sort,
  required String order,
  required int perPage,
  required bool manual,
  required bool ready,
  required bool useInitialData,
  required bool keepPreviousData,
  required bool refreshDepsEnabled,
  required bool customRefreshDepsAction,
  required int depsVersion,
  required bool pollingEnabled,
  required int pollingInterval,
  required bool pollingWhenHidden,
  required bool pausePollingOnError,
  required int pollingRetryInterval,
  required _RateMode rateMode,
  required int rateInterval,
  required bool debounceLeading,
  required bool debounceTrailing,
  required bool debounceMaxWaitEnabled,
  required int debounceMaxWait,
  required bool throttleLeading,
  required bool throttleTrailing,
  required bool retryEnabled,
  required int retryCount,
  required int retryInterval,
  required bool retryExponential,
  required int connectTimeout,
  required int receiveTimeout,
  required int sendTimeout,
  required int loadingDelay,
  required bool refreshOnFocus,
  required bool refreshOnReconnect,
  required bool cacheEnabled,
  required int cacheTime,
  required int staleTime,
  required bool fetchKeyByQuery,
  required bool loadMoreEnabled,
  required bool externalCancelEnabled,
  required bool callbacksEnabled,
  required bool forceError,
}) {
  final debounceInterval = rateMode == _RateMode.debounce
      ? 'Duration(milliseconds: $rateInterval)'
      : 'null';
  final throttleInterval = rateMode == _RateMode.throttle
      ? 'Duration(milliseconds: $rateInterval)'
      : 'null';
  final debounceMaxWaitLine = debounceMaxWaitEnabled
      ? 'Duration(milliseconds: $debounceMaxWait)'
      : 'null';

  return '''
final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
  searchService,
  options: UseRequestOptions(
    manual: ${_boolCode(manual)},
    ready: ${_boolCode(ready)},
    defaultParams: buildSearchParams(page: 1), // q=$query sort=$sort order=$order per_page=$perPage
    initialData: ${_boolCode(useInitialData)} ? {'items': []} : null,
    keepPreviousData: ${_boolCode(keepPreviousData)},
    refreshDeps: ${_boolCode(refreshDepsEnabled)} ? ['$query', '$sort', '$order', $perPage, $depsVersion] : null,
    refreshDepsAction: ${_boolCode(customRefreshDepsAction)} ? () => requestRef.value?.run(buildSearchParams(page: 1)) : null,
    pollingInterval: ${pollingEnabled ? 'Duration(seconds: $pollingInterval)' : 'null'},
    pollingWhenHidden: ${_boolCode(pollingWhenHidden)},
    pausePollingOnError: ${_boolCode(pausePollingOnError)},
    pollingRetryInterval: Duration(seconds: $pollingRetryInterval),
    debounceInterval: $debounceInterval,
    debounceLeading: ${_boolCode(debounceLeading)},
    debounceTrailing: ${_boolCode(debounceTrailing)},
    debounceMaxWait: $debounceMaxWaitLine,
    throttleInterval: $throttleInterval,
    throttleLeading: ${_boolCode(throttleLeading)},
    throttleTrailing: ${_boolCode(throttleTrailing)},
    retryCount: ${retryEnabled ? '$retryCount' : 'null'},
    retryInterval: ${retryEnabled ? 'Duration(milliseconds: $retryInterval)' : 'null'},
    retryExponential: ${_boolCode(retryExponential)},
    connectTimeout: Duration(seconds: $connectTimeout),
    receiveTimeout: Duration(seconds: $receiveTimeout),
    sendTimeout: Duration(seconds: $sendTimeout),
    loadingDelay: Duration(milliseconds: $loadingDelay),
    refreshOnFocus: ${_boolCode(refreshOnFocus)},
    refreshOnReconnect: ${_boolCode(refreshOnReconnect)},
    cacheKey: ${_boolCode(cacheEnabled)} ? (params) => 'gh:\${params.queryParameters}' : null,
    cacheTime: ${cacheEnabled ? 'Duration(seconds: $cacheTime)' : 'null'},
    staleTime: ${cacheEnabled ? 'Duration(seconds: $staleTime)' : 'null'},
    fetchKey: ${_boolCode(fetchKeyByQuery)} ? (params) => 'q:\${params.queryParameters?['q']}' : null,
    loadMoreParams: ${_boolCode(loadMoreEnabled)} ? (lastParams, _) => nextPage(lastParams) : null,
    dataMerger: ${_boolCode(loadMoreEnabled)} ? _mergeSearchResult : null,
    hasMore: ${_boolCode(loadMoreEnabled)} ? _hasMoreSearchResult : null,
    cancelToken: ${_boolCode(externalCancelEnabled)} ? externalToken : null,
    onBefore: ${_boolCode(callbacksEnabled)} ? (params) => log('onBefore') : null,
    onSuccess: ${_boolCode(callbacksEnabled)} ? (data, params) => log('onSuccess') : null,
    onError: ${_boolCode(callbacksEnabled)} ? (error, params) => log('onError \$error') : null,
    onFinally: ${_boolCode(callbacksEnabled)} ? (params, data, error) => log('onFinally') : null,
  ),
);
// forceError = ${_boolCode(forceError)}
''';
}

const String _sourceLevel1 = r'''
final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
  profileService,
  options: UseRequestOptions(
    manual: false,
    ready: ready.value,
    defaultParams: _searchConfig(
      query: 'user:${username.value}',
      page: 1,
      perPage: 1,
    ),
    refreshDeps: [username.value],
    loadingDelay: Duration(milliseconds: loadingDelayMs.value.toInt()),
    onBefore: (_) => log('onBefore'),
    onSuccess: (_, __) => log('onSuccess'),
    onError: (error, _) => log('onError: $error'),
  ),
);
''';

const String _sourceLevel2 = r'''
final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
  searchService,
  options: UseRequestOptions(
    manual: false,
    defaultParams: _searchConfig(
      query: query.value,
      page: 1,
      perPage: perPage.value.toInt(),
    ),
    refreshDeps: [query.value, mode.value, intervalMs.value.toInt()],
    keepPreviousData: keepPrevious.value,

    // 互斥:二选一
    debounceInterval: mode.value == _RateMode.debounce
        ? Duration(milliseconds: intervalMs.value.toInt())
        : null,
    debounceLeading: leading.value,
    debounceTrailing: trailing.value,
    debounceMaxWait: useMaxWait.value
        ? Duration(milliseconds: maxWaitMs.value.toInt())
        : null,

    throttleInterval: mode.value == _RateMode.throttle
        ? Duration(milliseconds: intervalMs.value.toInt())
        : null,
    throttleLeading: leading.value,
    throttleTrailing: trailing.value,
  ),
);
''';

const String _sourceLevel3 = r'''
final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
  repoService,
  options: UseRequestOptions(
    defaultParams: _repoConfig(repo.value, forceError: forceError.value),
    refreshDeps: [repo.value, forceError.value],

    pollingInterval: pollingEnabled.value
        ? Duration(seconds: pollingInterval.value.toInt())
        : null,
    pollingWhenHidden: pollingWhenHidden.value,
    pausePollingOnError: pausePollingOnError.value,
    pollingRetryInterval: Duration(seconds: pollingRetryInterval.value.toInt()),

    retryCount: retryCount.value.toInt(),
    retryInterval: Duration(milliseconds: retryInterval.value.toInt()),
    retryExponential: retryExponential.value,
    onRetryAttempt: (attempt, error) => log('retry #$attempt: $error'),

    connectTimeout: Duration(seconds: timeoutSec.value.toInt()),
    receiveTimeout: Duration(seconds: timeoutSec.value.toInt()),
    sendTimeout: Duration(seconds: timeoutSec.value.toInt()),

    onError: (error, _) => log('error: $error'),
  ),
);
''';

const String _sourceLevel4 = r'''
final request = useRequest<Map<String, dynamic>, HttpRequestConfig>(
  searchService,
  options: UseRequestOptions(
    // 基础
    manual: manual.value,
    ready: ready.value,
    defaultParams: buildSearchParams(page: 1),
    initialData: useInitialData.value ? {'items': []} : null,
    keepPreviousData: keepPreviousData.value,

    // 依赖刷新
    refreshDeps: refreshDepsEnabled.value ? [query.value, depsVersion.value] : null,
    refreshDepsAction: customRefreshDepsAction.value
        ? () => requestRef.value?.run(buildSearchParams(page: 1))
        : null,

    // 轮询
    pollingInterval: pollingEnabled.value
        ? Duration(seconds: pollingInterval.value.toInt())
        : null,
    pollingWhenHidden: pollingWhenHidden.value,
    pausePollingOnError: pausePollingOnError.value,
    pollingRetryInterval: Duration(seconds: pollingRetryInterval.value.toInt()),

    // 防抖 / 节流
    debounceInterval: rateMode.value == _RateMode.debounce
        ? Duration(milliseconds: rateInterval.value.toInt())
        : null,
    debounceLeading: debounceLeading.value,
    debounceTrailing: debounceTrailing.value,
    debounceMaxWait: debounceMaxWaitEnabled.value
        ? Duration(milliseconds: debounceMaxWait.value.toInt())
        : null,
    throttleInterval: rateMode.value == _RateMode.throttle
        ? Duration(milliseconds: rateInterval.value.toInt())
        : null,
    throttleLeading: throttleLeading.value,
    throttleTrailing: throttleTrailing.value,

    // 重试 + 超时
    retryCount: retryEnabled.value ? retryCount.value.toInt() : null,
    retryInterval: retryEnabled.value
        ? Duration(milliseconds: retryInterval.value.toInt())
        : null,
    retryExponential: retryExponential.value,
    connectTimeout: Duration(seconds: connectTimeout.value.toInt()),
    receiveTimeout: Duration(seconds: receiveTimeout.value.toInt()),
    sendTimeout: Duration(seconds: sendTimeout.value.toInt()),

    // 加载与刷新
    loadingDelay: Duration(milliseconds: loadingDelay.value.toInt()),
    refreshOnFocus: refreshOnFocus.value,
    refreshOnReconnect: refreshOnReconnect.value,
    reconnectStream: reconnectController.stream,

    // 缓存 + 并发
    cacheKey: cacheEnabled.value ? (params) => 'gh:${params.queryParameters}' : null,
    cacheTime: cacheEnabled.value ? Duration(seconds: cacheTime.value.toInt()) : null,
    staleTime: cacheEnabled.value ? Duration(seconds: staleTime.value.toInt()) : null,
    fetchKey: fetchKeyByQuery.value ? (params) => 'q:${params.queryParameters?['q']}' : null,

    // 加载更多
    loadMoreParams: loadMoreEnabled.value
        ? (lastParams, _) => lastParams.copyWith(
            queryParameters: {
              ...?lastParams.queryParameters,
              'page': ((lastParams.queryParameters?['page'] as num?)?.toInt() ?? 1) + 1,
            },
          )
        : null,
    dataMerger: loadMoreEnabled.value ? _mergeSearchResult : null,
    hasMore: loadMoreEnabled.value ? _hasMoreSearchResult : null,

    // 取消与回调
    cancelToken: externalCancelEnabled.value ? externalToken : null,
    onBefore: callbacksEnabled.value ? (_, ) => log('onBefore') : null,
    onSuccess: callbacksEnabled.value ? (_, __) => log('onSuccess') : null,
    onError: callbacksEnabled.value ? (error, _) => log('onError: $error') : null,
    onFinally: callbacksEnabled.value ? (_, __, ___) => log('onFinally') : null,
  ),
);
''';

const String _sourceLevel5 = r'''
// ✅ 闭包模式:条件从闭包读,refreshDeps 只作触发器
final closureReq = useRequestFn<List<String>>(
  () => runSearch(
    _searchConfig(query: keyword.value, sort: sortValues[sortTab.value]),
  ),
  options: UseRequestOptions(
    refreshDeps: [keyword.value, sortTab.value],
    debounceInterval: Duration(milliseconds: 300),
  ),
);

// ❌ 参数模式 + refreshDeps:切 tab 时 refresh() 复用上一次参数
final paramCfg = useMemoized(
  () => _searchConfig(query: keyword.value, sort: sortValues[sortTab.value]),
  [keyword.value, sortTab.value],
);
final paramsReq = useRequest<List<String>, HttpRequestConfig>(
  runSearch,
  options: UseRequestOptions(
    defaultParams: paramCfg,          // 仅首次请求使用
    refreshDeps: [sortTab.value],     // 之后 refresh() 复用旧 params,sort 卡住
  ),
);

// 参数模式确需自动刷新时,用 refreshDepsAction 显式带新参数:
// refreshDepsAction: () => paramsReq.run(paramCfg),
''';
1
likes
155
points
165
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter useRequest-style async request management library based on dio, flutter_hooks, and Riverpod.

Repository (GitHub)
View/report issues

Topics

#hooks #dio #riverpod #async #request

License

MIT (license)

Dependencies

dio, flutter, flutter_hooks, flutter_riverpod, web

More

Packages that depend on use_request