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
+260
View File
@@ -0,0 +1,260 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/storage/stores/wrong_question_store.dart';
/// ============================================================
/// 【IsarWatchDemoPage】Isar 深水区:watch 响应式(错题本 Demo)
///
/// 一、watch 是什么(对比"浅水区"的主动查询):
/// 浅水区:findAll() 主动查库,数据变了要自己再查/刷新;
/// 深水区:watch() 订阅数据库,数据一变【自动推送】新事件,
/// 页面 StreamBuilder 自动重建——像数据库版的响应式。
///
/// 二、本页怎么演示:
/// - 上方:添加错题(写入 Isar);
/// - 下方:列表用 watchAll() 订阅 → 添加/删除【无需任何刷新】
/// 列表自动更新(fireImmediately 首帧先出当前数据);
/// - 对比区:一个"手动查询"按钮模拟浅水区做法(要自己点刷新);
/// - 点列表项删除 → watch 同样自动收到删除事件。
///
/// 三、为什么经 store 而不是直接用 AppDatabase:
/// Isar 集合成员只在 native 编译,web 直接引用会编译失败;
/// WrongQuestionStore 条件导出(io 真实现 / web 空实现),
/// 页面永远只面向 store,web 端用 kIsWeb 降级提示。
///
/// 四、高频坑:
/// 1. watch 返回的是 Stream,页面必须配合 StreamBuilder,
/// 否则要自己 cancel(本例 StreamBuilder 自动管理);
/// 2. fireImmediately: false 时首帧是空的,通常要开 true;
/// 3. 写操作必须 writeTxn 包住(store 已处理);
/// 4. Web 端无法跑 Isar(空实现),只能真机/桌面看效果。
/// ============================================================
class IsarWatchDemoPage extends StatefulWidget {
const IsarWatchDemoPage({super.key});
@override
State<IsarWatchDemoPage> createState() => _IsarWatchDemoPageState();
}
class _IsarWatchDemoPageState extends State<IsarWatchDemoPage> {
final TextEditingController _titleC = TextEditingController();
/// 对比区:手动查询结果(非 watch,需要自己点按钮刷新)
int _manualCount = -1; // -1 = 还没查过
@override
void dispose() {
_titleC.dispose();
super.dispose();
}
/// 添加错题(写入 Isar → watch 自动推送 → 列表自动更新)
Future<void> _addWrong() async {
final title = _titleC.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入题干'), duration: Duration(seconds: 1)),
);
return;
}
// 模拟"把做错的题加入错题本":questionId 随机造一个
await WrongQuestionStore.instance.add(
questionId: DateTime.now().millisecondsSinceEpoch % 100000,
title: title,
wrongAnswer: '选错了',
);
_titleC.clear();
}
/// 手动查询(对比:非响应式,必须自己点才更新)
Future<void> _manualQuery() async {
final all = await WrongQuestionStore.instance.findAll();
if (!mounted) return;
setState(() => _manualCount = all.length);
}
/// 删除错题(watch 也会收到删除事件,列表自动减少)
Future<void> _delete(dynamic item) async {
final id = item.id as int;
await WrongQuestionStore.instance.remove(id);
}
@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(条件导出空实现)\n请在 Android/iOS/桌面端查看效果',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, height: 1.8),
),
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Isar watch 响应式 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTipCard(),
SizedBox(height: 12.h),
// 添加错题
Row(
children: [
Expanded(
child: TextField(
controller: _titleC,
decoration: const InputDecoration(
hintText: '输入题干,如:1+1=?',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addWrong(),
),
),
SizedBox(width: 8.w),
FilledButton(
onPressed: _addWrong,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3B82F6),
),
child: const Text('加入错题'),
),
],
),
SizedBox(height: 12.h),
// 对比区:手动查询
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _manualQuery,
child: Text(
_manualCount < 0
? '浅水区:手动查询(点我刷新)'
: '浅水区:手动查询 = $_manualCount 条',
),
),
),
],
),
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)),
),
);
}
/// watch 订阅列表:数据一变自动重建
Widget _buildWatchList() {
return StreamBuilder<List<dynamic>>(
// 响应式核心:数据库变化自动推送;fireImmediately 首帧先出一份
stream: WrongQuestionStore.instance.watchAll(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Text('监听出错');
}
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++) ...[
_buildItem(items[i], i),
if (i < items.length - 1)
const Divider(height: 1, color: Color(0xFFF0F2F5)),
],
],
),
);
},
);
}
Widget _buildItem(dynamic item, int index) {
// item 是 WrongQuestion 对象(页面不 import 模型,用 dynamic 访问)
final title = item.title as String? ?? '无题干';
final wrongAt = item.wrongAt as DateTime?;
return InkWell(
onTap: () => _delete(item),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
child: Row(
children: [
const Icon(Icons.cancel, color: Color(0xFFEF4444), size: 18),
SizedBox(width: 8.w),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, color: Color(0xFF333333)),
),
),
Text(
wrongAt != null
? '${wrongAt.month}-${wrongAt.day} ${wrongAt.hour}:${wrongAt.minute}'
: '',
style: const TextStyle(fontSize: 11, color: Color(0xFF999999)),
),
const Icon(Icons.close, size: 14, color: Color(0xFFC0C4CC)),
],
),
),
);
}
}