Files
2026-09-04 15:22:20 +08:00

400 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<WrongFlowDemoPage> createState() => _WrongFlowDemoPageState();
}
class _WrongFlowDemoPageState extends State<WrongFlowDemoPage> {
/// 题库:(题干, 选项, 正确答案索引)
static const List<(String, List<String>, 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<String> get _options => _bank[_qIndex].$2;
int get _rightIndex => _bank[_qIndex].$3;
@override
void dispose() {
super.dispose();
}
/// 答题:对 → 不记录;错 → 接口上报 + 本地错题(watch 自动刷新)
Future<void> _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<void> _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<void> _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<List<dynamic>>(
stream: WrongQuestionStore.instance.watchAll(),
builder: (context, snapshot) {
final items = snapshot.data ?? const <dynamic>[];
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),
),
),
],
),
),
],
),
);
}
}