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 createState() => _IsarWatchDemoPageState(); } class _IsarWatchDemoPageState extends State { final TextEditingController _titleC = TextEditingController(); /// 对比区:手动查询结果(非 watch,需要自己点按钮刷新) int _manualCount = -1; // -1 = 还没查过 @override void dispose() { _titleC.dispose(); super.dispose(); } /// 添加错题(写入 Isar → watch 自动推送 → 列表自动更新) Future _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 _manualQuery() async { final all = await WrongQuestionStore.instance.findAll(); if (!mounted) return; setState(() => _manualCount = all.length); } /// 删除错题(watch 也会收到删除事件,列表自动减少) Future _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>( // 响应式核心:数据库变化自动推送;fireImmediately 首帧先出一份 stream: WrongQuestionStore.instance.watchAll(), builder: (context, snapshot) { if (snapshot.hasError) { return const Text('监听出错'); } final items = snapshot.data ?? const []; 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)), ], ), ), ); } }