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

261 lines
9.2 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/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)),
],
),
),
);
}
}