flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user