flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+399
View File
@@ -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),
),
),
],
),
),
],
),
);
}
}