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