import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/storage/stores/wrong_question_store.dart'; /// ============================================================ /// 【WrongFlowDemoPage】接口 + Isar + watch 完整链路 Demo /// /// 一、本页演示的三层协作(回答"现在不都调接口吗"): /// 接口(远端)→ 上报答题结果,可能失败、可能慢 /// Isar(本地)→ 错题本快照,离线也能看 /// watch(响应式)→ 本地库一变,错题列表自动刷新 /// 流程:答错 → ① 上报接口(失败也继续)→ ② 写本地错题本 /// → ③ watch 自动推送 → 列表自动更新(零手动刷新) /// /// 二、为什么接口失败本地仍记录(离线兜底): /// 学生端弱网是常态。错题是"本地刚需数据",先落库先能用; /// 联网后再增量同步回服务端(真实项目用 sync 队列, /// 错题加 isSynced 字段标记,见注释,本例为演示未做)。 /// /// 三、对比"纯接口 + 手动刷新"的做法: /// 不 watch 就得:答完错题 → 手动通知错题 tab 刷新(事件总线/ /// 页面重建)。watch 让 UI 直接订阅数据库,写入和展示彻底解耦。 /// /// 四、高频坑: /// 1. watch 只在 native 可用(web 空实现),本页 kIsWeb 降级提示; /// 2. 写库必须在 writeTxn 内(store 已封装); /// 3. 接口上报和本地写入是两个独立步骤,别把本地写入放接口 /// onError 里——那叫"只有失败才记录",这里"先落库兜底"。 /// ============================================================ class WrongFlowDemoPage extends StatefulWidget { const WrongFlowDemoPage({super.key}); @override State createState() => _WrongFlowDemoPageState(); } class _WrongFlowDemoPageState extends State { /// 题库:(题干, 选项, 正确答案索引) static const List<(String, List, int)> _bank = [ ('7 × 8 = ?', ['54', '56', '58', '63'], 1), ('Flutter 官方推荐的状态管理?', ['Provider', 'Riverpod', 'BLoC', 'GetX'], 1), ('go_router 登录守卫配在哪个参数?', ['routes', 'redirect', 'initialLocation', 'routes'], 1), ]; int _qIndex = 0; // 当前题目索引 int? _selected; // 已选选项 bool? _isRight; // 对/错 bool _reporting = false; // 接口上报中 String _log = ''; // 链路日志 String get _question => _bank[_qIndex].$1; List get _options => _bank[_qIndex].$2; int get _rightIndex => _bank[_qIndex].$3; @override void dispose() { super.dispose(); } /// 答题:对 → 不记录;错 → 接口上报 + 本地错题(watch 自动刷新) Future _onPick(int index) async { if (_reporting) return; final isRight = index == _rightIndex; setState(() { _selected = index; _isRight = isRight; }); if (isRight) { _appendLog('✅ 回答正确,不记错题'); } else { _appendLog('❌ 答错「${_question}」(选了 ${_options[index]}),进入链路...'); // ① 上报接口:失败也继续(本地兜底) await _reportToServer(wrongAnswer: _options[index]); // ② 写本地错题本:watch 自动推送 → 下方列表自动更新 await WrongQuestionStore.instance.add( questionId: 100 + _qIndex, title: _question, wrongAnswer: _options[index], ); _appendLog('💾 已写入本地错题本 → watch 自动刷新列表'); } // 答完切下一题 if (!mounted) return; setState(() { _qIndex = (_qIndex + 1) % _bank.length; _selected = null; _isRight = null; }); } /// ① 接口上报答题结果(jsonplaceholder mock,模拟真实上报) Future _reportToServer({required String wrongAnswer}) async { setState(() => _reporting = true); try { await DioClient.instance.post( '/posts', data: { 'title': _question, 'wrongAnswer': wrongAnswer, 'source': 'student_app', }, ); _appendLog('📡 接口上报成功(服务端已记录错题)'); } on ApiException catch (e) { // 接口失败不阻断:本地仍要记,联网后再同步 _appendLog('📡 接口上报失败:${e.message}'); _appendLog(' ↳ 本地仍记录,联网后增量同步(sync 队列)'); } finally { if (mounted) setState(() => _reporting = false); } } /// 模拟断网:跳过接口,只写本地(演示离线兜底) Future _recordOffline() async { if (_reporting) return; await WrongQuestionStore.instance.add( questionId: 999, title: '离线练习:${_question}', wrongAnswer: '(断网场景,仅本地记录)', ); _appendLog('📴 断网:跳过接口,仅写本地错题本 → watch 自动刷新'); } void _appendLog(String line) { if (!mounted) return; setState(() => _log = '$line\n$_log'); } @override Widget build(BuildContext context) { // Web 降级:Isar 仅 native 可用 if (kIsWeb) { return const Scaffold( body: Center( child: Padding( padding: EdgeInsets.all(32), child: Text( '本实例依赖本地数据库(Isar),不支持 Web 调试\n请在 Android/iOS/桌面端查看', textAlign: TextAlign.center, style: TextStyle(fontSize: 15, height: 1.8), ), ), ), ); } return Scaffold( appBar: AppBar(title: const Text('答题 → 错题本 链路 Demo')), body: SingleChildScrollView( padding: EdgeInsets.all(16.w), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildTipCard(), SizedBox(height: 12.h), _buildQuestionCard(), SizedBox(height: 8.h), OutlinedButton.icon( onPressed: _recordOffline, icon: const Icon(Icons.cloud_off, size: 18), label: const Text('模拟断网:仅本地记录'), ), SizedBox(height: 12.h), const Text( '链路日志(接口 → 本地 → watch)', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF3B82F6), ), ), SizedBox(height: 8.h), Container( padding: EdgeInsets.all(12.w), decoration: BoxDecoration( color: const Color(0xFF0F172A), borderRadius: BorderRadius.circular(10.r), ), child: SelectableText( _log.isEmpty ? '(操作后这里显示完整链路日志)' : _log, style: TextStyle( fontSize: 12.sp, height: 1.6, color: const Color(0xFFE2E8F0), ), ), ), SizedBox(height: 12.h), const Text( '错题本(watch 自动刷新,零手动操作)', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF3B82F6), ), ), SizedBox(height: 8.h), _buildWatchList(), ], ), ), ); } Widget _buildTipCard() { return Container( padding: EdgeInsets.all(12.w), decoration: BoxDecoration( color: const Color(0xFFE8F0FE), borderRadius: BorderRadius.circular(12.r), ), child: const Text( '链路:答错 → ① 接口上报(失败也继续)→ ② 写本地错题本 ' '→ ③ watch 自动推送 → 下方列表自动更新。' '点选项答题,或点下方按钮模拟断网场景。', style: TextStyle(fontSize: 13, color: Color(0xFF3B82F6)), ), ); } /// 答题卡:题干 + 选项 Widget _buildQuestionCard() { return Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12.r), border: Border.all(color: const Color(0xFFEEF1F5)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '第 ${_qIndex + 1} 题', style: const TextStyle(fontSize: 12, color: Color(0xFF999999)), ), SizedBox(height: 4.h), Text( _question, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, color: const Color(0xFF333333), ), ), SizedBox(height: 12.h), for (int i = 0; i < _options.length; i++) ...[ _buildOption(i), if (i < _options.length - 1) SizedBox(height: 8.h), ], SizedBox(height: 8.h), if (_selected != null) Text( _isRight == true ? '✅ 正确,不记错题' : '❌ 已自动记入错题本', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: _isRight == true ? const Color(0xFF22C55E) : const Color(0xFFEF4444), ), ), ], ), ); } Widget _buildOption(int index) { final isSelected = _selected == index; final isRight = isSelected && _isRight == true; final isWrong = isSelected && _isRight == false; return InkWell( onTap: _reporting ? null : () => _onPick(index), borderRadius: BorderRadius.circular(8.r), child: Container( padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), decoration: BoxDecoration( color: isRight ? const Color(0xFFE8F7EE) : isWrong ? const Color(0xFFFDECEA) : Colors.white, borderRadius: BorderRadius.circular(8.r), border: Border.all( color: isRight ? const Color(0xFF22C55E) : isWrong ? const Color(0xFFEF4444) : const Color(0xFFE5E7EB), ), ), child: Row( children: [ Icon( isRight ? Icons.check_circle : isWrong ? Icons.cancel : Icons.radio_button_unchecked, size: 18, color: isRight ? const Color(0xFF22C55E) : isWrong ? const Color(0xFFEF4444) : const Color(0xFF9CA3AF), ), SizedBox(width: 8.w), Text( _options[index], style: const TextStyle(fontSize: 14, color: Color(0xFF333333)), ), ], ), ), ); } /// 错题本:watch 订阅,任何写入自动刷新 Widget _buildWatchList() { return StreamBuilder>( stream: WrongQuestionStore.instance.watchAll(), builder: (context, snapshot) { final items = snapshot.data ?? const []; if (items.isEmpty) { return Container( padding: EdgeInsets.all(24.h), alignment: Alignment.center, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12.r), border: Border.all(color: const Color(0xFFEEF1F5)), ), child: const Text( '(错题本为空,答错一题试试)', style: TextStyle(color: Color(0xFF999999)), ), ); } return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12.r), border: Border.all(color: const Color(0xFFEEF1F5)), ), child: Column( children: [ for (int i = 0; i < items.length; i++) ...[ _buildWrongItem(items[i]), if (i < items.length - 1) const Divider(height: 1, color: Color(0xFFF0F2F5)), ], ], ), ); }, ); } Widget _buildWrongItem(dynamic item) { final title = item.title as String? ?? '无题干'; final wrongAnswer = item.wrongAnswer as String? ?? ''; return Padding( padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), child: Row( children: [ const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 18), SizedBox(width: 8.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, color: Color(0xFF333333), ), ), Text( '我的答案:$wrongAnswer', style: const TextStyle( fontSize: 11, color: Color(0xFF999999), ), ), ], ), ), ], ), ); } }