diff --git a/AGENTS.md b/AGENTS.md index 90f5f9f..9d9f6e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ | `AI_ANALYSIS.md` | 模块分析文档:功能、文件结构、数据流、关键类、修改建议 | | 路由注册 | 在 `lib/router/app_route_table.dart` 的 `_modules` 中注册 | | 模块元数据 | `ModuleEntry` 必须填写 `category`、`difficulty`、`concepts`、`estimatedMinutes`、`status`、`subtitle` | -| 教学页面 | 至少 1 个页面使用 `lib/shared/learning/` 中的教学模板组件 | +| 教学页面 | 至少 1 个页面使用外部 `flutter_study_learning` 包中的教学模板组件(`LearningScaffold` 等) | ## 修改模块规则 @@ -57,7 +57,7 @@ dart run flutterguard_cli:flutterguard scan --path . --fail-on high 1. 扫描 `lib/` 下所有模块目录,检查是否都在 `_modules` 中注册 2. 检查每个模块是否有 `AI_ANALYSIS.md` -3. 检查重点模块是否使用教学模板(`lib/shared/learning/`) +3. 检查重点模块是否使用教学模板(`flutter_study_learning` 包) 4. 检查 `ModuleEntry` 元数据是否完整(所有必填字段) 5. 标记低质量模块的 `status` 为 `ModuleStatus.pending` 6. 检查 `flutter analyze` 和 `dart format` 是否通过 @@ -73,11 +73,12 @@ git config core.hooksPath .githooks ## 模块分类枚举 ```dart -ModuleCategory.basic // 基础机制 -ModuleCategory.async // 异步并发 -ModuleCategory.state // 状态管理 -ModuleCategory.ui // UI 与动效 -ModuleCategory.platform // 网络与平台 +ModuleCategory.basic // 基础机制 +ModuleCategory.async // 异步并发 +ModuleCategory.state // 状态管理 +ModuleCategory.ui // UI 与动效 +ModuleCategory.popupTable // 弹窗与列表 +ModuleCategory.platform // 网络与平台 ``` ## 难度等级枚举 diff --git a/AI_PROJECT_CONTEXT.md b/AI_PROJECT_CONTEXT.md index 1b8c822..582b2e8 100644 --- a/AI_PROJECT_CONTEXT.md +++ b/AI_PROJECT_CONTEXT.md @@ -37,20 +37,19 @@ lib/ │ ├── module_entry.dart │ └── module_category.dart ├── shared/ # 共享能力 -│ ├── learning/ -│ │ └── learning_scaffold.dart # 教学模板组件 -│ ├── platform/ # 平台通道与系统能力封装 -│ ├── widgets/ -│ ├── utils/ -│ └── theme/ -└── modules/ # 学习模块分区 - ├── basic/ # 基础机制 - ├── async/ # 异步并发 - ├── state/ # 状态管理 - ├── ui/ # UI 与动效 - └── platform/ # 网络与平台 +│ ├── multi_window/ # 多窗口能力封装 +│ └── platform/ # 平台通道与系统能力封装 +├── modules/ # 学习模块分区 +│ ├── basic/ # 基础机制 +│ ├── async/ # 异步并发 +│ ├── state/ # 状态管理 +│ ├── ui/ # UI 与动效 +│ ├── popup_table/ # 弹窗与列表 +│ └── platform/ # 网络与平台 ``` +教学模板组件由外部包 `flutter_study_learning` 提供(`LearningScaffold`、`LearningObjectives`、`ConceptChips`、`CodeSnippetCard`、`CommonPitfalls`、`ExerciseCard`、`StateLogView`)。 + ## 模块内部结构(推荐) ``` diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index 0906419..fa3f3a7 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -81,8 +81,9 @@ abstract class FilePickerService { ### 2.2 shared 能力治理 -- [ ] 为 `lib/shared/learning/` 补充/更新 `AI_ANALYSIS.md` -- [ ] 为 `lib/shared/platform/` 建立边界: 只放跨平台/平台通道能力,不放具体业务解析逻辑 +- [x] 教学模板组件已从 `lib/shared/learning/` 抽出为外部 `flutter_study_learning` 包 +- [x] `file_picker` 已从 `lib/shared/platform/` 抽出为外部 `file_picker_bridge` 包 +- [ ] 为 `lib/shared/platform/` 补充平台能力说明 `AI_ANALYSIS.md` - [ ] 共享能力必须提供业务无关接口,模块只能传入业务参数 - [ ] 共享能力新增后必须至少被 1 个模块接入验证 - [ ] 若 shared 能力 3 个月内仍只有 1 个模块使用,保留在 shared,但不升级为独立插件 diff --git a/lib/app/router/app_route_table.dart b/lib/app/router/app_route_table.dart index a47be65..32e113c 100644 --- a/lib/app/router/app_route_table.dart +++ b/lib/app/router/app_route_table.dart @@ -19,9 +19,9 @@ import '../../modules/async/stream_subscription/module_entry.dart'; import '../../modules/async/stream_subscription/module_routes.dart'; import '../../modules/state/flutter_ioc/module_entry.dart'; -import '../../modules/state/status_management/app/app_routes.dart'; -import '../../modules/state/status_management/module_entry.dart'; +import '../../modules/state/status_management/module_routes.dart'; +import '../../modules/state/status_management/module_entry.dart'; import '../../modules/ui/adsorption_line/module_entry.dart'; import '../../modules/ui/download_animation/module_entry.dart'; import '../../modules/ui/download_animation/module_routes.dart'; @@ -38,14 +38,16 @@ import '../../modules/platform/usb_detector/module_entry.dart'; // ==================== 状态管理子路由(模块内部已定义映射) ==================== -List _buildStatusManageRoutes() => AppRoutes.routes.entries - .map( - (entry) => GoRoute( - path: entry.key.startsWith('/') ? entry.key.substring(1) : entry.key, - builder: (context, state) => entry.value(context), - ), - ) - .toList(); +List _buildStatusManageRoutes() => + StatusManagementRoutes.routes.entries + .map( + (entry) => GoRoute( + path: + entry.key.startsWith('/') ? entry.key.substring(1) : entry.key, + builder: (context, state) => entry.value(context), + ), + ) + .toList(); // ==================== 模块注册 ==================== @@ -217,7 +219,7 @@ final List _modules = [ difficulty: Difficulty.beginner, concepts: ['TableView', '固定表头', '二维滚动'], estimatedMinutes: 15, - status: ModuleStatus.pending, + status: ModuleStatus.ready, builder: (context) => const ScrollTableEntry(), ), ModuleEntry( @@ -259,7 +261,7 @@ final List _modules = [ difficulty: Difficulty.intermediate, concepts: ['usb_serial', 'device_info_plus', 'Stream 广播', '设备扫描'], estimatedMinutes: 25, - status: ModuleStatus.pending, + status: ModuleStatus.ready, builder: (context) => const UsbDetectorEntry(), ), ]; diff --git a/lib/modules/async/isolate_basic/AI_ANALYSIS.md b/lib/modules/async/isolate_basic/AI_ANALYSIS.md index 4b4d06c..516a015 100644 --- a/lib/modules/async/isolate_basic/AI_ANALYSIS.md +++ b/lib/modules/async/isolate_basic/AI_ANALYSIS.md @@ -8,11 +8,28 @@ "path": "lib/modules/async/isolate_basic", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["module_registry","go_router"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry","go_router"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["module_entry.dart","module_root.dart","module_routes.dart","with_isolate_page.dart","without_isolate_page.dart"], + "files": [ + "module_entry.dart", + "module_root.dart", + "module_routes.dart", + "with_isolate_page.dart", + "without_isolate_page.dart" + ], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/async/isolate_basic/module_root.dart b/lib/modules/async/isolate_basic/module_root.dart index a3af47a..ded49c3 100644 --- a/lib/modules/async/isolate_basic/module_root.dart +++ b/lib/modules/async/isolate_basic/module_root.dart @@ -1,4 +1,6 @@ +// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'package:go_router/go_router.dart'; class HomePage extends StatelessWidget { @@ -8,79 +10,109 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: const Text('Isolate 对比演示'), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'Isolate 测试与对比', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 20), - const Text( - '本演示通过大量计算来对比使用和不使用 Isolate 的差异', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16), - ), - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.all(16), - margin: const EdgeInsets.symmetric(horizontal: 20), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.amber), + return LearningScaffold( + title: 'Isolate 并发对比', + interactiveDemo: SizedBox( + height: 350, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '本演示通过大量计算来对比使用和不使用 Isolate 的差异', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), ), - child: const Column( - children: [ - Text( - '测试说明:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - SizedBox(height: 8), - Text( - '1. 两个页面执行相同的计算任务(查找素数)\n' - '2. 计算过程中观察动画流畅度\n' - '3. 尝试点击"点击测试响应"按钮\n' - '4. 尝试在蓝色区域滑动\n' - '5. 对比两者UI响应差异', - style: TextStyle(fontSize: 14), - ), - ], + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber), + ), + child: const Column( + children: [ + Text('测试说明:', + style: TextStyle(fontWeight: FontWeight.bold)), + SizedBox(height: 8), + Text( + '1. 两个页面执行相同的计算任务(查找素数)\n' + '2. 计算过程中观察动画流畅度\n' + '3. 尝试点击"点击测试响应"按钮\n' + '4. 尝试在蓝色区域滑动\n' + '5. 对比两者UI响应差异', + style: TextStyle(fontSize: 14), + ), + ], + ), ), - ), - const SizedBox(height: 40), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - backgroundColor: Colors.red[100], + const SizedBox(height: 40), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + backgroundColor: Colors.red[100], + ), + onPressed: () { + context.push('$_baseRoute/without-isolate'); + }, + child: const Text('不使用 Isolate (卡顿示例)'), ), - onPressed: () { - context.push('$_baseRoute/without-isolate'); - }, - child: const Text('不使用 Isolate (卡顿示例)'), - ), - const SizedBox(height: 20), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - backgroundColor: Colors.green[100], + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + backgroundColor: Colors.green[100], + ), + onPressed: () { + context.push('$_baseRoute/with-isolate'); + }, + child: const Text('使用 Isolate (流畅示例)'), ), - onPressed: () { - context.push('$_baseRoute/with-isolate'); - }, - child: const Text('使用 Isolate (流畅示例)'), - ), - ], + ], + ), ), ), + sections: [ + LearningObjectives(objectives: [ + '理解 Isolate 与主线程的内存隔离机制', + '掌握 Isolate.spawn + SendPort/ReceivePort 通信模式', + '对比有/无 Isolate 时 UI 流畅度差异', + ]), + ConceptChips(concepts: [ + 'Isolate', + 'SendPort', + 'ReceivePort', + '并发', + 'UI 流畅度', + '耗时计算', + ]), + CodeSnippetCard( + title: 'Isolate 基础用法', + code: 'final receivePort = ReceivePort();\n' + 'await Isolate.spawn(\n' + ' computeTask,\n' + ' receivePort.sendPort,\n' + ');\n' + 'receivePort.listen((result) {\n' + ' // 接收计算结果\n' + '});', + explanation: 'Isolate.spawn 在新 Isolate 中执行函数,通过 Port 通信。', + ), + CommonPitfalls(pitfalls: [ + 'Isolate 间不能共享变量 — 必须通过消息传递,无法直接访问主线程数据', + 'ReceivePort 需要及时关闭 — 不关闭会导致内存泄漏', + '大量小消息的性能开销 — 频繁跨 Isolate 通信可能得不偿失', + ]), + ExerciseCard( + task: + '修改 without_isolate_page.dart,尝试使用 compute 工具函数替换 Isolate.spawn,对比两种 API 的差异。', + hint: 'Flutter 提供了 compute() 函数作为 Isolate 的简化封装,适合一次性耗时计算。', + ), + ], ); } } diff --git a/lib/modules/async/isolate_basic/with_isolate_page.dart b/lib/modules/async/isolate_basic/with_isolate_page.dart index f52acb2..960736f 100644 --- a/lib/modules/async/isolate_basic/with_isolate_page.dart +++ b/lib/modules/async/isolate_basic/with_isolate_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'dart:math'; import 'dart:isolate'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; class WithIsolatePage extends StatefulWidget { const WithIsolatePage({super.key}); @@ -15,8 +16,6 @@ class _WithIsolatePageState extends State String _result = ''; double _progress = 0.0; final Stopwatch _stopwatch = Stopwatch(); - - // 添加动画控制器和动画值 late AnimationController _animationController; late Animation _animation; int _counter = 0; @@ -24,13 +23,10 @@ class _WithIsolatePageState extends State @override void initState() { super.initState(); - // 创建动画控制器 _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(reverse: true); - - // 创建动画 _animation = Tween(begin: 0, end: 1).animate(_animationController); } @@ -40,112 +36,114 @@ class _WithIsolatePageState extends State super.dispose(); } - // 增加计数器 void _incrementCounter() { - setState(() { - _counter++; - }); + setState(() => _counter++); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('使用 Isolate'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - '这个示例使用 Isolate 在后台线程执行大量计算,期间界面保持流畅', - style: TextStyle(fontSize: 16), - ), - const SizedBox(height: 20), - LinearProgressIndicator( - value: _progress, - minHeight: 10, - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: _isCalculating ? null : _startHeavyCalculation, - child: Text(_isCalculating ? '计算中...' : '开始计算'), - ), - const SizedBox(height: 10), - - // 添加计数器和按钮来测试UI响应 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: _incrementCounter, - child: const Text('点击测试响应'), - ), - const SizedBox(width: 20), - Text('计数: $_counter', style: const TextStyle(fontSize: 18)), - ], - ), - - const SizedBox(height: 20), - - // 添加动画元素 - AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Container( - height: 50, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: const [Colors.blue, Colors.purple], - stops: [0, _animation.value], + return LearningScaffold( + title: '使用 Isolate', + interactiveDemo: SizedBox( + height: 500, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LinearProgressIndicator(value: _progress, minHeight: 10), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _isCalculating ? null : _startHeavyCalculation, + child: Text(_isCalculating ? '计算中...' : '开始计算'), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _incrementCounter, + child: const Text('点击测试响应')), + const SizedBox(width: 20), + Text('计数: $_counter', style: const TextStyle(fontSize: 18)), + ], + ), + const SizedBox(height: 12), + AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + height: 50, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: const [Colors.blue, Colors.purple], + stops: [0, _animation.value], + ), + borderRadius: BorderRadius.circular(8), ), - borderRadius: BorderRadius.circular(8), - ), - child: const Center( - child: Text( - '这个动画应该平滑运行', - style: TextStyle(color: Colors.white, fontSize: 16), + child: const Center( + child: Text('这个动画应该平滑运行', + style: TextStyle(color: Colors.white, fontSize: 16)), ), - ), - ); - }, - ), - - const SizedBox(height: 20), - AnimatedContainer( - duration: const Duration(milliseconds: 500), - height: 100, - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), + ); + }, ), - child: const Center( - child: Text( - '尝试滑动和点击,感受界面响应', - style: TextStyle(fontSize: 16), + const SizedBox(height: 12), + AnimatedContainer( + duration: const Duration(milliseconds: 500), + height: 80, + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Text('尝试滑动和点击,感受界面响应', style: TextStyle(fontSize: 16)), ), ), - ), - const SizedBox(height: 20), - const Text('结果:'), - const SizedBox(height: 10), - Expanded( - child: Container( - padding: const EdgeInsets.all(12), + const SizedBox(height: 12), + const Text('结果:', style: TextStyle(fontSize: 16)), + const SizedBox(height: 4), + Container( + height: 100, + padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8), ), - child: SingleChildScrollView( - child: Text(_result), - ), + child: SingleChildScrollView(child: Text(_result)), ), - ), - ], + ], + ), ), ), + sections: [ + LearningObjectives(objectives: [ + '理解 Isolate 在 Flutter 中的工作原理', + '掌握 Isolate.spawn 创建后台线程的方法', + '学会使用 SendPort/ReceivePort 进行 Isolate 通信', + ]), + ConceptChips(concepts: [ + 'Isolate', + '多线程', + 'SendPort', + 'ReceivePort', + '并发计算', + 'UI 流畅度', + ]), + CodeSnippetCard( + title: 'Isolate 基本使用', + code: 'final receivePort = ReceivePort();\n' + 'await Isolate.spawn(entryPoint, message,\n' + ' onError: errorPort.sendPort);\n' + 'await for (final msg in receivePort) {\n' + ' // 处理计算结果\n' + '}', + explanation: 'Isolate.spawn 创建独立内存的 Isolate,通过 Port 进行消息通信。', + ), + ExerciseCard( + task: '点击"开始计算"后尝试点击测试按钮和观察动画,与"不使用 Isolate"页面对比。', + hint: '计算在后台 Isolate 中执行,主 Isolate 保持 UI 响应的区别非常明显。', + ), + ], ); } @@ -157,21 +155,14 @@ class _WithIsolatePageState extends State _stopwatch.reset(); _stopwatch.start(); }); - - // 使用 isolate 执行大量计算 _findPrimesWithIsolate(); } void _findPrimesWithIsolate() async { - // 增加迭代次数和计算范围,与不使用 isolate 的版本保持一致 const int iterations = 20; const int maxNumber = 500000; - - // 创建发送和接收端口 final receivePort = ReceivePort(); final errorPort = ReceivePort(); - - // 启动 isolate await Isolate.spawn( _isolateEntryPoint, _IsolateMessage( @@ -181,16 +172,12 @@ class _WithIsolatePageState extends State ), onError: errorPort.sendPort, ); - - // 监听错误 errorPort.listen((error) { setState(() { _isCalculating = false; _result += '\n发生错误: $error'; }); }); - - // 处理 isolate 的返回消息 await for (final message in receivePort) { if (message is _ProgressMessage) { setState(() { @@ -205,8 +192,6 @@ class _WithIsolatePageState extends State _progress = 1.0; _result += '\n计算完成! 耗时: ${_stopwatch.elapsedMilliseconds / 1000} 秒'; }); - - // 关闭端口 receivePort.close(); errorPort.close(); break; @@ -215,35 +200,22 @@ class _WithIsolatePageState extends State } } -// Isolate 入口点 - 必须是顶层函数或静态方法 void _isolateEntryPoint(_IsolateMessage message) { - final int iterations = message.iterations; - final int maxNumber = message.maxNumber; - - for (int i = 0; i < iterations; i++) { - // 计算素数 - List primes = _calculatePrimes(maxNumber); - - // 发送进度消息 + for (int i = 0; i < message.iterations; i++) { + List primes = _calculatePrimes(message.maxNumber); message.sendPort.send(_ProgressMessage( iteration: i + 1, - progress: i / iterations, + progress: i / message.iterations, primeCount: primes.length, )); } - - // 发送完成消息 message.sendPort.send(_ResultMessage()); } -// 计算素数的方法 - 与不使用 isolate 的版本相同 List _calculatePrimes(int max) { List primes = []; - - // 埃拉托斯特尼筛法 (Sieve of Eratosthenes) List sieve = List.filled(max + 1, true); sieve[0] = sieve[1] = false; - for (int i = 2; i <= sqrt(max).floor(); i++) { if (sieve[i]) { for (int j = i * i; j <= max; j += i) { @@ -251,13 +223,9 @@ List _calculatePrimes(int max) { } } } - - // 额外增加更多计算量,使计算更耗时 for (int number = 2; number <= max; number++) { if (sieve[number]) { - // 增加一些额外的计算 - // 增加一些额外的计算以延长执行时间 - // ignore: unused_local_variable, no_leading_underscores_for_local_identifiers + // ignore: unused_local_variable double sum = 0; for (int j = 0; j < 2000; j++) { sum += sin(j * 0.01) * cos(j * 0.01) * tan(j * 0.005); @@ -265,16 +233,13 @@ List _calculatePrimes(int max) { primes.add(number); } } - return primes; } -// 用于 isolate 通信的消息类 class _IsolateMessage { final SendPort sendPort; final int iterations; final int maxNumber; - _IsolateMessage({ required this.sendPort, required this.iterations, @@ -282,12 +247,10 @@ class _IsolateMessage { }); } -// 进度消息 class _ProgressMessage { final int iteration; final double progress; final int primeCount; - _ProgressMessage({ required this.iteration, required this.progress, @@ -295,5 +258,4 @@ class _ProgressMessage { }); } -// 结果消息 class _ResultMessage {} diff --git a/lib/modules/async/isolate_basic/without_isolate_page.dart b/lib/modules/async/isolate_basic/without_isolate_page.dart index 9cacc5d..6387880 100644 --- a/lib/modules/async/isolate_basic/without_isolate_page.dart +++ b/lib/modules/async/isolate_basic/without_isolate_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'dart:math'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; class WithoutIsolatePage extends StatefulWidget { const WithoutIsolatePage({super.key}); @@ -14,8 +15,6 @@ class _WithoutIsolatePageState extends State String _result = ''; double _progress = 0.0; final Stopwatch _stopwatch = Stopwatch(); - - // 添加动画控制器和动画值 late AnimationController _animationController; late Animation _animation; int _counter = 0; @@ -23,13 +22,10 @@ class _WithoutIsolatePageState extends State @override void initState() { super.initState(); - // 创建动画控制器 _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(reverse: true); - - // 创建动画 _animation = Tween(begin: 0, end: 1).animate(_animationController); } @@ -39,112 +35,114 @@ class _WithoutIsolatePageState extends State super.dispose(); } - // 增加计数器 void _incrementCounter() { - setState(() { - _counter++; - }); + setState(() => _counter++); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('不使用 Isolate'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - '这个示例在主线程上执行大量的计算,期间界面会卡顿', - style: TextStyle(fontSize: 16), - ), - const SizedBox(height: 20), - LinearProgressIndicator( - value: _progress, - minHeight: 10, - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: _isCalculating ? null : _startHeavyCalculation, - child: Text(_isCalculating ? '计算中...' : '开始计算'), - ), - const SizedBox(height: 10), - - // 添加计数器和按钮来测试UI响应 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: _incrementCounter, - child: const Text('点击测试响应'), - ), - const SizedBox(width: 20), - Text('计数: $_counter', style: const TextStyle(fontSize: 18)), - ], - ), - - const SizedBox(height: 20), - - // 添加动画元素 - AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Container( - height: 50, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: const [Colors.blue, Colors.purple], - stops: [0, _animation.value], + return LearningScaffold( + title: '不使用 Isolate', + interactiveDemo: SizedBox( + height: 500, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LinearProgressIndicator(value: _progress, minHeight: 10), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _isCalculating ? null : _startHeavyCalculation, + child: Text(_isCalculating ? '计算中...' : '开始计算'), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _incrementCounter, + child: const Text('点击测试响应')), + const SizedBox(width: 20), + Text('计数: $_counter', style: const TextStyle(fontSize: 18)), + ], + ), + const SizedBox(height: 12), + AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + height: 50, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: const [Colors.blue, Colors.purple], + stops: [0, _animation.value], + ), + borderRadius: BorderRadius.circular(8), ), - borderRadius: BorderRadius.circular(8), - ), - child: const Center( - child: Text( - '这个动画应该平滑运行', - style: TextStyle(color: Colors.white, fontSize: 16), + child: const Center( + child: Text('这个动画应该平滑运行', + style: TextStyle(color: Colors.white, fontSize: 16)), ), - ), - ); - }, - ), - - const SizedBox(height: 20), - AnimatedContainer( - duration: const Duration(milliseconds: 500), - height: 100, - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), + ); + }, ), - child: const Center( - child: Text( - '尝试滑动和点击,感受界面响应', - style: TextStyle(fontSize: 16), + const SizedBox(height: 12), + AnimatedContainer( + duration: const Duration(milliseconds: 500), + height: 80, + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Text('尝试滑动和点击,感受界面响应', style: TextStyle(fontSize: 16)), ), ), - ), - const SizedBox(height: 20), - const Text('结果:'), - const SizedBox(height: 10), - Expanded( - child: Container( - padding: const EdgeInsets.all(12), + const SizedBox(height: 12), + const Text('结果:', style: TextStyle(fontSize: 16)), + const SizedBox(height: 4), + Container( + height: 100, + padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8), ), - child: SingleChildScrollView( - child: Text(_result), - ), + child: SingleChildScrollView(child: Text(_result)), ), - ), - ], + ], + ), ), ), + sections: [ + LearningObjectives(objectives: [ + '理解主 Isolate 被阻塞时界面卡顿的原理', + '对比使用和不使用 Isolate 时的界面响应差异', + '掌握计算密集型任务对 UI 性能的影响', + ]), + ConceptChips(concepts: [ + 'Isolate', + '主线程', + 'UI 卡顿', + '计算密集型', + '事件循环', + ]), + CodeSnippetCard( + title: '主线程计算的问题', + code: '// 主线程执行大量计算\n' + 'void _findPrimes() {\n' + ' for (int i = 0; i < 20; i++) {\n' + ' _calculatePrimes(500000); // 阻塞 UI\n' + ' await Future.delayed(Duration.ms(1));\n' + ' }\n' + '}', + explanation: '计算在事件循环中执行,阻塞了 UI 渲染和手势处理。', + ), + ExerciseCard( + task: '点击"开始计算"后尝试点击测试按钮和观察动画,感受界面卡顿程度。', + hint: '对比"使用 Isolate"页面,体验两种方式的响应差异。', + ), + ], ); } @@ -156,36 +154,24 @@ class _WithoutIsolatePageState extends State _stopwatch.reset(); _stopwatch.start(); }); - - // 执行大量计算(计算大量的素数) _findPrimes(); } void _findPrimes() async { - // 增加迭代次数和计算范围 const int iterations = 20; const int maxNumber = 500000; List primes = []; - for (int i = 0; i < iterations; i++) { - // 更新进度 setState(() { _progress = i / iterations; _result += '迭代 ${i + 1}/$iterations 开始...\n'; }); - - // 计算素数 primes = _calculatePrimes(maxNumber); - - // 由于是在主线程上执行,UI会更新但会感到卡顿 - // 添加一个很短的延迟来让UI有机会更新 await Future.delayed(const Duration(milliseconds: 1)); - setState(() { _result += '找到 ${primes.length} 个素数 (≤ $maxNumber)\n'; }); } - _stopwatch.stop(); setState(() { _isCalculating = false; @@ -196,11 +182,8 @@ class _WithoutIsolatePageState extends State List _calculatePrimes(int max) { List primes = []; - - // 埃拉托斯特尼筛法 (Sieve of Eratosthenes) List sieve = List.filled(max + 1, true); sieve[0] = sieve[1] = false; - for (int i = 2; i <= sqrt(max).floor(); i++) { if (sieve[i]) { for (int j = i * i; j <= max; j += i) { @@ -208,13 +191,9 @@ class _WithoutIsolatePageState extends State } } } - - // 额外增加更多计算量,使计算更耗时 for (int number = 2; number <= max; number++) { if (sieve[number]) { - // 增加一些额外的计算 - // 增加一些额外的计算以延长执行时间 - // ignore: unused_local_variable, no_leading_underscores_for_local_identifiers + // ignore: unused_local_variable double sum = 0; for (int j = 0; j < 2000; j++) { sum += sin(j * 0.01) * cos(j * 0.01) * tan(j * 0.005); @@ -222,7 +201,6 @@ class _WithoutIsolatePageState extends State primes.add(number); } } - return primes; } } diff --git a/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md b/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md index 72e6758..a3a751f 100644 --- a/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md +++ b/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md @@ -8,11 +8,22 @@ "path": "lib/modules/async/isolate_task_manager", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["module_registry"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], "files": ["module_entry.dart","module_root.dart","task_manager.dart"], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/async/isolate_task_manager/module_root.dart b/lib/modules/async/isolate_task_manager/module_root.dart index f7e96fb..e62048a 100644 --- a/lib/modules/async/isolate_task_manager/module_root.dart +++ b/lib/modules/async/isolate_task_manager/module_root.dart @@ -1,4 +1,6 @@ +// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'task_manager.dart'; @@ -53,115 +55,139 @@ class _MultiTaskIsolatePageState extends State { setState(() {}); } - void _stopAllTasks() { - _taskManager.stopAllTasks(); - setState(() {}); - } - @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), - actions: [ - if (_taskManager.tasks.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear_all), - onPressed: _stopAllTasks, - tooltip: '停止所有任务', - ), - ], + return LearningScaffold( + title: widget.title, + floatingActionButton: FloatingActionButton( + onPressed: _startNewTask, + tooltip: '添加新任务', + child: const Icon(Icons.add), ), - body: _taskManager.tasks.isEmpty - ? const Center( - child: Text('点击下方按钮添加任务'), - ) - : ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: _taskManager.tasks.length, - itemBuilder: (context, index) { - final task = _taskManager.tasks[index]; - return Card( - elevation: 4, - margin: const EdgeInsets.only(bottom: 12.0), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - task.name, - style: Theme.of(context).textTheme.headlineSmall, - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => _stopTask(task), - tooltip: '停止任务', - ), - ], - ), - const SizedBox(height: 8), - LinearProgressIndicator( - value: task.progress / 100, - backgroundColor: Colors.grey[200], - color: task.color, - minHeight: 12, - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '进度: ${task.progress}%', - style: Theme.of(context).textTheme.bodyMedium, - ), - Text( - task.isCompleted - ? '已完成' - : task.isPaused - ? '已暂停' - : '进行中', - style: TextStyle( - color: task.isCompleted - ? Colors.green - : task.isPaused - ? Colors.orange - : Colors.blue, - fontWeight: FontWeight.bold, + interactiveDemo: SizedBox( + height: 500, + child: _taskManager.tasks.isEmpty + ? const Center(child: Text('点击下方按钮添加任务')) + : ListView.builder( + padding: const EdgeInsets.all(16.0), + itemCount: _taskManager.tasks.length, + itemBuilder: (context, index) { + final task = _taskManager.tasks[index]; + return Card( + elevation: 4, + margin: const EdgeInsets.only(bottom: 12.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + task.name, + style: + Theme.of(context).textTheme.headlineSmall, ), - ), - ], - ), - if (!task.isCompleted) + if (task.isCompleted) + const Icon(Icons.check_circle, + color: Colors.green) + else + IconButton( + icon: const Icon(Icons.close), + onPressed: () => _stopTask(task), + tooltip: '停止任务', + ), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: task.progress / 100, + backgroundColor: Colors.grey[200], + color: task.color, + minHeight: 12, + ), + const SizedBox(height: 8), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - icon: Icon(task.isPaused - ? Icons.play_arrow - : Icons.pause), - onPressed: task.isPaused - ? () => _resumeTask(task) - : () => _pauseTask(task), - tooltip: task.isPaused ? '继续任务' : '暂停任务', + Text('进度: ${task.progress}%'), + Text( + task.isCompleted + ? '已完成' + : task.isPaused + ? '已暂停' + : '进行中', + style: TextStyle( + color: task.isCompleted + ? Colors.green + : task.isPaused + ? Colors.orange + : Colors.blue, + fontWeight: FontWeight.bold, + ), ), ], ), - ], + if (!task.isCompleted) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: Icon(task.isPaused + ? Icons.play_arrow + : Icons.pause), + onPressed: task.isPaused + ? () => _resumeTask(task) + : () => _pauseTask(task), + tooltip: task.isPaused ? '继续任务' : '暂停任务', + ), + ], + ), + ], + ), ), - ), - ); - }, - ), - floatingActionButton: FloatingActionButton( - onPressed: _startNewTask, - tooltip: '添加新任务', - child: const Icon(Icons.add), + ); + }, + ), ), + sections: [ + LearningObjectives(objectives: [ + '理解 Isolate 多任务并行执行的原理', + '掌握通过 Stream 实时监控任务进度', + '学会管理多个 Isolate 的生命周期', + ]), + ConceptChips(concepts: [ + 'Isolate', + '多任务', + 'Stream', + '进度上报', + '暂停/恢复', + '并发控制', + ]), + CodeSnippetCard( + title: 'TaskManager 核心用法', + code: 'final manager = TaskManager(\n' + ' onTaskUpdate: (task) => setState(() {}),\n' + ' onTaskComplete: (task) => setState(() {}),\n' + ');\n' + 'manager.startNewTask();\n' + 'manager.pauseTask(task);\n' + 'manager.resumeTask(task);\n' + 'manager.stopTask(task);\n' + 'manager.dispose();', + explanation: 'TaskManager 封装了 Isolate 的创建、通信和销毁流程。', + ), + CommonPitfalls(pitfalls: [ + '忘记 dispose TaskManager — Isolate 不会自动终止,需显式释放资源', + '在 Isolate 中访问主线程对象 — Isolate 是独立内存空间,只能通过消息传递数据', + '任务过密导致 UI 卡顿 — 大量任务同时运行时注意控制并发数量', + ]), + ExerciseCard( + task: '为 TaskManager 增加"任务优先级"功能,高优先级任务先执行。', + hint: '在 Task 模型中增加 priority 字段,在 startNewTask 中对队列排序。', + ), + ], ); } } diff --git a/lib/modules/async/stream_subscription/AI_ANALYSIS.md b/lib/modules/async/stream_subscription/AI_ANALYSIS.md index 364b015..5ddd2d1 100644 --- a/lib/modules/async/stream_subscription/AI_ANALYSIS.md +++ b/lib/modules/async/stream_subscription/AI_ANALYSIS.md @@ -8,11 +8,31 @@ "path": "lib/modules/async/stream_subscription", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["module_registry","go_router"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry","go_router"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["models/message_model.dart","module_entry.dart","module_routes.dart","pages/broadcast_demo/broadcast_demo_page.dart","pages/home_page.dart","pages/stream_demo_controller.dart","pages/stream_demo_page.dart","services/stream_service.dart","utils/stream_utils.dart"], + "files": [ + "module_entry.dart", + "module_root.dart", + "module_routes.dart", + "pages/home_page.dart", + "pages/stream_demo_page.dart", + "pages/broadcast_demo_page.dart", + "services/stream_service.dart", + "utils/stream_utils.dart" + ], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/async/stream_subscription/module_routes.dart b/lib/modules/async/stream_subscription/module_routes.dart index 8a1cdb7..61c6c4a 100644 --- a/lib/modules/async/stream_subscription/module_routes.dart +++ b/lib/modules/async/stream_subscription/module_routes.dart @@ -1,7 +1,7 @@ import 'package:go_router/go_router.dart'; import 'pages/stream_demo_page.dart'; -import 'pages/broadcast_demo/broadcast_demo_page.dart'; +import 'pages/broadcast_demo_page.dart'; /// Stream 订阅机制模块子路由 class StreamSubscriptionRoutes { diff --git a/lib/modules/async/stream_subscription/pages/broadcast_demo/broadcast_demo_page.dart b/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart similarity index 69% rename from lib/modules/async/stream_subscription/pages/broadcast_demo/broadcast_demo_page.dart rename to lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart index 7a6c4e3..40d8e39 100644 --- a/lib/modules/async/stream_subscription/pages/broadcast_demo/broadcast_demo_page.dart +++ b/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart @@ -1,9 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; -/// 广播Stream演示页面 class BroadcastDemoPage extends StatefulWidget { - /// 构造函数 const BroadcastDemoPage({super.key}); @override @@ -11,22 +10,11 @@ class BroadcastDemoPage extends StatefulWidget { } class _BroadcastDemoPageState extends State { - // 广播Stream控制器 late StreamController _broadcastController; - - // 控制推送状态 bool _isPushingActive = false; - - // 推送计数器 int _pushCount = 0; - - // 推送间隔 int _interval = 2; - - // 定时器 Timer? _timer; - - // 订阅列表 final List<_Subscriber> _subscribers = []; @override @@ -35,25 +23,16 @@ class _BroadcastDemoPageState extends State { _initBroadcastStream(); } - // 初始化广播Stream void _initBroadcastStream() { _broadcastController = StreamController.broadcast( - onListen: () { - debugPrint('有人开始监听广播Stream'); - }, - onCancel: () { - debugPrint('有人取消监听广播Stream'); - }, + onListen: () => debugPrint('有人开始监听广播Stream'), + onCancel: () => debugPrint('有人取消监听广播Stream'), ); } - // 开始推送消息 void _startPushing() { if (_isPushingActive) return; - - setState(() { - _isPushingActive = true; - }); + setState(() => _isPushingActive = true); _timer = Timer.periodic(Duration(seconds: _interval), (timer) { if (!_isPushingActive) { @@ -61,34 +40,24 @@ class _BroadcastDemoPageState extends State { _timer = null; return; } - _pushCount++; final message = '广播消息 #$_pushCount - ${DateTime.now().toString().substring(11, 19)}'; - if (!_broadcastController.isClosed) { _broadcastController.add(message); } }); - _showMessage('开始推送消息'); } - // 停止推送消息 void _stopPushing() { if (!_isPushingActive) return; - - setState(() { - _isPushingActive = false; - }); - + setState(() => _isPushingActive = false); _timer?.cancel(); _timer = null; - _showMessage('停止推送消息'); } - // 添加新订阅者 void _addSubscriber() { final subscriberId = _subscribers.length + 1; final subscriber = _Subscriber( @@ -96,8 +65,6 @@ class _BroadcastDemoPageState extends State { name: '订阅者 $subscriberId', subscription: _broadcastController.stream.listen( (data) { - // 实际情况中,这里不应该调用setState,因为这会导致整个页面刷新 - // 这里为了简单演示,采用这种方式 setState(() { _subscribers .firstWhere((s) => s.id == subscriberId) @@ -113,126 +80,85 @@ class _BroadcastDemoPageState extends State { }, ), ); - - setState(() { - _subscribers.add(subscriber); - }); - + setState(() => _subscribers.add(subscriber)); _showMessage('添加了新订阅者: ${subscriber.name}'); } - // 移除订阅者 void _removeSubscriber(int id) { final subscriber = _subscribers.firstWhere((s) => s.id == id); subscriber.subscription.cancel(); - - setState(() { - _subscribers.remove(subscriber); - }); - + setState(() => _subscribers.remove(subscriber)); _showMessage('移除了订阅者: ${subscriber.name}'); } - // 添加错误事件 void _addError() { if (!_isPushingActive) { _showMessage('请先开始推送'); return; } - if (!_broadcastController.isClosed) { _broadcastController.addError('模拟的广播错误事件'); _showMessage('添加了错误事件'); } } - // 关闭Stream void _closeStream() { if (_broadcastController.isClosed) { _showMessage('Stream已经关闭'); return; } - _stopPushing(); _broadcastController.close(); - - // 重新初始化Stream以便再次使用 _initBroadcastStream(); - - setState(() { - // 清空所有订阅者 - _subscribers.clear(); - }); - + setState(() => _subscribers.clear()); _showMessage('Stream已关闭,所有订阅已移除'); } - // 修改推送间隔 void _changeInterval(int newInterval) { if (newInterval < 1) return; - - setState(() { - _interval = newInterval; - }); - + setState(() => _interval = newInterval); if (_isPushingActive) { _stopPushing(); _startPushing(); } - _showMessage('推送间隔已设置为 $_interval 秒'); } - // 显示提示消息 void _showMessage(String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); } @override void dispose() { - // 停止推送 _timer?.cancel(); - - // 取消所有订阅 for (var subscriber in _subscribers) { subscriber.subscription.cancel(); } - - // 关闭控制器 if (!_broadcastController.isClosed) { _broadcastController.close(); } - super.dispose(); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Stream 广播订阅示例'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), + return LearningScaffold( + title: 'Stream 广播订阅示例', + interactiveDemo: SizedBox( + height: 500, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Card( child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '控制面板', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), + Text('控制面板', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -288,12 +214,16 @@ class _BroadcastDemoPageState extends State { ), ), ), - const SizedBox(height: 16), - Text( - '订阅者 (${_subscribers.length}):', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + '订阅者 (${_subscribers.length}):', + style: + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 4), Expanded( child: _subscribers.isEmpty ? const Center(child: Text('暂无订阅者,请添加')) @@ -302,43 +232,35 @@ class _BroadcastDemoPageState extends State { itemBuilder: (context, index) { final subscriber = _subscribers[index]; return Card( - margin: const EdgeInsets.only(bottom: 8.0), + margin: const EdgeInsets.only(bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( title: Text(subscriber.name), trailing: IconButton( - icon: const Icon( - Icons.delete, - color: Colors.red, - ), + icon: const Icon(Icons.delete, + color: Colors.red), onPressed: () => _removeSubscriber(subscriber.id), ), ), const Divider(), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( - '收到的消息 (${subscriber.messages.length}):', - ), + '收到的消息 (${subscriber.messages.length}):'), ), SizedBox( - height: 100, + height: 80, child: subscriber.messages.isEmpty ? const Center(child: Text('暂无消息')) : ListView.builder( itemCount: subscriber.messages.length, - itemBuilder: ( - context, - messageIndex, - ) { + itemBuilder: (context, messageIndex) { return Padding( padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 4.0, - ), + horizontal: 16, vertical: 2), child: Text( subscriber.messages[messageIndex], style: TextStyle( @@ -356,38 +278,66 @@ class _BroadcastDemoPageState extends State { }, ), ), - const SizedBox(height: 8), - Text( - '状态: ${_isPushingActive ? "推送中" : "已停止"} | ' - '订阅者数量: ${_subscribers.length}', - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: FontWeight.bold, - color: _isPushingActive ? Colors.green : Colors.red, + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '状态: ${_isPushingActive ? "推送中" : "已停止"} | ' + '订阅者数量: ${_subscribers.length}', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + color: _isPushingActive ? Colors.green : Colors.red, + ), ), ), ], ), ), + sections: [ + LearningObjectives(objectives: [ + '理解广播 Stream 的多订阅者特性', + '掌握 StreamController.broadcast() 的创建与使用', + '学会管理多个 StreamSubscription 的生命周期', + ]), + ConceptChips(concepts: [ + '广播 Stream', + '多订阅者', + 'StreamController', + '错误处理', + '定时推送', + ]), + CodeSnippetCard( + title: '广播模式核心代码', + code: 'final controller = StreamController.broadcast(\n' + ' onListen: () => print("首次订阅"),\n' + ' onCancel: () => print("末次取消"),\n' + ');\n' + 'final sub1 = controller.stream.listen((d) => print(d));\n' + 'final sub2 = controller.stream.listen((d) => print(d));\n' + 'controller.add("hello"); // 两个订阅者都收到', + explanation: '广播流允许任意数量的监听器同时订阅同一个数据源。', + ), + CommonPitfalls(pitfalls: [ + '广播 Stream 没有缓存 — 在订阅前发送的消息会被丢失', + 'onListen 只在第一个订阅者加入时触发,onCancel 在最后一个离开时触发', + 'close() 后不能再用 add(),需重新创建 StreamController', + ]), + ExerciseCard( + task: '为每个订阅者设置独立的过滤器,让其只接收包含特定关键词的消息。', + hint: + '在 stream.listen() 前使用 .where((msg) => msg.contains(keyword)) 进行过滤。', + ), + ], ); } } -/// 订阅者模型 class _Subscriber { - /// 订阅者ID final int id; - - /// 订阅者名称 final String name; - - /// 订阅对象 final StreamSubscription subscription; - - /// 接收到的消息 final List messages = []; - /// 构造函数 _Subscriber({ required this.id, required this.name, diff --git a/lib/modules/async/stream_subscription/pages/home_page.dart b/lib/modules/async/stream_subscription/pages/home_page.dart index f8837ea..e4cd66c 100644 --- a/lib/modules/async/stream_subscription/pages/home_page.dart +++ b/lib/modules/async/stream_subscription/pages/home_page.dart @@ -1,109 +1,110 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'package:go_router/go_router.dart'; -/// 应用主页 class HomePage extends StatelessWidget { - /// 构造函数 const HomePage({super.key}); static const String _baseRoute = '/stream-subscription'; @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Flutter Stream 学习'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return LearningScaffold( + title: 'Flutter Stream 学习', + interactiveDemo: SizedBox( + height: 400, + child: ListView( + padding: const EdgeInsets.all(16), children: [ - const SizedBox(height: 20), - const Center( - child: Text( - 'Stream 基础学习', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 20), - const Text( - 'Stream 是 Flutter 中处理异步数据流的核心工具,其流式推送机制通过持续发送数据事件实现动态响应。', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16), - ), - const SizedBox(height: 40), - _buildExampleCard( + _buildNavCard( context, - title: '单订阅 Stream 示例', - description: '单订阅流只允许一个监听器,适用于点对点通信场景', + title: '单订阅 Stream', + subtitle: '单订阅流只允许一个监听器,适用于点对点通信场景', icon: Icons.person, color: Colors.blue, - onTap: () { - context.push('$_baseRoute/stream-demo'); - }, + route: '$_baseRoute/stream-demo', ), - const SizedBox(height: 20), - _buildExampleCard( + const SizedBox(height: 12), + _buildNavCard( context, - title: '广播 Stream 示例', - description: '广播流允许多个监听器,适用于一对多通信场景', + title: '广播 Stream', + subtitle: '广播流允许多个监听器,适用于一对多通信场景', icon: Icons.people, color: Colors.green, - onTap: () { - context.push('$_baseRoute/broadcast-demo'); - }, + route: '$_baseRoute/broadcast-demo', ), - const SizedBox(height: 20), - _buildExampleCard( + const SizedBox(height: 12), + _buildNavCard( context, title: 'Stream 变换示例', - description: '使用各种操作符对数据流进行变换处理', + subtitle: '使用各种操作符对数据流进行变换处理', icon: Icons.transform, color: Colors.orange, onTap: () { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('此功能暂未实现,敬请期待!'), - ), + const SnackBar(content: Text('此功能暂未实现,敬请期待!')), ); }, ), - const Spacer(), - const Center( - child: Text( - '© 2025 Stream 学习项目', - style: TextStyle( - color: Colors.grey, - fontSize: 12, - ), - ), - ), ], ), ), + sections: [ + LearningObjectives(objectives: [ + '理解 Flutter Stream 的概念与工作原理', + '掌握单订阅 Stream 和广播 Stream 的区别', + '学会使用 StreamController 创建和管理数据流', + '理解 StreamSubscription 的生命周期管理', + ]), + ConceptChips(concepts: [ + 'Stream', + 'StreamController', + 'StreamSubscription', + '单订阅', + '广播', + '异步数据流', + ]), + CodeSnippetCard( + title: '创建广播 Stream', + code: 'final controller = StreamController.broadcast();\n' + 'controller.stream.listen((data) {\n' + ' print("收到: \$data");\n' + '});\n' + 'controller.add("hello");\n' + 'controller.close();', + explanation: 'broadcast() 创建多订阅者流,支持一对多推送。', + ), + CommonPitfalls(pitfalls: [ + '单订阅 Stream 只能有一个监听器 — 添加第二个会抛出 StateError', + 'Stream 使用后必须 close() — 否则会导致内存泄漏', + '广播 Stream 的 onListen/onCancel 回调在第一个/最后一个监听器时触发', + ]), + ExerciseCard( + task: '实现一个带错误处理和 done 回调的 Stream,模拟三次数据推送后自动关闭。', + hint: + '使用 controller.add() 三次后调用 controller.close(),通过 onDone 回调监听关闭事件。', + ), + ], ); } - // 构建示例卡片 - Widget _buildExampleCard( + Widget _buildNavCard( BuildContext context, { required String title, - required String description, + required String subtitle, required IconData icon, required Color color, - required VoidCallback onTap, + String? route, + VoidCallback? onTap, }) { return Card( - elevation: 4, + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: InkWell( - onTap: onTap, + borderRadius: BorderRadius.circular(12), + onTap: route != null ? () => context.push(route) : onTap, child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16), child: Row( children: [ Container( @@ -112,39 +113,24 @@ class HomePage extends StatelessWidget { color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), - child: Icon( - icon, - color: color, - size: 32, - ), + child: Icon(icon, color: color, size: 28), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), + Text(title, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 4), - Text( - description, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), + Text(subtitle, + style: TextStyle( + fontSize: 14, color: Colors.grey.shade600)), ], ), ), - Icon( - Icons.chevron_right, - color: Colors.grey.shade400, - ), + Icon(Icons.chevron_right, color: Colors.grey.shade400), ], ), ), diff --git a/lib/modules/async/stream_subscription/pages/stream_demo_page.dart b/lib/modules/async/stream_subscription/pages/stream_demo_page.dart index 27636ce..a97e6c6 100644 --- a/lib/modules/async/stream_subscription/pages/stream_demo_page.dart +++ b/lib/modules/async/stream_subscription/pages/stream_demo_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + import 'stream_demo_controller.dart'; -/// Stream演示页面 class StreamDemoPage extends StatefulWidget { - /// 构造函数 const StreamDemoPage({super.key}); @override @@ -11,10 +11,7 @@ class StreamDemoPage extends StatefulWidget { } class _StreamDemoPageState extends State { - /// 控制器:封装业务逻辑与状态 late final StreamDemoController controller; - - /// 列表滚动控制器 final ScrollController _scrollController = ScrollController(); @override @@ -39,138 +36,175 @@ class _StreamDemoPageState extends State { @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: controller, - builder: (context, child) => child!, - child: Scaffold( - appBar: AppBar( - title: const Text('Stream 单订阅示例'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '控制面板', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - onPressed: - controller.isPushing ? null : controller.start, - child: const Text('开始推送'), - ), - ElevatedButton( - onPressed: - !controller.isPushing ? null : controller.stop, - child: const Text('停止推送'), - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - onPressed: !controller.isSubscribed - ? null - : controller.unsubscribe, - child: const Text('订阅'), - ), - ElevatedButton( - onPressed: controller.isSubscribed - ? null - : controller.subscribe, - child: const Text('取消订阅'), - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - onPressed: controller.addError, - child: const Text('添加错误'), - ), - ElevatedButton( - onPressed: controller.closeStream, - child: const Text('关闭Stream'), - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('推送间隔: '), - DropdownButton( - value: controller.interval, - items: [1, 2, 3, 5].map((value) { - return DropdownMenuItem( - value: value, - child: Text('$value秒'), - ); - }).toList(), - onChanged: (value) { - if (value != null) controller.setInterval(value); - }, - ), - ], - ), - ], + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + return LearningScaffold( + title: 'Stream 单订阅示例', + interactiveDemo: _buildInteractiveDemo(context), + sections: [ + const LearningObjectives( + objectives: [ + '理解 Stream 的「推」模式——数据从生产者到消费者的异步流动。', + '掌握 StreamController 的创建与事件推送(add、addError、close)。', + '学习 StreamSubscription 的 listen、cancel 生命周期管理。', + '理解单订阅流(single-subscription)只能有一个监听器。', + '掌握定时推送与消息接收的异步协作模式。', + ], + ), + const ConceptChips( + concepts: [ + 'Stream', + 'StreamController', + 'StreamSubscription', + 'listen', + 'cancel', + 'Timer', + 'ChangeNotifier', + ], + ), + const CodeSnippetCard( + title: 'Stream 创建与订阅', + code: '''// 创建 StreamController +final _controller = StreamController(); + +// 推送数据 +_controller.add('消息内容'); + +// 注入错误 +_controller.addError('错误信息'); + +// 订阅流 +_subscription = _controller.stream.listen( + (data) => print('收到: \$data'), + onError: (e) => print('错误: \$e'), + onDone: () => print('流关闭'), +); + +// 取消订阅 +_subscription?.cancel(); + +// 关闭流 +_controller.close();''', + explanation: + 'StreamController 管理数据生产,stream.listen 返回 StreamSubscription 用于管理消费端生命周期。', + ), + Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + height: 120, + child: ListView.builder( + controller: _scrollController, + itemCount: controller.messages.length, + itemBuilder: (context, index) { + return Text( + controller.messages[index], + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12), + ); + }, ), ), ), - const SizedBox(height: 16), - const Text( - '接收到的消息:', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Expanded( - child: Card( - child: controller.messages.isEmpty - ? const Center(child: Text('暂无消息')) - : ListView.builder( - controller: _scrollController, - itemCount: controller.messages.length, - itemBuilder: (context, index) { - return ListTile( - leading: const Icon(Icons.message), - title: Text(controller.messages[index]), - ); - }, - ), - ), - ), - const SizedBox(height: 8), - Text( - '状态: ${controller.isPushing ? "推送中" : "已停止"} | ${controller.isSubscribed ? "已订阅" : "未订阅"}', - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: FontWeight.bold, - color: controller.isPushing ? Colors.green : Colors.red, - ), - ), - ], + ), + const CommonPitfalls( + pitfalls: [ + '单订阅流不能多次调用 listen——第二次调用会抛出异常。需要广播流(broadcast)实现多监听。', + '忘记 cancel() 订阅会导致内存泄漏,StreamController 不会被垃圾回收。', + '未处理 onError 时,错误会冒泡到 Zone 层,可能导致应用崩溃。', + 'Stream 关闭后不能再推送数据,需要重新创建 StreamController。', + 'Timer.periodic 不会自动停止,务必在 dispose 中 cancel。', + ], + ), + const ExerciseCard( + task: '修改代码让 Stream 每次推送的数据中包含当前时间格式化的描述,观察消息列表的变化。', + hint: '修改 start() 方法中的 msg 构造逻辑,使用 DateFormat 或字符串格式化。', + ), + ], + ); + }, + ); + } + + Widget _buildInteractiveDemo(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.tune, + size: 18, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Text( + '控制面板', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton( + onPressed: controller.isPushing ? null : controller.start, + child: const Text('开始推送'), + ), + ElevatedButton( + onPressed: !controller.isPushing ? null : controller.stop, + child: const Text('停止推送'), + ), + ElevatedButton( + onPressed: !controller.isSubscribed ? null : controller.subscribe, + child: const Text('订阅'), + ), + ElevatedButton( + onPressed: + controller.isSubscribed ? null : controller.unsubscribe, + child: const Text('取消订阅'), + ), + ElevatedButton( + onPressed: controller.addError, + child: const Text('添加错误'), + ), + ElevatedButton( + onPressed: controller.closeStream, + child: const Text('关闭Stream'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('推送间隔: '), + DropdownButton( + value: controller.interval, + items: const [1, 2, 3, 5].map((value) { + return DropdownMenuItem( + value: value, + child: Text('$value秒'), + ); + }).toList(), + onChanged: (value) { + if (value != null) controller.setInterval(value); + }, + ), + ], + ), + const SizedBox(height: 8), + Text( + '状态: ${controller.isPushing ? "推送中" : "已停止"} | ${controller.isSubscribed ? "已订阅" : "未订阅"}', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + color: controller.isPushing ? Colors.green : Colors.red, ), ), - ), + ], ); } } diff --git a/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md b/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md index eb14ae0..50c1980 100644 --- a/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md +++ b/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md @@ -8,11 +8,22 @@ "path": "lib/modules/basic/debounce_throttle", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["module_registry"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], "files": ["module_entry.dart","module_root.dart","utils/debounce_throttle.dart"], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/basic/debounce_throttle/module_root.dart b/lib/modules/basic/debounce_throttle/module_root.dart index 2372650..89aecd7 100644 --- a/lib/modules/basic/debounce_throttle/module_root.dart +++ b/lib/modules/basic/debounce_throttle/module_root.dart @@ -1,5 +1,8 @@ -import 'utils/debounce_throttle.dart' show Debouncer, Throttle; +// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + +import 'utils/debounce_throttle.dart'; class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); @@ -14,93 +17,145 @@ class _MyHomePageState extends State with TickerProviderStateMixin { int _currentPageIndex = 0; final PageController _pageController = PageController(); + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Column( - children: [ - // 场景选择器 - Container( - padding: const EdgeInsets.all(10), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: _currentPageIndex == 0 - ? Theme.of(context).colorScheme.primaryContainer - : null, + return LearningScaffold( + title: widget.title, + interactiveDemo: SizedBox( + height: 500, + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _currentPageIndex == 0 + ? Theme.of(context).colorScheme.primaryContainer + : null, + ), + onPressed: () { + setState(() => _currentPageIndex = 0); + _pageController.animateToPage(0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut); + }, + child: const Text('按钮点击场景'), ), - onPressed: () { - setState(() => _currentPageIndex = 0); - _pageController.animateToPage(0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut); - }, - child: const Text('按钮点击场景'), ), - ), - const SizedBox(width: 10), - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: _currentPageIndex == 1 - ? Theme.of(context).colorScheme.primaryContainer - : null, + const SizedBox(width: 10), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _currentPageIndex == 1 + ? Theme.of(context).colorScheme.primaryContainer + : null, + ), + onPressed: () { + setState(() => _currentPageIndex = 1); + _pageController.animateToPage(1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut); + }, + child: const Text('滚动场景'), ), - onPressed: () { - setState(() => _currentPageIndex = 1); - _pageController.animateToPage(1, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut); - }, - child: const Text('滚动场景'), ), - ), - ], + ], + ), ), - ), - - // 防抖节流描述 - Container( - padding: const EdgeInsets.all(16), - color: Colors.grey[100], - child: const Column( - children: [ - Text( - '防抖(Debounce):在一段时间内多次触发事件,只执行最后一次。', - style: - TextStyle(fontWeight: FontWeight.bold, color: Colors.red), - ), - SizedBox(height: 8), - Text( - '节流(Throttle):在一段时间内多次触发事件,只执行第一次。', - style: TextStyle( - fontWeight: FontWeight.bold, color: Colors.blue), - ), - ], + Container( + padding: const EdgeInsets.all(16), + color: Colors.grey[100], + child: const Column( + children: [ + Text( + '防抖(Debounce):在一段时间内多次触发事件,只执行最后一次。', + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.red), + ), + SizedBox(height: 8), + Text( + '节流(Throttle):在一段时间内多次触发事件,只执行第一次。', + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.blue), + ), + ], + ), ), - ), - - // 场景内容 - Expanded( - child: PageView( - controller: _pageController, - onPageChanged: (index) { - setState(() => _currentPageIndex = index); - }, - children: const [ - ButtonScene(), - ScrollScene(), - ], + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: (index) { + setState(() => _currentPageIndex = index); + }, + children: const [ + ButtonScene(), + ScrollScene(), + ], + ), ), - ), - ], + ], + ), ), + sections: [ + LearningObjectives(objectives: [ + '理解防抖(Debounce)与节流(Throttle)的核心区别', + '掌握 Debouncer 和 Throttle 的代码实现', + '学会在实际场景中选择合适的频率控制策略', + ]), + ConceptChips(concepts: [ + 'Debounce', + 'Throttle', + 'Timer', + '频率控制', + '性能优化', + ]), + CodeSnippetCard( + title: 'Debouncer 实现', + code: 'class Debouncer {\n' + ' final Duration delay;\n' + ' Timer? _timer;\n\n' + ' void run(VoidCallback action) {\n' + ' _timer?.cancel();\n' + ' _timer = Timer(delay, action);\n' + ' }\n\n' + ' void dispose() => _timer?.cancel();\n' + '}', + explanation: '防抖在延迟时间内重置计时器,只有最后一次触发生效。', + ), + CodeSnippetCard( + title: 'Throttle 实现', + code: 'class Throttle {\n' + ' final Duration limit;\n' + ' DateTime? _lastCall;\n\n' + ' void run(VoidCallback action) {\n' + ' final now = DateTime.now();\n' + ' if (_lastCall != null &&\n' + ' now.difference(_lastCall!) < limit) return;\n' + ' _lastCall = now;\n' + ' action();\n' + ' }\n' + '}', + explanation: '节流在限制时间内忽略后续触发,只有第一次生效。', + ), + CommonPitfalls(pitfalls: [ + '防抖延迟过长会降低响应感 — 按钮点击场景建议 300-500ms,滚动场景可适当延长', + '节流可能会导致关键更新丢失 — 不适合需要实时反馈的场景', + '忘记 dispose — Timer 和 StreamSubscription 必须在 dispose 中清理', + ]), + ExerciseCard( + task: '实现一个"先执行一次"的防抖(leading edge debounce),首次点击立即执行,后续连续点击只执行最后一次。', + hint: '在 Debouncer 中增加 _leadingExecuted 标记,首次调用时立即执行再启动延迟。', + ), + ], ); } } @@ -118,17 +173,14 @@ class _ButtonSceneState extends State int _throttleCount = 0; int _normalCount = 0; - // 普通事件、防抖事件和节流事件的处理次数 final List _normalEvents = []; final List _debounceEvents = []; final List _throttleEvents = []; - // 用于展示事件触发的动画控制器 late AnimationController _normalAnim; late AnimationController _debounceAnim; late AnimationController _throttleAnim; - // 防抖和节流实例 final Debouncer _debouncer = Debouncer(delay: const Duration(milliseconds: 500)); final Throttle _throttler = @@ -156,41 +208,38 @@ class _ButtonSceneState extends State _normalAnim.dispose(); _debounceAnim.dispose(); _throttleAnim.dispose(); + _debouncer.dispose(); super.dispose(); } - // 添加事件记录 - void _addEventRecord(List list, int count) { + void _addEventRecord(List list) { setState(() { - list.add(count); + list.add(DateTime.now().millisecondsSinceEpoch); if (list.length > 10) { list.removeAt(0); } }); } - // 处理普通点击 void _handleNormalClick() { setState(() => _normalCount++); _normalAnim.forward(from: 0); - _addEventRecord(_normalEvents, _normalCount); + _addEventRecord(_normalEvents); } - // 处理防抖点击 void _handleDebounceClick() { _debounceAnim.forward(from: 0); _debouncer.run(() { setState(() => _debounceCount++); - _addEventRecord(_debounceEvents, _debounceCount); + _addEventRecord(_debounceEvents); }); } - // 处理节流点击 void _handleThrottleClick() { _throttleAnim.forward(from: 0); _throttler.run(() { setState(() => _throttleCount++); - _addEventRecord(_throttleEvents, _throttleCount); + _addEventRecord(_throttleEvents); }); } @@ -206,35 +255,18 @@ class _ButtonSceneState extends State style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 20), - - // 按钮区域 Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildAnimatedButton( - '普通点击', - Colors.grey, - _handleNormalClick, - _normalAnim, - ), + '普通点击', Colors.grey, _handleNormalClick, _normalAnim), _buildAnimatedButton( - '防抖点击', - Colors.red, - _handleDebounceClick, - _debounceAnim, - ), + '防抖点击', Colors.red, _handleDebounceClick, _debounceAnim), _buildAnimatedButton( - '节流点击', - Colors.blue, - _handleThrottleClick, - _throttleAnim, - ), + '节流点击', Colors.blue, _handleThrottleClick, _throttleAnim), ], ), - const SizedBox(height: 20), - - // 计数器显示 Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -243,13 +275,10 @@ class _ButtonSceneState extends State _buildCounter('节流:', _throttleCount, Colors.blue), ], ), - const SizedBox(height: 30), const Text('事件触发可视化:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 10), - - // 事件触发可视化区域 Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -265,51 +294,32 @@ class _ButtonSceneState extends State ); } - // 创建带动画的按钮 Widget _buildAnimatedButton( String text, Color color, VoidCallback onTap, AnimationController anim) { - return Column( - children: [ - ScaleTransition( - scale: CurvedAnimation( - parent: anim, - curve: Curves.elasticOut, - ), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: color.withValues(alpha: 0.2), - foregroundColor: color, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - ), - onPressed: () { - anim.forward(from: 0.0); // 添加按钮点击动画触发 - onTap(); // 触发点击回调 - }, - child: - Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), - ), + return ScaleTransition( + scale: CurvedAnimation(parent: anim, curve: Curves.elasticOut), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: color.withValues(alpha: 0.2), + foregroundColor: color, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), - ], + onPressed: onTap, + child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), + ), ); } - // 创建计数器显示 Widget _buildCounter(String prefix, int count, Color color) { return Column( children: [ - Text( - '$prefix $count', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: color, - ), - ), + Text('$prefix $count', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, color: color)), ], ); } - // 创建事件触发可视化组件 Widget _buildEventVisualization(String title, List events, Color color) { return Expanded( child: Container( @@ -328,9 +338,8 @@ class _ButtonSceneState extends State itemCount: events.length, reverse: true, itemBuilder: (context, index) { - final timestamp = events[index]; return Text( - DateTime.fromMillisecondsSinceEpoch(timestamp) + DateTime.fromMillisecondsSinceEpoch(events[index]) .toString() .substring(11, 19), style: TextStyle(color: color.withValues(alpha: 0.8)), @@ -374,17 +383,12 @@ class _ScrollSceneState extends State duration: const Duration(milliseconds: 300), ); - // 滚动监听 _scrollController.addListener(() { setState(() => _scrollPosition = _scrollController.offset); - - // 防抖处理滚动位置 _debouncer.run(() { setState(() => _debouncePosition = _scrollController.offset); _positionAnimController.forward(from: 0); }); - - // 节流处理滚动位置 _throttler.run(() { setState(() => _throttlePosition = _scrollController.offset); _positionAnimController.forward(from: 0); @@ -396,6 +400,7 @@ class _ScrollSceneState extends State void dispose() { _scrollController.dispose(); _positionAnimController.dispose(); + _debouncer.dispose(); super.dispose(); } @@ -403,7 +408,6 @@ class _ScrollSceneState extends State Widget build(BuildContext context) { return Column( children: [ - // 滚动位置指示器 Container( padding: const EdgeInsets.all(16), color: Colors.grey[200], @@ -428,13 +432,19 @@ class _ScrollSceneState extends State ], ), ), - - // 可滚动列表 Expanded( child: ListView.builder( controller: _scrollController, itemCount: 100, itemBuilder: (context, index) { + final double itemPosition = index * 100.0; + final bool isNearReal = + (_scrollPosition - itemPosition).abs() < 200; + final bool isNearDebounce = + (_debouncePosition - itemPosition).abs() < 200; + final bool isNearThrottle = + (_throttlePosition - itemPosition).abs() < 200; + return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Padding( @@ -442,13 +452,18 @@ class _ScrollSceneState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '列表项 #$index', - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 16), - ), + Text('列表项 #$index', + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 16)), const SizedBox(height: 8), - _buildPositionVisualization(index), + Row( + children: [ + _buildPositionDot( + '实时', isNearReal, Colors.grey[800]!), + _buildPositionDot('防抖', isNearDebounce, Colors.red), + _buildPositionDot('节流', isNearThrottle, Colors.blue), + ], + ), ], ), ), @@ -460,19 +475,13 @@ class _ScrollSceneState extends State ); } - // 创建位置指示器 Widget _buildPositionIndicator(String title, double position, Color color) { return Row( children: [ SizedBox( width: 100, - child: Text( - title, - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, - ), - ), + child: Text(title, + style: TextStyle(color: color, fontWeight: FontWeight.bold)), ), Expanded( child: Container( @@ -500,30 +509,21 @@ class _ScrollSceneState extends State const SizedBox(width: 8), SizedBox( width: 60, - child: Text( - '${position.toStringAsFixed(0)}px', - textAlign: TextAlign.right, - style: TextStyle(color: color), - ), + child: Text('${position.toStringAsFixed(0)}px', + textAlign: TextAlign.right, style: TextStyle(color: color)), ), ], ); } - // 创建带动画的位置指示器 Widget _buildAnimatedPositionIndicator( String title, double position, Color color) { return Row( children: [ SizedBox( width: 80, - child: Text( - title, - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, - ), - ), + child: Text(title, + style: TextStyle(color: color, fontWeight: FontWeight.bold)), ), Expanded( child: Stack( @@ -560,33 +560,13 @@ class _ScrollSceneState extends State const SizedBox(width: 8), SizedBox( width: 60, - child: Text( - '${position.toStringAsFixed(0)}px', - textAlign: TextAlign.right, - style: TextStyle(color: color), - ), + child: Text('${position.toStringAsFixed(0)}px', + textAlign: TextAlign.right, style: TextStyle(color: color)), ), ], ); } - // 为列表项创建位置可视化 - Widget _buildPositionVisualization(int index) { - final double itemPosition = index * 100.0; // 模拟列表项位置 - final bool isNearReal = (_scrollPosition - itemPosition).abs() < 200; - final bool isNearDebounce = (_debouncePosition - itemPosition).abs() < 200; - final bool isNearThrottle = (_throttlePosition - itemPosition).abs() < 200; - - return Row( - children: [ - _buildPositionDot('实时', isNearReal, Colors.grey[800]!), - _buildPositionDot('防抖', isNearDebounce, Colors.red), - _buildPositionDot('节流', isNearThrottle, Colors.blue), - ], - ); - } - - // 创建位置指示点 Widget _buildPositionDot(String label, bool isActive, Color color) { return Padding( padding: const EdgeInsets.only(right: 16.0), @@ -602,13 +582,11 @@ class _ScrollSceneState extends State ), ), const SizedBox(width: 4), - Text( - label, - style: TextStyle( - color: color, - fontWeight: isActive ? FontWeight.bold : FontWeight.normal, - ), - ), + Text(label, + style: TextStyle( + color: color, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + )), ], ), ); diff --git a/lib/modules/basic/debounce_throttle/utils/debounce_throttle.dart b/lib/modules/basic/debounce_throttle/utils/debounce_throttle.dart index afe6e33..397f43a 100644 --- a/lib/modules/basic/debounce_throttle/utils/debounce_throttle.dart +++ b/lib/modules/basic/debounce_throttle/utils/debounce_throttle.dart @@ -10,6 +10,10 @@ class Debouncer { _timer?.cancel(); _timer = Timer(delay, action); } + + void dispose() { + _timer?.cancel(); + } } class Throttle { diff --git a/lib/modules/basic/microtask/AI_ANALYSIS.md b/lib/modules/basic/microtask/AI_ANALYSIS.md index fd01468..097b752 100644 --- a/lib/modules/basic/microtask/AI_ANALYSIS.md +++ b/lib/modules/basic/microtask/AI_ANALYSIS.md @@ -8,11 +8,32 @@ "path": "lib/modules/basic/microtask", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["module_registry","go_router"], + "entrypoints": ["module_entry.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry","go_router"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["core/models/event_log.dart","core/widgets/code_snippet_view.dart","core/widgets/event_log_view.dart","features/advanced_examples/advanced_examples_page.dart","features/event_queue/event_queue_page.dart","features/home_page.dart","features/microtask_queue/microtask_queue_page.dart","module_entry.dart","module_routes.dart"], + "files": [ + "module_entry.dart", + "module_routes.dart", + "models/event_log.dart", + "widgets/code_snippet_view.dart", + "widgets/event_log_view.dart", + "pages/home_page.dart", + "pages/event_queue_page.dart", + "pages/microtask_queue_page.dart", + "pages/advanced_examples_page.dart" + ], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/basic/microtask/features/advanced_examples/advanced_examples_page.dart b/lib/modules/basic/microtask/features/advanced_examples/advanced_examples_page.dart deleted file mode 100644 index 7ce6ae6..0000000 --- a/lib/modules/basic/microtask/features/advanced_examples/advanced_examples_page.dart +++ /dev/null @@ -1,421 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import '../../core/models/event_log.dart'; -import '../../core/widgets/event_log_view.dart'; -import '../../core/widgets/code_snippet_view.dart'; - -class AdvancedExamplesPage extends StatefulWidget { - const AdvancedExamplesPage({super.key}); - - @override - State createState() => _AdvancedExamplesPageState(); -} - -class _AdvancedExamplesPageState extends State - with SingleTickerProviderStateMixin { - final List _logs = []; - bool _isRunning = false; - final ScrollController _scrollController = ScrollController(); - bool _showTimestamps = true; - late TabController _tabController; - - final Map _codeExamples = { - 'async/await': r''' -Future example() async { - print('函数开始'); - - // await之前的代码是同步执行的 - print('await之前的代码'); - - // await会暂停函数,并将await之后的代码包装成微任务 - await Future(() { - print('await的Future执行'); - }); - - // 这部分代码会被包装成微任务 - print('await之后的代码'); - - await Future.delayed(Duration(milliseconds: 500)); - - // 第二个await之后的代码也会被包装成微任务 - print('第二个await之后的代码'); -}''', - 'Future.value': r''' -// Future.value会立即完成并将then回调添加到微任务队列 -Future.value('immediate value').then((value) { - print('Future.value微任务: $value'); -}); - -// 对比普通Future -Future(() { - return 'computed value'; -}).then((value) { - print('普通Future: $value'); -});''', - 'Future链式调用': r''' -Future(() => print('初始Future')) - .then((_) => print('第一个then微任务')) - .then((_) { - print('第二个then微任务'); - return Future(() => print('嵌套事件任务')); - }) - .then((_) => print('第三个then微任务'));''', - 'Zone': r''' -import 'dart:async'; - -// Zone可以拦截和修改异步操作 -runZoned(() { - Future(() => print('在自定义Zone中执行的Future')); - scheduleMicrotask(() => print('在自定义Zone中执行的微任务')); -}, zoneSpecification: ZoneSpecification( - scheduleMicrotask: (Zone self, ZoneDelegate parent, Zone zone, void Function() f) { - print('微任务被调度'); - parent.scheduleMicrotask(zone, f); - }, - createTimer: (Zone self, ZoneDelegate parent, Zone zone, - Duration duration, void Function() f) { - print('计时器被创建,持续时间: $duration'); - return parent.createTimer(zone, duration, f); - }, -));''', - }; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 4, vsync: this); - } - - @override - void dispose() { - _scrollController.dispose(); - _tabController.dispose(); - super.dispose(); - } - - void _scrollToBottom() { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - } - - void _addLog(String message, EventType type) { - if (!_isRunning) return; - - setState(() { - _logs.add(EventLog( - message: message, - type: type, - id: _logs.length + 1, - )); - }); - - // 使用微任务来确保在当前帧渲染后滚动到底部 - scheduleMicrotask(_scrollToBottom); - } - - void _clearLogs() { - setState(() { - _logs.clear(); - }); - } - - void _runAsyncAwaitTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始async/await测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - _addLog('即将调用async函数', EventType.sync); - await _executeAsyncFunction(); - _addLog('async函数调用完成', EventType.sync); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 2), () { - _addLog('async/await测试结束', EventType.info); - _isRunning = false; - }); - } - - Future _executeAsyncFunction() async { - _addLog('async函数开始', EventType.sync); - - // await之前的代码是同步执行的 - _addLog('await之前的代码', EventType.sync); - - // await会暂停当前函数,并将await之后的代码作为微任务 - await Future(() { - _addLog('await的Future执行', EventType.event); - }); - - // await之后的代码会被包装成微任务 - _addLog('await之后的代码', EventType.microtask); - - // 再次await - await Future.delayed(const Duration(milliseconds: 500), () { - _addLog('第二个await的Future执行(延迟500ms)', EventType.event); - }); - - // 第二个await之后的代码也会被包装成微任务 - _addLog('第二个await之后的代码', EventType.microtask); - - _addLog('async函数结束', EventType.microtask); - } - - void _runFutureValueTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始Future.value测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - // 使用Future.value - Future.value('立即值').then((value) { - _addLog('Future.value微任务: $value', EventType.microtask); - }); - - // 对比普通Future - Future(() { - _addLog('普通Future事件任务执行', EventType.event); - return '计算值'; - }).then((value) { - _addLog('普通Future的then回调: $value', EventType.microtask); - }); - - // 添加一些额外的微任务和事件任务 - scheduleMicrotask(() { - _addLog('独立的微任务', EventType.microtask); - }); - - Future(() { - _addLog('独立的事件任务', EventType.event); - }); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 2), () { - _addLog('Future.value测试结束', EventType.info); - _isRunning = false; - }); - } - - void _runFutureChainTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始Future链式调用测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - Future(() { - _addLog('初始Future', EventType.event); - }).then((_) { - _addLog('第一个then微任务', EventType.microtask); - }).then((_) { - _addLog('第二个then微任务', EventType.microtask); - - // 在then回调中返回一个新的Future - return Future(() { - _addLog('嵌套事件任务', EventType.event); - }); - }).then((_) { - _addLog('第三个then微任务', EventType.microtask); - }); - - // 添加一些额外的任务来对比执行顺序 - scheduleMicrotask(() { - _addLog('独立的微任务', EventType.microtask); - }); - - Future(() { - _addLog('独立的事件任务', EventType.event); - }); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 2), () { - _addLog('Future链式调用测试结束', EventType.info); - _isRunning = false; - }); - } - - void _runZoneTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始Zone测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - // 创建一个自定义Zone来拦截异步操作 - runZoned(() { - _addLog('进入自定义Zone', EventType.sync); - - Future(() { - _addLog('在自定义Zone中执行的Future', EventType.event); - }); - - scheduleMicrotask(() { - _addLog('在自定义Zone中执行的微任务', EventType.microtask); - }); - - _addLog('离开自定义Zone', EventType.sync); - }, - zoneSpecification: ZoneSpecification( - scheduleMicrotask: - (Zone self, ZoneDelegate parent, Zone zone, void Function() f) { - _addLog('微任务被调度', EventType.info); - parent.scheduleMicrotask(zone, f); - }, - createTimer: (Zone self, ZoneDelegate parent, Zone zone, - Duration duration, void Function() f) { - _addLog('计时器被创建,持续时间: $duration', EventType.info); - return parent.createTimer(zone, duration, f); - }, - )); - - // 在主Zone中添加一些任务 - Future(() { - _addLog('在主Zone中执行的Future', EventType.event); - }); - - scheduleMicrotask(() { - _addLog('在主Zone中执行的微任务', EventType.microtask); - }); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 2), () { - _addLog('Zone测试结束', EventType.info); - _isRunning = false; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('高级事件机制演示'), - bottom: TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'async/await'), - Tab(text: 'Future.value'), - Tab(text: 'Future链'), - Tab(text: 'Zone'), - ], - ), - actions: [ - IconButton( - icon: Icon(_showTimestamps ? Icons.timer : Icons.timer_off), - onPressed: () { - setState(() { - _showTimestamps = !_showTimestamps; - }); - }, - tooltip: _showTimestamps ? '隐藏时间戳' : '显示时间戳', - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: _clearLogs, - tooltip: '清除日志', - ), - ], - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - SizedBox( - height: 200, - child: TabBarView( - controller: _tabController, - children: [ - _buildTabContent( - 'async/await', - 'async/await语法是Future的语法糖,使异步代码看起来像同步代码。' - 'await会暂停函数执行,并将await之后的代码包装成微任务。', - _runAsyncAwaitTest, - ), - _buildTabContent( - 'Future.value', - 'Future.value会立即完成一个Future,不会将回调添加到事件队列,' - '而是直接将then回调添加到微任务队列。', - _runFutureValueTest, - ), - _buildTabContent( - 'Future链式调用', - 'Future的then方法返回一个新的Future,可以形成链式调用。' - '每个then回调都会被添加到微任务队列,而不是事件队列。', - _runFutureChainTest, - ), - _buildTabContent( - 'Zone', - 'Zone允许拦截和修改异步操作,如调度微任务和创建计时器。' - '可用于错误处理、性能追踪和测试。', - _runZoneTest, - ), - ], - ), - ), - const SizedBox(height: 16), - const Text( - '执行日志', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Expanded( - child: EventLogView( - logs: _logs, - showTimestamp: _showTimestamps, - scrollController: _scrollController, - ), - ), - ], - ), - ), - ); - } - - Widget _buildTabContent( - String title, String description, VoidCallback onRun) { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Text( - description, - style: const TextStyle(fontSize: 16), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: onRun, - icon: const Icon(Icons.play_arrow), - label: Text('运行$title测试'), - ), - const SizedBox(height: 8), - CodeSnippetView( - title: '$title 示例代码', - code: _codeExamples[title] ?? '', - ), - ], - ), - ); - } -} diff --git a/lib/modules/basic/microtask/features/event_queue/event_queue_page.dart b/lib/modules/basic/microtask/features/event_queue/event_queue_page.dart deleted file mode 100644 index 176e9c9..0000000 --- a/lib/modules/basic/microtask/features/event_queue/event_queue_page.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import '../../core/models/event_log.dart'; -import '../../core/widgets/event_log_view.dart'; -import '../../core/widgets/code_snippet_view.dart'; - -class EventQueuePage extends StatefulWidget { - const EventQueuePage({super.key}); - - @override - State createState() => _EventQueuePageState(); -} - -class _EventQueuePageState extends State { - final List _logs = []; - bool _isRunning = false; - final ScrollController _scrollController = ScrollController(); - bool _showTimestamps = true; - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _scrollToBottom() { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - } - - void _addLog(String message, EventType type) { - if (!_isRunning) return; - - setState(() { - _logs.add(EventLog( - message: message, - type: type, - id: _logs.length + 1, - )); - }); - - // 使用微任务来确保在当前帧渲染后滚动到底部 - scheduleMicrotask(_scrollToBottom); - } - - void _clearLogs() { - setState(() { - _logs.clear(); - }); - } - - void _runBasicEventTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始事件队列测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - // 使用Future()添加到事件队列 - Future(() { - _addLog('Future() 执行', EventType.event); - }); - - // 使用Future.delayed添加到事件队列,延迟0.5秒 - Future.delayed(const Duration(milliseconds: 500), () { - _addLog('Future.delayed 0.5秒后执行', EventType.event); - }); - - // 使用Future.delayed添加到事件队列,延迟1秒 - Future.delayed(const Duration(seconds: 1), () { - _addLog('Future.delayed 1秒后执行', EventType.event); - }); - - // 使用Future.delayed添加到事件队列,延迟2秒 - Future.delayed(const Duration(seconds: 2), () { - _addLog('Future.delayed 2秒后执行', EventType.event); - }); - - // 使用Timer.run添加到事件队列 - Timer.run(() { - _addLog('Timer.run 执行', EventType.event); - }); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 3), () { - _addLog('事件队列测试结束', EventType.info); - _isRunning = false; - }); - } - - void _runIoEventTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始IO事件测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - // 模拟网络请求或IO操作 - Future(() { - _addLog('模拟IO操作开始', EventType.event); - - // 模拟IO操作处理时间 - return Future.delayed(const Duration(seconds: 1), () { - return '数据加载完成'; - }); - }).then((result) { - _addLog('IO操作结果: $result', EventType.event); - }); - - // 模拟多个并发IO操作 - Future.wait([ - Future.delayed(const Duration(milliseconds: 800), () { - _addLog('并发IO操作1完成', EventType.event); - return 'Result 1'; - }), - Future.delayed(const Duration(milliseconds: 1200), () { - _addLog('并发IO操作2完成', EventType.event); - return 'Result 2'; - }), - ]).then((results) { - _addLog('所有并发操作完成: $results', EventType.event); - }); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 3), () { - _addLog('IO事件测试结束', EventType.info); - _isRunning = false; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('事件队列演示'), - actions: [ - IconButton( - icon: Icon(_showTimestamps ? Icons.timer : Icons.timer_off), - onPressed: () { - setState(() { - _showTimestamps = !_showTimestamps; - }); - }, - tooltip: _showTimestamps ? '隐藏时间戳' : '显示时间戳', - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: _clearLogs, - tooltip: '清除日志', - ), - ], - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '事件队列介绍', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - const Text( - '事件队列是 Flutter 事件循环的一部分,用于处理异步操作如 I/O、计时器等。事件队列中的任务会在微任务队列清空后执行。', - style: TextStyle(fontSize: 16), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: _runBasicEventTest, - icon: const Icon(Icons.play_arrow), - label: const Text('基础事件队列测试'), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton.icon( - onPressed: _runIoEventTest, - icon: const Icon(Icons.play_arrow), - label: const Text('IO事件测试'), - ), - ), - ], - ), - const SizedBox(height: 16), - const Text( - '代码示例', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const CodeSnippetView( - title: 'Event Queue 示例代码', - code: ''' -// 使用Future()添加到事件队列 -Future(() { - print('事件队列任务执行'); -}); - -// 使用Future.delayed添加到事件队列并延迟执行 -Future.delayed(Duration(seconds: 1), () { - print('延迟1秒后执行的事件队列任务'); -}); - -// 使用Timer.run添加到事件队列 -Timer.run(() { - print('Timer事件队列任务执行'); -}); -''', - ), - const SizedBox(height: 16), - const Text( - '执行日志', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Expanded( - child: EventLogView( - logs: _logs, - showTimestamp: _showTimestamps, - scrollController: _scrollController, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/modules/basic/microtask/features/home_page.dart b/lib/modules/basic/microtask/features/home_page.dart deleted file mode 100644 index bb98779..0000000 --- a/lib/modules/basic/microtask/features/home_page.dart +++ /dev/null @@ -1,287 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - static const String _baseRoute = '/microtask'; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Flutter 事件机制学习'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Flutter 事件循环与异步编程', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - const Text( - 'Flutter的事件处理机制基于事件循环(Event Loop)模型,包含两个关键队列:', - style: TextStyle(fontSize: 16), - ), - const SizedBox(height: 8), - _buildInfoCard( - title: '微任务队列 (Microtask Queue)', - description: '处理高优先级的异步操作,总是在事件任务之前执行', - icon: Icons.fast_forward, - color: Colors.blue, - ), - const SizedBox(height: 8), - _buildInfoCard( - title: '事件队列 (Event Queue)', - description: '处理普通异步操作,如I/O、计时器和用户输入等', - icon: Icons.event, - color: Colors.red, - ), - const SizedBox(height: 24), - const Text( - '学习内容', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Expanded( - child: GridView.count( - crossAxisCount: 2, - childAspectRatio: 1.5, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - children: [ - _buildNavigationCard( - context, - title: '事件队列', - description: '学习事件队列的基本概念和使用方式', - icon: Icons.queue, - color: Colors.red.shade100, - routePath: '$_baseRoute/event-queue', - ), - _buildNavigationCard( - context, - title: '微任务队列', - description: '学习微任务队列的特性和优先级', - icon: Icons.speed, - color: Colors.blue.shade100, - routePath: '$_baseRoute/microtask-queue', - ), - _buildNavigationCard( - context, - title: '高级示例', - description: '探索async/await、Future链和Zone', - icon: Icons.code, - color: Colors.purple.shade100, - routePath: '$_baseRoute/advanced', - ), - _buildResourceCard( - title: '学习资源', - description: '查看更多关于Flutter事件机制的学习资源', - icon: Icons.book, - color: Colors.green.shade100, - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildInfoCard({ - required String title, - required String description, - required IconData icon, - required Color color, - }) { - return Card( - elevation: 2, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: color), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Text( - description, - style: TextStyle(color: Colors.grey[700]), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildNavigationCard( - BuildContext context, { - required String title, - required String description, - required IconData icon, - required Color color, - required String routePath, - }) { - return Card( - clipBehavior: Clip.antiAlias, - elevation: 3, - child: InkWell( - onTap: () => context.push(routePath), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [color, color.withValues(alpha: 0.7)], - ), - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, color: Colors.grey[800]), - const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - color: Colors.grey[800], - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - description, - style: TextStyle(color: Colors.grey[800]), - ), - const Spacer(), - Align( - alignment: Alignment.bottomRight, - child: Icon( - Icons.arrow_forward, - color: Colors.grey[800], - size: 20, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildResourceCard({ - required String title, - required String description, - required IconData icon, - required Color color, - }) { - return Card( - clipBehavior: Clip.antiAlias, - elevation: 3, - child: InkWell( - onTap: () { - // 实际项目中可能会打开一个资源列表页面 - }, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [color, color.withValues(alpha: 0.7)], - ), - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, color: Colors.grey[800]), - const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - color: Colors.grey[800], - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - description, - style: TextStyle(color: Colors.grey[800]), - ), - const Spacer(), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildResourceLink('Flutter官方文档'), - _buildResourceLink('Dart异步编程指南'), - _buildResourceLink('事件循环和隔离区'), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildResourceLink(String text) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - Icon(Icons.link, size: 14, color: Colors.grey[700]), - const SizedBox(width: 4), - Text( - text, - style: TextStyle( - color: Colors.grey[800], - fontSize: 12, - decoration: TextDecoration.underline, - ), - ), - ], - ), - ); - } -} diff --git a/lib/modules/basic/microtask/features/microtask_queue/microtask_queue_page.dart b/lib/modules/basic/microtask/features/microtask_queue/microtask_queue_page.dart deleted file mode 100644 index 7f1824e..0000000 --- a/lib/modules/basic/microtask/features/microtask_queue/microtask_queue_page.dart +++ /dev/null @@ -1,300 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import '../../core/models/event_log.dart'; -import '../../core/widgets/event_log_view.dart'; -import '../../core/widgets/code_snippet_view.dart'; - -class MicrotaskQueuePage extends StatefulWidget { - const MicrotaskQueuePage({super.key}); - - @override - State createState() => _MicrotaskQueuePageState(); -} - -class _MicrotaskQueuePageState extends State { - final List _logs = []; - bool _isRunning = false; - final ScrollController _scrollController = ScrollController(); - bool _showTimestamps = true; - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _scrollToBottom() { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - } - - void _addLog(String message, EventType type) { - if (!_isRunning) return; - - setState(() { - _logs.add(EventLog( - message: message, - type: type, - id: _logs.length + 1, - )); - }); - - // 使用微任务来确保在当前帧渲染后滚动到底部 - scheduleMicrotask(_scrollToBottom); - } - - void _clearLogs() { - setState(() { - _logs.clear(); - }); - } - - void _runBasicMicrotaskTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始微任务队列测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - // 添加事件任务 - Future(() { - _addLog('事件任务执行', EventType.event); - }); - - // 使用scheduleMicrotask添加微任务 - scheduleMicrotask(() { - _addLog('scheduleMicrotask 微任务1执行', EventType.microtask); - }); - - // 使用Future.microtask添加微任务 - Future.microtask(() { - _addLog('Future.microtask 微任务2执行', EventType.microtask); - }); - - // 再添加一个事件任务 - Future(() { - _addLog('另一个事件任务执行', EventType.event); - }); - - // 再添加一个微任务 - scheduleMicrotask(() { - _addLog('scheduleMicrotask 微任务3执行', EventType.microtask); - }); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 2), () { - _addLog('微任务队列测试结束', EventType.info); - _isRunning = false; - }); - } - - void _runThenMicrotaskTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始Future.then微任务测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - // Future.then 会将回调注册为微任务 - Future(() { - _addLog('Future事件任务执行', EventType.event); - }).then((_) { - _addLog('Future.then微任务1执行', EventType.microtask); - }).then((_) { - _addLog('Future.then微任务2执行', EventType.microtask); - }); - - // 另外添加一个事件任务和微任务,观察执行顺序 - Future(() { - _addLog('另一个事件任务执行', EventType.event); - }); - - scheduleMicrotask(() { - _addLog('scheduleMicrotask 微任务执行', EventType.microtask); - }); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 2), () { - _addLog('Future.then微任务测试结束', EventType.info); - _isRunning = false; - }); - } - - void _runNestedMicrotaskTest() async { - if (_isRunning) return; - _isRunning = true; - _clearLogs(); - - _addLog('开始嵌套微任务测试', EventType.info); - _addLog('代码开始执行', EventType.sync); - - // 外层微任务中嵌套其他微任务 - scheduleMicrotask(() { - _addLog('外层微任务1执行', EventType.microtask); - - // 在微任务中添加新的微任务 - scheduleMicrotask(() { - _addLog('嵌套微任务1执行', EventType.microtask); - }); - - // 添加另一个嵌套微任务 - scheduleMicrotask(() { - _addLog('嵌套微任务2执行', EventType.microtask); - - // 再嵌套一层微任务 - scheduleMicrotask(() { - _addLog('二层嵌套微任务执行', EventType.microtask); - }); - }); - - _addLog('外层微任务1继续执行', EventType.microtask); - }); - - // 添加另一个外层微任务 - scheduleMicrotask(() { - _addLog('外层微任务2执行', EventType.microtask); - }); - - // 添加一个事件任务 - Future(() { - _addLog('事件任务执行', EventType.event); - }); - - _addLog('代码结束执行', EventType.sync); - - // 给测试一个结束标记 - Future.delayed(const Duration(seconds: 2), () { - _addLog('嵌套微任务测试结束', EventType.info); - _isRunning = false; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('微任务队列演示'), - actions: [ - IconButton( - icon: Icon(_showTimestamps ? Icons.timer : Icons.timer_off), - onPressed: () { - setState(() { - _showTimestamps = !_showTimestamps; - }); - }, - tooltip: _showTimestamps ? '隐藏时间戳' : '显示时间戳', - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: _clearLogs, - tooltip: '清除日志', - ), - ], - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '微任务队列介绍', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - const Text( - '微任务队列(Microtask Queue)用于处理高优先级的异步任务。微任务总是在事件任务之前执行,且会阻塞事件循环直到微任务队列清空。', - style: TextStyle(fontSize: 16), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: _runBasicMicrotaskTest, - icon: const Icon(Icons.play_arrow), - label: const Text('基础微任务测试'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - onPressed: _runThenMicrotaskTest, - icon: const Icon(Icons.play_arrow), - label: const Text('Future.then测试'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - onPressed: _runNestedMicrotaskTest, - icon: const Icon(Icons.play_arrow), - label: const Text('嵌套微任务测试'), - ), - ), - ], - ), - const SizedBox(height: 16), - const Text( - '代码示例', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const CodeSnippetView( - title: 'Microtask Queue 示例代码', - code: ''' -// 使用scheduleMicrotask添加微任务 -scheduleMicrotask(() { - print('微任务执行'); -}); - -// 使用Future.microtask添加微任务 -Future.microtask(() { - print('另一个微任务执行'); -}); - -// Future完成后的then回调也是微任务 -Future(() { - print('事件任务执行'); -}).then((_) { - print('then回调作为微任务执行'); -}); -''', - ), - const SizedBox(height: 16), - const Text( - '执行日志', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Expanded( - child: EventLogView( - logs: _logs, - showTimestamp: _showTimestamps, - scrollController: _scrollController, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/modules/basic/microtask/core/models/event_log.dart b/lib/modules/basic/microtask/models/event_log.dart similarity index 100% rename from lib/modules/basic/microtask/core/models/event_log.dart rename to lib/modules/basic/microtask/models/event_log.dart diff --git a/lib/modules/basic/microtask/module_entry.dart b/lib/modules/basic/microtask/module_entry.dart index d2c89b0..13ff92b 100644 --- a/lib/modules/basic/microtask/module_entry.dart +++ b/lib/modules/basic/microtask/module_entry.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'features/home_page.dart' as microtask; +import 'pages/home_page.dart' as microtask; class MicrotaskEntry extends StatelessWidget { const MicrotaskEntry({super.key}); diff --git a/lib/modules/basic/microtask/module_routes.dart b/lib/modules/basic/microtask/module_routes.dart index e993181..1259242 100644 --- a/lib/modules/basic/microtask/module_routes.dart +++ b/lib/modules/basic/microtask/module_routes.dart @@ -1,10 +1,9 @@ import 'package:go_router/go_router.dart'; -import 'features/event_queue/event_queue_page.dart'; -import 'features/microtask_queue/microtask_queue_page.dart'; -import 'features/advanced_examples/advanced_examples_page.dart'; +import 'pages/event_queue_page.dart'; +import 'pages/microtask_queue_page.dart'; +import 'pages/advanced_examples_page.dart'; -/// 事件循环与微任务模块子路由 class MicrotaskRoutes { MicrotaskRoutes._(); diff --git a/lib/modules/basic/microtask/pages/advanced_examples_page.dart b/lib/modules/basic/microtask/pages/advanced_examples_page.dart new file mode 100644 index 0000000..56bc215 --- /dev/null +++ b/lib/modules/basic/microtask/pages/advanced_examples_page.dart @@ -0,0 +1,321 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import '../models/event_log.dart'; +import '../widgets/event_log_view.dart'; +import '../widgets/code_snippet_view.dart'; + +class AdvancedExamplesPage extends StatefulWidget { + const AdvancedExamplesPage({super.key}); + + @override + State createState() => _AdvancedExamplesPageState(); +} + +class _AdvancedExamplesPageState extends State + with SingleTickerProviderStateMixin { + final List _logs = []; + bool _isRunning = false; + final ScrollController _scrollController = ScrollController(); + bool _showTimestamps = true; + late TabController _tabController; + + final Map _codeExamples = { + 'async/await': r''' +Future example() async { + print('函数开始'); + print('await之前的代码'); + await Future(() { print('await的Future执行'); }); + print('await之后的代码'); + await Future.delayed(Duration(milliseconds: 500)); + print('第二个await之后的代码'); +}''', + 'Future.value': r''' +Future.value('immediate value').then((value) { + print('Future.value微任务: $value'); +}); +Future(() { return 'computed value'; }) + .then((value) { print('普通Future: $value'); });''', + 'Future链': r''' +Future(() => print('初始Future')) + .then((_) => print('第一个then微任务')) + .then((_) { + print('第二个then微任务'); + return Future(() => print('嵌套事件任务')); + }) + .then((_) => print('第三个then微任务'));''', + 'Zone': r''' +runZoned(() { + Future(() => print('在自定义Zone中执行的Future')); + scheduleMicrotask(() => print('在自定义Zone中执行的微任务')); +}, zoneSpecification: ZoneSpecification( + scheduleMicrotask: (self, parent, zone, f) { + print('微任务被调度'); + parent.scheduleMicrotask(zone, f); + }, +));''', + }; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _scrollController.dispose(); + _tabController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + void _addLog(String message, EventType type) { + if (!_isRunning) return; + setState(() { + _logs.add(EventLog( + message: message, + type: type, + id: _logs.length + 1, + )); + }); + scheduleMicrotask(_scrollToBottom); + } + + void _clearLogs() { + setState(() => _logs.clear()); + } + + void _runAsyncAwaitTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始async/await测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + _addLog('即将调用async函数', EventType.sync); + await _executeAsyncFunction(); + _addLog('async函数调用完成', EventType.sync); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 2), () { + _addLog('async/await测试结束', EventType.info); + _isRunning = false; + }); + } + + Future _executeAsyncFunction() async { + _addLog('async函数开始', EventType.sync); + _addLog('await之前的代码', EventType.sync); + await Future(() => _addLog('await的Future执行', EventType.event)); + _addLog('await之后的代码', EventType.microtask); + await Future.delayed(const Duration(milliseconds: 500), + () => _addLog('第二个await的Future执行', EventType.event)); + _addLog('第二个await之后的代码', EventType.microtask); + _addLog('async函数结束', EventType.microtask); + } + + void _runFutureValueTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始Future.value测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + Future.value('立即值').then( + (value) => _addLog('Future.value微任务: $value', EventType.microtask)); + Future(() { + _addLog('普通Future事件任务执行', EventType.event); + return '计算值'; + }).then((value) => _addLog('普通Future的then回调: $value', EventType.microtask)); + scheduleMicrotask(() => _addLog('独立的微任务', EventType.microtask)); + Future(() => _addLog('独立的事件任务', EventType.event)); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 2), () { + _addLog('Future.value测试结束', EventType.info); + _isRunning = false; + }); + } + + void _runFutureChainTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始Future链式调用测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + Future(() => _addLog('初始Future', EventType.event)) + .then((_) => _addLog('第一个then微任务', EventType.microtask)) + .then((_) { + _addLog('第二个then微任务', EventType.microtask); + return Future(() => _addLog('嵌套事件任务', EventType.event)); + }).then((_) => _addLog('第三个then微任务', EventType.microtask)); + scheduleMicrotask(() => _addLog('独立的微任务', EventType.microtask)); + Future(() => _addLog('独立的事件任务', EventType.event)); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 2), () { + _addLog('Future链式调用测试结束', EventType.info); + _isRunning = false; + }); + } + + void _runZoneTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始Zone测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + runZoned(() { + _addLog('进入自定义Zone', EventType.sync); + Future(() => _addLog('在自定义Zone中执行的Future', EventType.event)); + scheduleMicrotask(() => _addLog('在自定义Zone中执行的微任务', EventType.microtask)); + _addLog('离开自定义Zone', EventType.sync); + }, + zoneSpecification: ZoneSpecification( + scheduleMicrotask: (self, parent, zone, f) { + _addLog('微任务被调度', EventType.info); + parent.scheduleMicrotask(zone, f); + }, + createTimer: (self, parent, zone, duration, f) { + _addLog('计时器被创建,持续时间: $duration', EventType.info); + return parent.createTimer(zone, duration, f); + }, + )); + Future(() => _addLog('在主Zone中执行的Future', EventType.event)); + scheduleMicrotask(() => _addLog('在主Zone中执行的微任务', EventType.microtask)); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 2), () { + _addLog('Zone测试结束', EventType.info); + _isRunning = false; + }); + } + + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: '高级事件机制演示', + floatingActionButton: FloatingActionButton( + onPressed: _clearLogs, + child: const Icon(Icons.delete), + ), + interactiveDemo: SizedBox( + height: 500, + child: Column( + children: [ + SizedBox( + height: 380, + child: DefaultTabController( + length: 4, + child: Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'async/await'), + Tab(text: 'Future.value'), + Tab(text: 'Future链'), + Tab(text: 'Zone'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildTabContent( + 'async/await', + 'async/await语法是Future的语法糖。' + 'await会暂停函数并将后续代码包装成微任务。', + _runAsyncAwaitTest, + ), + _buildTabContent( + 'Future.value', + 'Future.value立即完成,then回调直接进微任务队列。', + _runFutureValueTest, + ), + _buildTabContent( + 'Future链式调用', + '每个then回调都是微任务,不是事件任务。', + _runFutureChainTest, + ), + _buildTabContent( + 'Zone', + 'Zone可拦截和修改异步操作调度。', + _runZoneTest, + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 4), + Expanded( + child: EventLogView( + logs: _logs, + showTimestamp: _showTimestamps, + scrollController: _scrollController, + )), + ], + ), + ), + sections: [ + LearningObjectives(objectives: [ + '理解 async/await 背后的微任务调度机制', + '掌握 Future.value 与普通 Future 的区别', + '理解 Future 链式调用中 then 回调的调度行为', + '了解 Zone 的异步操作拦截机制', + ]), + ConceptChips(concepts: [ + 'async/await', + 'Future.value', + '链式调用', + 'Zone', + '微任务调度', + ]), + CodeSnippetCard( + title: 'async/await 调度原理', + code: 'void main() async {\n' + ' print("1: 同步");\n' + ' await Future(() => print("3: 事件"));\n' + ' print("2: await后的微任务");\n' + '}', + explanation: 'await 将后续代码封装为微任务,在 Future 完成后以微任务形式执行。', + ), + ExerciseCard( + task: '在 Zone 测试中观察微任务调度日志,尝试理解 ZoneSpecification 的工作原理。', + hint: 'Zone 的 scheduleMicrotask 拦截器会在每次微任务调度时触发,createTimer 拦截定时器创建。', + ), + ], + ); + } + + Widget _buildTabContent( + String title, String description, VoidCallback onRun) { + return SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: const TextStyle(fontSize: 14)), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: onRun, + icon: const Icon(Icons.play_arrow), + label: Text('运行$title测试'), + ), + const SizedBox(height: 4), + CodeSnippetView( + title: '$title 示例代码', + code: _codeExamples[title] ?? '', + ), + ], + ), + ); + } +} diff --git a/lib/modules/basic/microtask/pages/event_queue_page.dart b/lib/modules/basic/microtask/pages/event_queue_page.dart new file mode 100644 index 0000000..4be8e9b --- /dev/null +++ b/lib/modules/basic/microtask/pages/event_queue_page.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import '../models/event_log.dart'; +import '../widgets/event_log_view.dart'; +import '../widgets/code_snippet_view.dart'; + +class EventQueuePage extends StatefulWidget { + const EventQueuePage({super.key}); + + @override + State createState() => _EventQueuePageState(); +} + +class _EventQueuePageState extends State { + final List _logs = []; + bool _isRunning = false; + final ScrollController _scrollController = ScrollController(); + bool _showTimestamps = true; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + void _addLog(String message, EventType type) { + if (!_isRunning) return; + setState(() { + _logs.add(EventLog( + message: message, + type: type, + id: _logs.length + 1, + )); + }); + scheduleMicrotask(_scrollToBottom); + } + + void _clearLogs() { + setState(() => _logs.clear()); + } + + void _runBasicEventTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始事件队列测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + Future(() => _addLog('Future() 执行', EventType.event)); + Future.delayed(const Duration(milliseconds: 500), + () => _addLog('Future.delayed 0.5秒后执行', EventType.event)); + Future.delayed(const Duration(seconds: 1), + () => _addLog('Future.delayed 1秒后执行', EventType.event)); + Future.delayed(const Duration(seconds: 2), + () => _addLog('Future.delayed 2秒后执行', EventType.event)); + Timer.run(() => _addLog('Timer.run 执行', EventType.event)); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 3), () { + _addLog('事件队列测试结束', EventType.info); + _isRunning = false; + }); + } + + void _runIoEventTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始IO事件测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + Future(() { + _addLog('模拟IO操作开始', EventType.event); + return Future.delayed(const Duration(seconds: 1), () => '数据加载完成'); + }).then((result) { + _addLog('IO操作结果: $result', EventType.event); + }); + Future.wait([ + Future.delayed(const Duration(milliseconds: 800), + () => _addLog('并发IO操作1完成', EventType.event)), + Future.delayed(const Duration(milliseconds: 1200), + () => _addLog('并发IO操作2完成', EventType.event)), + ]).then((results) { + _addLog('所有并发操作完成: $results', EventType.event); + }); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 3), () { + _addLog('IO事件测试结束', EventType.info); + _isRunning = false; + }); + } + + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: '事件队列演示', + floatingActionButton: FloatingActionButton( + onPressed: _clearLogs, + child: const Icon(Icons.delete), + ), + interactiveDemo: SizedBox( + height: 500, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _runBasicEventTest, + icon: const Icon(Icons.play_arrow), + label: const Text('基础事件队列测试'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: _runIoEventTest, + icon: const Icon(Icons.play_arrow), + label: const Text('IO事件测试'), + ), + ), + ], + ), + const SizedBox(height: 8), + const CodeSnippetView( + title: 'Event Queue 示例代码', + code: ''' +Future(() { print('事件队列任务执行'); }); +Future.delayed(Duration(seconds: 1), () { + print('延迟1秒后执行的事件队列任务'); +}); +Timer.run(() { print('Timer事件队列任务执行'); });''', + ), + const SizedBox(height: 8), + Expanded( + child: EventLogView( + logs: _logs, + showTimestamp: _showTimestamps, + scrollController: _scrollController, + ), + ), + ], + ), + ), + sections: [ + LearningObjectives(objectives: [ + '理解事件队列 (Event Queue) 的工作原理', + '掌握 Future、Future.delayed、Timer.run 的调度行为', + '区分同步代码与事件队列任务的执行顺序', + ]), + ConceptChips(concepts: [ + '事件队列', + 'Future', + 'Timer', + '异步调度', + '执行顺序', + ]), + CodeSnippetCard( + title: '事件队列调度机制', + code: '// 事件队列任务总是在当前同步代码之后执行\n' + 'print("1: 同步");\n' + 'Future(() => print("3: 事件任务"));\n' + 'print("2: 同步");', + explanation: 'Future() 将回调放入事件队列,在当前帧同步代码执行完毕后触发。', + ), + CommonPitfalls(pitfalls: [ + 'Future.delayed 即使延迟为 0 也会进入事件队列,不会在当前帧执行', + '多个 Future.delayed 按延迟时间排序,但同延迟时按注册顺序执行', + 'Timer.run 等价于 Future(null),回调在事件队列中执行', + ]), + ExerciseCard( + task: '修改 _runBasicEventTest 中的延迟时间,观察执行顺序的变化。', + hint: '尝试将 Future.delayed 的延迟时间设为相同值,观察按注册顺序执行的特点。', + ), + ], + ); + } +} diff --git a/lib/modules/basic/microtask/pages/home_page.dart b/lib/modules/basic/microtask/pages/home_page.dart new file mode 100644 index 0000000..da4863f --- /dev/null +++ b/lib/modules/basic/microtask/pages/home_page.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:go_router/go_router.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + static const String _baseRoute = '/microtask'; + + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: 'Flutter 事件机制学习', + interactiveDemo: SizedBox( + height: 300, + child: GridView.count( + crossAxisCount: 2, + childAspectRatio: 1.5, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + padding: const EdgeInsets.all(16), + children: [ + _buildNavCard( + context, + title: '事件队列', + subtitle: '学习事件队列的基本概念和使用方式', + icon: Icons.queue, + color: Colors.red.shade100, + route: '$_baseRoute/event-queue', + ), + _buildNavCard( + context, + title: '微任务队列', + subtitle: '学习微任务队列的特性和优先级', + icon: Icons.speed, + color: Colors.blue.shade100, + route: '$_baseRoute/microtask-queue', + ), + _buildNavCard( + context, + title: '高级示例', + subtitle: '探索async/await、Future链和Zone', + icon: Icons.code, + color: Colors.purple.shade100, + route: '$_baseRoute/advanced', + ), + _buildNavCard( + context, + title: '学习资源', + subtitle: '查看更多关于Flutter事件机制的学习资源', + icon: Icons.book, + color: Colors.green.shade100, + ), + ], + ), + ), + sections: [ + LearningObjectives(objectives: [ + '理解 Flutter 事件循环 (Event Loop) 的运作机制', + '掌握微任务队列 (Microtask Queue) 和事件队列 (Event Queue) 的区别', + '学会使用 scheduleMicrotask 和 Future 管理异步任务', + '理解 async/await 背后的微任务调度原理', + ]), + ConceptChips(concepts: [ + '事件循环', + 'Event Loop', + '微任务队列', + '事件队列', + 'scheduleMicrotask', + 'Future', + 'Zone', + ]), + CodeSnippetCard( + title: '微任务 vs 事件任务', + code: 'scheduleMicrotask(() {\n' + ' print("微任务优先执行");\n' + '});\n\n' + 'Future(() {\n' + ' print("事件任务后执行");\n' + '});', + explanation: '微任务队列优先级高于事件队列,每次事件循环先清空微任务再处理事件。', + ), + CommonPitfalls(pitfalls: [ + '微任务会阻塞事件循环 — 过多微任务会导致 UI 卡顿', + 'Future.then 的回调是微任务,不是事件任务', + 'scheduleMicrotask 在同一个微任务中嵌套调用仍会优先于事件任务', + ]), + ExerciseCard( + task: '运行"基础微任务测试"观察微任务与事件任务的执行顺序,然后用代码验证你的猜测。', + hint: '点击导航中的"微任务队列"页面,运行"基础微任务测试"按钮观察日志输出顺序。', + ), + ], + ); + } + + Widget _buildNavCard( + BuildContext context, { + required String title, + required String subtitle, + required IconData icon, + required Color color, + String? route, + }) { + return Card( + clipBehavior: Clip.antiAlias, + elevation: 3, + child: InkWell( + onTap: route != null ? () => context.push(route) : null, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color, color.withValues(alpha: 0.7)], + ), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: Colors.grey[800]), + const SizedBox(width: 8), + Text(title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Colors.grey[800])), + ], + ), + const SizedBox(height: 8), + Text(subtitle, style: TextStyle(color: Colors.grey[800])), + const Spacer(), + Align( + alignment: Alignment.bottomRight, + child: Icon(Icons.arrow_forward, + color: Colors.grey[800], size: 20), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/basic/microtask/pages/microtask_queue_page.dart b/lib/modules/basic/microtask/pages/microtask_queue_page.dart new file mode 100644 index 0000000..4482c71 --- /dev/null +++ b/lib/modules/basic/microtask/pages/microtask_queue_page.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import '../models/event_log.dart'; +import '../widgets/event_log_view.dart'; +import '../widgets/code_snippet_view.dart'; + +class MicrotaskQueuePage extends StatefulWidget { + const MicrotaskQueuePage({super.key}); + + @override + State createState() => _MicrotaskQueuePageState(); +} + +class _MicrotaskQueuePageState extends State { + final List _logs = []; + bool _isRunning = false; + final ScrollController _scrollController = ScrollController(); + bool _showTimestamps = true; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + void _addLog(String message, EventType type) { + if (!_isRunning) return; + setState(() { + _logs.add(EventLog( + message: message, + type: type, + id: _logs.length + 1, + )); + }); + scheduleMicrotask(_scrollToBottom); + } + + void _clearLogs() { + setState(() => _logs.clear()); + } + + void _runBasicMicrotaskTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始微任务队列测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + Future(() => _addLog('事件任务执行', EventType.event)); + scheduleMicrotask( + () => _addLog('scheduleMicrotask 微任务1执行', EventType.microtask)); + Future.microtask( + () => _addLog('Future.microtask 微任务2执行', EventType.microtask)); + Future(() => _addLog('另一个事件任务执行', EventType.event)); + scheduleMicrotask( + () => _addLog('scheduleMicrotask 微任务3执行', EventType.microtask)); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 2), () { + _addLog('微任务队列测试结束', EventType.info); + _isRunning = false; + }); + } + + void _runThenMicrotaskTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始Future.then微任务测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + Future(() => _addLog('Future事件任务执行', EventType.event)) + .then((_) => _addLog('Future.then微任务1执行', EventType.microtask)) + .then((_) => _addLog('Future.then微任务2执行', EventType.microtask)); + Future(() => _addLog('另一个事件任务执行', EventType.event)); + scheduleMicrotask( + () => _addLog('scheduleMicrotask 微任务执行', EventType.microtask)); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 2), () { + _addLog('Future.then微任务测试结束', EventType.info); + _isRunning = false; + }); + } + + void _runNestedMicrotaskTest() async { + if (_isRunning) return; + _isRunning = true; + _clearLogs(); + _addLog('开始嵌套微任务测试', EventType.info); + _addLog('代码开始执行', EventType.sync); + scheduleMicrotask(() { + _addLog('外层微任务1执行', EventType.microtask); + scheduleMicrotask(() => _addLog('嵌套微任务1执行', EventType.microtask)); + scheduleMicrotask(() { + _addLog('嵌套微任务2执行', EventType.microtask); + scheduleMicrotask(() => _addLog('二层嵌套微任务执行', EventType.microtask)); + }); + _addLog('外层微任务1继续执行', EventType.microtask); + }); + scheduleMicrotask(() => _addLog('外层微任务2执行', EventType.microtask)); + Future(() => _addLog('事件任务执行', EventType.event)); + _addLog('代码结束执行', EventType.sync); + Future.delayed(const Duration(seconds: 2), () { + _addLog('嵌套微任务测试结束', EventType.info); + _isRunning = false; + }); + } + + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: '微任务队列演示', + floatingActionButton: FloatingActionButton( + onPressed: _clearLogs, + child: const Icon(Icons.delete), + ), + interactiveDemo: SizedBox( + height: 500, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _runBasicMicrotaskTest, + icon: const Icon(Icons.play_arrow), + label: const Text('基础微任务测试'), + ), + ), + const SizedBox(width: 6), + Expanded( + child: ElevatedButton.icon( + onPressed: _runThenMicrotaskTest, + icon: const Icon(Icons.play_arrow), + label: const Text('Future.then测试'), + ), + ), + const SizedBox(width: 6), + Expanded( + child: ElevatedButton.icon( + onPressed: _runNestedMicrotaskTest, + icon: const Icon(Icons.play_arrow), + label: const Text('嵌套微任务测试'), + ), + ), + ], + ), + const SizedBox(height: 8), + const CodeSnippetView( + title: 'Microtask Queue 示例代码', + code: ''' +scheduleMicrotask(() { print('微任务执行'); }); +Future.microtask(() { print('另一个微任务执行'); }); +Future(() { print('事件任务执行'); }) + .then((_) { print('then回调作为微任务执行'); });''', + ), + const SizedBox(height: 8), + Expanded( + child: EventLogView( + logs: _logs, + showTimestamp: _showTimestamps, + scrollController: _scrollController, + ), + ), + ], + ), + ), + sections: [ + LearningObjectives(objectives: [ + '理解微任务队列 (Microtask Queue) 的优先级特性', + '掌握 scheduleMicrotask 和 Future.microtask 的使用', + '理解 Future.then 回调作为微任务执行的机制', + '掌握嵌套微任务的行为特征', + ]), + ConceptChips(concepts: [ + '微任务队列', + 'scheduleMicrotask', + 'Future.microtask', + 'Future.then', + '优先级', + '嵌套微任务', + ]), + CodeSnippetCard( + title: '微任务优先级示例', + code: 'scheduleMicrotask(() => print("1: 微任务"));\n' + 'Future(() => print("3: 事件任务"));\n' + 'scheduleMicrotask(() => print("2: 微任务"));', + explanation: '微任务始终在事件任务之前执行,即使微任务在事件任务之后注册。', + ), + CommonPitfalls(pitfalls: [ + '微任务过多会导致事件队列饿死 — UI事件(触摸、渲染)也无法处理', + 'scheduleMicrotask 嵌套调用会递归清空微任务队列,可能导致长时间阻塞', + 'Future.then 是微任务不是事件任务 — 注意与 Future 本身的区别', + ]), + ExerciseCard( + task: '运行"嵌套微任务测试"观察执行顺序,理解为什么嵌套微任务会先于事件任务执行。', + hint: '微任务队列的"清空"策略是持续处理直到队列为空,新添加的微任务也会在当前批次处理。', + ), + ], + ); + } +} diff --git a/lib/modules/basic/microtask/core/widgets/code_snippet_view.dart b/lib/modules/basic/microtask/widgets/code_snippet_view.dart similarity index 100% rename from lib/modules/basic/microtask/core/widgets/code_snippet_view.dart rename to lib/modules/basic/microtask/widgets/code_snippet_view.dart diff --git a/lib/modules/basic/microtask/core/widgets/event_log_view.dart b/lib/modules/basic/microtask/widgets/event_log_view.dart similarity index 100% rename from lib/modules/basic/microtask/core/widgets/event_log_view.dart rename to lib/modules/basic/microtask/widgets/event_log_view.dart diff --git a/lib/modules/basic/tree_state/AI_ANALYSIS.md b/lib/modules/basic/tree_state/AI_ANALYSIS.md index e6f1e6e..eaffc4c 100644 --- a/lib/modules/basic/tree_state/AI_ANALYSIS.md +++ b/lib/modules/basic/tree_state/AI_ANALYSIS.md @@ -12,7 +12,18 @@ "owns": ["module_entry","module_ui","module_state","module_docs"], "depends": ["flutter_study_learning","module_registry","go_router"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["module_entry.dart","module_routes.dart","pages/basic_widgets_page.dart","pages/demo_home_page.dart","pages/painter_demo_page.dart","pages/repaint_boundary_demo_page.dart","pages/state_lifecycle_page.dart","routes.dart"], + "files": ["module_entry.dart","module_routes.dart","pages/basic_widgets_page.dart","pages/demo_home_page.dart","pages/painter_demo_page.dart","pages/repaint_boundary_demo_page.dart","pages/state_lifecycle_page.dart"], + "teaching_components": { + "page": "pages (全部 5 页)", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/basic/tree_state/pages/demo_home_page.dart b/lib/modules/basic/tree_state/pages/demo_home_page.dart index e92f40b..8753f25 100644 --- a/lib/modules/basic/tree_state/pages/demo_home_page.dart +++ b/lib/modules/basic/tree_state/pages/demo_home_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import '../module_routes.dart'; @@ -34,28 +35,78 @@ class DemoHomePage extends StatelessWidget { ), ]; - return Scaffold( - appBar: AppBar( - title: const Text('Flutter 三棵树 & 生命周期示例'), - ), - body: ListView( - children: [ - const Padding( - padding: EdgeInsets.all(16), - child: Text( - '观察 Widget / Element / RenderObject 的关系以及生命周期日志', - style: TextStyle(fontSize: 16), - ), - ), - for (final demo in demos) - ListTile( - title: Text(demo.title), - subtitle: Text(demo.subtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push('$_baseRoute${demo.routeName}'), + return LearningScaffold( + title: 'Flutter 三棵树 & 生命周期示例', + interactiveDemo: SizedBox( + height: 260, + child: Column( + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text( + '观察 Widget / Element / RenderObject 的关系以及生命周期日志', + style: TextStyle(fontSize: 14), + ), ), - ], + for (final demo in demos) + ListTile( + title: Text(demo.title), + subtitle: Text(demo.subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('$_baseRoute${demo.routeName}'), + ), + ], + ), ), + sections: const [ + LearningObjectives( + objectives: [ + '理解 Widget、Element、RenderObject 三棵树的角色与关系', + '掌握 StatefulWidget 生命周期回调顺序', + '理解 RepaintBoundary 的局部重绘机制', + '掌握 CustomPainter 渲染与 shouldRepaint 逻辑', + ], + ), + ConceptChips( + concepts: [ + 'Widget 树', + 'Element 树', + 'RenderObject', + '生命周期', + 'setState', + 'RepaintBoundary', + 'CustomPainter' + ], + ), + CodeSnippetCard( + title: '三棵树协作模式', + code: '''// Widget 树 — 配置描述 +Widget build(BuildContext context) => + Column(children: [Text('hello')]); + +// Element 树 — 桥梁,管理 State +class _MyState extends State { + // State 附着在 Element 上,不被重建 +} + +// RenderObject 树 — 布局/绘制 +// CustomPainter 在 paint 阶段触发''', + explanation: + 'setState → Element 标记 dirty → build 新 Widget → 比对更新 RenderObject → 触发重绘', + ), + CommonPitfalls( + pitfalls: [ + 'Widget 不是界面本身,只是轻量级配置对象,每次 build 都会新建', + '不要将可变状态直接放在 Widget 字段中,应放在 State 中', + 'CustomPainter 的 shouldRepaint 应正确实现,避免无意义重绘', + ], + ), + ExerciseCard( + task: + '进入子页面观察 debugPrint 日志,理解每个生命周期方法何时被调用。尝试在不同子页面之间切换,观察 Element 树的复用与销毁。', + hint: '在 iOS/Android 终端或 IDE 的 Run 面板查看 debugPrint 输出。', + ), + ], ); } } diff --git a/lib/modules/basic/tree_state/pages/painter_demo_page.dart b/lib/modules/basic/tree_state/pages/painter_demo_page.dart index 0432edb..3ca72dc 100644 --- a/lib/modules/basic/tree_state/pages/painter_demo_page.dart +++ b/lib/modules/basic/tree_state/pages/painter_demo_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; /// CustomPainter 的布局/重绘示例,日志标记了 build、shouldRepaint、paint 调用。 class PainterDemoPage extends StatefulWidget { @@ -10,37 +11,54 @@ class PainterDemoPage extends StatefulWidget { class _PainterDemoPageState extends State { double _radius = 60; + final List _logs = []; @override Widget build(BuildContext context) { debugPrint('[PainterDemoPage] build radius=${_radius.toStringAsFixed(1)}'); - return Scaffold( - appBar: AppBar(title: const Text('Painter Demo')), - body: Padding( - padding: const EdgeInsets.all(16), + return LearningScaffold( + title: 'CustomPainter 渲染流程', + interactiveDemo: SizedBox( + height: 400, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('CustomPainter 展示 build/layout/paint 分离'), - const SizedBox(height: 12), - Slider( - value: _radius, - min: 20, - max: 140, - label: _radius.toStringAsFixed(0), - onChanged: (value) { - setState(() { - _radius = value; - }); - }, + const Padding( + padding: EdgeInsets.all(16), + child: Text('CustomPainter 展示 build/layout/paint 分离'), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Text('半径: '), + Expanded( + child: Slider( + value: _radius, + min: 20, + max: 140, + label: _radius.toStringAsFixed(0), + onChanged: (value) { + setState(() { + _radius = value; + }); + _logs.add( + 'Slider → radius=${_radius.toStringAsFixed(0)}'); + }, + ), + ), + TextButton( + onPressed: () => setState(() => _logs.clear()), + child: const Text('清空'), + ), + ], + ), ), - const SizedBox(height: 12), Expanded( child: Center( child: SizedBox( width: 280, - height: 280, - // CustomPaint 是 RenderObjectWidget,会创建 RenderCustomPaint。 + height: 220, child: CustomPaint( painter: PainterDemoPainter(radius: _radius), child: const Center(child: Text('拖动 Slider 观察日志')), @@ -48,9 +66,79 @@ class _PainterDemoPageState extends State { ), ), ), + Container( + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ListView( + children: _logs.isEmpty + ? [ + const Text('拖动 Slider 观察 paint 和 shouldRepaint 日志', + style: TextStyle(color: Colors.grey, fontSize: 12)) + ] + : _logs + .map((l) => Text(l, + style: const TextStyle( + fontSize: 11, fontFamily: 'monospace'))) + .toList(), + ), + ), ], ), ), + sections: const [ + LearningObjectives( + objectives: [ + '理解 Widget build / shouldRepaint / paint 的三阶段分离', + '观察 Slider 拖动时 CustomPaint 的重绘行为', + '理解 CustomPainter 的 shouldRepaint 优化机制', + ], + ), + ConceptChips( + concepts: [ + 'CustomPaint', + 'CustomPainter', + 'shouldRepaint', + 'Canvas', + 'paint' + ], + ), + CodeSnippetCard( + title: 'CustomPainter 核心模式', + code: '''class MyPainter extends CustomPainter { + final double radius; + + MyPainter({required this.radius}); + + @override + void paint(Canvas canvas, Size size) { + // 在此绘制图形 + canvas.drawCircle(center, radius, paint); + } + + @override + bool shouldRepaint(MyPainter oldDelegate) { + // 仅当数据变化时才重绘 + return oldDelegate.radius != radius; + } +}''', + explanation: + 'CustomPainter 接收不可变配置参数,shouldRepaint 决定是否触发 paint 阶段,避免无意义重绘。', + ), + CommonPitfalls( + pitfalls: [ + 'CustomPainter 的参数必须是不可变(final)的,否则 shouldRepaint 判断会出错', + 'paint 方法中不要做耗时操作——它在光栅化线程执行', + 'shouldRepaint 返回 true 过于频繁会导致性能问题', + 'Canvas 绘制没有自动保存状态,需要手动 save/restore', + ], + ), + ExerciseCard( + task: + '拖动 Slider 并观察调试控制台的 shouldRepaint 和 paint 日志。思考:为什么某些情况 paint 被调用了但视觉上没变化?', + hint: + 'paint 被调用不代表一定有视觉变化——如果 shouldRepaint 返回 true 但绘制内容不变,仍会触发 paint。', + ), + ], ); } } @@ -63,7 +151,8 @@ class PainterDemoPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { debugPrint( - '[PainterDemoPainter] paint, radius=${radius.toStringAsFixed(1)}, size=$size'); + '[PainterDemoPainter] paint, radius=${radius.toStringAsFixed(1)}, size=$size', + ); final center = size.center(Offset.zero); final axisPaint = Paint() ..color = Colors.grey diff --git a/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart b/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart index d390c8d..2942725 100644 --- a/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart +++ b/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; /// 对比使用/不使用 RepaintBoundary 时的重绘范围,方便解释 RenderObject 分叉。 class RepaintBoundaryDemoPage extends StatefulWidget { @@ -17,70 +18,150 @@ class _RepaintBoundaryDemoPageState extends State { Colors.purple, Colors.blue, ]; + final List _logs = []; @override Widget build(BuildContext context) { final color = _colors[_colorIndex % _colors.length]; debugPrint('[RepaintBoundaryDemoPage] build color=$color'); - return Scaffold( - appBar: AppBar(title: const Text('RepaintBoundary Demo')), - body: Column( - children: [ - const Padding( - padding: EdgeInsets.all(16), - child: Text('对比没有/有 RepaintBoundary 时的局部重绘范围'), - ), - Expanded( - child: Row( - children: [ - const Expanded( - child: Column( - children: [ - Text('无 RepaintBoundary'), - Expanded( - child: CustomPaint( - painter: NoBoundaryPainter(), - child: SizedBox.expand(), - ), + return LearningScaffold( + title: 'RepaintBoundary 局部重绘', + interactiveDemo: SizedBox( + height: 320, + child: Column( + children: [ + const Padding( + padding: EdgeInsets.all(8), + child: Text('对比没有/有 RepaintBoundary 时的局部重绘范围'), + ), + Expanded( + child: Row( + children: [ + Expanded( + child: _buildSection( + label: '无 RepaintBoundary', + child: CustomPaint( + painter: NoBoundaryPainter(), + child: const SizedBox.expand(), ), - ], + ), ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - children: [ - const Text('有 RepaintBoundary'), - Expanded( - child: RepaintBoundary( - // RenderObject 树在此断开,只会重绘边界内的 RenderCustomPaint。 - child: CustomPaint( - painter: BoundaryPainter(color: color), - child: const SizedBox.expand(), - ), + const SizedBox(width: 8), + Expanded( + child: _buildSection( + label: '有 RepaintBoundary', + child: RepaintBoundary( + child: CustomPaint( + painter: BoundaryPainter(color: color), + child: const SizedBox.expand(), ), ), - ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + ElevatedButton( + onPressed: () { + setState(() { + _colorIndex++; + }); + final msg = 'changeColor → index=$_colorIndex'; + debugPrint('[RepaintBoundaryDemoPage] $msg'); + _logs.add(msg); + }, + child: const Text('只改变右侧颜色'), ), - ), - ], + const SizedBox(width: 12), + TextButton( + onPressed: () => setState(() => _logs.clear()), + child: const Text('清空日志'), + ), + ], + ), ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: ElevatedButton( - onPressed: () { - setState(() { - _colorIndex++; - }); - debugPrint( - '[RepaintBoundaryDemoPage] changeColor -> index=$_colorIndex'); - }, - child: const Text('只改变右侧颜色'), + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ListView( + scrollDirection: Axis.horizontal, + children: _logs.isEmpty + ? [ + const Text('点击按钮观察 paint 日志', + style: TextStyle(color: Colors.grey, fontSize: 12)) + ] + : _logs + .map((l) => Padding( + padding: const EdgeInsets.only(right: 8), + child: Text(l, + style: const TextStyle( + fontSize: 11, fontFamily: 'monospace')), + )) + .toList(), + ), ), - ), - ], + ], + ), ), + sections: const [ + LearningObjectives( + objectives: [ + '理解 RepaintBoundary 如何隔离子树的重绘范围', + '观察 shouldRepaint 返回 true/false 时的不同行为', + '理解 RenderObject 树的局部更新机制', + ], + ), + ConceptChips( + concepts: [ + 'RepaintBoundary', + 'CustomPainter', + 'shouldRepaint', + '局部重绘', + 'RenderObject' + ], + ), + CodeSnippetCard( + title: 'RepaintBoundary 用法', + code: '''RepaintBoundary( + child: CustomPaint( + painter: MyPainter(color: color), + ), +) + +// 外部 setState 时,RepaintBoundary +// 内部的 RenderObject 不会无条件重绘''', + explanation: + 'RepaintBoundary 在 RenderObject 树中创建一个独立的重绘边界,内部子树只在自己 dirty 时重绘。', + ), + CommonPitfalls( + pitfalls: [ + 'RepaintBoundary 不会阻止 Widget build,只阻止 RenderObject paint', + 'CustomPainter 的 shouldRepaint 返回 false 时,即使父组件 setState 也不会重绘', + '过度使用 RepaintBoundary 会增加内存开销,只在必要时使用', + ], + ), + ExerciseCard( + task: + '点击「只改变右侧颜色」并观察 debugPrint:NoBoundaryPainter 每次都被调用 paint,而 BoundaryPainter 只在颜色变化时重绘。', + hint: + 'NoBoundaryPainter.shouldRepaint 始终返回 false,但因为它的父组件没有 RepaintBoundary 保护,所以仍随父组件重绘。', + ), + ], + ); + } + + Widget _buildSection({required String label, required Widget child}) { + return Column( + children: [ + Text(label, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Expanded(child: child), + ], ); } } @@ -122,7 +203,8 @@ class BoundaryPainter extends CustomPainter { bool shouldRepaint(covariant BoundaryPainter oldDelegate) { final should = oldDelegate.color != color; debugPrint( - '[BoundaryPainter] shouldRepaint old=${oldDelegate.color}, new=$color, should=$should'); + '[BoundaryPainter] shouldRepaint old=${oldDelegate.color}, new=$color, should=$should', + ); return should; } } diff --git a/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart b/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart index aaae515..3758f31 100644 --- a/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart +++ b/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; /// 侧重打印 StatefulWidget 生命周期,配合 push/pop、setState 观察回调顺序。 class StateLifecyclePage extends StatefulWidget { @@ -10,17 +11,20 @@ class StateLifecyclePage extends StatefulWidget { class _StateLifecyclePageState extends State { int _counter = 0; + final List _logs = []; @override void initState() { super.initState(); debugPrint('[StateLifecyclePage] initState'); + _logs.add('initState 执行'); } @override void didChangeDependencies() { super.didChangeDependencies(); debugPrint('[StateLifecyclePage] didChangeDependencies'); + _logs.add('didChangeDependencies'); } @override @@ -44,46 +48,128 @@ class _StateLifecyclePageState extends State { @override Widget build(BuildContext context) { debugPrint('[StateLifecyclePage] build counter=$_counter'); - return Scaffold( - appBar: AppBar(title: const Text('State Lifecycle Demo')), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('StatefulWidget 生命周期 + setState 行为'), - const SizedBox(height: 16), - Text('Counter: $_counter'), - const SizedBox(height: 16), - Wrap( - spacing: 12, - children: [ - ElevatedButton( - onPressed: () { - setState(() { - _counter++; - }); - debugPrint('[StateLifecyclePage] setState -> $_counter'); - }, - child: const Text('setState +1'), - ), - ElevatedButton( - onPressed: () async { - debugPrint('[StateLifecyclePage] push sample page'); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const LifecycleChildPage(), - ), - ); - debugPrint('[StateLifecyclePage] pop sample page'); - }, - child: const Text('Push & Pop 页面'), + return LearningScaffold( + title: 'State Lifecycle', + interactiveDemo: SizedBox( + height: 320, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('StatefulWidget 生命周期 + setState 行为'), + const SizedBox(height: 16), + Text('Counter: $_counter'), + const SizedBox(height: 16), + Wrap( + spacing: 12, + children: [ + ElevatedButton( + onPressed: () { + setState(() { + _counter++; + }); + final msg = 'setState → $_counter'; + debugPrint('[StateLifecyclePage] $msg'); + _logs.add(msg); + }, + child: const Text('setState +1'), + ), + ElevatedButton( + onPressed: () async { + debugPrint('[StateLifecyclePage] push sample page'); + _logs.add('push ChildPage'); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const LifecycleChildPage(), + ), + ); + debugPrint('[StateLifecyclePage] pop sample page'); + _logs.add('pop from ChildPage'); + }, + child: const Text('Push & Pop 页面'), + ), + TextButton( + onPressed: () => setState(() => _logs.clear()), + child: const Text('清空日志'), + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: ListView( + children: _logs.isEmpty + ? [ + const Text('操作按钮观察生命周期日志', + style: TextStyle(color: Colors.grey)) + ] + : _logs + .map((l) => Text(l, + style: const TextStyle( + fontSize: 12, fontFamily: 'monospace'))) + .toList(), + ), ), - ], - ), - ], + ), + ], + ), ), ), + sections: const [ + LearningObjectives( + objectives: [ + '理解 StatefulWidget 完整的生命周期回调顺序', + '观察 setState 触发的 build 过程', + '理解 push/pop 时 deactivate 与 dispose 的触发时机', + ], + ), + ConceptChips( + concepts: [ + 'initState', + 'didChangeDependencies', + 'build', + 'setState', + 'didUpdateWidget', + 'deactivate', + 'dispose' + ], + ), + CodeSnippetCard( + title: '生命周期的正确用法', + code: '''@override +void initState() { + super.initState(); + // 初始化数据、监听器、控制器 + _controller = AnimationController(vsync: this); +} + +@override +void dispose() { + _controller.dispose(); // 清理资源 + super.dispose(); +}''', + explanation: 'initState 中初始化资源,dispose 中释放资源,成对出现防止内存泄漏。', + ), + CommonPitfalls( + pitfalls: [ + '不要在 initState 中调用 BuildContext 相关方法(如 MediaQuery.of),应使用 didChangeDependencies', + 'dispose 中记得释放 AnimationController、StreamSubscription 等资源', + 'didUpdateWidget 的参数是 oldWidget,可通过 widget 属性访问当前 widget', + ], + ), + ExerciseCard( + task: + '点击「Push & Pop 页面」观察 deactivate 和 dispose 的调用顺序。思考:为什么 deactivate 在 dispose 之前被调用?', + hint: + 'deactivate 允许 State 被重新插入 Element 树(GlobalKey 移动场景),dispose 是最终销毁。', + ), + ], ); } } diff --git a/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md b/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md index 08f1c76..b623aaf 100644 --- a/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md +++ b/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md @@ -8,11 +8,35 @@ "path": "lib/modules/platform/dio_interceptor", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["dio","module_registry","go_router"], + "entrypoints": ["module_entry.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry","go_router","dio"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["mock_server/mock_server.dart","models/article.dart","module_entry.dart","module_routes.dart","network/api/api_service.dart","network/http_client.dart","network/interceptor/auth_interceptor.dart","network/interceptor/error_interceptor.dart","network/interceptor/log_interceptor.dart","network/interceptor/retry_interceptor.dart","pages/home_page.dart","pages/login_page.dart"], + "files": [ + "module_entry.dart", + "module_routes.dart", + "mock_server/mock_server.dart", + "models/article.dart", + "network/http_client.dart", + "network/api/api_service.dart", + "network/interceptor/auth_interceptor.dart", + "network/interceptor/error_interceptor.dart", + "network/interceptor/log_interceptor.dart", + "network/interceptor/retry_interceptor.dart", + "pages/home_page.dart", + "pages/login_page.dart" + ], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/platform/dio_interceptor/pages/home_page.dart b/lib/modules/platform/dio_interceptor/pages/home_page.dart index 0c08ccf..af7ae20 100644 --- a/lib/modules/platform/dio_interceptor/pages/home_page.dart +++ b/lib/modules/platform/dio_interceptor/pages/home_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'package:go_router/go_router.dart'; import '../models/article.dart'; @@ -28,23 +29,23 @@ class _HomePageState extends State { _loadArticles(); } - /// 加载文章列表 + @override + void dispose() { + super.dispose(); + } + Future _loadArticles() async { if (_isLoading) return; - setState(() { _isLoading = true; _errorMessage = null; }); - try { final result = await _apiService.getArticles(page: _currentPage); - if (result['success'] == true && result['data'] != null) { final articlesData = result['data']['articles'] as List; final articles = articlesData.map((json) => Article.fromJson(json)).toList(); - setState(() { _articles = articles; _totalPages = result['data']['totalPages'] as int; @@ -64,43 +65,30 @@ class _HomePageState extends State { } } - /// 刷新文章列表 Future _refreshArticles() async { _currentPage = 1; await _loadArticles(); } - /// 加载下一页 Future _loadNextPage() async { if (_currentPage < _totalPages) { - setState(() { - _currentPage++; - }); + setState(() => _currentPage++); await _loadArticles(); } } - /// 加载上一页 Future _loadPreviousPage() async { if (_currentPage > 1) { - setState(() { - _currentPage--; - }); + setState(() => _currentPage--); await _loadArticles(); } } - /// 打开登录页面 void _openLoginPage() async { final result = await context.push('$_baseRoute/login'); - - if (result == true) { - // 登录成功,刷新文章列表 - await _refreshArticles(); - } + if (result == true) await _refreshArticles(); } - /// 退出登录 void _logout() { AuthInterceptor.clearToken(); ScaffoldMessenger.of(context).showSnackBar( @@ -109,29 +97,12 @@ class _HomePageState extends State { _refreshArticles(); } + bool get isLoggedIn => AuthInterceptor.getToken() != null; + @override Widget build(BuildContext context) { - final isLoggedIn = AuthInterceptor.getToken() != null; - - return Scaffold( - appBar: AppBar( - title: const Text('文章列表'), - actions: [ - if (isLoggedIn) - IconButton( - icon: const Icon(Icons.logout), - onPressed: _logout, - tooltip: '退出登录', - ) - else - IconButton( - icon: const Icon(Icons.login), - onPressed: _openLoginPage, - tooltip: '登录', - ), - ], - ), - body: _buildBody(), + return LearningScaffold( + title: 'Dio 拦截器演示', floatingActionButton: isLoggedIn ? FloatingActionButton( onPressed: _showAddArticleDialog, @@ -139,39 +110,90 @@ class _HomePageState extends State { child: const Icon(Icons.add), ) : null, + interactiveDemo: SizedBox( + height: 500, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text(isLoggedIn ? '已登录' : '未登录', + style: TextStyle( + fontSize: 12, + color: isLoggedIn ? Colors.green : Colors.grey)), + const SizedBox(width: 8), + IconButton( + icon: Icon(isLoggedIn ? Icons.logout : Icons.login, size: 20), + onPressed: isLoggedIn ? _logout : _openLoginPage, + tooltip: isLoggedIn ? '退出登录' : '登录', + ), + ], + ), + Expanded(child: _buildBody()), + ], + ), + ), + sections: [ + LearningObjectives(objectives: [ + '理解 Dio 拦截器的工作原理和链路机制', + '掌握 Auth 拦截器实现 Token 自动注入', + '理解 Error 拦截器统一错误处理', + '掌握 Retry 拦截器实现请求重试', + ]), + ConceptChips(concepts: [ + 'Dio', + '拦截器', + 'Token', + '重试机制', + '错误处理', + 'Mock Server', + ]), + CodeSnippetCard( + title: 'Dio 拦截器链路', + code: 'final dio = Dio(BaseOptions(baseUrl: url));\n' + 'dio.interceptors.addAll([\n' + ' AuthInterceptor(),\n' + ' LoggingInterceptor(),\n' + ' RetryInterceptor(),\n' + ' ErrorInterceptor(),\n' + ']);', + explanation: '拦截器按添加顺序组成链路,请求从 Auth → Logging → Retry → Error 依次经过。', + ), + CommonPitfalls(pitfalls: [ + '拦截器顺序很重要 — Auth 应放在首位确保后续拦截器也能使用 Token', + 'Retry 拦截器需避免死循环 — 设置最大重试次数和指数退避策略', + 'Error 拦截器不要吞掉异常 — 统一处理后应继续抛出或返回友好提示', + ]), + ExerciseCard( + task: '在 RetryInterceptor 中添加"登录过期"检测,当响应为 401 时自动跳转登录页。', + hint: + '在 onError 中检查 DioException.response?.statusCode == 401,然后触发全局导航事件。', + ), + ], ); } - /// 构建页面主体 Widget _buildBody() { if (_isLoading && _articles.isEmpty) { return const Center(child: CircularProgressIndicator()); } - if (_errorMessage != null && _articles.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - '错误: $_errorMessage', - style: const TextStyle(color: Colors.red), - textAlign: TextAlign.center, - ), + Text('错误: $_errorMessage', + style: const TextStyle(color: Colors.red)), const SizedBox(height: 16), ElevatedButton( - onPressed: _refreshArticles, - child: const Text('重试'), - ), + onPressed: _refreshArticles, child: const Text('重试')), ], ), ); } - if (_articles.isEmpty) { return const Center(child: Text('没有文章')); } - return Column( children: [ Expanded( @@ -182,7 +204,7 @@ class _HomePageState extends State { itemBuilder: (context, index) { final article = _articles[index]; return Card( - margin: const EdgeInsets.all(8.0), + margin: const EdgeInsets.all(8), child: ListTile( title: Text(article.title), subtitle: Column( @@ -208,10 +230,9 @@ class _HomePageState extends State { ); } - /// 构建分页控件 Widget _buildPagination() { return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -231,13 +252,10 @@ class _HomePageState extends State { ); } - /// 显示添加文章对话框 void _showAddArticleDialog() { final titleController = TextEditingController(); final contentController = TextEditingController(); - // Store the scaffold messenger context final scaffoldMessenger = ScaffoldMessenger.of(context); - showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -247,52 +265,37 @@ class _HomePageState extends State { children: [ TextField( controller: titleController, - decoration: const InputDecoration( - labelText: '标题', - hintText: '请输入文章标题', - ), + decoration: + const InputDecoration(labelText: '标题', hintText: '请输入文章标题'), ), const SizedBox(height: 16), TextField( controller: contentController, - decoration: const InputDecoration( - labelText: '内容', - hintText: '请输入文章内容', - ), + decoration: + const InputDecoration(labelText: '内容', hintText: '请输入文章内容'), maxLines: 3, ), ], ), actions: [ TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('取消'), - ), + onPressed: () => Navigator.pop(dialogContext), + child: const Text('取消')), TextButton( onPressed: () async { final title = titleController.text.trim(); final content = contentController.text.trim(); - if (title.isEmpty || content.isEmpty) { scaffoldMessenger.showSnackBar( const SnackBar(content: Text('标题和内容不能为空')), ); return; } - Navigator.pop(dialogContext); - - // 显示加载指示器 - setState(() { - _isLoading = true; - }); - + setState(() => _isLoading = true); try { final result = await _apiService.createArticle(title, content); - - // Check if the widget is still mounted before updating UI if (!mounted) return; - if (result['success'] == true) { scaffoldMessenger.showSnackBar( const SnackBar(content: Text('文章创建成功')), @@ -302,20 +305,14 @@ class _HomePageState extends State { scaffoldMessenger.showSnackBar( SnackBar(content: Text(result['message'] ?? '文章创建失败')), ); - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = false); } } catch (e) { - // Check if the widget is still mounted before updating UI if (!mounted) return; - scaffoldMessenger.showSnackBar( SnackBar(content: Text('创建失败: $e')), ); - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = false); } }, child: const Text('添加'), @@ -325,7 +322,6 @@ class _HomePageState extends State { ); } - /// 格式化日期 String _formatDate(DateTime date) { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } diff --git a/lib/modules/platform/dio_interceptor/pages/login_page.dart b/lib/modules/platform/dio_interceptor/pages/login_page.dart index 630ce63..33d3a78 100644 --- a/lib/modules/platform/dio_interceptor/pages/login_page.dart +++ b/lib/modules/platform/dio_interceptor/pages/login_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import '../network/api/api_service.dart'; import '../network/interceptor/auth_interceptor.dart'; @@ -14,10 +15,8 @@ class _LoginPageState extends State { final _formKey = GlobalKey(); final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); - bool _isLoading = false; String? _errorMessage; - final ApiService _apiService = ApiService(); @override @@ -27,33 +26,20 @@ class _LoginPageState extends State { super.dispose(); } - /// 处理登录 Future _login() async { - // 表单验证 - if (!_formKey.currentState!.validate()) { - return; - } - + if (!_formKey.currentState!.validate()) return; final username = _usernameController.text.trim(); final password = _passwordController.text.trim(); - setState(() { _isLoading = true; _errorMessage = null; }); - try { final result = await _apiService.login(username, password); - if (result['success'] == true && result['data'] != null) { - // 登录成功,保存token final token = result['data']['token'] as String; AuthInterceptor.setToken(token); - - if (mounted) { - // 关闭登录页并返回成功状态 - Navigator.pop(context, true); - } + if (mounted) Navigator.pop(context, true); } else { setState(() { _errorMessage = result['message'] ?? '登录失败'; @@ -70,18 +56,15 @@ class _LoginPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('登录'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), + return LearningScaffold( + title: '登录', + interactiveDemo: SizedBox( + height: 400, child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 错误信息 if (_errorMessage != null) Container( margin: const EdgeInsets.only(bottom: 16), @@ -91,13 +74,9 @@ class _LoginPageState extends State { borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.red.shade200), ), - child: Text( - _errorMessage!, - style: const TextStyle(color: Colors.red), - ), + child: Text(_errorMessage!, + style: const TextStyle(color: Colors.red)), ), - - // 用户名 TextFormField( controller: _usernameController, decoration: const InputDecoration( @@ -105,18 +84,12 @@ class _LoginPageState extends State { hintText: '请输入用户名', prefixIcon: Icon(Icons.person), ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return '请输入用户名'; - } - return null; - }, + validator: (value) => + value == null || value.trim().isEmpty ? '请输入用户名' : null, textInputAction: TextInputAction.next, enabled: !_isLoading, ), const SizedBox(height: 16), - - // 密码 TextFormField( controller: _passwordController, decoration: const InputDecoration( @@ -125,35 +98,24 @@ class _LoginPageState extends State { prefixIcon: Icon(Icons.lock), ), obscureText: true, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return '请输入密码'; - } - return null; - }, + validator: (value) => + value == null || value.trim().isEmpty ? '请输入密码' : null, textInputAction: TextInputAction.done, onFieldSubmitted: (_) => _login(), enabled: !_isLoading, ), const SizedBox(height: 24), - - // 登录按钮 ElevatedButton( onPressed: _isLoading ? null : _login, child: _isLoading ? const SizedBox( height: 20, width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), + child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('登录'), ), - const SizedBox(height: 16), - - // 提示信息 const Text( '提示: 用户名: admin 或 user,密码: password123 或 user123', style: TextStyle(color: Colors.grey), @@ -163,6 +125,31 @@ class _LoginPageState extends State { ), ), ), + sections: [ + LearningObjectives(objectives: [ + '理解 Token 认证流程及拦截器自动注入机制', + '掌握 AuthInterceptor 的实现与使用', + ]), + ConceptChips(concepts: ['Token', '认证', '登录', 'AuthInterceptor']), + CodeSnippetCard( + title: 'AuthInterceptor 实现', + code: 'class AuthInterceptor extends Interceptor {\n' + ' @override\n' + ' void onRequest(options, handler) {\n' + ' final token = getToken();\n' + ' if (token != null) {\n' + ' options.headers["Authorization"] = "Bearer \$token";\n' + ' }\n' + ' handler.next(options);\n' + ' }\n' + '}', + explanation: '拦截器在请求前检查 Token,自动注入 Authorization 请求头。', + ), + ExerciseCard( + task: '在登录页添加"显示/隐藏密码"切换按钮,提升用户体验。', + hint: '使用 TextFormField 的 obscureText 属性配合 State 中的 bool 变量切换显示状态。', + ), + ], ); } } diff --git a/lib/modules/platform/usb_detector/AI_ANALYSIS.md b/lib/modules/platform/usb_detector/AI_ANALYSIS.md index 30944c9..27af86a 100644 --- a/lib/modules/platform/usb_detector/AI_ANALYSIS.md +++ b/lib/modules/platform/usb_detector/AI_ANALYSIS.md @@ -8,11 +8,22 @@ "path": "lib/modules/platform/usb_detector", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["usb_serial","device_info_plus","module_registry"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["models/usb_device_info.dart","module_entry.dart","module_root.dart","services/usb_detection_service.dart"], + "files": ["module_entry.dart","module_root.dart","models/usb_device_info.dart","services/usb_detection_service.dart"], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/platform/usb_detector/module_root.dart b/lib/modules/platform/usb_detector/module_root.dart index ac5b5c7..5691cce 100644 --- a/lib/modules/platform/usb_detector/module_root.dart +++ b/lib/modules/platform/usb_detector/module_root.dart @@ -1,4 +1,6 @@ +// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'models/usb_device_info.dart'; import 'services/usb_detection_service.dart'; @@ -59,20 +61,15 @@ class _MyHomePageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _refreshDevices, - tooltip: '刷新设备列表', - ), - ], + return LearningScaffold( + title: widget.title, + floatingActionButton: FloatingActionButton( + onPressed: _refreshDevices, + tooltip: '刷新设备', + child: const Icon(Icons.refresh), ), - body: Padding( - padding: const EdgeInsets.all(16.0), + interactiveDemo: SizedBox( + height: 500, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -135,10 +132,10 @@ class _MyHomePageState extends State { const SizedBox(height: 16), Text( _isInitialized ? '未检测到USB设备' : 'USB服务未初始化', - style: - Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Colors.grey[600], - ), + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Colors.grey[600]), ), ], ), @@ -197,11 +194,44 @@ class _MyHomePageState extends State { ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: _refreshDevices, - tooltip: '刷新设备', - child: const Icon(Icons.refresh), - ), + sections: [ + LearningObjectives(objectives: [ + '理解 Flutter 中 USB 设备检测的实现方式', + '掌握 MethodChannel 与原生平台通信的模式', + '学会使用 Stream 监听设备插拔事件', + ]), + ConceptChips(concepts: [ + 'USB', + '设备检测', + 'MethodChannel', + 'Stream', + '平台通道', + ]), + CodeSnippetCard( + title: 'USB 检测服务使用', + code: 'final service = UsbDetectionService();\n' + 'await service.initialize();\n' + 'service.deviceStream.listen((devices) {\n' + ' // 设备列表更新\n' + '});\n' + 'service.statusStream.listen((status) {\n' + ' // 状态变化通知\n' + '});\n' + 'await service.refreshDevices();\n' + 'service.dispose();', + explanation: 'UsbDetectionService 封装了平台通道调用和设备状态管理。', + ), + CommonPitfalls(pitfalls: [ + 'USB 检测需要平台特定权限 — macOS 需在 entitlements 中声明,Android 需声明 USB 权限', + '平台通道需在后台线程操作 — USB 通信可能阻塞,避免在主 Isolate 中执行耗时操作', + '设备热插拔监听需及时注册 — initState 中启动监听,dispose 中释放', + ]), + ExerciseCard( + task: '实现设备连接时的 Toast 或 SnackBar 提示,当 USB 设备插入时自动弹出通知。', + hint: + '在 deviceStream 监听中检查设备数量变化,使用 ScaffoldMessenger.of(context).showSnackBar()。', + ), + ], ); } diff --git a/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md b/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md index 6126cd5..3382b7a 100644 --- a/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md +++ b/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md @@ -21,6 +21,17 @@ "widgets/dropdown_surface.dart", "widgets/status_info.dart" ], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/popup_table/overlay_follow_compare/module_root.dart b/lib/modules/popup_table/overlay_follow_compare/module_root.dart index f97e735..dd8578f 100644 --- a/lib/modules/popup_table/overlay_follow_compare/module_root.dart +++ b/lib/modules/popup_table/overlay_follow_compare/module_root.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'widgets/compare_panel.dart'; import 'widgets/follower_demo.dart'; @@ -9,16 +10,41 @@ class OverlayComparePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Overlay 跟随方案对照组'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth > 600; - if (isWide) { - return Row( + return LearningScaffold( + title: 'Overlay 跟随方案对照组', + interactiveDemo: SizedBox( + height: 420, + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 600; + if (isWide) { + return Row( + children: [ + Expanded( + child: ComparePanel( + title: 'Follower 自动跟随', + color: Colors.orange.shade700, + followMethod: 'LayerLink', + scrollListener: '无', + rebuildLevel: '极低(1次)', + demo: const FollowerDemo(), + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: ComparePanel( + title: 'onScroll 手动刷新', + color: Colors.blue.shade700, + followMethod: 'localToGlobal', + scrollListener: '有', + rebuildLevel: '随滚动递增', + demo: const ManualRebuildDemo(), + ), + ), + ], + ); + } + return Column( children: [ Expanded( child: ComparePanel( @@ -30,7 +56,7 @@ class OverlayComparePage extends StatelessWidget { demo: const FollowerDemo(), ), ), - const VerticalDivider(width: 1), + const Divider(height: 1), Expanded( child: ComparePanel( title: 'onScroll 手动刷新', @@ -43,34 +69,84 @@ class OverlayComparePage extends StatelessWidget { ), ], ); - } - return Column( - children: [ - Expanded( - child: ComparePanel( - title: 'Follower 自动跟随', - color: Colors.orange.shade700, - followMethod: 'LayerLink', - scrollListener: '无', - rebuildLevel: '极低(1次)', - demo: const FollowerDemo(), - ), - ), - const Divider(height: 1), - Expanded( - child: ComparePanel( - title: 'onScroll 手动刷新', - color: Colors.blue.shade700, - followMethod: 'localToGlobal', - scrollListener: '有', - rebuildLevel: '随滚动递增', - demo: const ManualRebuildDemo(), - ), - ), - ], - ); - }, + }, + ), ), + sections: const [ + LearningObjectives( + objectives: [ + '理解 Overlay + OverlayEntry 实现全局弹层的机制。', + '掌握 LayerLink + CompositedTransformFollower 实现自动跟随定位。', + '掌握 localToGlobal 手动计算位置并调用 markNeedsBuild 刷新。', + '对比自动跟随与手动刷新的性能差异与适用场景。', + '理解 CompositedTransformTarget/Follower 的 Layer 同步原理。', + ], + ), + ConceptChips( + concepts: [ + 'Overlay', + 'OverlayEntry', + 'LayerLink', + 'CompositedTransformFollower', + 'CompositedTransformTarget', + 'localToGlobal', + 'markNeedsBuild', + ], + ), + CodeSnippetCard( + title: 'Follower vs Manual 核心代码', + code: '''// Follower 自动跟随 +final link = LayerLink(); + +CompositedTransformTarget( + link: link, + child: targetButton, // 触发按钮 +); + +// Overlay 内使用 Follower +CompositedTransformFollower( + link: link, + offset: Offset(0, 48), + child: dropdownPanel, +); + +// --- + +// Manual 手动刷新 +final key = GlobalKey(); + +final box = key.currentContext!.findRenderObject() as RenderBox; +final pos = box.localToGlobal(Offset.zero); + +// OverlayEntry 监听滚动 +_scrollController.addListener(() { + _overlayEntry?.markNeedsBuild(); +}); + +Positioned( + left: pos.dx, + top: pos.dy + 48, + child: dropdownPanel, +);''', + explanation: + 'Follower 自动跟随基于 Layer 层同步机制,不触发 widget 重建;Manual 方案通过 markNeedsBuild 手动刷新,每次滚动都重建 OverlayEntry。', + ), + CommonPitfalls( + pitfalls: [ + 'OverlayEntry 必须通过 Overlay.of(context).insert 插入,不能直接作为子 widget。', + '忘记在 dispose 中 remove OverlayEntry 会导致 Overlay 泄漏,触发 WidgetsBinding 异常。', + 'CompositedTransformFollower 的 showWhenUnlinked 默认为 true,未链接时仍然显示。', + 'markNeedsBuild 每次调用都会重建 OverlayEntry 的 builder,频繁滚动时影响性能。', + 'localToGlobal 在滚动时需要重新计算,必须通过 ScrollController 监听驱动。', + ], + ), + ExerciseCard( + task: + '在 ManualRebuildDemo 中增加状态信息 Widget,实时显示当前 OverlayEntry 的 rebuildCount,对比 FollowerDemo 的 rebuildCount 差异。', + hint: + '在 ManualRebuildDemo 的 _toggleOverlay 中增加回调,将 rebuildCount 传递给 StatusInfo 或新增 Widget 展示。', + ), + ], ); } } diff --git a/lib/modules/popup_table/overlay_follow_compare/widgets/status_info.dart b/lib/modules/popup_table/overlay_follow_compare/widgets/status_info.dart index 395430d..4acfeb6 100644 --- a/lib/modules/popup_table/overlay_follow_compare/widgets/status_info.dart +++ b/lib/modules/popup_table/overlay_follow_compare/widgets/status_info.dart @@ -21,9 +21,9 @@ class StatusInfo extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _chip('跟随: $followMethod'), - _chip('监听: $scrollListener'), - _chip('重绘: $rebuildLevel'), + Flexible(child: _chip('跟随: $followMethod')), + Flexible(child: _chip('监听: $scrollListener')), + Flexible(child: _chip('重绘: $rebuildLevel')), ], ), ); @@ -32,6 +32,7 @@ class StatusInfo extends StatelessWidget { Widget _chip(String text) { return Text( text, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.black54), ); } diff --git a/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md b/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md index ac256c0..7e72d43 100644 --- a/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md +++ b/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md @@ -8,11 +8,22 @@ "path": "lib/modules/popup_table/popup_list_interaction", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages"], - "owns": ["module_entry","module_ui","module_docs"], - "depends": ["popup_widgets","scroll_table","module_registry","go_router"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry","go_router"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], "files": ["module_entry.dart","module_root.dart","module_routes.dart","pages/popup_page.dart","pages/list_page.dart"], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/popup_table/popup_list_interaction/module_root.dart b/lib/modules/popup_table/popup_list_interaction/module_root.dart index 955eba3..10c3a5f 100644 --- a/lib/modules/popup_table/popup_list_interaction/module_root.dart +++ b/lib/modules/popup_table/popup_list_interaction/module_root.dart @@ -1,4 +1,6 @@ +// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'package:go_router/go_router.dart'; import 'module_routes.dart'; @@ -8,29 +10,59 @@ class PopupListInteractionHome extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('弹窗与列表交互'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _NavCard( - title: '弹窗组件', - subtitle: 'AlertDialog、BottomSheet、Overlay、ContextMenu 等弹窗类型', - icon: Icons.open_in_browser, - onTap: () => context.push(PopupListInteractionRoutes.popup), - ), - const SizedBox(height: 16), - _NavCard( - title: '列表交互', - subtitle: '二维滚动表格,固定表头与行头', - icon: Icons.table_chart, - onTap: () => context.push(PopupListInteractionRoutes.list), - ), - ], + return LearningScaffold( + title: '弹窗与列表交互', + interactiveDemo: SizedBox( + height: 250, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _NavCard( + title: '弹窗组件', + subtitle: 'AlertDialog、BottomSheet、Overlay、ContextMenu 等弹窗类型', + icon: Icons.open_in_browser, + onTap: () => context.push(PopupListInteractionRoutes.popup), + ), + const SizedBox(height: 16), + _NavCard( + title: '列表交互', + subtitle: '二维滚动表格,固定表头与行头', + icon: Icons.table_chart, + onTap: () => context.push(PopupListInteractionRoutes.list), + ), + ], + ), ), + sections: [ + LearningObjectives(objectives: [ + '掌握 Flutter 弹窗组件(Dialog、BottomSheet、Overlay)的使用', + '理解二维滚动表格的原理与实现', + '学习弹窗与列表的协同交互方式', + ]), + ConceptChips(concepts: [ + 'Dialog', + 'BottomSheet', + 'Overlay', + 'ContextMenu', + 'TableView', + '二维滚动', + ]), + CodeSnippetCard( + title: '弹窗与列表路由配置', + code: "context.push(PopupListInteractionRoutes.popup);\n" + "context.push(PopupListInteractionRoutes.list);", + explanation: '模块内部使用 go_router 子路由管理多个演示页面。', + ), + CommonPitfalls(pitfalls: [ + 'OverlayEntry 需在 dispose 时清理 — 否则会造成内存泄漏', + 'BottomSheet 在 ListView 中可能出现手势冲突 — 注意 GestureDetector 的嵌套', + ]), + ExerciseCard( + task: '在列表页中长按列表项弹出 ContextMenu,选择后执行对应操作。', + hint: + '使用 showMenu 配合 onLongPress 或 GestureDetector.onLongPressStart。', + ), + ], ); } } diff --git a/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md b/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md index bd846a1..84000e3 100644 --- a/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md +++ b/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md @@ -8,11 +8,18 @@ "path": "lib/modules/popup_table/popup_widgets", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["module_registry"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["module_registry","flutter_study_learning"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], "files": ["module_entry.dart","module_root.dart"], + "classes": { + "PopDemoHomePage": "StatefulWidget page entry", + "_PopDemoHomePageState": "Main state: LearningScaffold build, 9 demo trigger methods, chain/overlay dialog management", + "ChainOrderStore": "ValueNotifier-based data model for chain dialog ordering (open/close order)", + "_CupertinoDoubleTapTile": "GestureDetector tile for double-tap Cupertino dialog trigger", + "_ContextMenuTile": "GestureDetector tile for onTapDown context menu trigger" + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", @@ -20,5 +27,17 @@ "update_required_on_file_change": true, "import_direction_enforced": true }, - "validation": ["flutter analyze","flutter test"] + "validation": ["flutter analyze","flutter test"], + "refactoring": { + "date": "2026-06-15", + "summary": "Retrofit P0: Replaced PopScope+Scaffold with LearningScaffold from flutter_study_learning package", + "changes": [ + "Removed PopScope exit confirmation (no back-swipe guard needed in teaching module)", + "Removed Drawer (AboutDialog moved into interactive demo list)", + "Removed AppBar actions (moved to interactive demo toolbar row)", + "Removed GlobalKey/PersistentBottomSheetController (replaced with inline _showBottomSheet bool toggle)", + "Added 5 teaching sections: LearningObjectives, ConceptChips (×2 CodeSnippetCard), CommonPitfalls, ExerciseCard", + "Added smoke test test/popup_widgets/popup_widgets_page_test.dart" + ] + } } diff --git a/lib/modules/popup_table/popup_widgets/module_root.dart b/lib/modules/popup_table/popup_widgets/module_root.dart index 481eacd..ac7fb15 100644 --- a/lib/modules/popup_table/popup_widgets/module_root.dart +++ b/lib/modules/popup_table/popup_widgets/module_root.dart @@ -1,5 +1,7 @@ +// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; // 固定链式弹窗代号,便于确认与复用(顺序可变,代号固定) const List kChainDialogIds = ['A', 'B', 'C']; @@ -35,195 +37,292 @@ class PopDemoHomePage extends StatefulWidget { } class _PopDemoHomePageState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - PersistentBottomSheetController? _bottomSheetController; + bool _showBottomSheet = false; @override Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) { - return; - } - final shouldPop = await _showExitConfirmDialog(context); - if ((shouldPop ?? false) && context.mounted) { - Navigator.of(context).pop(); - } - }, - child: Scaffold( - key: _scaffoldKey, - appBar: AppBar( - title: Text(widget.title), - actions: [ - IconButton( - tooltip: '显示 AboutDialog', - onPressed: _showAbout, - icon: const Icon(Icons.info_outline), - ), - PopupMenuButton( - tooltip: '选择更多弹窗', - onSelected: (value) async { - switch (value) { - case 'date': - await _showDatePicker(); - break; - case 'time': - await _showTimePicker(); - break; - } - }, - itemBuilder: (context) => const [ - PopupMenuItem(value: 'date', child: Text('日期选择弹窗')), - PopupMenuItem(value: 'time', child: Text('时间选择弹窗')), - ], - ), + return LearningScaffold( + title: widget.title, + floatingActionButton: FloatingActionButton.extended( + onPressed: _togglePersistentBottomSheet, + icon: const Icon(Icons.vertical_align_top), + label: Text(_showBottomSheet ? '关闭底部条' : '显示底部条'), + ), + interactiveDemo: SizedBox( + height: 500, + child: Column( + children: [ + _buildToolbar(), + Expanded(child: _buildDemoList()), + if (_showBottomSheet) _buildBottomSheetBar(), ], ), - drawer: Drawer( - child: ListView( - padding: EdgeInsets.zero, - children: [ - const DrawerHeader( - decoration: BoxDecoration(color: Colors.deepPurple), - child: Align( - alignment: Alignment.bottomLeft, - child: Text('弹窗演示菜单', - style: TextStyle(color: Colors.white, fontSize: 18)), - ), - ), - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('显示 AboutDialog'), - onTap: () { - Navigator.pop(context); - _showAbout(); - }, - ), - ], - ), + ), + sections: [ + LearningObjectives(objectives: [ + '掌握 Flutter 中多种弹窗的创建方式', + '理解 AlertDialog、SimpleDialog、BottomSheet 的区别与适用场景', + '掌握通过 Navigator 管理链式对话框', + '学习使用 OverlayEntry 自定义弹窗', + '理解 ContextMenu(showMenu)的触发方式', + ]), + ConceptChips(concepts: [ + 'showDialog', + 'AlertDialog', + 'SimpleDialog', + 'BottomSheet', + 'showMenu', + 'OverlayEntry', + 'Navigator', + 'showDatePicker', + 'showTimePicker', + ]), + CodeSnippetCard( + title: 'AlertDialog 基础用法', + code: 'Future showAlertDialog() async {\n' + ' await showDialog(\n' + ' context: context,\n' + ' builder: (context) => AlertDialog(\n' + ' title: Text(\'提示\'),\n' + ' content: Text(\'这是 AlertDialog\'),\n' + ' actions: [\n' + ' TextButton(\n' + ' onPressed: () => Navigator.pop(context),\n' + ' child: Text(\'确定\'),\n' + ' ),\n' + ' ],\n' + ' ),\n' + ' );\n' + '}', + explanation: 'showDialog + AlertDialog 是最常用的弹窗组合。', ), - body: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - ListTile( - leading: const Icon(Icons.warning_amber_rounded), - title: const Text('AlertDialog (普通对话框)'), - subtitle: const Text('点击触发'), - onTap: () => _showAlertDialog(), - ), - ListTile( - leading: const Icon(Icons.list_alt), - title: const Text('SimpleDialog (选项对话框)'), - subtitle: const Text('点击右侧图标触发'), - trailing: IconButton( - icon: const Icon(Icons.open_in_new), - onPressed: _showSimpleDialog, - ), - ), - ListTile( - leading: const Icon(Icons.keyboard_double_arrow_up), - title: const Text('Modal Bottom Sheet (模态底部弹窗)'), - subtitle: const Text('长按触发'), - onLongPress: _showModalBottomSheet, - ), - const Divider(height: 16), - _CupertinoDoubleTapTile(onDoubleTap: _showCupertinoAlert), - ListTile( - leading: const Icon(Icons.design_services), - title: const Text('自定义 Dialog'), - subtitle: const Text('点击按钮触发'), - trailing: ElevatedButton( - onPressed: _showCustomDialog, - child: const Text('打开'), + CodeSnippetCard( + title: 'Modal Bottom Sheet', + code: 'Future showBottomSheetDemo() async {\n' + ' await showModalBottomSheet(\n' + ' context: context,\n' + ' showDragHandle: true,\n' + ' builder: (context) => SafeArea(\n' + ' child: Padding(\n' + ' padding: EdgeInsets.all(16),\n' + ' child: Column(\n' + ' mainAxisSize: MainAxisSize.min,\n' + ' children: [\n' + ' Text(\'底部弹窗\'),\n' + ' ElevatedButton(\n' + ' onPressed: () => Navigator.pop(context),\n' + ' child: Text(\'关闭\'),\n' + ' ),\n' + ' ],\n' + ' ),\n' + ' ),\n' + ' ),\n' + ' );\n' + '}', + explanation: 'showModalBottomSheet 从屏幕底部滑入,支持拖动关闭。', + ), + CommonPitfalls(pitfalls: [ + '忘记 Navigator.pop(context) — 对话框不会自动关闭,需要在按钮回调中显式调用 Navigator.pop(context)', + 'context 生命周期 — 异步操作后需检查 mounted,否则调用 Navigator.pop(context) 可能抛异常', + 'showDialog 与 showCupertinoDialog 使用不同的主题上下文,不可混用', + 'OverlayEntry 需手动管理 — 不会自动释放,必须在 dispose 时清理所有 entry', + ]), + ExerciseCard( + task: '创建一个包含输入框的自定义对话框,用户输入文字后点击"提交",在 SnackBar 中显示输入内容。', + hint: + '使用 showDialog + AlertDialog,在 builder 中使用 TextField,通过 Navigator.pop(context, inputValue) 返回结果。', + ), + ], + ); + } + + Widget _buildToolbar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + border: + Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: Row( + children: [ + IconButton( + tooltip: '显示 AboutDialog', + onPressed: _showAbout, + icon: const Icon(Icons.info_outline), + ), + const Spacer(), + PopupMenuButton( + tooltip: '选择更多弹窗', + onSelected: (value) async { + switch (value) { + case 'date': + await _showDatePicker(); + break; + case 'time': + await _showTimePicker(); + break; + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'date', child: Text('日期选择弹窗')), + PopupMenuItem(value: 'time', child: Text('时间选择弹窗')), + ], + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.date_range, size: 20), + SizedBox(width: 4), + Text('日期/时间', style: TextStyle(fontSize: 13)), + ], ), ), - _ContextMenuTile(onShowMenu: _showContextMenu), - const Divider(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + ], + ), + ); + } + + Widget _buildDemoList() { + return ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + ListTile( + leading: const Icon(Icons.warning_amber_rounded), + title: const Text('AlertDialog (普通对话框)'), + subtitle: const Text('点击触发'), + onTap: () => _showAlertDialog(), + ), + ListTile( + leading: const Icon(Icons.list_alt), + title: const Text('SimpleDialog (选项对话框)'), + subtitle: const Text('点击右侧图标触发'), + trailing: IconButton( + icon: const Icon(Icons.open_in_new), + onPressed: _showSimpleDialog, + ), + ), + ListTile( + leading: const Icon(Icons.keyboard_double_arrow_up), + title: const Text('Modal Bottom Sheet (模态底部弹窗)'), + subtitle: const Text('长按触发'), + onLongPress: _showModalBottomSheet, + ), + const Divider(height: 16), + _CupertinoDoubleTapTile(onDoubleTap: _showCupertinoAlert), + ListTile( + leading: const Icon(Icons.design_services), + title: const Text('自定义 Dialog'), + subtitle: const Text('点击按钮触发'), + trailing: ElevatedButton( + onPressed: _showCustomDialog, + child: const Text('打开'), + ), + ), + _ContextMenuTile(onShowMenu: _showContextMenu), + const Divider(height: 16), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('显示 AboutDialog'), + subtitle: const Text('系统自带"关于"对话框'), + onTap: _showAbout, + ), + const Divider(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('顺序链式弹窗(自定义开关顺序)', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + Text('示例:打开 A→B→C;关闭 B→A→C', + style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Text('顺序链式弹窗(自定义开关顺序)', - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 6), - Text('示例:打开 A→B→C;关闭 B→A→C', - style: Theme.of(context).textTheme.bodySmall), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ElevatedButton( - onPressed: _demoOpenChain, - child: const Text('打开 A→B→C'), - ), - OutlinedButton( - onPressed: _demoCloseChain, - child: const Text('关闭 B→A→C'), - ), - ], + ElevatedButton( + onPressed: _demoOpenChain, + child: const Text('打开 A→B→C'), + ), + OutlinedButton( + onPressed: _demoCloseChain, + child: const Text('关闭 B→A→C'), ), ], ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Overlay 链式弹窗(对比 Navigator)', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + ValueListenableBuilder>( + valueListenable: _orderStore.openOrder, + builder: (_, open, __) => ValueListenableBuilder>( + valueListenable: _orderStore.closeOrder, + builder: (_, close, __) => Text( + '示例:打开 ${open.join('→')};关闭 ${close.join('→')}(使用 OverlayEntry)', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Text('Overlay 链式弹窗(对比 Navigator)', - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 6), ValueListenableBuilder>( valueListenable: _orderStore.openOrder, - builder: (_, open, __) => - ValueListenableBuilder>( - valueListenable: _orderStore.closeOrder, - builder: (_, close, __) => Text( - '示例:打开 ${open.join('→')};关闭 ${close.join('→')}(使用 OverlayEntry)', - style: Theme.of(context).textTheme.bodySmall, - ), + builder: (_, open, __) => ElevatedButton( + onPressed: _demoOpenOverlayChain, + child: Text('Overlay 打开 ${open.join('→')}'), ), ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ValueListenableBuilder>( - valueListenable: _orderStore.openOrder, - builder: (_, open, __) => ElevatedButton( - onPressed: _demoOpenOverlayChain, - child: Text('Overlay 打开 ${open.join('→')}'), - ), - ), - ValueListenableBuilder>( - valueListenable: _orderStore.closeOrder, - builder: (_, close, __) => OutlinedButton( - onPressed: _demoCloseOverlayChain, - child: Text('Overlay 关闭 ${close.join('→')}'), - ), - ), - TextButton.icon( - onPressed: _editOverlayOrders, - icon: const Icon(Icons.tune), - label: const Text('编辑 Overlay 顺序'), - ), - ], + ValueListenableBuilder>( + valueListenable: _orderStore.closeOrder, + builder: (_, close, __) => OutlinedButton( + onPressed: _demoCloseOverlayChain, + child: Text('Overlay 关闭 ${close.join('→')}'), + ), + ), + TextButton.icon( + onPressed: _editOverlayOrders, + icon: const Icon(Icons.tune), + label: const Text('编辑 Overlay 顺序'), ), ], ), - ), - const SizedBox(height: 80), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: _togglePersistentBottomSheet, - icon: const Icon(Icons.vertical_align_top), - label: Text(_bottomSheetController == null ? '显示底部工具条' : '关闭底部工具条'), + ], + ), ), + const SizedBox(height: 80), + ], + ); + } + + Widget _buildBottomSheetBar() { + return Container( + color: Theme.of(context).colorScheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const Icon(Icons.tips_and_updates_outlined), + const SizedBox(width: 8), + const Expanded(child: Text('这是一个持久化底部工具条,你可以手动关闭。')), + TextButton( + onPressed: _togglePersistentBottomSheet, + child: const Text('关闭'), + ), + ], ), ); } @@ -755,35 +854,9 @@ class _PopDemoHomePageState extends State { // 6) 持久化 BottomSheet(FAB 切换) void _togglePersistentBottomSheet() { - if (_bottomSheetController != null) { - _bottomSheetController!.close(); - _bottomSheetController = null; - setState(() {}); - return; - } - _bottomSheetController = - _scaffoldKey.currentState?.showBottomSheet((context) { - return SafeArea( - child: Container( - color: Theme.of(context).colorScheme.surface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - const Icon(Icons.tips_and_updates_outlined), - const SizedBox(width: 8), - const Expanded(child: Text('这是一个持久化底部工具条,你可以手动关闭。')), - TextButton( - onPressed: () => _bottomSheetController?.close(), - child: const Text('关闭')), - ], - ), - ), - ); - }); - _bottomSheetController?.closed.whenComplete(() { - if (mounted) setState(() => _bottomSheetController = null); + setState(() { + _showBottomSheet = !_showBottomSheet; }); - setState(() {}); } // 7) 日期/时间选择 @@ -815,26 +888,7 @@ class _PopDemoHomePageState extends State { ); } - // 9) 退出确认弹窗(返回键触发) - Future _showExitConfirmDialog(BuildContext context) { - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('确认退出'), - content: const Text('确定要退出应用吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('取消')), - ElevatedButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('退出')), - ], - ), - ); - } - - // 10) 上下文菜单(在点击位置弹出) + // 9) 上下文菜单(在点击位置弹出) Future _showContextMenu(TapDownDetails details) async { final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final selected = await showMenu( diff --git a/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md b/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md index 1081f0c..9c2e572 100644 --- a/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md +++ b/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md @@ -8,11 +8,22 @@ "path": "lib/modules/popup_table/scroll_table", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["two_dimensional_scrollables","module_registry"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], "files": ["module_entry.dart","module_root.dart","widgets/scroll_table.dart"], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/popup_table/scroll_table/module_root.dart b/lib/modules/popup_table/scroll_table/module_root.dart index 8b65b4e..786f662 100644 --- a/lib/modules/popup_table/scroll_table/module_root.dart +++ b/lib/modules/popup_table/scroll_table/module_root.dart @@ -1,24 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'widgets/scroll_table.dart'; -class ScrollTableDemo extends StatefulWidget { +class ScrollTableDemo extends StatelessWidget { const ScrollTableDemo({super.key}); - @override - State createState() => _ScrollTableDemoState(); -} - -class _ScrollTableDemoState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('二维滚动表格演示'), - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), + return LearningScaffold( + title: '二维滚动表格演示', + interactiveDemo: SizedBox( + height: 400, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -44,6 +37,39 @@ class _ScrollTableDemoState extends State { ], ), ), + sections: [ + LearningObjectives(objectives: [ + '掌握二维滚动表格的基本使用方式', + '理解固定表头与行头的实现原理', + '学会使用 TableView 处理大量数据展示', + ]), + ConceptChips(concepts: [ + 'TableView', + '二维滚动', + '固定表头', + '行头', + 'two_dimensional_scrollables', + ]), + CodeSnippetCard( + title: 'ScrollTable 使用示例', + code: 'ScrollTable(\n' + ' columnHeaders: columnHeaders,\n' + ' rowHeaders: rowHeaders,\n' + ' data: sampleData,\n' + ' cellHeight: 56.0,\n' + ' cellWidth: 140.0,\n' + ')', + explanation: 'ScrollTable 封装了 TableView 的常见配置,简化使用。', + ), + CommonPitfalls(pitfalls: [ + '数据量大时需注意性能 — TableView 本身支持懒加载,但 cellWidget 避免复杂构建', + '宽高需明确指定 — TableView 的单元格宽高必须固定,不支持自适应', + ]), + ExerciseCard( + task: '在现有表格基础上增加一列"操作",包含编辑和删除按钮。', + hint: '在 columnHeaders 和 data 中同步增加列,TableData 数据类中增加对应字段。', + ), + ], ); } } diff --git a/lib/modules/state/flutter_ioc/AI_ANALYSIS.md b/lib/modules/state/flutter_ioc/AI_ANALYSIS.md index 59e7e9d..d2208d2 100644 --- a/lib/modules/state/flutter_ioc/AI_ANALYSIS.md +++ b/lib/modules/state/flutter_ioc/AI_ANALYSIS.md @@ -20,5 +20,16 @@ "update_required_on_file_change": true, "import_direction_enforced": true }, + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "validation": ["flutter analyze","flutter test"] } diff --git a/lib/modules/state/flutter_ioc/module_entry.dart b/lib/modules/state/flutter_ioc/module_entry.dart index 3a4805e..c65aecc 100644 --- a/lib/modules/state/flutter_ioc/module_entry.dart +++ b/lib/modules/state/flutter_ioc/module_entry.dart @@ -24,6 +24,11 @@ class _FlutterIocEntryState extends State { ); } + @override + void dispose() { + super.dispose(); + } + @override Widget build(BuildContext context) { return Provider.value( diff --git a/lib/modules/state/flutter_ioc/module_root.dart b/lib/modules/state/flutter_ioc/module_root.dart index be1579e..60e227f 100644 --- a/lib/modules/state/flutter_ioc/module_root.dart +++ b/lib/modules/state/flutter_ioc/module_root.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'package:provider/provider.dart'; import 'model/counter_model.dart'; @@ -10,35 +11,77 @@ class CounterScreen extends StatelessWidget { Widget build(BuildContext context) { final counterModel = Provider.of(context); - return Scaffold( - appBar: AppBar( - title: const Text('Counter'), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Name: ${counterModel.name}', - style: const TextStyle(fontSize: 24), - ), - Text( - 'Count: ${counterModel.count}', - style: const TextStyle(fontSize: 24), - ), - TextField( - onChanged: (value) { - counterModel.setName(value); - }, - decoration: const InputDecoration(labelText: 'Enter new name'), - ), - ], - ), - ), + return LearningScaffold( + title: 'Flutter IoC 容器', floatingActionButton: FloatingActionButton( onPressed: counterModel.increment, child: const Icon(Icons.add), ), + interactiveDemo: SizedBox( + height: 300, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Name: ${counterModel.name}', + style: const TextStyle(fontSize: 24), + ), + const SizedBox(height: 12), + Text( + 'Count: ${counterModel.count}', + style: const TextStyle(fontSize: 24), + ), + const SizedBox(height: 16), + TextField( + onChanged: (value) { + counterModel.setName(value); + }, + decoration: const InputDecoration( + labelText: 'Enter new name', + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ), + sections: [ + LearningObjectives(objectives: [ + '理解 IoC 容器与依赖注入的基本概念', + '掌握单例、瞬态、作用域三种生命周期', + '学会使用 Provider 与 IoC 容器集成', + ]), + ConceptChips(concepts: [ + 'IoC', + '依赖注入', + 'Singleton', + 'Transient', + 'Scoped', + 'Provider', + ]), + CodeSnippetCard( + title: 'IoC 容器注册与使用', + code: 'final container = Container(\n' + " environment: {'appName': 'Counter'},\n" + ');\n' + 'container.registerSingleton(\n' + ' (_) => CounterModel(name: \'Default\', count: 0),\n' + ');\n' + 'final model = container.resolve();', + explanation: '容器管理对象生命周期,模块无需关心实例化细节。', + ), + CommonPitfalls(pitfalls: [ + '忘记在 dispose 中释放容器 — IoC 容器不会自动回收注册的对象', + '循环依赖 — 容器无法自动检测循环依赖,需自行注意依赖方向', + '过度使用全局单例 — 单例作用域应尽量缩小,避免状态污染', + ]), + ExerciseCard( + task: '注册一个 Transient 作用域的 Service,每次 resolve 都返回新实例,验证行为。', + hint: + '使用 container.registerTransient(factory) 注册,多次 resolve 后比较引用是否相同。', + ), + ], ); } } diff --git a/lib/modules/state/status_management/AI_ANALYSIS.md b/lib/modules/state/status_management/AI_ANALYSIS.md index bb3ad00..7038803 100644 --- a/lib/modules/state/status_management/AI_ANALYSIS.md +++ b/lib/modules/state/status_management/AI_ANALYSIS.md @@ -8,11 +8,42 @@ "path": "lib/modules/state/status_management", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["provider","flutter_riverpod","flutter_bloc","module_registry"], + "entrypoints": ["module_entry.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry","go_router","provider","flutter_riverpod","flutter_bloc"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["app/app_routes.dart","app/route_paths.dart","app/state_flow_app.dart","features/bloc/bloc_route.dart","features/bloc/counter_bloc.dart","features/bloc/counter_event.dart","features/bloc/counter_state.dart","features/provider/models/counter_cn.dart","features/provider/models/counter_model.dart","features/provider/provider_future_route.dart","features/provider/provider_lifting_route.dart","features/provider/provider_route.dart","features/provider/provider_todo_route.dart","features/provider/widgets/granular_grid.dart","features/provider/widgets/provider_perks.dart","features/riverpod/riverpod_future_route.dart","features/riverpod/riverpod_lifting_route.dart","features/riverpod/riverpod_route.dart","features/riverpod/riverpod_todo_route.dart","module_entry.dart","shared/widgets/state_flow_scaffold.dart"], + "files": [ + "module_entry.dart", + "module_routes.dart", + "pages/home_page.dart", + "pages/provider/provider_route.dart", + "pages/provider/provider_lifting_route.dart", + "pages/provider/provider_future_route.dart", + "pages/provider/provider_todo_route.dart", + "pages/provider/models/counter_cn.dart", + "pages/provider/widgets/granular_grid.dart", + "pages/provider/widgets/provider_perks.dart", + "pages/riverpod/riverpod_route.dart", + "pages/riverpod/riverpod_lifting_route.dart", + "pages/riverpod/riverpod_future_route.dart", + "pages/riverpod/riverpod_todo_route.dart", + "pages/bloc/bloc_route.dart", + "pages/bloc/counter_bloc.dart", + "pages/bloc/counter_event.dart", + "pages/bloc/counter_state.dart", + "widgets/state_flow_demo.dart" + ], + "teaching_components": { + "page": "module_root.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/state/status_management/app/app_routes.dart b/lib/modules/state/status_management/app/app_routes.dart deleted file mode 100644 index bb63bf2..0000000 --- a/lib/modules/state/status_management/app/app_routes.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; -import '../features/provider/provider_route.dart'; -import '../features/provider/provider_lifting_route.dart'; -import '../features/provider/provider_future_route.dart'; -import '../features/provider/provider_todo_route.dart'; -import '../features/riverpod/riverpod_route.dart'; -import '../features/riverpod/riverpod_lifting_route.dart'; -import '../features/riverpod/riverpod_future_route.dart'; -import '../features/riverpod/riverpod_todo_route.dart'; -import 'route_paths.dart'; -import '../features/bloc/bloc_route.dart'; - -class AppRoutes { - static Map routes = { - RoutePaths.provider: (_) => const ProviderRoute(), - RoutePaths.providerLifting: (_) => const ProviderLiftingRoute(), - RoutePaths.providerFuture: (_) => const ProviderFutureRoute(), - RoutePaths.providerTodo: (_) => const ProviderTodoRoute(), - RoutePaths.riverpod: (_) => const RiverpodRoute(), - RoutePaths.riverpodLifting: (_) => const RiverpodLiftingRoute(), - RoutePaths.riverpodFuture: (_) => const RiverpodFutureRoute(), - RoutePaths.riverpodTodo: (_) => const RiverpodTodoRoute(), - RoutePaths.bloc: (_) => const BlocRoute(), - }; -} diff --git a/lib/modules/state/status_management/app/route_paths.dart b/lib/modules/state/status_management/app/route_paths.dart deleted file mode 100644 index 2480d6e..0000000 --- a/lib/modules/state/status_management/app/route_paths.dart +++ /dev/null @@ -1,12 +0,0 @@ -class RoutePaths { - static const home = '/'; - static const provider = '/provider'; - static const providerLifting = '/provider/lifting'; - static const providerFuture = '/provider/future'; - static const providerTodo = '/provider/todo'; - static const riverpod = '/riverpod'; - static const riverpodLifting = '/riverpod/lifting'; - static const riverpodFuture = '/riverpod/future'; - static const riverpodTodo = '/riverpod/todo'; - static const bloc = '/bloc'; -} diff --git a/lib/modules/state/status_management/features/bloc/bloc_route.dart b/lib/modules/state/status_management/features/bloc/bloc_route.dart deleted file mode 100644 index 0111f9f..0000000 --- a/lib/modules/state/status_management/features/bloc/bloc_route.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../shared/widgets/state_flow_scaffold.dart'; -import 'counter_bloc.dart'; -import 'counter_event.dart'; -import 'counter_state.dart'; - -class BlocRoute extends StatelessWidget { - const BlocRoute({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => CounterBloc()..add(LoadInitial()), - child: BlocBuilder( - builder: (context, state) { - final bloc = context.read(); - return StateFlowScaffold( - pageTitle: 'Bloc / flutter_bloc', - subtitle: 'on -> emit(State) -> BlocBuilder 重建', - value: state.value, - flowSteps: const [ - 'onPressed 事件', - 'add(Event)', - 'Bloc 逻辑处理', - 'emit(State)', - '订阅者重建', - ], - onAdd: () => bloc.add(IncrementPressed()), - onReset: () => bloc.add(ResetPressed()), - ); - }, - ), - ); - } -} diff --git a/lib/modules/state/status_management/features/provider/provider_future_route.dart b/lib/modules/state/status_management/features/provider/provider_future_route.dart deleted file mode 100644 index 3da7610..0000000 --- a/lib/modules/state/status_management/features/provider/provider_future_route.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class ProviderFutureRoute extends StatelessWidget { - const ProviderFutureRoute({super.key}); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => _UserModel()..load(), - child: Scaffold( - appBar: AppBar(title: const Text('Provider 数据获取与缓存')), - body: Center( - child: Consumer<_UserModel>( - builder: (_, m, __) => m.name == null - ? const CircularProgressIndicator() - : Text(m.name!, - style: Theme.of(context).textTheme.headlineSmall), - ), - ), - ), - ); - } -} - -class _UserModel extends ChangeNotifier { - String? name; - Future load() async { - await Future.delayed(const Duration(milliseconds: 300)); - name = 'Alice'; - notifyListeners(); - } -} diff --git a/lib/modules/state/status_management/features/provider/provider_lifting_route.dart b/lib/modules/state/status_management/features/provider/provider_lifting_route.dart deleted file mode 100644 index 4431ff5..0000000 --- a/lib/modules/state/status_management/features/provider/provider_lifting_route.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class ProviderLiftingRoute extends StatelessWidget { - const ProviderLiftingRoute({super.key}); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => _LiftingCN(), - child: Scaffold( - appBar: AppBar(title: const Text('Provider 状态提升')), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: const Padding( - padding: EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _LDisplay(), - SizedBox(height: 16), - _LControls(), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _LiftingCN extends ChangeNotifier { - int value = 0; - void inc() { - value++; - notifyListeners(); - } - - void reset() { - value = 0; - notifyListeners(); - } -} - -class _LDisplay extends StatelessWidget { - const _LDisplay(); - @override - Widget build(BuildContext context) { - final v = context.select<_LiftingCN, int>((s) => s.value); - return Text('$v', style: Theme.of(context).textTheme.headlineMedium); - } -} - -class _LControls extends StatelessWidget { - const _LControls(); - @override - Widget build(BuildContext context) { - final s = context.read<_LiftingCN>(); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton.icon( - onPressed: s.inc, - icon: const Icon(Icons.exposure_plus_1), - label: const Text('加 1')), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: s.reset, - icon: const Icon(Icons.restart_alt), - label: const Text('重置')), - ], - ); - } -} diff --git a/lib/modules/state/status_management/features/provider/provider_route.dart b/lib/modules/state/status_management/features/provider/provider_route.dart deleted file mode 100644 index 5301312..0000000 --- a/lib/modules/state/status_management/features/provider/provider_route.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../shared/widgets/state_flow_scaffold.dart'; -import 'models/counter_cn.dart'; -import 'widgets/provider_perks.dart'; - -class ProviderRoute extends StatelessWidget { - const ProviderRoute({super.key}); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) { - debugPrint('[Provider] 创建 CounterCN,并开始监听 notifyListeners'); - return CounterCN(); - }, - child: Builder( - builder: (context) { - final value = context.select((s) => s.value); - debugPrint('[Provider] 顶层 build,仅监听 value=$value'); - final counter = context.read(); - - return StateFlowScaffold( - pageTitle: 'Provider / ChangeNotifier', - subtitle: - 'notifyListeners() -> Provider 找到依赖字段 -> markNeedsBuild() 仅重建对应 Widget', - value: value, - flowSteps: const [ - 'onPressed 事件(任意层)', - 'value++ / leafTaps++', - 'notifyListeners()', - '依赖字段的 Widget 重建', - ], - onAdd: counter.increment, - onReset: counter.reset, - extra: const ProviderPerks(), - ); - }, - ), - ); - } -} diff --git a/lib/modules/state/status_management/features/provider/provider_todo_route.dart b/lib/modules/state/status_management/features/provider/provider_todo_route.dart deleted file mode 100644 index 366df04..0000000 --- a/lib/modules/state/status_management/features/provider/provider_todo_route.dart +++ /dev/null @@ -1,79 +0,0 @@ -// ignore_for_file: unused_element_parameter - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class ProviderTodoRoute extends StatelessWidget { - const ProviderTodoRoute({super.key}); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => _TodoStore(), - child: Scaffold( - appBar: AppBar(title: const Text('Provider 全局状态示例')), - body: const _TodoBody(), - floatingActionButton: const _AddFab(), - ), - ); - } -} - -class _Todo { - _Todo(this.title, {this.done = false}); - final String title; - bool done; -} - -class _TodoStore extends ChangeNotifier { - final List<_Todo> list = []; - void add(String t) { - list.add(_Todo(t)); - notifyListeners(); - } - - void toggle(int i) { - list[i].done = !list[i].done; - notifyListeners(); - } - - void remove(int i) { - list.removeAt(i); - notifyListeners(); - } -} - -class _TodoBody extends StatelessWidget { - const _TodoBody({super.key}); - @override - Widget build(BuildContext context) { - final store = context.watch<_TodoStore>(); - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: store.list.length, - itemBuilder: (_, i) { - final item = store.list[i]; - return ListTile( - title: Text(item.title), - leading: - Checkbox(value: item.done, onChanged: (_) => store.toggle(i)), - trailing: IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => store.remove(i)), - ); - }, - ); - } -} - -class _AddFab extends StatelessWidget { - const _AddFab({super.key}); - @override - Widget build(BuildContext context) { - final store = context.read<_TodoStore>(); - return FloatingActionButton( - onPressed: () => store.add('Item ${store.list.length + 1}'), - child: const Icon(Icons.add), - ); - } -} diff --git a/lib/modules/state/status_management/features/riverpod/riverpod_future_route.dart b/lib/modules/state/status_management/features/riverpod/riverpod_future_route.dart deleted file mode 100644 index 3a284e8..0000000 --- a/lib/modules/state/status_management/features/riverpod/riverpod_future_route.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class RiverpodFutureRoute extends ConsumerWidget { - const RiverpodFutureRoute({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final async = ref.watch(_userProvider); - return Scaffold( - appBar: AppBar(title: const Text('Riverpod 数据获取与缓存')), - body: Center( - child: async.when( - data: (v) => - Text(v, style: Theme.of(context).textTheme.headlineSmall), - loading: () => const CircularProgressIndicator(), - error: (e, _) => Text('Error: $e'), - ), - ), - ); - } -} - -final _userProvider = FutureProvider((ref) async { - await Future.delayed(const Duration(milliseconds: 300)); - return 'Alice'; -}); diff --git a/lib/modules/state/status_management/features/riverpod/riverpod_route.dart b/lib/modules/state/status_management/features/riverpod/riverpod_route.dart deleted file mode 100644 index a9c3981..0000000 --- a/lib/modules/state/status_management/features/riverpod/riverpod_route.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../shared/widgets/state_flow_scaffold.dart'; - -class RiverpodRoute extends ConsumerWidget { - const RiverpodRoute({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final count = ref.watch(counterProvider); - debugPrint('[Riverpod] Consumer build,state=$count'); - - return StateFlowScaffold( - pageTitle: 'Riverpod / StateNotifier', - subtitle: 'state = newState -> ProviderContainer 广播 -> 订阅者重建', - value: count, - flowSteps: const [ - 'onPressed 事件', - 'state = newState', - '容器 diff 并广播', - '监听者 build()', - ], - onAdd: () => ref.read(counterProvider.notifier).increment(), - onReset: () => ref.read(counterProvider.notifier).reset(), - ); - } -} - -/// Riverpod 方案:StateNotifier 专注在状态流转,UI 仅订阅 Provider。 -final counterProvider = StateNotifierProvider((ref) { - debugPrint('[Riverpod] 创建 CounterRP'); - return CounterRP(); -}); - -class CounterRP extends StateNotifier { - CounterRP() : super(0); - - void increment() { - final old = state; - state = state + 1; - debugPrint('[Riverpod] increment: $old -> $state (自动通知依赖)'); - } - - void reset() { - state = 0; - debugPrint('[Riverpod] reset -> 0'); - } -} diff --git a/lib/modules/state/status_management/features/riverpod/riverpod_todo_route.dart b/lib/modules/state/status_management/features/riverpod/riverpod_todo_route.dart deleted file mode 100644 index 70dc1ea..0000000 --- a/lib/modules/state/status_management/features/riverpod/riverpod_todo_route.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class RiverpodTodoRoute extends ConsumerWidget { - const RiverpodTodoRoute({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar(title: const Text('Riverpod 全局状态示例')), - body: const _TodoBody(), - floatingActionButton: const _AddFab(), - ); - } -} - -class _Todo { - _Todo(this.title); - final String title; - bool done = false; -} - -class _TodoRP extends StateNotifier> { - _TodoRP() : super([]); - void add(String t) => state = [...state, _Todo(t)]; - void toggle(int i) { - final l = [...state]; - l[i].done = !l[i].done; - state = l; - } - - void remove(int i) { - final l = [...state]..removeAt(i); - state = l; - } -} - -final _todoProvider = - StateNotifierProvider<_TodoRP, List<_Todo>>((ref) => _TodoRP()); - -class _TodoBody extends ConsumerWidget { - const _TodoBody(); - @override - Widget build(BuildContext context, WidgetRef ref) { - final list = ref.watch(_todoProvider); - final n = ref.read(_todoProvider.notifier); - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: list.length, - itemBuilder: (_, i) { - final item = list[i]; - return ListTile( - title: Text(item.title), - leading: Checkbox(value: item.done, onChanged: (_) => n.toggle(i)), - trailing: IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => n.remove(i)), - ); - }, - ); - } -} - -class _AddFab extends ConsumerWidget { - const _AddFab(); - @override - Widget build(BuildContext context, WidgetRef ref) { - final n = ref.read(_todoProvider.notifier); - return FloatingActionButton( - onPressed: () => n.add('Item ${ref.read(_todoProvider).length + 1}'), - child: const Icon(Icons.add), - ); - } -} diff --git a/lib/modules/state/status_management/module_entry.dart b/lib/modules/state/status_management/module_entry.dart index 20e6cee..b736567 100644 --- a/lib/modules/state/status_management/module_entry.dart +++ b/lib/modules/state/status_management/module_entry.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'app/state_flow_app.dart'; +import 'pages/home_page.dart'; class StatusManageEntry extends StatelessWidget { const StatusManageEntry({super.key}); diff --git a/lib/modules/state/status_management/module_routes.dart b/lib/modules/state/status_management/module_routes.dart new file mode 100644 index 0000000..935e745 --- /dev/null +++ b/lib/modules/state/status_management/module_routes.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import 'pages/provider/provider_route.dart'; +import 'pages/provider/provider_lifting_route.dart'; +import 'pages/provider/provider_future_route.dart'; +import 'pages/provider/provider_todo_route.dart'; +import 'pages/riverpod/riverpod_route.dart'; +import 'pages/riverpod/riverpod_lifting_route.dart'; +import 'pages/riverpod/riverpod_future_route.dart'; +import 'pages/riverpod/riverpod_todo_route.dart'; +import 'pages/bloc/bloc_route.dart'; + +class StatusManagementRoutes { + StatusManagementRoutes._(); + + static Map get routes => { + '/provider': (_) => const ProviderRoute(), + '/provider/lifting': (_) => const ProviderLiftingRoute(), + '/provider/future': (_) => const ProviderFutureRoute(), + '/provider/todo': (_) => const ProviderTodoRoute(), + '/riverpod': (_) => const RiverpodRoute(), + '/riverpod/lifting': (_) => const RiverpodLiftingRoute(), + '/riverpod/future': (_) => const RiverpodFutureRoute(), + '/riverpod/todo': (_) => const RiverpodTodoRoute(), + '/bloc': (_) => const BlocRoute(), + }; +} diff --git a/lib/modules/state/status_management/pages/bloc/bloc_route.dart b/lib/modules/state/status_management/pages/bloc/bloc_route.dart new file mode 100644 index 0000000..dd80c4e --- /dev/null +++ b/lib/modules/state/status_management/pages/bloc/bloc_route.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + +import '../../widgets/state_flow_demo.dart'; +import 'counter_bloc.dart'; +import 'counter_event.dart'; +import 'counter_state.dart'; + +class BlocRoute extends StatelessWidget { + const BlocRoute({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => CounterBloc()..add(LoadInitial()), + child: BlocBuilder( + builder: (context, state) { + final bloc = context.read(); + return LearningScaffold( + title: 'Bloc / flutter_bloc', + interactiveDemo: StateFlowDemo( + pageTitle: 'Bloc / flutter_bloc', + subtitle: 'on → emit(State) → BlocBuilder 重建', + value: state.value, + flowSteps: const [ + 'onPressed 事件', + 'add(Event)', + 'Bloc 逻辑处理', + 'emit(State)', + '订阅者重建', + ], + onAdd: () => bloc.add(IncrementPressed()), + onReset: () => bloc.add(ResetPressed()), + ), + sections: [ + LearningObjectives(objectives: [ + '理解 Bloc 的事件驱动状态管理机制', + '掌握 Event → Bloc → State 的完整链路', + ]), + ConceptChips(concepts: [ + 'Bloc', + 'Event', + 'State', + 'emit', + 'BlocBuilder', + ]), + CodeSnippetCard( + title: 'Bloc 核心模式', + code: + 'class CounterBloc extends Bloc {\n' + ' CounterBloc() : super(CounterState(0)) {\n' + ' on((e, emit) {\n' + ' emit(state.copyWith(value: state.value + 1));\n' + ' });\n' + ' }\n' + '}', + explanation: 'Event 驱动 Bloc 逻辑处理,通过 emit 发送新 State 触发 UI 重建。', + ), + ExerciseCard( + task: '在 Bloc 中添加"减 1"功能,观察事件驱动的完整链路。', + hint: + '添加 DecrementPressed 事件类,在 CounterBloc 中注册 on。', + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/modules/state/status_management/features/bloc/counter_bloc.dart b/lib/modules/state/status_management/pages/bloc/counter_bloc.dart similarity index 100% rename from lib/modules/state/status_management/features/bloc/counter_bloc.dart rename to lib/modules/state/status_management/pages/bloc/counter_bloc.dart diff --git a/lib/modules/state/status_management/features/bloc/counter_event.dart b/lib/modules/state/status_management/pages/bloc/counter_event.dart similarity index 100% rename from lib/modules/state/status_management/features/bloc/counter_event.dart rename to lib/modules/state/status_management/pages/bloc/counter_event.dart diff --git a/lib/modules/state/status_management/features/bloc/counter_state.dart b/lib/modules/state/status_management/pages/bloc/counter_state.dart similarity index 100% rename from lib/modules/state/status_management/features/bloc/counter_state.dart rename to lib/modules/state/status_management/pages/bloc/counter_state.dart diff --git a/lib/modules/state/status_management/app/state_flow_app.dart b/lib/modules/state/status_management/pages/home_page.dart similarity index 52% rename from lib/modules/state/status_management/app/state_flow_app.dart rename to lib/modules/state/status_management/pages/home_page.dart index 61c03cb..8813238 100644 --- a/lib/modules/state/status_management/app/state_flow_app.dart +++ b/lib/modules/state/status_management/pages/home_page.dart @@ -1,35 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'package:go_router/go_router.dart'; -import 'app_routes.dart'; -import 'route_paths.dart'; - const String _statusManageBaseRoute = '/status-management'; -/// 根部 MaterialApp,集中声明路由,避免示例全部堆在 main.dart 中。 -class StateFlowApp extends StatelessWidget { - const StateFlowApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: '状态刷新流学习 Demo', - theme: ThemeData( - useMaterial3: true, - colorSchemeSeed: Colors.blue, - visualDensity: VisualDensity.standard, - ), - routes: { - RoutePaths.home: (_) => const StateFlowHome(), - ...AppRoutes.routes, - }, - initialRoute: RoutePaths.home, - debugShowCheckedModeBanner: false, - ); - } -} - -/// 首页:用卡片讲清楚「事件 → 状态 → UI」链路,并跳转到对应插件页面。 class StateFlowHome extends StatelessWidget { const StateFlowHome({super.key}); @@ -38,74 +12,66 @@ class StateFlowHome extends StatelessWidget { const categories = <_RouteCategory>[ _RouteCategory( title: 'Provider', - description: '基于 InheritedWidget + ChangeNotifier,侧重依赖收集与粒度刷新', + description: '基于 InheritedWidget + ChangeNotifier', badge: 'Widget 树驱动', icon: Icons.extension, routes: [ _HomeCardData( - title: '基础 / 粒度刷新', - flow: '事件 → notifyListeners → Selector 仅重建依赖字段', - icon: Icons.auto_fix_high, - routeName: RoutePaths.provider, - chipLabel: 'ChangeNotifier', - ), + title: '基础 / 粒度刷新', + flow: '事件 → notifyListeners → 重建', + icon: Icons.auto_fix_high, + routeName: '/provider', + chipLabel: 'ChangeNotifier'), _HomeCardData( - title: '状态提升', - flow: '父级集中管理 → 子组件共享', - icon: Icons.vertical_align_top, - routeName: RoutePaths.providerLifting, - chipLabel: 'props 上提', - ), + title: '状态提升', + flow: '父级集中管理 → 子组件共享', + icon: Icons.vertical_align_top, + routeName: '/provider/lifting', + chipLabel: 'props 上提'), _HomeCardData( - title: '数据获取', - flow: '异步加载 → 缓存 → 重建', - icon: Icons.cloud_download, - routeName: RoutePaths.providerFuture, - chipLabel: 'FutureBuilder 缓存', - ), + title: '数据获取', + flow: '异步加载 → 缓存 → 重建', + icon: Icons.cloud_download, + routeName: '/provider/future', + chipLabel: 'FutureBuilder 缓存'), _HomeCardData( - title: '全局 Todo', - flow: '列表变更 → notifyListeners', - icon: Icons.checklist, - routeName: RoutePaths.providerTodo, - chipLabel: 'ChangeNotifier', - ), + title: '全局 Todo', + flow: '列表变更 → notifyListeners', + icon: Icons.checklist, + routeName: '/provider/todo', + chipLabel: 'ChangeNotifier'), ], ), _RouteCategory( title: 'Riverpod', - description: '容器化 Provider 图谱,无需 context,自动依赖追踪', + description: '容器化 Provider 图谱,无需 context', badge: '声明式', icon: Icons.sync_alt, routes: [ _HomeCardData( - title: 'StateNotifier 基础', - flow: '事件 → state=new → 容器广播 → 重建', - icon: Icons.sync_alt, - routeName: RoutePaths.riverpod, - chipLabel: '声明式图谱', - ), + title: 'StateNotifier 基础', + flow: '事件 → state=new → 重建', + icon: Icons.sync_alt, + routeName: '/riverpod', + chipLabel: '声明式图谱'), _HomeCardData( - title: '状态提升', - flow: 'Provider 图谱共享', - icon: Icons.vertical_align_top, - routeName: RoutePaths.riverpodLifting, - chipLabel: 'StateNotifierProvider', - ), + title: '状态提升', + flow: 'Provider 图谱共享', + icon: Icons.vertical_align_top, + routeName: '/riverpod/lifting', + chipLabel: 'StateNotifierProvider'), _HomeCardData( - title: '数据获取', - flow: 'FutureProvider → when()', - icon: Icons.cloud_download, - routeName: RoutePaths.riverpodFuture, - chipLabel: '缓存与错误处理', - ), + title: '数据获取', + flow: 'FutureProvider → when()', + icon: Icons.cloud_download, + routeName: '/riverpod/future', + chipLabel: '缓存与错误处理'), _HomeCardData( - title: '全局 Todo', - flow: '列表不可变 → state 赋值广播', - icon: Icons.checklist, - routeName: RoutePaths.riverpodTodo, - chipLabel: 'StateNotifier', - ), + title: '全局 Todo', + flow: '列表不可变 → state 赋值广播', + icon: Icons.checklist, + routeName: '/riverpod/todo', + chipLabel: 'StateNotifier'), ], ), _RouteCategory( @@ -115,35 +81,79 @@ class StateFlowHome extends StatelessWidget { icon: Icons.scatter_plot, routes: [ _HomeCardData( - title: 'flutter_bloc 基础', - flow: 'Event → Bloc → emit(State) → 重建', - icon: Icons.scatter_plot, - routeName: RoutePaths.bloc, - chipLabel: '事件流 + 不可变状态', - ), + title: 'flutter_bloc 基础', + flow: 'Event → Bloc → emit(State) → 重建', + icon: Icons.scatter_plot, + routeName: '/bloc', + chipLabel: '事件流 + 不可变状态'), ], ), ]; - return Scaffold( - appBar: AppBar( - title: const Text('状态刷新流总览'), - centerTitle: true, - ), - body: ListView( - padding: const EdgeInsets.all(20), - children: [ - for (final category in categories) - _RouteCategoryCard(category: category), - ], + return LearningScaffold( + title: '状态刷新流总览', + interactiveDemo: SizedBox( + height: 500, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final category in categories) + _RouteCategoryCard(category: category), + ], + ), ), + sections: [ + LearningObjectives(objectives: [ + '理解三大状态管理框架的核心机制', + '对比 Provider、Riverpod、Bloc 的异同', + '掌握状态刷新链路:事件 → 状态 → 通知 → UI 重建', + ]), + ConceptChips(concepts: [ + 'Provider', + 'Riverpod', + 'Bloc', + 'ChangeNotifier', + 'StateNotifier', + '事件驱动', + ]), + CodeSnippetCard( + title: '状态刷新链路对比', + code: '// Provider: context.select + notifyListeners\n' + '// Riverpod: ref.watch + state = new\n' + '// Bloc: context.read + emit(state)', + explanation: '三种框架的核心理念不同,但都遵循"事件 → 状态 → UI"的刷新链路。', + ), + CommonPitfalls(pitfalls: [ + 'Provider 的 context.select 只监听指定字段,避免不必要的重建', + 'Riverpod 的 Provider 是全局的,无需 Widget 树嵌套', + 'Bloc 的 Event/State 必须是不可变对象,使用 copyWith 或 freezed', + ]), + ExerciseCard( + task: '在 Provider "基础/粒度刷新"页面中观察 context.select 对重建粒度的影响。', + hint: '打开调试控制台查看日志,点击加 1 按钮观察哪些 Widget 重建了。', + ), + ], ); } } +class _RouteCategory { + const _RouteCategory({ + required this.title, + required this.description, + required this.badge, + required this.icon, + required this.routes, + }); + final String title; + final String description; + final String badge; + final IconData icon; + final List<_HomeCardData> routes; +} + class _RouteCategoryCard extends StatelessWidget { const _RouteCategoryCard({required this.category}); - final _RouteCategory category; @override @@ -173,11 +183,9 @@ class _RouteCategoryCard extends StatelessWidget { children: [ Text(category.title, style: theme.textTheme.titleLarge), const SizedBox(height: 6), - Text( - category.description, - style: theme.textTheme.bodyMedium - ?.copyWith(color: Colors.black54), - ), + Text(category.description, + style: theme.textTheme.bodyMedium + ?.copyWith(color: Colors.black54)), ], ), ), @@ -206,7 +214,6 @@ class _RouteCategoryCard extends StatelessWidget { class _RouteListTile extends StatelessWidget { const _RouteListTile({required this.data}); - final _HomeCardData data; @override @@ -224,10 +231,8 @@ class _RouteListTile extends StatelessWidget { title: Text(data.title, style: theme.textTheme.titleMedium), subtitle: Padding( padding: const EdgeInsets.only(top: 4), - child: Text( - '刷新链路:${data.flow}', - style: theme.textTheme.bodyMedium?.copyWith(color: Colors.black54), - ), + child: Text('刷新链路:${data.flow}', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.black54)), ), trailing: Chip( avatar: const Icon(Icons.visibility, size: 16), @@ -237,22 +242,6 @@ class _RouteListTile extends StatelessWidget { } } -class _RouteCategory { - const _RouteCategory({ - required this.title, - required this.description, - required this.badge, - required this.icon, - required this.routes, - }); - - final String title; - final String description; - final String badge; - final IconData icon; - final List<_HomeCardData> routes; -} - class _HomeCardData { const _HomeCardData({ required this.title, @@ -261,7 +250,6 @@ class _HomeCardData { required this.routeName, required this.chipLabel, }); - final String title; final String flow; final IconData icon; diff --git a/lib/modules/state/status_management/features/provider/models/counter_cn.dart b/lib/modules/state/status_management/pages/provider/models/counter_cn.dart similarity index 100% rename from lib/modules/state/status_management/features/provider/models/counter_cn.dart rename to lib/modules/state/status_management/pages/provider/models/counter_cn.dart diff --git a/lib/modules/state/status_management/features/provider/models/counter_model.dart b/lib/modules/state/status_management/pages/provider/models/counter_model.dart similarity index 100% rename from lib/modules/state/status_management/features/provider/models/counter_model.dart rename to lib/modules/state/status_management/pages/provider/models/counter_model.dart diff --git a/lib/modules/state/status_management/pages/provider/provider_future_route.dart b/lib/modules/state/status_management/pages/provider/provider_future_route.dart new file mode 100644 index 0000000..7b08f68 --- /dev/null +++ b/lib/modules/state/status_management/pages/provider/provider_future_route.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:provider/provider.dart'; + +class ProviderFutureRoute extends StatelessWidget { + const ProviderFutureRoute({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => _UserModel()..load(), + child: _FutureContent(), + ); + } +} + +class _UserModel extends ChangeNotifier { + String? name; + Future load() async { + await Future.delayed(const Duration(milliseconds: 300)); + name = 'Alice'; + notifyListeners(); + } +} + +class _FutureContent extends StatelessWidget { + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: 'Provider 数据获取与缓存', + interactiveDemo: SizedBox( + height: 200, + child: Center( + child: Consumer<_UserModel>( + builder: (_, m, __) => m.name == null + ? const CircularProgressIndicator() + : Text(m.name!, + style: Theme.of(context).textTheme.headlineSmall), + ), + ), + ), + sections: [ + LearningObjectives(objectives: [ + '掌握 Provider 中异步数据获取的模式', + '理解 ChangeNotifier 中的状态缓存机制', + ]), + ConceptChips(concepts: [ + 'Provider', + 'Future', + '缓存', + 'ChangeNotifier', + ]), + CodeSnippetCard( + title: 'Provider 异步加载', + code: 'class _UserModel extends ChangeNotifier {\n' + ' String? name;\n' + ' Future load() async {\n' + ' name = await fetchUser();\n' + ' notifyListeners();\n' + ' }\n' + '}', + explanation: 'load 方法执行异步操作后调用 notifyListeners 触发 UI 更新。', + ), + ExerciseCard( + task: '添加错误处理状态,当加载失败时显示错误提示。', + hint: '在 _UserModel 中添加 error 字段和 setState 条件分支。', + ), + ], + ); + } +} diff --git a/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart b/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart new file mode 100644 index 0000000..16f5276 --- /dev/null +++ b/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:provider/provider.dart'; + +class ProviderLiftingRoute extends StatelessWidget { + const ProviderLiftingRoute({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => _LiftingCN(), + child: _LiftingContent(), + ); + } +} + +class _LiftingCN extends ChangeNotifier { + int value = 0; + void inc() { + value++; + notifyListeners(); + } + + void reset() { + value = 0; + notifyListeners(); + } +} + +class _LiftingContent extends StatelessWidget { + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: 'Provider 状态提升', + interactiveDemo: SizedBox( + height: 200, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _LDisplay(), + const SizedBox(height: 16), + _LControls(), + ], + ), + ), + sections: [ + LearningObjectives(objectives: [ + '理解 Provider 状态提升 (Lifting State Up) 模式', + '掌握父级集中管理状态、子组件共享的模式', + ]), + ConceptChips(concepts: [ + '状态提升', + 'Provider', + 'ChangeNotifier', + '共享状态', + ]), + CodeSnippetCard( + title: '状态提升模式', + code: 'ChangeNotifierProvider(\n' + ' create: (_) => _LiftingCN(),\n' + ' child: _LDisplay(),\n' + ');\n' + '// _LDisplay 和 _LControls 共享同一个 Provider', + explanation: + '将状态提升到父级 Provider 中,子组件通过 context.select 或 context.read 访问。', + ), + ExerciseCard( + task: '添加一个新的子组件来消费 _LiftingCN 状态,观察共享效果。', + hint: '在 _LiftingContent 的 Column 中添加一个新 StatelessWidget。', + ), + ], + ); + } +} + +class _LDisplay extends StatelessWidget { + const _LDisplay(); + @override + Widget build(BuildContext context) { + final v = context.select<_LiftingCN, int>((s) => s.value); + return Text('$v', style: Theme.of(context).textTheme.headlineMedium); + } +} + +class _LControls extends StatelessWidget { + const _LControls(); + @override + Widget build(BuildContext context) { + final s = context.read<_LiftingCN>(); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.icon( + onPressed: s.inc, + icon: const Icon(Icons.exposure_plus_1), + label: const Text('加 1')), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: s.reset, + icon: const Icon(Icons.restart_alt), + label: const Text('重置')), + ], + ); + } +} diff --git a/lib/modules/state/status_management/pages/provider/provider_route.dart b/lib/modules/state/status_management/pages/provider/provider_route.dart new file mode 100644 index 0000000..a925512 --- /dev/null +++ b/lib/modules/state/status_management/pages/provider/provider_route.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:provider/provider.dart'; + +import '../../widgets/state_flow_demo.dart'; +import 'models/counter_cn.dart'; +import 'widgets/provider_perks.dart'; + +class ProviderRoute extends StatelessWidget { + const ProviderRoute({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) { + debugPrint('[Provider] 创建 CounterCN,并开始监听 notifyListeners'); + return CounterCN(); + }, + child: Builder( + builder: (context) { + final value = context.select((s) => s.value); + debugPrint('[Provider] 顶层 build,仅监听 value=$value'); + final counter = context.read(); + + return LearningScaffold( + title: 'Provider / ChangeNotifier', + interactiveDemo: StateFlowDemo( + pageTitle: 'Provider / ChangeNotifier', + subtitle: + 'notifyListeners() → Provider 找到依赖字段 → markNeedsBuild()', + value: value, + flowSteps: const [ + 'onPressed 事件', + 'value++ / leafTaps++', + 'notifyListeners()', + '依赖字段 Widget 重建', + ], + onAdd: counter.increment, + onReset: counter.reset, + extra: const ProviderPerks(), + ), + sections: [ + LearningObjectives(objectives: [ + '理解 Provider + ChangeNotifier 的状态刷新链路', + '掌握 context.select 的粒度刷新机制', + ]), + ConceptChips(concepts: [ + 'Provider', + 'ChangeNotifier', + 'notifyListeners', + 'context.select', + ]), + CodeSnippetCard( + title: 'Provider 粒度刷新', + code: 'final value = context.select((s) => s.value);\n' + 'context.read().increment();', + explanation: 'select 只监听指定字段,字段未变时 Widget 不会重建。', + ), + ExerciseCard( + task: '点击"加 1"按钮,观察控制台日志中 Provider 的刷新链路。', + hint: 'ProviderPerks 组件展示了额外字段的独立刷新效果。', + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/modules/state/status_management/pages/provider/provider_todo_route.dart b/lib/modules/state/status_management/pages/provider/provider_todo_route.dart new file mode 100644 index 0000000..d76ae15 --- /dev/null +++ b/lib/modules/state/status_management/pages/provider/provider_todo_route.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:provider/provider.dart'; + +class ProviderTodoRoute extends StatelessWidget { + const ProviderTodoRoute({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => _TodoStore(), + child: _TodoContent(), + ); + } +} + +class _Todo { + _Todo(this.title); + final String title; + bool done = false; +} + +class _TodoStore extends ChangeNotifier { + final List<_Todo> list = []; + void add(String t) { + list.add(_Todo(t)); + notifyListeners(); + } + + void toggle(int i) { + list[i].done = !list[i].done; + notifyListeners(); + } + + void remove(int i) { + list.removeAt(i); + notifyListeners(); + } +} + +class _TodoContent extends StatelessWidget { + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: 'Provider 全局状态示例', + floatingActionButton: const _AddFab(), + interactiveDemo: SizedBox( + height: 400, + child: Consumer<_TodoStore>( + builder: (_, store, __) => ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: store.list.length, + itemBuilder: (_, i) { + final item = store.list[i]; + return ListTile( + title: Text(item.title), + leading: Checkbox( + value: item.done, onChanged: (_) => store.toggle(i)), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => store.remove(i)), + ); + }, + ), + ), + ), + sections: [ + LearningObjectives(objectives: [ + '掌握 Provider 管理全局列表状态', + '理解 notifyListeners 在 CRUD 操作中的触发时机', + ]), + ConceptChips(concepts: [ + 'Provider', + '全局状态', + 'CRUD', + 'ChangeNotifier', + ]), + CodeSnippetCard( + title: 'Provider Todo 模式', + code: 'class TodoStore extends ChangeNotifier {\n' + ' final list = [];\n' + ' void add(String t) { list.add(Todo(t)); notifyListeners(); }\n' + ' void remove(int i) { list.removeAt(i); notifyListeners(); }\n' + '}', + explanation: '每次 CRUD 操作后调用 notifyListeners 通知所有消费者。', + ), + ExerciseCard( + task: '添加"编辑 Todo"功能,修改已有 Todo 的标题。', + hint: '在 _TodoStore 中添加 edit 方法,使用 TextEditingController 获取新标题。', + ), + ], + ); + } +} + +class _AddFab extends StatelessWidget { + const _AddFab(); + @override + Widget build(BuildContext context) { + final store = context.read<_TodoStore>(); + return FloatingActionButton( + onPressed: () => store.add('Item ${store.list.length + 1}'), + child: const Icon(Icons.add), + ); + } +} diff --git a/lib/modules/state/status_management/features/provider/widgets/granular_grid.dart b/lib/modules/state/status_management/pages/provider/widgets/granular_grid.dart similarity index 100% rename from lib/modules/state/status_management/features/provider/widgets/granular_grid.dart rename to lib/modules/state/status_management/pages/provider/widgets/granular_grid.dart diff --git a/lib/modules/state/status_management/features/provider/widgets/provider_perks.dart b/lib/modules/state/status_management/pages/provider/widgets/provider_perks.dart similarity index 100% rename from lib/modules/state/status_management/features/provider/widgets/provider_perks.dart rename to lib/modules/state/status_management/pages/provider/widgets/provider_perks.dart diff --git a/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart b/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart new file mode 100644 index 0000000..6af675f --- /dev/null +++ b/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + +class RiverpodFutureRoute extends ConsumerWidget { + const RiverpodFutureRoute({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(_userProvider); + return LearningScaffold( + title: 'Riverpod 数据获取与缓存', + interactiveDemo: SizedBox( + height: 200, + child: Center( + child: async.when( + data: (v) => + Text(v, style: Theme.of(context).textTheme.headlineSmall), + loading: () => const CircularProgressIndicator(), + error: (e, _) => Text('Error: $e'), + ), + ), + ), + sections: [ + LearningObjectives(objectives: [ + '掌握 Riverpod FutureProvider 的异步数据管理', + '理解 when() 方法处理 loading/data/error 三态', + ]), + ConceptChips(concepts: [ + 'Riverpod', + 'FutureProvider', + 'async', + 'when', + '缓存', + ]), + CodeSnippetCard( + title: 'FutureProvider 模式', + code: 'final userProvider = FutureProvider((ref) async {\n' + ' await Future.delayed(Duration(milliseconds: 300));\n' + ' return "Alice";\n' + '});\n' + '// ref.watch(userProvider) 返回 AsyncValue', + explanation: 'FutureProvider 自动处理 loading/data/error 状态,无需手动管理。', + ), + ExerciseCard( + task: '添加"刷新"按钮来重新加载数据,验证缓存机制。', + hint: '使用 ref.invalidate(userProvider) 可使缓存失效并触发重新加载。', + ), + ], + ); + } +} + +final _userProvider = FutureProvider((ref) async { + await Future.delayed(const Duration(milliseconds: 300)); + return 'Alice'; +}); diff --git a/lib/modules/state/status_management/features/riverpod/riverpod_lifting_route.dart b/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart similarity index 51% rename from lib/modules/state/status_management/features/riverpod/riverpod_lifting_route.dart rename to lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart index 4a1f4f6..59083e5 100644 --- a/lib/modules/state/status_management/features/riverpod/riverpod_lifting_route.dart +++ b/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart @@ -1,29 +1,49 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; class RiverpodLiftingRoute extends ConsumerWidget { const RiverpodLiftingRoute({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar(title: const Text('Riverpod 状态提升')), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: const Padding( - padding: EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _LDisplay(), - SizedBox(height: 16), - _LControls(), - ], - ), - ), + return LearningScaffold( + title: 'Riverpod 状态提升', + interactiveDemo: SizedBox( + height: 200, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _LDisplay(), + const SizedBox(height: 16), + _LControls(), + ], ), ), + sections: [ + LearningObjectives(objectives: [ + '理解 Riverpod 的状态提升模式', + '掌握 Provider 图谱中状态的共享机制', + ]), + ConceptChips(concepts: [ + 'Riverpod', + '状态提升', + 'Provider 图谱', + 'StateNotifierProvider', + ]), + CodeSnippetCard( + title: 'Riverpod 状态提升', + code: 'final liftProvider = StateNotifierProvider(\n' + ' (ref) => LiftRP(),\n' + ');\n' + '// 任意组件可 watch/read 同一 Provider', + explanation: 'Provider 是全局的,无需 Widget 树传递,任意层级组件均可消费。', + ), + ExerciseCard( + task: '添加第三个组件同时消费 _liftProvider,验证共享状态。', + hint: '创建一个新的 ConsumerWidget 调用 ref.watch(_liftProvider)。', + ), + ], ); } } diff --git a/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart b/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart new file mode 100644 index 0000000..43e5a67 --- /dev/null +++ b/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + +import '../../widgets/state_flow_demo.dart'; + +class RiverpodRoute extends ConsumerWidget { + const RiverpodRoute({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final count = ref.watch(counterProvider); + debugPrint('[Riverpod] Consumer build,state=$count'); + + return LearningScaffold( + title: 'Riverpod / StateNotifier', + interactiveDemo: StateFlowDemo( + pageTitle: 'Riverpod / StateNotifier', + subtitle: 'state = newState → ProviderContainer 广播 → 订阅者重建', + value: count, + flowSteps: const [ + 'onPressed 事件', + 'state = newState', + '容器 diff 并广播', + '监听者 build()', + ], + onAdd: () => ref.read(counterProvider.notifier).increment(), + onReset: () => ref.read(counterProvider.notifier).reset(), + ), + sections: [ + LearningObjectives(objectives: [ + '理解 Riverpod StateNotifier 的状态管理机制', + '掌握 Provider 容器广播与消费者重建的关系', + ]), + ConceptChips(concepts: [ + 'Riverpod', + 'StateNotifier', + 'ProviderContainer', + 'ref.watch', + 'ref.read', + ]), + CodeSnippetCard( + title: 'Riverpod 核心模式', + code: + 'final provider = StateNotifierProvider((ref) => RP());\n' + 'class RP extends StateNotifier {\n' + ' void increment() => state = state + 1;\n' + '}', + explanation: 'state = newState 自动触发依赖广播,无需手动调用通知方法。', + ), + ExerciseCard( + task: '观察 ref.watch 与 ref.read 的区别,理解监听 vs 一次性读取。', + hint: 'watch 会订阅变化,read 只获取当前值不订阅。', + ), + ], + ); + } +} + +final counterProvider = StateNotifierProvider((ref) { + debugPrint('[Riverpod] 创建 CounterRP'); + return CounterRP(); +}); + +class CounterRP extends StateNotifier { + CounterRP() : super(0); + + void increment() { + final old = state; + state = state + 1; + debugPrint('[Riverpod] increment: $old -> $state (自动通知依赖)'); + } + + void reset() { + state = 0; + debugPrint('[Riverpod] reset -> 0'); + } +} diff --git a/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart b/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart new file mode 100644 index 0000000..6077624 --- /dev/null +++ b/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + +class RiverpodTodoRoute extends ConsumerWidget { + const RiverpodTodoRoute({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return _TodoContent(); + } +} + +class _Todo { + _Todo(this.title); + final String title; + bool done = false; +} + +class _TodoRP extends StateNotifier> { + _TodoRP() : super([]); + void add(String t) => state = [...state, _Todo(t)]; + void toggle(int i) { + final l = [...state]; + l[i].done = !l[i].done; + state = l; + } + + void remove(int i) { + final l = [...state]..removeAt(i); + state = l; + } +} + +final _todoProvider = + StateNotifierProvider<_TodoRP, List<_Todo>>((ref) => _TodoRP()); + +class _TodoContent extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final list = ref.watch(_todoProvider); + final n = ref.read(_todoProvider.notifier); + return LearningScaffold( + title: 'Riverpod 全局状态示例', + floatingActionButton: Consumer( + builder: (context, ref, _) { + final n2 = ref.read(_todoProvider.notifier); + return FloatingActionButton( + onPressed: () => + n2.add('Item ${ref.read(_todoProvider).length + 1}'), + child: const Icon(Icons.add), + ); + }, + ), + interactiveDemo: SizedBox( + height: 400, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: list.length, + itemBuilder: (_, i) { + final item = list[i]; + return ListTile( + title: Text(item.title), + leading: + Checkbox(value: item.done, onChanged: (_) => n.toggle(i)), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => n.remove(i)), + ); + }, + ), + ), + sections: [ + LearningObjectives(objectives: [ + '掌握 Riverpod 管理全局列表状态', + '理解 StateNotifier 不可变状态更新模式', + ]), + ConceptChips(concepts: [ + 'Riverpod', + '全局状态', + 'CRUD', + '不可变数据', + 'StateNotifier', + ]), + CodeSnippetCard( + title: 'Riverpod Todo 模式', + code: 'class TodoRP extends StateNotifier> {\n' + ' void add(t) => state = [...state, Todo(t)];\n' + ' void remove(i) => state = [...state]..removeAt(i);\n' + '}', + explanation: 'Riverpod 使用不可变状态更新,每次操作都创建新列表触发广播。', + ), + ExerciseCard( + task: '使用 ref.invalidate 实现"清空所有 Todo"功能。', + hint: '调用 ref.invalidate(_todoProvider) 可重置状态到初始值。', + ), + ], + ); + } +} diff --git a/lib/modules/state/status_management/shared/widgets/state_flow_scaffold.dart b/lib/modules/state/status_management/shared/widgets/state_flow_scaffold.dart deleted file mode 100644 index d94cfc0..0000000 --- a/lib/modules/state/status_management/shared/widgets/state_flow_scaffold.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:flutter/material.dart'; - -/// 统一的示例界面:中间卡片 + 动画数值 + 刷新链路步骤。 -class StateFlowScaffold extends StatelessWidget { - const StateFlowScaffold({ - super.key, - required this.pageTitle, - required this.subtitle, - required this.value, - required this.flowSteps, - required this.onAdd, - required this.onReset, - this.extra, - }); - - final String pageTitle; - final String subtitle; - final int value; - final List flowSteps; - final VoidCallback onAdd; - final VoidCallback onReset; - final Widget? extra; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final animatedColor = value.isEven - ? colorScheme.primaryContainer.withValues(alpha: 0.4) - : colorScheme.secondaryContainer.withValues(alpha: 0.4); - - return Scaffold( - appBar: AppBar(title: Text(pageTitle), centerTitle: true), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(28), - gradient: LinearGradient( - colors: [animatedColor, Colors.white], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - pageTitle, - style: Theme.of(context).textTheme.headlineSmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - subtitle, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.black54), - textAlign: TextAlign.center, - ), - const Divider(height: 32), - AnimatedSwitcher( - duration: const Duration(milliseconds: 260), - transitionBuilder: (child, animation) => - ScaleTransition(scale: animation, child: child), - child: Text( - '$value', - key: ValueKey(value), - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.displayLarge?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 1.5, - ), - ), - ), - const SizedBox(height: 12), - _FlowTimeline(steps: flowSteps), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.exposure_plus_1), - label: const Text('加 1'), - ), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: onReset, - icon: const Icon(Icons.restart_alt), - label: const Text('重置'), - ), - ], - ), - const SizedBox(height: 12), - const Text( - '提示:配合调试控制台日志,可完整追踪 “事件 → 状态变化 → 通知 → build() 重建”。', - textAlign: TextAlign.center, - ), - if (extra != null) ...[ - const SizedBox(height: 16), - extra!, - ], - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -/// 小型时间轴,视觉化刷新链路。 -class _FlowTimeline extends StatelessWidget { - const _FlowTimeline({required this.steps}); - - final List steps; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4, - runSpacing: 4, - children: [ - for (var i = 0; i < steps.length; i++) ...[ - Chip( - label: Text( - steps[i], - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - avatar: CircleAvatar( - radius: 10, - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.2), - child: Text('${i + 1}', style: theme.textTheme.labelSmall), - ), - backgroundColor: theme.colorScheme.surfaceContainerHighest - .withValues(alpha: 0.6), - ), - if (i != steps.length - 1) - Icon(Icons.trending_flat, color: theme.colorScheme.primary), - ], - ], - ); - } -} diff --git a/lib/modules/state/status_management/widgets/state_flow_demo.dart b/lib/modules/state/status_management/widgets/state_flow_demo.dart new file mode 100644 index 0000000..bd87351 --- /dev/null +++ b/lib/modules/state/status_management/widgets/state_flow_demo.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; + +class StateFlowDemo extends StatelessWidget { + const StateFlowDemo({ + super.key, + required this.pageTitle, + required this.subtitle, + required this.value, + required this.flowSteps, + required this.onAdd, + required this.onReset, + this.extra, + }); + + final String pageTitle; + final String subtitle; + final int value; + final List flowSteps; + final VoidCallback onAdd; + final VoidCallback onReset; + final Widget? extra; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final animatedColor = value.isEven + ? colorScheme.primaryContainer.withValues(alpha: 0.4) + : colorScheme.secondaryContainer.withValues(alpha: 0.4); + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + colors: [animatedColor, Colors.white], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Card( + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(pageTitle, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center), + const SizedBox(height: 8), + Text(subtitle, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Colors.black54), + textAlign: TextAlign.center), + const Divider(height: 32), + AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) => + ScaleTransition(scale: animation, child: child), + child: Text('$value', + key: ValueKey(value), + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .displayLarge + ?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 1.5)), + ), + const SizedBox(height: 12), + _FlowTimeline(steps: flowSteps), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.exposure_plus_1), + label: const Text('加 1'), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: onReset, + icon: const Icon(Icons.restart_alt), + label: const Text('重置'), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '提示:配合调试控制台日志,可完整追踪 "事件 → 状态变化 → 通知 → build() 重建"。', + textAlign: TextAlign.center, + ), + if (extra != null) ...[ + const SizedBox(height: 16), + extra!, + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _FlowTimeline extends StatelessWidget { + const _FlowTimeline({required this.steps}); + final List steps; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4, + runSpacing: 4, + children: [ + for (var i = 0; i < steps.length; i++) ...[ + Chip( + label: Text(steps[i], + style: theme.textTheme.bodySmall + ?.copyWith(fontWeight: FontWeight.w600)), + avatar: CircleAvatar( + radius: 10, + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.2), + child: Text('${i + 1}', style: theme.textTheme.labelSmall), + ), + backgroundColor: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.6), + ), + if (i != steps.length - 1) + Icon(Icons.trending_flat, color: theme.colorScheme.primary), + ], + ], + ); + } +} diff --git a/lib/modules/ui/adsorption_line/AI_ANALYSIS.md b/lib/modules/ui/adsorption_line/AI_ANALYSIS.md index 039b007..a34e078 100644 --- a/lib/modules/ui/adsorption_line/AI_ANALYSIS.md +++ b/lib/modules/ui/adsorption_line/AI_ANALYSIS.md @@ -8,11 +8,31 @@ "path": "lib/modules/ui/adsorption_line", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], + "entrypoints": ["module_entry.dart","pages/adsorption_line_page.dart","widgets","state"], "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["provider","module_registry"], + "depends": ["provider","module_registry","flutter_study_learning"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["models/drawing_element.dart","module_entry.dart","services/adsorption_manager.dart","state/drawing_state.dart","widgets/drawing_board.dart","widgets/drawing_canvas.dart"], + "files": [ + "models/drawing_element.dart", + "module_entry.dart", + "services/adsorption_manager.dart", + "state/drawing_state.dart", + "widgets/drawing_board.dart", + "widgets/drawing_canvas.dart", + "pages/adsorption_line_page.dart" + ], + "teaching_components": { + "page": "pages/adsorption_line_page.dart", + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "StateLogView", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/ui/adsorption_line/module_entry.dart b/lib/modules/ui/adsorption_line/module_entry.dart index a6303f9..ac1601b 100644 --- a/lib/modules/ui/adsorption_line/module_entry.dart +++ b/lib/modules/ui/adsorption_line/module_entry.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'state/drawing_state.dart'; -import 'widgets/drawing_board.dart'; +import 'pages/adsorption_line_page.dart'; class AdsorptionLineEntry extends StatelessWidget { const AdsorptionLineEntry({super.key}); @@ -11,7 +11,7 @@ class AdsorptionLineEntry extends StatelessWidget { Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => DrawingState(), - child: const DrawingBoard(), + child: const AdsorptionLinePage(), ); } } diff --git a/lib/modules/ui/adsorption_line/pages/adsorption_line_page.dart b/lib/modules/ui/adsorption_line/pages/adsorption_line_page.dart new file mode 100644 index 0000000..7f21779 --- /dev/null +++ b/lib/modules/ui/adsorption_line/pages/adsorption_line_page.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + +import '../state/drawing_state.dart'; +import '../widgets/drawing_board.dart'; + +class AdsorptionLinePage extends StatelessWidget { + const AdsorptionLinePage({super.key}); + + @override + Widget build(BuildContext context) { + return LearningScaffold( + title: '智能吸附线画板', + interactiveDemo: const SizedBox( + height: 360, + child: DrawingBoard(embedInScaffold: false), + ), + floatingActionButton: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton.small( + heroTag: 'clear_logs', + onPressed: () => context.read().clearLogs(), + tooltip: '清空日志', + child: const Icon(Icons.clear_all), + ), + const SizedBox(width: 8), + FloatingActionButton.small( + heroTag: 'clear_board', + onPressed: () => context.read().clear(), + tooltip: '清空画板', + child: const Icon(Icons.delete_outline), + ), + ], + ), + sections: [ + const LearningObjectives( + objectives: [ + '理解 CustomPaint 与 CustomPainter 的绘制流程和重绘机制。', + '掌握 GestureDetector 的手势事件处理(点击、拖拽)。', + '学习吸附对齐算法的基本原理——在阈值范围内自动对齐到参考点。', + '理解 ChangeNotifier 如何驱动 UI 状态更新。', + '了解吸附线(SnapLine)辅助显示的视觉反馈机制。', + ], + ), + const ConceptChips( + concepts: [ + 'CustomPaint', + 'CustomPainter', + 'GestureDetector', + '吸附对齐', + 'ChangeNotifier', + 'Provider', + 'SnapLine', + ], + ), + const CodeSnippetCard( + title: 'CustomPainter 绘制与重绘', + code: '''// 绘制器:负责实际绘制逻辑 +class DrawingCanvasPainter extends CustomPainter { + final List elements; + + @override + void paint(Canvas canvas, Size size) { + for (final element in elements) { + _drawElement(canvas, element); + } + } + + // 必须重写 shouldRepaint 驱动重绘 + @override + bool shouldRepaint(DrawingCanvasPainter oldDelegate) { + return elements != oldDelegate.elements; + } +} + +// 使用 ChangeNotifier 驱动重绘 +class DrawingState extends ChangeNotifier { + void addElement(DrawingElement element) { + _elements.add(element); + notifyListeners(); // 触发 Consumer/ListenableBuilder 重建 + } +}''', + explanation: + 'CustomPainter 通过 shouldRepaint 判断是否需要重绘;ChangeNotifier 的 notifyListeners 触发 UI 更新。', + ), + StateLogView( + logs: context.read().logs.isEmpty + ? ['在画板上点击添加元素,或拖拽已有元素开始体验...'] + : context.read().logs, + maxLines: 6, + ), + const CommonPitfalls( + pitfalls: [ + 'CustomPaint 不会自动重绘——需要通过 ChangeNotifier.notifyListeners() 或 setState 触发。', + 'shouldRepaint 返回 false 时即使数据变了也不会重绘,务必正确实现。', + '屏幕坐标系 Y 轴向下为正,与数学坐标相反,计算吸附时需要注意方向。', + '吸附阈值(snapThreshold)过大或过小都会影响体验——25px 是一个常用值。', + 'GestureDetector 的 onPanUpdate 频率很高,吸附计算要保持轻量。', + ], + ), + const ExerciseCard( + task: + '尝试修改吸附阈值(snapThreshold),观察吸附灵敏度变化。当前阈值为 25px,分别改为 10px 和 50px 体验效果差异。', + hint: '在 adsorption_manager.dart 中修改 snapThreshold 常量的值。', + ), + ], + ); + } +} diff --git a/lib/modules/ui/adsorption_line/state/drawing_state.dart b/lib/modules/ui/adsorption_line/state/drawing_state.dart index 45e8b18..06c2f0e 100644 --- a/lib/modules/ui/adsorption_line/state/drawing_state.dart +++ b/lib/modules/ui/adsorption_line/state/drawing_state.dart @@ -7,22 +7,38 @@ class DrawingState extends ChangeNotifier { DrawingElement? _selectedElement; bool _isDragging = false; Offset? _dragOffset; + final List _logs = []; List get elements => List.unmodifiable(_elements); DrawingElement? get selectedElement => _selectedElement; bool get isDragging => _isDragging; + List get logs => List.unmodifiable(_logs); + + void _addLog(String message) { + _logs.add(message); + notifyListeners(); + } void addElement(DrawingElement element) { _elements.add(element); - notifyListeners(); + _addLog('添加了 ${element.type.name} 元素 (${_elements.length})'); } void removeElement(String elementId) { + final removed = _elements.firstWhere( + (e) => e.id == elementId, + orElse: () => const DrawingElement( + id: '', + position: Offset.zero, + size: Size.zero, + type: ElementType.rectangle, + ), + ); _elements.removeWhere((element) => element.id == elementId); if (_selectedElement?.id == elementId) { _selectedElement = null; } - notifyListeners(); + _addLog('删除了 ${removed.type.name} 元素'); } void updateElement(DrawingElement updatedElement) { @@ -41,11 +57,15 @@ class DrawingState extends ChangeNotifier { _selectedElement = elementId != null ? _elements.firstWhere((element) => element.id == elementId) : null; + if (_selectedElement != null) { + _addLog('选中了 ${_selectedElement!.type.name} 元素'); + } notifyListeners(); } void clearSelection() { _selectedElement = null; + _addLog('取消选中'); notifyListeners(); } @@ -87,26 +107,30 @@ class DrawingState extends ChangeNotifier { _selectedElement = null; _isDragging = false; _dragOffset = null; - notifyListeners(); + _addLog('清空画板'); } /// 删除选中的元素 void deleteSelectedElement() { if (_selectedElement != null) { + _addLog('删除了选中的 ${_selectedElement!.type.name} 元素'); removeElement(_selectedElement!.id); } } + void clearLogs() { + _logs.clear(); + notifyListeners(); + } + /// 处理键盘事件 bool handleKeyEvent(KeyEvent event) { if (event is KeyDownEvent) { - // Delete键或Backspace键删除选中元素 if (event.logicalKey == LogicalKeyboardKey.delete || event.logicalKey == LogicalKeyboardKey.backspace) { deleteSelectedElement(); return true; } - // Escape键取消选择 if (event.logicalKey == LogicalKeyboardKey.escape) { clearSelection(); return true; diff --git a/lib/modules/ui/adsorption_line/widgets/drawing_board.dart b/lib/modules/ui/adsorption_line/widgets/drawing_board.dart index d2bfe14..b9dadb8 100644 --- a/lib/modules/ui/adsorption_line/widgets/drawing_board.dart +++ b/lib/modules/ui/adsorption_line/widgets/drawing_board.dart @@ -7,7 +7,9 @@ import 'drawing_canvas.dart'; /// 画板主界面 class DrawingBoard extends StatefulWidget { - const DrawingBoard({super.key}); + final bool embedInScaffold; + + const DrawingBoard({super.key, this.embedInScaffold = true}); @override State createState() => _DrawingBoardState(); @@ -27,6 +29,8 @@ class _DrawingBoardState extends State { @override Widget build(BuildContext context) { + final body = _buildBody(context); + if (!widget.embedInScaffold) return body; return KeyboardListener( focusNode: FocusNode()..requestFocus(), onKeyEvent: (event) { @@ -58,34 +62,35 @@ class _DrawingBoardState extends State { ), ], ), - body: Column( - children: [ - // 工具栏 - _buildToolbar(), - // 画板区域 - Expanded( - child: Container( - width: double.infinity, - color: Colors.white, - child: Consumer( - builder: (context, drawingState, child) { - return DrawingCanvas( - elements: drawingState.elements, - selectedElement: drawingState.selectedElement, - onTap: _handleCanvasTap, - onPanStart: _handlePanStart, - onPanUpdate: _handlePanUpdate, - onPanEnd: _handlePanEnd, - ); - }, - ), - ), + body: body, + ), + ); + } + + Widget _buildBody(BuildContext context) { + return Column( + children: [ + _buildToolbar(), + Expanded( + child: Container( + width: double.infinity, + color: Colors.white, + child: Consumer( + builder: (context, drawingState, child) { + return DrawingCanvas( + elements: drawingState.elements, + selectedElement: drawingState.selectedElement, + onTap: _handleCanvasTap, + onPanStart: _handlePanStart, + onPanUpdate: _handlePanUpdate, + onPanEnd: _handlePanEnd, + ); + }, ), - // 状态栏 - _buildStatusBar(), - ], + ), ), - ), + _buildStatusBar(), + ], ); } diff --git a/lib/modules/ui/download_animation/AI_ANALYSIS.md b/lib/modules/ui/download_animation/AI_ANALYSIS.md index 5c21da4..484b6fc 100644 --- a/lib/modules/ui/download_animation/AI_ANALYSIS.md +++ b/lib/modules/ui/download_animation/AI_ANALYSIS.md @@ -8,11 +8,38 @@ "path": "lib/modules/ui/download_animation", "status": "active" }, - "entrypoints": ["module_entry.dart","module_routes.dart","module_root.dart","pages","widgets","state"], - "owns": ["module_entry","module_ui","module_state","module_docs"], - "depends": ["module_registry","go_router"], + "entrypoints": ["module_entry.dart","module_root.dart"], + "owns": ["module_entry","module_ui"], + "depends": ["flutter_study_learning","module_registry","go_router"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["models/animation_config.dart","models/download_item.dart","models/overlay_download_item.dart","module_entry.dart","module_root.dart","module_routes.dart","pages/download_animation_page.dart","pages/download_comparison_page.dart","pages/paint_animation_page.dart","services/overlay_download_service.dart"], + "files": [ + "module_entry.dart", + "module_root.dart", + "module_routes.dart", + "models/animation_config.dart", + "models/download_item.dart", + "models/overlay_download_item.dart", + "pages/download_animation_page.dart", + "pages/download_comparison_page.dart", + "pages/paint_animation_page.dart", + "services/overlay_download_service.dart" + ], + "teaching_components": { + "pages": [ + "module_root.dart", + "pages/download_animation_page.dart", + "pages/download_comparison_page.dart", + "pages/paint_animation_page.dart" + ], + "components": [ + "LearningScaffold", + "LearningObjectives", + "ConceptChips", + "CodeSnippetCard", + "CommonPitfalls", + "ExerciseCard" + ] + }, "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/ui/download_animation/module_root.dart b/lib/modules/ui/download_animation/module_root.dart index a610d53..a4a4358 100644 --- a/lib/modules/ui/download_animation/module_root.dart +++ b/lib/modules/ui/download_animation/module_root.dart @@ -1,7 +1,8 @@ +// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'package:go_router/go_router.dart'; -/// 主页面,提供导航选择 class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -9,20 +10,13 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[100], - appBar: AppBar( - title: const Text('下载动画演示'), - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - elevation: 1, - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return LearningScaffold( + title: '下载动画演示', + interactiveDemo: SizedBox( + height: 500, + child: ListView( + padding: const EdgeInsets.all(16), children: [ - const SizedBox(height: 40), Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( @@ -89,7 +83,7 @@ class HomePage extends StatelessWidget { color: Colors.purple, routePath: '$_baseRoute/comparison', ), - const Spacer(), + const SizedBox(height: 24), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -116,7 +110,9 @@ class HomePage extends StatelessWidget { ), const SizedBox(height: 8), Text( - '• Custom View: 使用 Stack + AnimatedBuilder 实现\n• Paint: 使用 CustomPaint 绘制动画\n• Overlay: 使用全局 Overlay 实现,不受视图层级限制', + '• Custom View: 使用 Stack + AnimatedBuilder 实现\n' + '• Paint: 使用 CustomPaint 绘制动画\n' + '• Overlay: 使用全局 Overlay 实现,不受视图层级限制', style: TextStyle( fontSize: 14, color: Colors.blue.shade700, @@ -128,6 +124,44 @@ class HomePage extends StatelessWidget { ], ), ), + sections: [ + LearningObjectives(objectives: [ + '理解 Flutter 动画的基础实现方式', + '对比 Custom View、CustomPaint、Overlay 三种方案的差异', + '掌握不同场景下选择合适的动画实现策略', + ]), + ConceptChips(concepts: [ + 'Tween 动画', + 'CustomPaint', + 'OverlayEntry', + 'Stack', + 'AnimatedBuilder', + '动画配置', + ]), + CodeSnippetCard( + title: '三种动画实现对比', + code: '// 1. Custom View (Stack)\n' + 'Stack(children: [\n' + ' AnimatedBuilder(\n' + ' animation: controller,\n' + ' builder: (context, child) => ...\n' + ' ),\n' + ']);\n\n' + '// 2. CustomPaint\n' + 'CustomPaint(\n' + ' painter: DownloadPainter(progress),\n' + ');\n\n' + '// 3. Overlay\n' + 'Overlay.of(context).insert(\n' + ' OverlayEntry(builder: (_) => ...)\n' + ');', + explanation: '三种方案各有优劣:Stack 直观、CustomPaint 灵活、Overlay 全局。', + ), + ExerciseCard( + task: '为 Paint 绘制动画增加"暂停/继续"功能,使用 AnimationController 的 stop/resume。', + hint: 'AnimationController 自带 stop() 和 repeat(),在按钮回调中切换即可。', + ), + ], ); } @@ -141,9 +175,7 @@ class HomePage extends StatelessWidget { }) { return Card( elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () => context.push(routePath), @@ -157,40 +189,25 @@ class HomePage extends StatelessWidget { color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), - child: Icon( - icon, - color: color, - size: 28, - ), + child: Icon(icon, color: color, size: 28), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), + Text(title, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), + Text(subtitle, + style: TextStyle( + fontSize: 14, color: Colors.grey.shade600)), ], ), ), - Icon( - Icons.arrow_forward_ios, - color: Colors.grey.shade400, - size: 16, - ), + Icon(Icons.arrow_forward_ios, + color: Colors.grey.shade400, size: 16), ], ), ), diff --git a/lib/modules/ui/download_animation/pages/download_animation_page.dart b/lib/modules/ui/download_animation/pages/download_animation_page.dart index bf584e5..503ce16 100644 --- a/lib/modules/ui/download_animation/pages/download_animation_page.dart +++ b/lib/modules/ui/download_animation/pages/download_animation_page.dart @@ -1,9 +1,12 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'dart:math' as math; -import '../models/download_item.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; + import '../models/animation_config.dart'; +import '../models/download_item.dart'; /// 下载动画页面组件 class DownloadAnimationPage extends StatefulWidget { @@ -23,10 +26,8 @@ class _DownloadAnimationPageState extends State final GlobalKey _downloadAreaKey = GlobalKey(); Offset? _downloadAreaPosition; - // 使用AnimationConfig类管理动画参数 late AnimationConfig animationConfig; - // 控制是否显示参数控制面板 bool showControlPanel = false; @override @@ -37,7 +38,6 @@ class _DownloadAnimationPageState extends State vsync: this, ); - // 初始化动画配置 - 优先使用父组件传入的配置 animationConfig = widget.animationConfig ?? const AnimationConfig(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -149,51 +149,142 @@ class _DownloadAnimationPageState extends State @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[100], - appBar: AppBar( - title: const Text('下载飞入动画'), - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - elevation: 1, - actions: [ - IconButton( - icon: Icon(showControlPanel ? Icons.close : Icons.settings), - onPressed: () { - setState(() { - showControlPanel = !showControlPanel; - }); - }, - tooltip: '动画设置', + return Stack( + children: [ + LearningScaffold( + title: '下载飞入动画', + interactiveDemo: SizedBox( + height: 480, + child: Column( + children: [ + _buildSettingsToggle(), + if (showControlPanel) _buildControlPanel(), + Flexible(child: _buildFileList()), + _buildDownloadArea(), + ], + ), ), - ], - ), - body: Stack( + sections: const [ + LearningObjectives( + objectives: [ + '理解 AnimationController 的生命周期管理——创建、forward、dispose。', + '掌握 Tween 与 CurvedAnimation 的组合使用来控制动画轨迹。', + '理解 AnimatedBuilder 的刷新机制——监听动画值变化重建 widget。', + '掌握 Stack + Positioned 实现元素飞入效果。', + '理解 Offset 动画与缩放、透明度动画的协同调度。', + ], + ), + ConceptChips( + concepts: [ + 'AnimationController', + 'Tween', + 'CurvedAnimation', + 'AnimatedBuilder', + 'Stack', + 'Positioned', + 'Interval', + ], + ), + CodeSnippetCard( + title: '飞入动画核心模式', + code: '''// 创建动画控制器 +final controller = AnimationController( + duration: Duration(seconds: 2), + vsync: this, +); + +// 位置动画 +final positionAnim = Tween( + begin: startPos, + end: endPos, +).animate(CurvedAnimation( + parent: controller, + curve: Curves.easeInOut, +)); + +// 使用 AnimatedBuilder 驱动 widget +AnimatedBuilder( + animation: controller, + builder: (context, child) { + return Positioned( + left: positionAnim.value.dx, + top: positionAnim.value.dy, + child: child!, + ); + }, + child: flyingWidget, +);''', + explanation: + 'AnimationController 驱动 Tween 输出插值,AnimatedBuilder 监听动画变化并重建 widget 树,实现流畅的飞入效果。', + ), + CommonPitfalls( + pitfalls: [ + 'AnimationController 必须在使用后 dispose(),否则会造成内存泄漏。', + '在 State 中混入 TickerProviderStateMixin 才能创建 AnimationController。', + 'AnimationController 的 duration 过短会导致动画卡顿,建议 800ms 以上。', + '多个动画组合时(位置+缩放+透明度),注意 Interval 的时间重叠,避免视觉冲突。', + 'Positioned 必须在 Stack 中才能正确定位,否则会抛出布局异常。', + ], + ), + ExerciseCard( + task: + '为每个文件添加不同的飞入颜色(如 PDF 蓝色、ZIP 橙色、视频红色),在 _buildFlyingItem 中根据 file type 设置不同的 Container 颜色。', + hint: '在 DownloadItem 中添加 color 字段,或在 _startDownload 时传入文件类型标记。', + ), + ], + ), + ...downloadItems.map((item) => _buildFlyingItem(item)), + ], + ); + } + + Widget _buildSettingsToggle() { + return Padding( + padding: const EdgeInsets.only(top: 4, right: 4), + child: Row( children: [ - Column( - children: [ - if (showControlPanel) _buildControlPanel(), - _buildFileList(), - const SizedBox(height: 40), - _buildDownloadArea(), - ], + const SizedBox(width: 8), + Icon(Icons.tune, + size: 16, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 4), + Text( + '下载演示', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const Spacer(), + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon( + showControlPanel ? Icons.close : Icons.settings, + size: 20, + ), + onPressed: () { + setState(() { + showControlPanel = !showControlPanel; + }); + }, + tooltip: '动画设置', + ), ), - ...downloadItems.map((item) => _buildFlyingItem(item)), ], ), ); } - /// 构建动画参数控制面板 Widget _buildControlPanel() { return Card( - margin: const EdgeInsets.all(16), + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -201,115 +292,84 @@ class _DownloadAnimationPageState extends State '动画参数设置', style: TextStyle( fontWeight: FontWeight.bold, - fontSize: 16, + fontSize: 14, ), ), - const SizedBox(height: 16), - - // 动画持续时间滑块 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('动画持续时间: ${animationConfig.animationDuration} 毫秒'), - Slider( - value: animationConfig.animationDuration.toDouble(), - min: 500, - max: 3000, - divisions: 25, - onChanged: (value) { - setState(() { - animationConfig = animationConfig.copyWith( - animationDuration: value.toInt(), - ); - }); - }, - ), - ], + const SizedBox(height: 12), + _buildSliderRow( + label: '持续时间', + value: animationConfig.animationDuration.toDouble(), + min: 500, + max: 3000, + divisions: 25, + display: '${animationConfig.animationDuration} 毫秒', + onChanged: (v) { + setState(() { + animationConfig = animationConfig.copyWith( + animationDuration: v.toInt(), + ); + }); + }, ), - - // 飞入点偏移量滑块 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('飞入点大小偏移: ${animationConfig.flyingItemOffset.toInt()}'), - Slider( - value: animationConfig.flyingItemOffset, - min: 10, - max: 50, - divisions: 40, - onChanged: (value) { - setState(() { - animationConfig = animationConfig.copyWith( - flyingItemOffset: value, - ); - }); - }, - ), - ], + _buildSliderRow( + label: '偏移量', + value: animationConfig.flyingItemOffset, + min: 10, + max: 50, + divisions: 40, + display: animationConfig.flyingItemOffset.toInt().toString(), + onChanged: (v) { + setState(() { + animationConfig = animationConfig.copyWith( + flyingItemOffset: v, + ); + }); + }, ), - - // 飞入点内边距滑块 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('飞入点内边距: ${animationConfig.flyingItemPadding.toInt()}'), - Slider( - value: animationConfig.flyingItemPadding, - min: 4, - max: 16, - divisions: 12, - onChanged: (value) { - setState(() { - animationConfig = animationConfig.copyWith( - flyingItemPadding: value, - ); - }); - }, - ), - ], + _buildSliderRow( + label: '内边距', + value: animationConfig.flyingItemPadding, + min: 4, + max: 16, + divisions: 12, + display: animationConfig.flyingItemPadding.toInt().toString(), + onChanged: (v) { + setState(() { + animationConfig = animationConfig.copyWith( + flyingItemPadding: v, + ); + }); + }, ), - - // 飞入点圆角滑块 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('飞入点圆角: ${animationConfig.flyingItemRadius.toInt()}'), - Slider( - value: animationConfig.flyingItemRadius, - min: 4, - max: 16, - divisions: 12, - onChanged: (value) { - setState(() { - animationConfig = animationConfig.copyWith( - flyingItemRadius: value, - ); - }); - }, - ), - ], + _buildSliderRow( + label: '圆角', + value: animationConfig.flyingItemRadius, + min: 4, + max: 16, + divisions: 12, + display: animationConfig.flyingItemRadius.toInt().toString(), + onChanged: (v) { + setState(() { + animationConfig = animationConfig.copyWith( + flyingItemRadius: v, + ); + }); + }, ), - - // 飞入速度滑块 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '飞入速度: ${animationConfig.flyingSpeed.toStringAsFixed(1)}x'), - Slider( - value: animationConfig.flyingSpeed, - min: 0.5, - max: 3.0, - divisions: 25, - onChanged: (value) { - setState(() { - animationConfig = animationConfig.copyWith( - flyingSpeed: value, - ); - }); - }, - ), - ], + _buildSliderRow( + label: '速度', + value: animationConfig.flyingSpeed, + min: 0.5, + max: 3.0, + divisions: 25, + display: '${animationConfig.flyingSpeed.toStringAsFixed(1)}x', + onChanged: (v) { + setState(() { + animationConfig = animationConfig.copyWith( + flyingSpeed: v, + ); + }); + }, ), ], ), @@ -317,6 +377,43 @@ class _DownloadAnimationPageState extends State ); } + Widget _buildSliderRow({ + required String label, + required double value, + required double min, + required double max, + required int divisions, + required String display, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(label, style: const TextStyle(fontSize: 12)), + const Spacer(), + Text(display, + style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + ], + ), + SizedBox( + height: 28, + child: Slider( + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + ), + ), + ], + ), + ); + } + Widget _buildFileList() { final files = [ { @@ -331,45 +428,46 @@ class _DownloadAnimationPageState extends State {'name': '音频文件.mp3', 'size': '12.4 MB', 'icon': Icons.audiotrack}, ]; - return Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: files.length, - itemBuilder: (context, index) { - final file = files[index]; - return Card( - margin: const EdgeInsets.only(bottom: 8), - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: files.length, + itemBuilder: (context, index) { + final file = files[index]; + return Card( + margin: const EdgeInsets.only(bottom: 4), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox( + height: 52, child: ListTile( contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + const EdgeInsets.symmetric(horizontal: 12, vertical: 0), leading: Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), ), child: Icon( file['icon'] as IconData, color: Colors.blue.shade600, - size: 28, + size: 20, ), ), title: Text( file['name'] as String, style: const TextStyle( fontWeight: FontWeight.w600, - fontSize: 16, + fontSize: 13, ), ), subtitle: Text( file['size'] as String, style: TextStyle( color: Colors.grey.shade600, - fontSize: 14, + fontSize: 11, ), ), trailing: Container( @@ -377,14 +475,13 @@ class _DownloadAnimationPageState extends State gradient: LinearGradient( colors: [Colors.blue.shade400, Colors.blue.shade600], ), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), ), child: Material( color: Colors.transparent, child: InkWell( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), onTapDown: (TapDownDetails details) { - // 直接使用点击位置作为动画起始点 final itemPosition = details.globalPosition; _startDownload( @@ -395,17 +492,18 @@ class _DownloadAnimationPageState extends State }, child: const Padding( padding: - EdgeInsets.symmetric(horizontal: 16, vertical: 8), + EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.download, color: Colors.white, size: 16), + Icon(Icons.download, color: Colors.white, size: 14), SizedBox(width: 4), Text( '下载', style: TextStyle( color: Colors.white, fontWeight: FontWeight.w500, + fontSize: 12, ), ), ], @@ -415,59 +513,58 @@ class _DownloadAnimationPageState extends State ), ), ), - ); - }, - ), + ), + ); + }, ); } Widget _buildDownloadArea() { return Container( key: _downloadAreaKey, - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(24), + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.blue.shade200, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.blue.shade100, - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade200, width: 1.5), ), - child: Column( + child: Row( children: [ Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.blue.shade50, shape: BoxShape.circle, ), child: Icon( Icons.download_done, - size: 40, + size: 24, color: Colors.blue.shade600, ), ), - const SizedBox(height: 16), - Text( - '下载中心', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.blue.shade800, - ), - ), - const SizedBox(height: 8), - Text( - '点击文件右侧下载按钮查看飞入效果', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '下载中心', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.blue.shade800, + ), + ), + const SizedBox(height: 2), + Text( + '点击文件右侧下载按钮查看飞入效果', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade600, + ), + ), + ], ), ], ), @@ -511,8 +608,8 @@ class _DownloadAnimationPageState extends State ), child: SvgPicture.asset( 'assets/icons/paper_plane.svg', - width: 32, - height: 32, + width: 24, + height: 24, colorFilter: const ColorFilter.mode( Colors.white, BlendMode.srcIn, diff --git a/lib/modules/ui/download_animation/pages/download_comparison_page.dart b/lib/modules/ui/download_animation/pages/download_comparison_page.dart index 9113f0a..649ff9e 100644 --- a/lib/modules/ui/download_animation/pages/download_comparison_page.dart +++ b/lib/modules/ui/download_animation/pages/download_comparison_page.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'dart:math' as math; + import '../models/download_item.dart'; import '../models/animation_config.dart'; import '../services/overlay_download_service.dart'; -import 'dart:math' as math; /// 下载动画对比页面 class DownloadComparisonPage extends StatefulWidget { @@ -159,39 +161,79 @@ class _DownloadComparisonPageState extends State @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[100], - appBar: AppBar( - title: const Text('下载动画对比'), - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - elevation: 1, - actions: [ - IconButton( - icon: Icon(showControlPanel ? Icons.close : Icons.settings), - onPressed: () { - setState(() { - showControlPanel = !showControlPanel; - }); - }, - tooltip: '动画设置', - ), - ], - ), - body: Stack( - children: [ - Column( - children: [ - if (showControlPanel) _buildControlPanel(), - _buildComparisonInfo(), - _buildFileList(), - const SizedBox(height: 40), - _buildDownloadArea(), - ], + return Stack( + children: [ + LearningScaffold( + title: '下载动画对比', + interactiveDemo: SizedBox( + height: 500, + child: Column( + children: [ + if (showControlPanel) _buildControlPanel(), + _buildComparisonInfo(), + Flexible(child: _buildFileList()), + _buildDownloadArea(), + ], + ), ), - ...downloadItems.map((item) => _buildFlyingItem(item)), - ], - ), + sections: const [ + LearningObjectives( + objectives: [ + '对比 Custom View(Stack + AnimatedBuilder)与 Overlay 两种动画实现方式的差异', + '理解 OverlayEntry 的全局渲染机制——不受视图层级限制', + '掌握 Tween 动画与 Interval 组合实现多阶段动画效果', + ], + ), + ConceptChips( + concepts: [ + 'Stack', + 'Positioned', + 'Overlay', + 'OverlayEntry', + 'AnimatedBuilder', + 'Tween', + 'Interval' + ], + ), + CodeSnippetCard( + title: 'Custom View vs Overlay', + code: '''// Custom View:使用 Stack + AnimatedBuilder +Stack( + children: [ + // 主内容 + Column(children: [...]), + // 飞入元素 + ...items.map((item) => Positioned( + left: item.position.value.dx, + top: item.position.value.dy, + child: flyingWidget, + )), + ], +) + +// Overlay:使用 Overlay.of(context).insert +final overlayEntry = OverlayEntry(builder: (_) => flyingWidget); +Overlay.of(context).insert(overlayEntry);''', + explanation: + 'Custom View 受父组件边界限制;Overlay 渲染在独立的 Overlay 层,不受任何父组件裁剪。', + ), + CommonPitfalls( + pitfalls: [ + 'OverlayEntry 必须在 dispose 中移除,否则会内存泄漏', + 'Positioned 必须在 Stack 中才能正确定位', + 'AnimationController 使用后必须 dispose', + 'globalPosition 在列表滚动时会偏移,注意使用 RenderBox.localToGlobal 转换', + ], + ), + ExerciseCard( + task: + '对比两种方式的下载动画:点击文件右侧的「View」和「Overlay」按钮,观察飞入动画的视觉差异。尝试调整动画参数面板中的持续时间与速度。', + hint: 'Overlay 方式不受页面边界限制,可以覆盖在其他组件(如 AppBar)之上。', + ), + ], + ), + ...downloadItems.map((item) => _buildFlyingItem(item)), + ], ); } @@ -372,141 +414,130 @@ class _DownloadComparisonPageState extends State {'name': '演示视频.mp4', 'size': '156.3 MB', 'icon': Icons.video_file}, ]; - return Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: files.length, - itemBuilder: (context, index) { - final file = files[index]; - return Card( - margin: const EdgeInsets.only(bottom: 8), - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - file['icon'] as IconData, - color: Colors.blue.shade600, - size: 28, - ), + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: files.length, + itemBuilder: (context, index) { + final file = files[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), ), - title: Text( - file['name'] as String, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), + child: Icon( + file['icon'] as IconData, + color: Colors.blue.shade600, + size: 28, ), - subtitle: Text( - file['size'] as String, - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 14, - ), + ), + title: Text( + file['name'] as String, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Custom View 下载按钮 - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.blue.shade400, Colors.blue.shade600], - ), - borderRadius: BorderRadius.circular(20), + ), + subtitle: Text( + file['size'] as String, + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.blue.shade400, Colors.blue.shade600], ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTapDown: (TapDownDetails details) { - _startCustomViewDownload( - file['name'] as String, - file['size'] as String, - details.globalPosition, - ); - }, - child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.download, - color: Colors.white, size: 14), - SizedBox(width: 4), - Text( - 'View', + borderRadius: BorderRadius.circular(20), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTapDown: (TapDownDetails details) { + _startCustomViewDownload( + file['name'] as String, + file['size'] as String, + details.globalPosition, + ); + }, + child: const Padding( + padding: + EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.download, color: Colors.white, size: 14), + SizedBox(width: 4), + Text('View', style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ), - ], - ), + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 12)), + ], ), ), ), ), - const SizedBox(width: 8), - // Overlay 下载按钮 - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.green.shade400, Colors.green.shade600], - ), - borderRadius: BorderRadius.circular(20), + ), + const SizedBox(width: 8), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.green.shade400, Colors.green.shade600], ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTapDown: (TapDownDetails details) { - _startOverlayDownload( - file['name'] as String, - file['size'] as String, - details.globalPosition, - ); - }, - child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.cloud_download, - color: Colors.white, size: 14), - SizedBox(width: 4), - Text( - 'Overlay', + borderRadius: BorderRadius.circular(20), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTapDown: (TapDownDetails details) { + _startOverlayDownload( + file['name'] as String, + file['size'] as String, + details.globalPosition, + ); + }, + child: const Padding( + padding: + EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_download, + color: Colors.white, size: 14), + SizedBox(width: 4), + Text('Overlay', style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ), - ], - ), + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 12)), + ], ), ), ), ), - ], - ), + ), + ], ), - ); - }, - ), + ), + ); + }, ); } diff --git a/lib/modules/ui/download_animation/pages/paint_animation_page.dart b/lib/modules/ui/download_animation/pages/paint_animation_page.dart index 0643139..e21e507 100644 --- a/lib/modules/ui/download_animation/pages/paint_animation_page.dart +++ b/lib/modules/ui/download_animation/pages/paint_animation_page.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_study_learning/flutter_study_learning.dart'; import '../models/animation_config.dart'; @@ -17,7 +18,6 @@ class PaintAnimationPage extends StatefulWidget { class _PaintAnimationPageState extends State with TickerProviderStateMixin { - // 存储所有正在飞行的动画项 final List _flyingItems = []; final GlobalKey _downloadAreaKey = GlobalKey(); @@ -77,7 +77,6 @@ class _PaintAnimationPageState extends State _flyingItems.add(item); }); - // 添加监听器以触发重绘 controller.addListener(() { setState(() {}); }); @@ -117,48 +116,91 @@ class _PaintAnimationPageState extends State @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[100], - appBar: AppBar( - title: const Text('Paint 绘制动画'), - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - elevation: 1, - actions: [ - IconButton( - icon: Icon(showControlPanel ? Icons.close : Icons.settings), - onPressed: () { - setState(() { - showControlPanel = !showControlPanel; - }); - }, - tooltip: '动画设置', - ), - ], - ), - body: Stack( - children: [ - Column( - children: [ - if (showControlPanel) _buildControlPanel(), - _buildFileList(), - const SizedBox(height: 40), - _buildDownloadArea(), - ], + return Stack( + children: [ + LearningScaffold( + title: 'Paint 绘制动画', + interactiveDemo: SizedBox( + height: 460, + child: Column( + children: [ + if (showControlPanel) _buildControlPanel(), + Flexible(child: _buildFileList()), + _buildDownloadArea(), + ], + ), ), - // 使用 CustomPaint 绘制所有飞行的动画 - Positioned.fill( - child: IgnorePointer( - child: CustomPaint( - painter: FlyingAnimationPainter( - items: _flyingItems, - animationConfig: animationConfig, - ), + sections: const [ + LearningObjectives( + objectives: [ + '掌握 CustomPainter 实现动画的方法——在 paint 中根据动画进度计算绘制位置', + '理解 Canvas 绘制坐标系变换(translate / scale / rotate)', + '掌握贝塞尔曲线轨迹与尾迹效果的绘制技巧', + ], + ), + ConceptChips( + concepts: [ + 'CustomPaint', + 'CustomPainter', + 'Canvas', + 'Path', + 'MaskFilter', + '贝塞尔曲线' + ], + ), + CodeSnippetCard( + title: 'CustomPainter 动画核心', + code: '''// 在 CustomPainter.paint() 中驱动动画 +class FlyingPainter extends CustomPainter { + final List items; + final AnimationConfig config; + + @override + void paint(Canvas canvas, Size size) { + for (var item in items) { + final progress = item.controller.value; + final pos = _calculatePosition(item, progress); + + canvas.save(); + canvas.translate(pos.dx, pos.dy); + canvas.scale(_calculateScale(progress)); + // 绘制图形... + canvas.restore(); + } + } + + @override + bool shouldRepaint(FlyingPainter old) => true; +}''', + explanation: + '动画每帧触发 setState → parent rebuild → CustomPaint repaint,在 paint 中根据 AnimationController.value 实时计算绘制位置。', + ), + CommonPitfalls( + pitfalls: [ + 'CustomPainter.shouldRepaint 应返回 true 以确保持续驱动画帧重绘', + 'Canvas 绘制的坐标变换需要 save/restore 配对使用,否则会影响后续绘制', + 'MaskFilter 性能开销较大,不宜在循环中创建过多实例', + '尾迹绘制涉及历史帧计算,注意性能优化', + ], + ), + ExerciseCard( + task: + '尝试调整动画参数(持续时间、飞入点大小、速度),观察 CustomPainter 绘制的飞入轨迹与尾迹效果的变化。对比与 Stack+Positioned 方式的视觉差异。', + hint: 'CustomPainter 可以绘制更复杂的图形效果(渐变、光晕、尾迹),不受 Widget 层级限制。', + ), + ], + ), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: FlyingAnimationPainter( + items: _flyingItems, + animationConfig: animationConfig, ), ), ), - ], - ), + ), + ], ); } @@ -176,10 +218,7 @@ class _PaintAnimationPageState extends State children: [ const Text( '动画参数设置', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), const SizedBox(height: 16), Column( @@ -286,93 +325,77 @@ class _PaintAnimationPageState extends State }, ]; - return Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: files.length, - itemBuilder: (context, index) { - final file = files[index]; - final color = file['color'] as Color; - - return Card( - margin: const EdgeInsets.only(bottom: 8), - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - file['icon'] as IconData, - color: color, - size: 28, - ), - ), - title: Text( - file['name'] as String, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: files.length, + itemBuilder: (context, index) { + final file = files[index]; + final color = file['color'] as Color; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), ), - subtitle: Text( - file['size'] as String, - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 14, + child: Icon(file['icon'] as IconData, color: color, size: 28), + ), + title: Text( + file['name'] as String, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + subtitle: Text( + file['size'] as String, + style: TextStyle(color: Colors.grey.shade600, fontSize: 14), + ), + trailing: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color.withValues(alpha: 0.8), color], ), + borderRadius: BorderRadius.circular(20), ), - trailing: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [color.withValues(alpha: 0.8), color], - ), + child: Material( + color: Colors.transparent, + child: InkWell( borderRadius: BorderRadius.circular(20), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTapDown: (TapDownDetails details) { - final itemPosition = details.globalPosition; - _startDownload( - file['name'] as String, - file['size'] as String, - itemPosition, - ); - }, - child: const Padding( - padding: - EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.download, color: Colors.white, size: 16), - SizedBox(width: 4), - Text( - '下载', + onTapDown: (TapDownDetails details) { + final itemPosition = details.globalPosition; + _startDownload( + file['name'] as String, + file['size'] as String, + itemPosition, + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.download, color: Colors.white, size: 16), + SizedBox(width: 4), + Text('下载', style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ], - ), + color: Colors.white, + fontWeight: FontWeight.w500)), + ], ), ), ), ), ), - ); - }, - ), + ), + ); + }, ); } @@ -401,28 +424,21 @@ class _PaintAnimationPageState extends State color: Colors.blue.shade50, shape: BoxShape.circle, ), - child: Icon( - Icons.download_done, - size: 40, - color: Colors.blue.shade600, - ), + child: Icon(Icons.download_done, + size: 40, color: Colors.blue.shade600), ), const SizedBox(height: 16), Text( '下载中心', style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.blue.shade800, - ), + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue.shade800), ), const SizedBox(height: 8), Text( '点击文件右侧下载按钮查看 Paint 动画效果', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), ), ], ), @@ -472,23 +488,18 @@ class FlyingAnimationPainter extends CustomPainter { void _drawFlyingItem(Canvas canvas, FlyingPaintItem item) { final progress = item.controller.value; - // 使用贝塞尔曲线计算路径 final curveProgress = Curves.easeInOut.transform(progress); - // 计算当前位置(带有弧线效果) final dx = item.startPosition.dx + (item.endPosition.dx - item.startPosition.dx) * curveProgress; final dy = item.startPosition.dy + (item.endPosition.dy - item.startPosition.dy) * curveProgress; - // 添加抛物线效果 final controlPointOffset = -100.0 * math.sin(math.pi * curveProgress); final currentPosition = Offset(dx, dy + controlPointOffset); - // 计算缩放(从 1.2 到 0.2) final scale = progress < 0.7 ? 1.2 : 1.2 - (progress - 0.7) / 0.3 * 1.0; - // 计算透明度(在最后 20% 淡出) final opacity = progress < 0.8 ? 1.0 : 1.0 - (progress - 0.8) / 0.2; if (opacity <= 0) return; @@ -497,16 +508,9 @@ class FlyingAnimationPainter extends CustomPainter { canvas.translate(currentPosition.dx, currentPosition.dy); canvas.scale(math.max(0.1, scale)); - // 绘制外圈光晕(多层) _drawGlow(canvas, opacity); - - // 绘制主圆圈 _drawMainCircle(canvas, opacity); - - // 绘制图标 _drawIcon(canvas, opacity); - - // 绘制尾迹效果 _drawTrail(canvas, item, progress, opacity); canvas.restore(); @@ -517,17 +521,14 @@ class FlyingAnimationPainter extends CustomPainter { ..color = Colors.blue.withValues(alpha: 0.1 * opacity) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 15); - // 外层光晕 canvas.drawCircle(Offset.zero, 40, glowPaint); - // 中层光晕 glowPaint.color = Colors.blue.withValues(alpha: 0.2 * opacity); glowPaint.maskFilter = const MaskFilter.blur(BlurStyle.normal, 10); canvas.drawCircle(Offset.zero, 30, glowPaint); } void _drawMainCircle(Canvas canvas, double opacity) { - // 渐变圆圈 const rect = Rect.fromLTRB(-25, -25, 25, 25); final gradient = RadialGradient( colors: [ @@ -542,7 +543,6 @@ class FlyingAnimationPainter extends CustomPainter { canvas.drawCircle(Offset.zero, 25, circlePaint); - // 边框高光 final borderPaint = Paint() ..color = Colors.white.withValues(alpha: 0.3 * opacity) ..style = PaintingStyle.stroke @@ -552,14 +552,12 @@ class FlyingAnimationPainter extends CustomPainter { } void _drawIcon(Canvas canvas, double opacity) { - // 绘制下载图标(简化版) final iconPaint = Paint() ..color = Colors.white.withValues(alpha: opacity) ..style = PaintingStyle.stroke ..strokeWidth = 3 ..strokeCap = StrokeCap.round; - // 箭头向下 final path = Path(); path.moveTo(0, -10); path.lineTo(0, 10); @@ -567,7 +565,6 @@ class FlyingAnimationPainter extends CustomPainter { path.lineTo(0, 10); path.lineTo(8, 3); - // 底部横线 path.moveTo(-10, 15); path.lineTo(10, 15); @@ -578,12 +575,10 @@ class FlyingAnimationPainter extends CustomPainter { Canvas canvas, FlyingPaintItem item, double progress, double opacity) { if (progress < 0.1) return; - // 绘制运动轨迹尾迹 final trailPaint = Paint() ..style = PaintingStyle.fill ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); - // 计算前几帧的位置并绘制尾迹 for (int i = 1; i <= 5; i++) { final trailProgress = math.max(0.0, progress - i * 0.02); if (trailProgress <= 0) break; @@ -617,6 +612,6 @@ class FlyingAnimationPainter extends CustomPainter { @override bool shouldRepaint(FlyingAnimationPainter oldDelegate) { - return true; // 始终重绘以确保动画流畅 + return true; } } diff --git a/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md b/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md index 30013ed..fd2263e 100644 --- a/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md +++ b/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md @@ -12,7 +12,7 @@ "owns": ["module_entry","module_page","module_state","module_docs"], "depends": ["gcode_core","flutter_study_learning","file_picker_bridge","module_registry"], "mutates": ["AI_ANALYSIS.md","**/*.dart"], - "files": ["module_entry.dart","pages/gcode_visualizer_page.dart","state/gcode_player_controller.dart","widgets/gcode_editor_panel.dart"], + "files": ["module_entry.dart","pages/gcode_visualizer_page.dart","state/gcode_player_controller.dart","widgets/gcode_editor_panel.dart","widgets/current_segment_inspector.dart"], "contracts": { "no_natural_language": true, "doc_consumer": "vibecoding", diff --git a/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart b/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart index 9f513bb..f1eb972 100644 --- a/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart +++ b/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_study_learning/flutter_study_learning.dart'; import 'package:gcode_core/gcode_core.dart'; +import '../widgets/current_segment_inspector.dart'; import '../widgets/gcode_editor_panel.dart'; import '../state/gcode_player_controller.dart'; @@ -96,6 +97,7 @@ class _GcodeVisualizerPageState extends State segments: _controller.segments, progress: _controller.progress, errorCount: _controller.errorCount, + commandCount: _controller.totalCommands, ), ), ), @@ -114,6 +116,7 @@ class _GcodeVisualizerPageState extends State segments: _controller.segments, progress: _controller.progress, errorCount: _controller.errorCount, + commandCount: _controller.totalCommands, ), ), const SizedBox(height: 12), @@ -136,6 +139,7 @@ class _GcodeVisualizerPageState extends State onResetSample: _onResetSample, errorCount: _controller.errorCount, commandCount: _controller.totalCommands, + segmentCount: _controller.segments.length, hasParsed: _controller.parseResult != null, linesRead: _controller.linesRead, loadStageLabel: _loadStageLabel(_controller.loadStage), @@ -163,6 +167,11 @@ class _GcodeVisualizerPageState extends State maxHeight: 140, ), ), + const SizedBox(height: 8), + CurrentSegmentInspector( + segment: _controller.currentSegment, + progress: _controller.currentSegmentProgress, + ), ], ], ); @@ -192,6 +201,7 @@ class _GcodeVisualizerPageState extends State concepts: [ 'G0 快速移动', 'G1 线性插补', + 'G90/G91 绝对/相对', 'Parser', 'Toolpath', 'CustomPaint', @@ -207,6 +217,16 @@ class _GcodeVisualizerPageState extends State 'G0 X0 Y0 ; 快速返回原点', explanation: 'G0 为快速定位(不切削),G1 为线性插补(切削进给)', ), + const CodeSnippetCard( + title: 'G90 绝对模式 vs G91 相对模式', + code: 'G90 ; 切换到绝对坐标模式\n' + 'G1 X10 Y10 ; 移动到 (10, 10)\n' + 'G1 X20 Y10 ; 移动到 (20, 10)\n' + 'G91 ; 切换到相对坐标模式\n' + 'G1 X10 Y0 ; 从当前位置向右移动 10\n' + 'G1 X10 Y0 ; 再向右移动 10(当前位置在 30,10)', + explanation: 'G90 模式下坐标值表示绝对位置,G91 模式下坐标值表示相对于当前位置的偏移量', + ), StateLogView( logs: _controller.logs, maxLines: 6, @@ -218,6 +238,7 @@ class _GcodeVisualizerPageState extends State '解析逻辑必须独立于 Flutter Widget,保持纯 Dart 可测试', '动画进度不应修改已解析的几何数据', '大文件不应在每帧都重新解析', + 'G91 相对模式下坐标会累加,同一段 G-code 在不同位置执行结果不同', ], ), const ExerciseCard( diff --git a/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart b/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart index ecbb9a0..14c6b5e 100644 --- a/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart +++ b/lib/modules/ui/gcode_visualizer/state/gcode_player_controller.dart @@ -70,6 +70,21 @@ class GcodePlayerController extends ChangeNotifier { int get totalCommands => _parseResult?.commands.length ?? 0; int get errorCount => _parseResult?.errors.length ?? 0; + ToolpathSegment? get currentSegment { + if (_segments.isEmpty) return null; + final totalSegments = _segments.length; + final idx = (_progress * totalSegments).floor().clamp(0, totalSegments - 1); + return _segments[idx]; + } + + double get currentSegmentProgress { + if (_segments.isEmpty) return 0; + final totalSegments = _segments.length; + final currentSegFloat = _progress * totalSegments; + final currentSegIndex = currentSegFloat.floor().clamp(0, totalSegments - 1); + return (currentSegFloat - currentSegIndex).clamp(0.0, 1.0); + } + void updateSource(String value) { _source = value; notifyListeners(); diff --git a/lib/modules/ui/gcode_visualizer/widgets/current_segment_inspector.dart b/lib/modules/ui/gcode_visualizer/widgets/current_segment_inspector.dart new file mode 100644 index 0000000..d430d09 --- /dev/null +++ b/lib/modules/ui/gcode_visualizer/widgets/current_segment_inspector.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:gcode_core/gcode_core.dart'; + +class CurrentSegmentInspector extends StatelessWidget { + const CurrentSegmentInspector({ + super.key, + required this.segment, + required this.progress, + }); + + final ToolpathSegment? segment; + final double progress; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, + size: 14, color: theme.colorScheme.primary), + const SizedBox(width: 6), + Text( + '当前轨迹段', + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + if (segment == null) + Text( + '无轨迹段', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ) + else + ..._buildSegmentInfo(segment!), + ], + ), + ); + } + + List _buildSegmentInfo(ToolpathSegment seg) { + return [ + _infoRow('行号', '#${seg.command.lineNumber}'), + _infoRow('指令', seg.command.rawLine), + _infoRow( + '类型', seg.type == GcodeSegmentType.rapid ? 'G0 快速移动' : 'G1 线性移动'), + _infoRow('起点', 'X ${_fmt(seg.start.x)} Y ${_fmt(seg.start.y)}'), + _infoRow('终点', 'X ${_fmt(seg.end.x)} Y ${_fmt(seg.end.y)}'), + _infoRow( + '进给率', + seg.command.feedRate != null + ? 'F ${_fmt(seg.command.feedRate!)}' + : '--'), + _infoRow('段进度', '${(progress * 100).toStringAsFixed(0)}%'), + ]; + } + + Widget _infoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + SizedBox( + width: 52, + child: Text( + label, + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + String _fmt(double v) => + v == v.truncateToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(2); +} diff --git a/lib/modules/ui/gcode_visualizer/widgets/gcode_editor_panel.dart b/lib/modules/ui/gcode_visualizer/widgets/gcode_editor_panel.dart index e31286b..678303e 100644 --- a/lib/modules/ui/gcode_visualizer/widgets/gcode_editor_panel.dart +++ b/lib/modules/ui/gcode_visualizer/widgets/gcode_editor_panel.dart @@ -10,6 +10,7 @@ class GcodeEditorPanel extends StatefulWidget { required this.onResetSample, required this.errorCount, required this.commandCount, + required this.segmentCount, required this.hasParsed, required this.linesRead, required this.loadStageLabel, @@ -23,6 +24,7 @@ class GcodeEditorPanel extends StatefulWidget { final VoidCallback onResetSample; final int errorCount; final int commandCount; + final int segmentCount; final bool hasParsed; final int linesRead; final String loadStageLabel; @@ -68,8 +70,10 @@ class GcodeEditorPanelState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 2, 8), - child: Row( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: OverflowBar( + spacing: 4, + overflowAlignment: OverflowBarAlignment.end, children: [ Text( 'G-code 编辑器', @@ -77,7 +81,6 @@ class GcodeEditorPanelState extends State { fontWeight: FontWeight.bold, ), ), - const Spacer(), _buildGestureButton( hovered: _resetHovered, pressed: _resetPressed, @@ -99,7 +102,6 @@ class GcodeEditorPanelState extends State { ], ), ), - const SizedBox(width: 4), _buildGestureButton( hovered: _parseHovered, pressed: _parsePressed, @@ -161,79 +163,23 @@ class GcodeEditorPanelState extends State { if (widget.hasParsed || widget.loadMessage.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: Row( + child: Wrap( + spacing: 6, + runSpacing: 4, children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - widget.loadStageLabel, - style: TextStyle( - fontSize: 11, - color: Colors.grey.shade700, - fontWeight: FontWeight.w500, - ), - ), - ), - if (widget.linesRead > 0) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.teal.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${widget.linesRead} 行', - style: const TextStyle( - fontSize: 11, - color: Colors.teal, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - const SizedBox(width: 6), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${widget.commandCount} 指令', - style: const TextStyle( - fontSize: 11, - color: Colors.blue, - fontWeight: FontWeight.w500, - ), - ), - ), - if (widget.errorCount > 0) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${widget.errorCount} 错误', - style: const TextStyle( - fontSize: 11, - color: Colors.red, - fontWeight: FontWeight.w500, - ), - ), - ), - ], + _badge(widget.loadStageLabel, Colors.grey.shade700, + Colors.grey.withValues(alpha: 0.12)), + if (widget.linesRead > 0) + _badge('${widget.linesRead} 行', Colors.teal, + Colors.teal.withValues(alpha: 0.1)), + _badge('${widget.commandCount} 指令', Colors.blue, + Colors.blue.withValues(alpha: 0.1)), + if (widget.segmentCount > 0) + _badge('${widget.segmentCount} 轨迹段', Colors.teal, + Colors.teal.withValues(alpha: 0.1)), + if (widget.errorCount > 0) + _badge('${widget.errorCount} 错误', Colors.red, + Colors.red.withValues(alpha: 0.1)), ], ), ), @@ -257,6 +203,24 @@ class GcodeEditorPanelState extends State { ); } + Widget _badge(String text, Color textColor, Color bgColor) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + text, + style: TextStyle( + fontSize: 11, + color: textColor, + fontWeight: FontWeight.w500, + ), + ), + ); + } + Widget _buildGestureButton({ required bool hovered, required bool pressed, diff --git a/test/adsorption_line/adsorption_line_page_test.dart b/test/adsorption_line/adsorption_line_page_test.dart new file mode 100644 index 0000000..e425a74 --- /dev/null +++ b/test/adsorption_line/adsorption_line_page_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:main_app/modules/ui/adsorption_line/pages/adsorption_line_page.dart'; +import 'package:main_app/modules/ui/adsorption_line/state/drawing_state.dart'; + +void main() { + testWidgets('AdsorptionLinePage renders teaching components', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: ChangeNotifierProvider( + create: (_) => DrawingState(), + child: const AdsorptionLinePage(), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('🎯 学习目标'), findsOneWidget); + expect(find.text('⚠️ 常见误区'), findsOneWidget); + }); +} diff --git a/test/download_animation/download_animation_page_test.dart b/test/download_animation/download_animation_page_test.dart new file mode 100644 index 0000000..ee0af31 --- /dev/null +++ b/test/download_animation/download_animation_page_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:main_app/modules/ui/download_animation/pages/download_animation_page.dart'; + +void main() { + testWidgets('DownloadAnimationPage renders teaching components', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: DownloadAnimationPage(), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('下载飞入动画'), findsOneWidget); + expect(find.text('🎯 学习目标'), findsOneWidget); + expect(find.text('⚠️ 常见误区'), findsOneWidget); + expect(find.text('下载'), findsWidgets); + }); +} diff --git a/test/gcode_visualizer/gcode_visualizer_page_test.dart b/test/gcode_visualizer/gcode_visualizer_page_test.dart new file mode 100644 index 0000000..e425236 --- /dev/null +++ b/test/gcode_visualizer/gcode_visualizer_page_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:main_app/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart'; + +void main() { + testWidgets('GcodeVisualizerPage renders key elements', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: GcodeVisualizerPage()), + ); + + await tester.pump(); + + expect(find.text('G-code 解析与轨迹动画'), findsOneWidget); + expect(find.text('G-code 编辑器'), findsOneWidget); + expect(find.text('🎯 学习目标'), findsOneWidget); + + expect(find.byIcon(Icons.play_arrow), findsWidgets); + expect(find.byIcon(Icons.refresh), findsOneWidget); + }); +} diff --git a/test/overlay_follow_compare/overlay_compare_page_test.dart b/test/overlay_follow_compare/overlay_compare_page_test.dart new file mode 100644 index 0000000..4d694eb --- /dev/null +++ b/test/overlay_follow_compare/overlay_compare_page_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:main_app/modules/popup_table/overlay_follow_compare/module_root.dart'; + +void main() { + testWidgets('OverlayComparePage renders teaching components', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: OverlayComparePage(), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Overlay 跟随方案对照组'), findsOneWidget); + expect(find.text('🎯 学习目标'), findsOneWidget); + expect(find.text('⚠️ 常见误区'), findsOneWidget); + expect(find.text('Follower 自动跟随'), findsOneWidget); + expect(find.text('onScroll 手动刷新'), findsOneWidget); + }); +} diff --git a/test/popup_widgets/popup_widgets_page_test.dart b/test/popup_widgets/popup_widgets_page_test.dart new file mode 100644 index 0000000..2c31bd4 --- /dev/null +++ b/test/popup_widgets/popup_widgets_page_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:main_app/modules/popup_table/popup_widgets/module_root.dart'; + +void main() { + testWidgets('PopDemoHomePage renders teaching components', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: PopDemoHomePage(title: 'Flutter 弹窗学习'), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Flutter 弹窗学习'), findsOneWidget); + expect(find.text('AlertDialog (普通对话框)'), findsOneWidget); + expect(find.text('SimpleDialog (选项对话框)'), findsOneWidget); + expect(find.text('Modal Bottom Sheet (模态底部弹窗)'), findsOneWidget); + expect(find.text('自定义 Dialog'), findsOneWidget); + expect(find.text('🎯 学习目标'), findsOneWidget); + }); + + testWidgets('PopDemoHomePage FAB toggles bottom bar', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: PopDemoHomePage(title: 'Flutter 弹窗学习'), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final fab = find.byType(FloatingActionButton); + expect(fab, findsOneWidget); + + await tester.tap(fab); + await tester.pump(); + + expect(find.text('这是一个持久化底部工具条,你可以手动关闭。'), findsOneWidget); + }); + + testWidgets('PopDemoHomePage toolbar shows date/time picker menu', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: PopDemoHomePage(title: 'Flutter 弹窗学习'), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('日期/时间'), findsOneWidget); + }); +} diff --git a/test/stream_subscription/stream_demo_page_test.dart b/test/stream_subscription/stream_demo_page_test.dart new file mode 100644 index 0000000..5f2b353 --- /dev/null +++ b/test/stream_subscription/stream_demo_page_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:main_app/modules/async/stream_subscription/pages/stream_demo_page.dart'; + +void main() { + testWidgets('StreamDemoPage renders teaching components', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: StreamDemoPage(), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Stream 单订阅示例'), findsOneWidget); + expect(find.text('🎯 学习目标'), findsOneWidget); + expect(find.text('⚠️ 常见误区'), findsOneWidget); + expect(find.text('开始推送'), findsOneWidget); + expect(find.text('订阅'), findsOneWidget); + }); +}