diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f4d43..61b87b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## DEVELOPING - ????-??-?? - (??????) + +### Added + +- 复习模块添加了强化学习选项 + +### Improvement + +- 优化了FSRS学习机制 + +### Fix + +- 修复了可能的复习列表重复问题 + ## v1.0.0 - 2026-3-18 - (100000) ### Added diff --git a/lib/funcs/fsrs_func.dart b/lib/funcs/fsrs_func.dart index 1fc94bc..b7e75e8 100644 --- a/lib/funcs/fsrs_func.dart +++ b/lib/funcs/fsrs_func.dart @@ -29,6 +29,28 @@ class FSRS { } else { config = FSRSConfig.buildFromMap(jsonDecode(appData.storage.getString("fsrsData")!)); logger.info("FSRS配置加载完成"); + + // 清洗潜在的重复脏数据 (Deduplication) + final Set seenIds = {}; + final List uniqueCards = []; + final List uniqueLogs = []; + + for(int i = 0; i < config.cards.length; i++) { + final currentCardId = config.cards[i].cardId; + if(!seenIds.contains(currentCardId)) { + seenIds.add(currentCardId); + uniqueCards.add(config.cards[i]); + if(i < config.reviewLogs.length) { + uniqueLogs.add(config.reviewLogs[i]); + } + } + } + + if(uniqueCards.length < config.cards.length) { + logger.warning("发现并清理了 ${config.cards.length - uniqueCards.length} 条重复复习记录"); + config = config.copyWith(cards: uniqueCards, reviewLogs: uniqueLogs); + save(); + } } if(config.enabled) return true; @@ -50,25 +72,43 @@ class FSRS { save(); } - int willDueIn(int index) { - return config.cards[index].due.toLocal().difference(DateTime.now()).inDays; + int willDueIn(Card card) { + return card.due.toLocal().difference(DateTime.now()).inDays; } - void reviewCard(int wordId, int duration, bool isCorrect, {Rating? forceRate}) { - logger.fine("记录复习卡片: Id: $wordId; duration: $duration; isCorrect: $isCorrect"); - int index = config.cards.indexWhere((Card card) => card.cardId == wordId); // 避免有时候cardId != wordId - logger.fine("定位复习卡片地址: $index, 目前阶段: ${config.cards[index].step}, 难度: ${config.cards[index].difficulty}, 稳定: ${config.cards[index].stability}, 过期时间(+8): ${config.cards[index].due.toLocal()}"); - final (:card, :reviewLog) = config.scheduler!.reviewCard(config.cards[index], forceRate ?? calculate(duration, isCorrect), reviewDateTime: DateTime.now().toUtc(), reviewDuration: duration); - config.cards[index] = card; - config.reviewLogs[index] = reviewLog; - logger.fine("卡片 $index 复习后: 目前阶段: ${config.cards[index].step}, 难度: ${config.cards[index].difficulty}, 稳定: ${config.cards[index].stability}, 过期时间(+8): ${config.cards[index].due.toLocal()}"); + void produceCard(int wordId, {int? duration, bool? isCorrect, Rating? forceRate}) { + logger.fine("记录复习卡片: Id: $wordId; duration: $duration; isCorrect: $isCorrect; forceRate: $forceRate"); + final int index = config.cards.indexWhere((Card card) => card.cardId == wordId); + if(index == -1) { + // 卡片不存在 进行添加 + logger.fine("添加复习卡片: Id: $wordId"); + if(config.cards.isEmpty) { + config = config.copyWith( + cards: [], + reviewLogs: [] + ); + } + config.cards.add(Card(cardId: wordId, state: State.learning)); + config.reviewLogs.add(ReviewLog(cardId: wordId, rating: Rating.good, reviewDateTime: DateTime.now())); + } else { + // 卡片存在 进行复习 + if((duration == null || isCorrect == null) && forceRate == null) { + logger.shout("传入信息缺失: wordId: $wordId; duration: $duration; isCorrect: $isCorrect; forceRate: $forceRate"); + return; // 避免错误信息导入 + } + logger.fine("定位复习卡片地址: $index, 目前阶段: ${config.cards[index].step}, 难度: ${config.cards[index].difficulty}, 稳定: ${config.cards[index].stability}, 过期时间(+8): ${config.cards[index].due.toLocal()}"); + final (:card, :reviewLog) = config.scheduler!.reviewCard(config.cards[index], forceRate ?? calculate(duration!, isCorrect!), reviewDateTime: DateTime.now().toUtc(), reviewDuration: duration); + config.cards[index] = card; + config.reviewLogs[index] = reviewLog; + logger.fine("卡片 $index 复习后: 目前阶段: ${config.cards[index].step}, 难度: ${config.cards[index].difficulty}, 稳定: ${config.cards[index].stability}, 过期时间(+8): ${config.cards[index].due.toLocal()}"); + } save(); } int getWillDueCount() { int dueCards = 0; - for(int i = 0; i < config.cards.length; i++) { - if(willDueIn(i) < 1) { + for(Card card in config.cards) { + if(willDueIn(card) < 1) { dueCards++; } } @@ -76,32 +116,22 @@ class FSRS { } int getLeastDueCard() { - if (config.cards.isEmpty) return -1; - int leastDueIndex = 0; - for(int i = 1; i < config.cards.length; i++) { - if(config.cards[i].due.toLocal().isBefore(config.cards[leastDueIndex].due.toLocal()) && config.cards[i].due.toLocal().difference(DateTime.now()) < Duration(days: 1)) { - leastDueIndex = i; + Card? leastDueCard; + for(Card card in config.cards) { + if(willDueIn(card) < 1) { + if(leastDueCard == null || card.due.toLocal().isBefore(leastDueCard.due.toLocal())) { + leastDueCard = card; + } } } - if(config.cards[leastDueIndex].due.difference(DateTime.now()) > Duration(days: 1)) return -1; - return config.cards[leastDueIndex].cardId; + if (leastDueCard == null) return -1; + return leastDueCard.cardId; } bool isContained(int wordId) { return config.cards.any((Card card) => card.cardId == wordId); } - void addWordCard(int wordId) { - logger.fine("添加复习卡片: Id: $wordId"); - if (config.cards.isEmpty) { - config = config.copyWith(cards: [], reviewLogs: []); - } - // os the wordID == cardID - config.cards.add(Card(cardId: wordId, state: State.learning)); - config.reviewLogs.add(ReviewLog(cardId: wordId, rating: Rating.good, reviewDateTime: DateTime.now())); - save(); - } - Rating calculate(int duration, bool isCorrect) { // duration in milliseconds if (!isCorrect) { @@ -133,6 +163,7 @@ class FSRSConfig { final bool preferSimilar; final bool selfEvaluate; final int pushAmount; + final bool reinforceMemory; const FSRSConfig({ bool? enabled, @@ -144,7 +175,8 @@ class FSRSConfig { int? goodDuration, bool? preferSimilar, bool? selfEvaluate, - int? pushAmount + int? pushAmount, + bool? reinforceMemory }) : enabled = enabled??false, cards = cards??const [], @@ -154,7 +186,8 @@ class FSRSConfig { goodDuration = goodDuration??6000, preferSimilar = preferSimilar??false, selfEvaluate = selfEvaluate??false, - pushAmount = pushAmount??0; + pushAmount = pushAmount??0, + reinforceMemory = reinforceMemory??false; Map toMap(){ return { @@ -167,7 +200,8 @@ class FSRSConfig { "goodDuration": goodDuration, "preferSimilar": preferSimilar, "selfEvaluate": selfEvaluate, - "pushAmount": pushAmount + "pushAmount": pushAmount, + "reinforceMemory": reinforceMemory }; } @@ -181,7 +215,8 @@ class FSRSConfig { int? goodDuration, bool? preferSimilar, bool? selfEvaluate, - int? pushAmount + int? pushAmount, + bool? reinforceMemory }) { return FSRSConfig( enabled: enabled??this.enabled, @@ -193,7 +228,8 @@ class FSRSConfig { goodDuration: goodDuration??this.goodDuration, preferSimilar: preferSimilar??this.preferSimilar, selfEvaluate: selfEvaluate??this.selfEvaluate, - pushAmount: pushAmount??this.pushAmount + pushAmount: pushAmount??this.pushAmount, + reinforceMemory: reinforceMemory??this.reinforceMemory ); } @@ -209,7 +245,8 @@ class FSRSConfig { goodDuration: configData["goodDuration"], preferSimilar: configData["preferSimilar"], selfEvaluate: configData["selfEvaluate"], - pushAmount: configData["pushAmount"] + pushAmount: configData["pushAmount"], + reinforceMemory: configData["reinforceMemory"] ); } return FSRSConfig(enabled: false); diff --git a/lib/sub_pages_builder/learning_pages/fsrs_pages.dart b/lib/sub_pages_builder/learning_pages/fsrs_pages.dart index cf0dc92..ae2c50f 100644 --- a/lib/sub_pages_builder/learning_pages/fsrs_pages.dart +++ b/lib/sub_pages_builder/learning_pages/fsrs_pages.dart @@ -229,6 +229,36 @@ class ForeFSRSSettingPage extends StatelessWidget { ], ), ), + Container( + decoration: BoxDecoration( + borderRadius: StaticsVar.br, + color: Theme.of(context).colorScheme.onPrimary + ), + margin: EdgeInsets.all(8.0), + padding: EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text("强化记忆循环", style: Theme.of(context).textTheme.bodyLarge)), + Switch( + value: fsrs.config.reinforceMemory, + onChanged: (value){ + setState(() { + fsrs.config = fsrs.config.copyWith( + reinforceMemory: value + ); + }); + } + ) + ], + ), + Text("开启后同一个到期卡片在队列中将按交错顺序出现多次(3次),加深印象。"), + Text("关闭后每个词汇当次复习只在队列里出现一次。") + ], + ), + ), ElevatedButton.icon( style: ElevatedButton.styleFrom( fixedSize: Size.fromHeight(100), @@ -281,25 +311,77 @@ class ForeFSRSSettingPage extends StatelessWidget { } } -class MainFSRSPage extends StatelessWidget { +class MainFSRSPage extends StatefulWidget { final FSRS fsrs; const MainFSRSPage({super.key, required this.fsrs}); - + + @override + State createState() => _MainFSRSPageState(); +} + +class _MainFSRSPageState extends State { + List queue = []; + final PageController controller = PageController(); + final Random sharedRnd = Random(); + + @override + void initState() { + super.initState(); + _extendQueue(); + } + + void _extendQueue() { + final List uniqueIds = widget.fsrs.config.cards + .where((card) => widget.fsrs.willDueIn(card) < 1) + .map((card) => card.cardId) + .toList(); + + if (uniqueIds.isEmpty) return; + + if (widget.fsrs.config.reinforceMemory) { + queue.addAll([ + for (int i = 0; i < 3; i++) ...uniqueIds + ]); + } else { + queue.addAll(uniqueIds); + } + } + + void _refresh() { + setState(() { + queue.clear(); + _extendQueue(); + }); + if (controller.hasClients) { + controller.jumpToPage(0); + } + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - context.read().uiLogger.info("构建 MainFSRSPage"); + context.read().uiLogger.info("构建 MainFSRSPage, 动态队列长度: ${queue.length}"); MediaQueryData mediaQuery = MediaQuery.of(context); - final PageController controller = PageController(); - Random sharedRnd = Random(); + return Scaffold( appBar: AppBar( title: const Text("规律学习"), actions: [ + IconButton( + icon: Icon(Icons.refresh), + tooltip: "强制刷新", + onPressed: _refresh, + ), IconButton( icon: Icon(Icons.keyboard_option_key), onPressed: () { showModalBottomSheet( - context: context, + context: context, isScrollControlled: true, enableDrag: false, builder: (context) => ForeFSRSSettingPage(forceChoosing: true) @@ -311,24 +393,69 @@ class MainFSRSPage extends StatelessWidget { body: PageView.builder( scrollDirection: Axis.vertical, controller: controller, + physics: const PageScrollPhysics(), itemBuilder: (context, index) { - final wordID = fsrs.getLeastDueCard(); - if(wordID == -1) { - return Center( - child: TextContainer(text: "当前无需要复习的内容\n点击右上角可修改配置"), - ); - } - if(index == 0) { + // Page 0: 引导页 + if (index == 0) { + if (queue.isEmpty) { + return Center( + child: TextContainer(text: "当前无任何待复习的内容\n可以休息会儿或者去[学习推送]里加些新词"), + ); + } return Center( child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - TextContainer(text: "你有${fsrs.getWillDueCount().toString()}个单词需要复习!\n上滑页面开始复习",size: Size(mediaQuery.size.width * 0.8, mediaQuery.size.height * 0.4),textAlign: TextAlign.center), + TextContainer( + text: "已载入待复习队列\n上滑页面开始复习", + size: Size(mediaQuery.size.width * 0.8, mediaQuery.size.height * 0.4), + textAlign: TextAlign.center, + ), Icon(Icons.arrow_upward, size: 48.0, color: Colors.grey) ], ), ); } - return FSRSReviewCardPage(wordID: wordID, fsrs: fsrs, rnd: sharedRnd, controller: controller,); + + int arrayIndex = index - 1; + + // 动态扩充:当滑到底时触发延展 + if (arrayIndex >= queue.length) { + _extendQueue(); + } + + // 扩充了还是不够,说明真的穷尽了 + if (arrayIndex >= queue.length) { + if (arrayIndex == queue.length) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextContainer( + text: "太棒了!当前没有任何卡片需要复习!\n若刚复习完请等待下一个间隔", + size: Size(mediaQuery.size.width * 0.8, mediaQuery.size.height * 0.4), + textAlign: TextAlign.center, + ), + ElevatedButton.icon( + onPressed: _refresh, + icon: Icon(Icons.refresh), + label: Text("全盘刷新"), + ) + ], + ), + ); + } + // 返回 null 阻断 PageView 继续下滑 + return null; + } + + // 常规复习页 + return FSRSReviewCardPage( + wordID: queue[arrayIndex], + fsrs: widget.fsrs, + rnd: sharedRnd, + controller: controller, + ); } ) ); @@ -389,14 +516,14 @@ class _FSRSReviewCardPage extends State { }); context.read().updateLearningStreak(); if(widget.fsrs.config.selfEvaluate) { - widget.fsrs.reviewCard(widget.wordID, end.difference(start).inMilliseconds, true, forceRate: (const [Rating.easy, Rating.good, Rating.hard, Rating.again]).elementAt(value)); + widget.fsrs.produceCard(widget.wordID, forceRate: (const [Rating.easy, Rating.good, Rating.hard, Rating.again]).elementAt(value)); return true; } else { if(correct == value) { - widget.fsrs.reviewCard(widget.wordID, end.difference(start).inMilliseconds, true); + widget.fsrs.produceCard(widget.wordID, duration: end.difference(start).inMilliseconds, isCorrect: true); return true; } else { - widget.fsrs.reviewCard(widget.wordID, end.difference(start).inMilliseconds, false); + widget.fsrs.produceCard(widget.wordID, duration: end.difference(start).inMilliseconds, isCorrect: false); return false; } } @@ -548,7 +675,7 @@ class _FSRSLearningPageState extends State { setState(() { corrected = true; }); - widget.fsrs.addWordCard(widget.words[index].id); + widget.fsrs.produceCard(widget.words[index].id); return true; } else { return false; diff --git a/lib/sub_pages_builder/learning_pages/learning_pages_build.dart b/lib/sub_pages_builder/learning_pages/learning_pages_build.dart index 2d82a83..41c61e4 100644 --- a/lib/sub_pages_builder/learning_pages/learning_pages_build.dart +++ b/lib/sub_pages_builder/learning_pages/learning_pages_build.dart @@ -45,16 +45,11 @@ class _InLearningPageState extends State { if(isCorrect) correctCount++; FSRS fsrs = FSRS(); if(widget.countInReview && fsrs.config.enabled) { - if(fsrs.isContained(targetWord.id)){ - if(isTypingQuestion) { - fsrs.reviewCard(targetWord.id, takentime, isCorrect, forceRate: isCorrect ? Rating.good : Rating.again); - } else { - fsrs.reviewCard(targetWord.id, takentime, isCorrect); - } + if(isTypingQuestion) { + fsrs.produceCard(targetWord.id, forceRate: isCorrect ? Rating.good : Rating.again); } else { - if(isCorrect) fsrs.addWordCard(targetWord.id); + fsrs.produceCard(targetWord.id, duration: takentime, isCorrect: isCorrect); } - } }