-
Notifications
You must be signed in to change notification settings - Fork 0
fix: 修复 FAB 滚动隐藏功能在页面切换后失效的问题 #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. Walkthrough将全局 Provider 管理的 ScrollController 重构为页面自管理:书签列表页各自创建并共享 ScrollController 给 MainLayout 与 BookmarkListScreen,MainLayout 直接传给 BookmarkListFab;FAB 的监听在 build/didUpdateWidget 中绑定/重绑定;路由新增 selfManagedRoutes,部分路由绕过统一 MainLayout 包裹。 Changes
Sequence Diagram(s)sequenceDiagram
participant Router
participant Screen as BookmarkScreen(Stateful)
participant MainLayout
participant BookmarkListScreen
participant BookmarkListFab
Router->>Screen: 构建 self-managed 书签页面
activate Screen
Screen->>Screen: initState 创建 ScrollController
Screen->>MainLayout: build(title, scrollController, child)
Screen->>BookmarkListScreen: build(viewModel, scrollController)
activate MainLayout
MainLayout->>BookmarkListFab: 构建并传入 scrollController
Note over BookmarkListFab: 在 initState/didUpdateWidget 中绑定/重绑定监听\n阈值调整为 5;顶部强制显示
deactivate MainLayout
BookmarkListScreen-->>BookmarkListFab: 用户滚动事件(同一 controller)
BookmarkListFab->>BookmarkListFab: _onScroll: 顶部显示 / 下滑隐藏 / 上滑显示
Router->>Screen: 页面切换
Screen->>BookmarkListFab: didUpdateWidget → 重新绑定 & 重置状态
Assessment against linked issues
Out-of-scope changes
Possibly related issues
Poem
✨ Finishing Touches🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Summary of Changes
Hello @shadowfish07, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!
此拉取请求旨在修复 FloatingActionButton (FAB) 在页面切换后滚动隐藏功能失效的问题。通过将 FAB 的滚动控制器管理从全局 Provider 模式重构为页面级独立管理,解决了因控制器共享导致的失效问题,提升了用户体验的一致性和系统的稳定性。
Highlights
- 架构重构: 将 FloatingActionButton (FAB) 的 ScrollController 管理从全局 ScrollControllerProvider 模式改为每个页面独立管理,从而解决了页面切换后 FAB 滚动隐藏功能失效的问题。
- 移除全局依赖: 删除了旧的全局 ScrollControllerProvider 类,MainLayout 组件现在直接接收 ScrollController 参数,简化了依赖管理。
- 优化监听器绑定: BookmarkListFab 组件的滚动监听器现在直接在 build 方法中绑定,确保了监听器的及时和正确工作,避免了异步时机问题。
- 统一页面行为: 所有书签列表页面(如每日阅读、未读、阅读中、已归档、收藏)都已更新,以独立管理其 FAB 及其滚动行为,确保了功能的一致性和可维护性。
- 路由配置更新: 路由配置已调整,使书签列表页面不再被 MainLayout 包装,而是自行管理其 UI,避免了双重包装和潜在的冲突。
Using Gemini Code Assist
The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.
Invoking Gemini
You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.
| Feature | Command | Description |
|---|---|---|
| Code Review | /gemini review |
Performs a code review for the current pull request in its current state. |
| Pull Request Summary | /gemini summary |
Provides a summary of the current pull request in its current state. |
| Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments. |
| Help | /gemini help |
Displays a list of available commands. |
Customization
To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.
Limitations & Feedback
Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.
You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.
Footnotes
-
Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
本次 PR 旨在修复页面切换后 FAB 滚动隐藏功能失效的问题,通过将 ScrollController 的管理从全局 Provider 模式重构为页面级管理,解决方案思路清晰且正确。代码重构范围较大,但提升了页面的封装性和可维护性。
我发现了一些可以改进的地方:
- 在
BookmarkListFab中,ScrollController监听器的管理方式可以优化。当前在build方法中操作监听器是 Flutter 的反模式,可能引起性能问题。建议使用initState,didUpdateWidget和dispose来管理监听器生命周期。 - 在
BookmarkListScreen中,对外部传入的scrollController的处理存在缺陷。当widget.scrollController发生变化时,组件内部状态不会随之更新,可能导致 bug。这也需要通过didUpdateWidget来正确处理。
除了以上两点,其余的修改都很好地完成了重构目标。修复这些问题将使代码更加健壮和高效。
| // 使用外部提供的 ScrollController 或创建自己的 | ||
| _scrollController ??= widget.scrollController ?? ScrollController(); | ||
| _scrollController?.addListener(_onScroll); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
当前在 didChangeDependencies 中初始化 _scrollController 的方式存在一个潜在的 bug。如果父组件重建并传入一个新的 scrollController 实例,_scrollController 不会更新,因为它只在 null 的时候被赋值一次。这会导致 BookmarkListScreen 仍然使用旧的 controller,与父组件(如 MainLayout 和 BookmarkListFab)使用的 controller 不一致。
为了正确处理 scrollController 的更新,建议采用标准的 StatefulWidget 生命周期来管理 controller:
- 在
initState中初始化_scrollController。 - 实现
didUpdateWidget,在widget.scrollController改变时,更新_scrollController并正确处理监听器的移除和添加。 - 相应地更新
dispose逻辑。
例如:
class _BookmarkListScreenState<T extends BaseBookmarksViewmodel>
extends State<BookmarkListScreen<T>> {
ScrollController? _scrollController;
bool _isInternalController = false;
@override
void initState() {
super.initState();
_setupController();
// ... other initState logic
}
@override
void didUpdateWidget(BookmarkListScreen<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.scrollController != oldWidget.scrollController) {
_cleanupController();
_setupController();
}
}
void _setupController() {
if (widget.scrollController == null) {
_scrollController = ScrollController();
_isInternalController = true;
} else {
_scrollController = widget.scrollController;
_isInternalController = false;
}
_scrollController!.addListener(_onScroll);
}
void _cleanupController() {
_scrollController?.removeListener(_onScroll);
if (_isInternalController) {
_scrollController?.dispose();
}
}
@override
void dispose() {
_cleanupController();
// ... other dispose logic
super.dispose();
}
// ...
}这样可以确保 BookmarkListScreen 始终使用正确的 ScrollController 实例。
| // 简单直接地绑定监听器 | ||
| if (widget.scrollController != null) { | ||
| widget.scrollController!.removeListener(_onScroll); | ||
| widget.scrollController!.addListener(_onScroll); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在 build 方法中添加和移除监听器是一种反模式(anti-pattern),可能会导致性能问题,因为 build 方法会被频繁调用。
推荐将监听器的管理逻辑移到 State 的生命周期方法中:
- 在
initState中,为初始的scrollController添加监听器。 - 在
didUpdateWidget中,当widget.scrollController发生变化时,先从旧的controller移除监听器,再为新的controller添加监听器。 - 在
dispose中,移除监听器以防止内存泄漏。
例如:
@override
void initState() {
super.initState();
// ... other init logic
widget.scrollController?.addListener(_onScroll);
}
@override
void didUpdateWidget(BookmarkListFab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.scrollController != widget.scrollController) {
oldWidget.scrollController?.removeListener(_onScroll);
widget.scrollController?.addListener(_onScroll);
// ... reset state logic from your existing didUpdateWidget
}
}
@override
void dispose() {
widget.scrollController?.removeListener(_onScroll);
_animationController.dispose();
super.dispose();
}这个改动可以提高性能和代码的可维护性。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
lib/ui/daily_read/widgets/daily_read_screen.dart (1)
65-105: 请在 initState 中订阅 Command 错误并在 dispose 中取消监听当前在
lib/ui/daily_read/widgets/daily_read_screen.dart的didChangeDependencies(约第 66 行)里,对widget.viewModel.load、widget.viewModel.toggleBookmarkArchived、widget.viewModel.toggleBookmarkMarked的.errors.where(...).listen(...)进行了多次注册,但在dispose(第 123–133 行)中并未调用对应的cancel(),会导致内存泄漏并在页面销毁后仍触发 SnackBar。
问题位置
- didChangeDependencies 注册监听:第 66–75 行
- dispose 中仅取消了
_deleteSuccessSubscription,未对上述错误监听取消:第 123–133 行建议改动
- 将
.errors.where(...).listen(...)迁移到initState(),并将返回的ListenableSubscription保存为字段。- 在
dispose()中对所有这些订阅调用cancel()。- 在回调内首行加上
if (!mounted) return;防止页面已卸载时操作 context。class _DailyReadScreenState extends State<DailyReadScreen> { // 新增订阅字段 + late final ListenableSubscription _loadErrorSub; + late final ListenableSubscription _toggleArchiveErrorSub; + late final ListenableSubscription _toggleMarkedErrorSub; @override void initState() { super.initState(); + _loadErrorSub = widget.viewModel.load.errors + .where((x) => x != null) + .listen((error, _) { + appLogger.e('加载书签失败', error: error); + if (!mounted) return; + SnackBarHelper.showError(context, '加载书签失败'); + }); + _toggleArchiveErrorSub = widget.viewModel.toggleBookmarkArchived.errors + .where((x) => x != null) + .listen((error, _) { + appLogger.e('切换书签归档状态失败', error: error); + if (!mounted) return; + SnackBarHelper.showError(context, '切换书签归档状态失败'); + }); + _toggleMarkedErrorSub = widget.viewModel.toggleBookmarkMarked.errors + .where((x) => x != null) + .listen((error, _) { + appLogger.e('切换书签标记状态失败', error: error); + if (!mounted) return; + SnackBarHelper.showError(context, '切换书签标记状态失败'); + }); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - // 创建ScrollController - _scrollController ??= ScrollController(); - widget.viewModel.load.errors.where((x) => x != null).listen((error, _) { - appLogger.e( - '加载书签失败', - error: error, - ); - SnackBarHelper.showError( - context, - '加载书签失败', - ); - }); - widget.viewModel.toggleBookmarkArchived.errors - .where((x) => x != null) - .listen((error, _) { - appLogger.e( - '切换书签归档状态失败', - error: error, - ); - SnackBarHelper.showError( - context, - '切换书签归档状态失败', - ); - }); - widget.viewModel.toggleBookmarkMarked.errors - .where((x) => x != null) - .listen((error, _) { - appLogger.e( - '切换书签标记状态失败', - error: error, - ); - SnackBarHelper.showError( - context, - '切换书签标记状态失败', - ); - }); - } @override void dispose() { + // 取消命令错误监听订阅 + _loadErrorSub.cancel(); + _toggleArchiveErrorSub.cancel(); + _toggleMarkedErrorSub.cancel(); // 释放动画控制器 _confettiController.dispose(); // 释放滚动控制器 _scrollController?.dispose(); // 取消删除成功监听 _deleteSuccessSubscription.cancel(); // 清除回调 widget.viewModel.setOnBookmarkArchivedCallback(null); widget.viewModel.setNavigateToDetailCallback((_) {}); super.dispose(); }lib/ui/core/main_layout.dart (1)
80-100: AboutViewModel 的 addListener 未在 dispose 中移除,存在泄漏与重复提示风险main 布局在整个 App 中出现频繁,未移除监听会造成多次弹出“发现新版本”SnackBar。建议保存回调并在 dispose 中移除。
class _MainLayoutState extends State<MainLayout> { StreamSubscription<String>? _shareTextSubscription; bool _isProcessingShare = false; bool _hasUpdate = false; + VoidCallback? _aboutUpdateListener; + AboutViewModel? _aboutViewModel; ... void _setupUpdateListener() { - final aboutViewModel = context.read<AboutViewModel>(); - aboutViewModel.addListener(() { + _aboutViewModel = context.read<AboutViewModel>(); + _aboutUpdateListener = () { if (mounted && aboutViewModel.updateInfo != null && !_hasUpdate) { setState(() { _hasUpdate = true; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('发现新版本: ${aboutViewModel.updateInfo!.version}'), action: SnackBarAction( label: '前往更新', onPressed: () { context.go(Routes.about); }, ), ), ); } - }); + }; + _aboutViewModel!.addListener(_aboutUpdateListener!); } @override void dispose() { _shareTextSubscription?.cancel(); + if (_aboutUpdateListener != null) { + _aboutViewModel?.removeListener(_aboutUpdateListener!); + } super.dispose(); }Also applies to: 115-118
🧹 Nitpick comments (9)
lib/ui/bookmarks/widget/bookmark_list_screen.dart (2)
115-121: 滚动触底触发翻页缺少“正在加载/无更多”保护,易重复触发 loadNextPage当滚动位置停留在阈值区间内时,会持续触发 loadNextPage。建议在触发前判断
hasMoreData与loadMore.isExecuting,降低无谓调用。应用如下修改:
// 处理分页加载 if (currentScrollPosition >= _scrollController!.position.maxScrollExtent - 200) { + // 避免在已无更多或正在加载时重复触发 + if (!widget.viewModel.hasMoreData || + widget.viewModel.loadMore.isExecuting.value) { + return; + } widget.viewModel.loadNextPage(); }
151-201: 空态样式已使用主题色,建议将尺寸常量抽取为设计系统/主题扩展(可选)当前使用了若干硬编码尺寸(如 64、16、8)。从可维护性与一致性考虑,可将常用间距/尺寸抽取为统一的 spacing 常量或 ThemeExtension。
如项目已有 spacing 约定,可替换为对应常量;否则可后续统一抽取,不必阻塞本 PR。
lib/ui/core/ui/bookmark_list_fab.dart (2)
11-12: 文档注释未同步:仍称“通过Provider获取ScrollController”实现已改为通过构造参数传入 controller,注释需更新以避免误导。
应用如下修改:
-/// - 通过Provider获取ScrollController +/// - 通过构造参数传入 ScrollController(与列表共享同一控制器)
78-83: 滚动阈值从 3.0 提升到 5.0 的改动请与列表页保持一致(可选)为保证多页体验一致,建议将阈值抽为常量(例如在 theme/const 中统一定义),FAB 与列表页若有类似阈值逻辑请复用同一处定义。
如暂无统一定义,可先在本文件顶部定义
const kScrollThreshold = 5.0;并在后续 PR 统一。lib/routing/router.dart (1)
89-102: 使用 selfManagedRoutes 绕过 MainLayout 包装的策略正确;建议改为常量 Set 并抽到外层以避免每次构建重复分配当前在 builder 内部每次构建都会创建一个 List;改用顶层/静态常量 Set,可读性与性能更优,且包含判断语义更贴近。
在文件顶部(或 builder 外)新增:
const Set<String> kSelfManagedRoutes = { Routes.dailyRead, Routes.unarchived, Routes.reading, Routes.archived, Routes.marked, };并替换本段为:
- // 书签列表页面不被 MainLayout 包装,它们自己管理 UI - final selfManagedRoutes = [ - Routes.dailyRead, - Routes.unarchived, - Routes.reading, - Routes.archived, - Routes.marked, - ]; - - if (selfManagedRoutes - .contains(state.fullPath ?? state.matchedLocation)) { + // 书签列表页面不被 MainLayout 包装,它们自己管理 UI + if (kSelfManagedRoutes + .contains(state.fullPath ?? state.matchedLocation)) { return child; }test/ui/daily_read/widgets/daily_read_screen_test.dart (1)
58-122: 覆盖了空数据/有数据/加载中态,建议新增“滚动隐藏 FAB”回归测试(可选)本 PR 的核心是修复 FAB 在页面切换后的滚动隐藏失效。建议新增一个 widget 测试:构建含 MainLayout+DailyReadScreen 的树,模拟滚动手势(向下再向上),断言 FAB 的可见性(例如通过查找 FloatingActionButton 或动画值/opacity)。
需要的话我可以起草一个测试用例,包含:
- pump 初始布局,断言 FAB 可见
- 执行拖拽
tester.drag(find.byType(ListView), const Offset(0, -300)),pump 动画,断言 FAB 隐藏- 反向拖拽,断言 FAB 显示
并再构造一次页面切换场景(push 到另一书签页后 pop/切换分支),验证回到原页后逻辑仍然生效。lib/ui/daily_read/widgets/daily_read_screen.dart (1)
28-36: 统一 ScrollController 生命周期至 initState,避免在 didChangeDependencies 中初始化造成不一致其他书签列表页面(未读/阅读中/已归档等)均在 initState 中创建并在 dispose 中释放 ScrollController;本文件却在 didChangeDependencies 中惰性创建(且将字段声明为可空),模式不一致且增加理解成本。建议与其它页面对齐:在 initState 中创建,字段改为 late final,并移除 didChangeDependencies 中的初始化代码。这样也能更早可用,避免极端场景的空值判断。
可按如下 diff 调整:
-class _DailyReadScreenState extends State<DailyReadScreen> { - late ConfettiController _confettiController; - ScrollController? _scrollController; +class _DailyReadScreenState extends State<DailyReadScreen> { + late ConfettiController _confettiController; + late final ScrollController _scrollController; late final ListenableSubscription _deleteSuccessSubscription; ... @override void initState() { super.initState(); // 初始化礼花控制器 _confettiController = ConfettiController( duration: const Duration(seconds: 3), ); + // 创建 ScrollController + _scrollController = ScrollController(); ... } @override void didChangeDependencies() { super.didChangeDependencies(); - - // 创建ScrollController - _scrollController ??= ScrollController(); ... } @override void dispose() { // 释放动画控制器 _confettiController.dispose(); // 释放滚动控制器 - _scrollController?.dispose(); + _scrollController.dispose(); ... }Also applies to: 69-70, 122-128
lib/ui/core/main_layout.dart (2)
18-32: 对外暴露 scrollController + 保留 showFab:建议添加构造断言防止误用当前当
showFab == true且未提供scrollController时会回退到floatingActionButton,这可能掩盖接线遗漏,导致 FAB 缺失。建议在构造函数添加断言,强制要求 showFab 时必须提供 scrollController。- const MainLayout({ - super.key, - required this.child, - this.appBar, - this.title, - this.actions, - this.leading, - this.automaticallyImplyLeading = true, - this.floatingActionButton, - this.scrollController, - this.showFab = false, - }); + const MainLayout({ + super.key, + required this.child, + this.appBar, + this.title, + this.actions, + this.leading, + this.automaticallyImplyLeading = true, + this.floatingActionButton, + this.scrollController, + this.showFab = false, + }) : assert(!showFab || scrollController != null, + 'When showFab is true, a non-null scrollController must be provided.');为确保所有调用方满足该约束,可运行我在下方评论提供的脚本进行静态检查。
122-179: 可选:Drawer 中的导航建议去抖以避免极快多次点击造成重复路由入栈目前通过关闭 Drawer 再延后一帧跳转已降低风险,但仍可考虑简单去抖。若有需要我可以补一个通用的
navigateDebounced工具。
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (10)
lib/routing/router.dart(1 hunks)lib/ui/bookmarks/widget/archived_screen.dart(1 hunks)lib/ui/bookmarks/widget/bookmark_list_screen.dart(2 hunks)lib/ui/bookmarks/widget/marked_screen.dart(1 hunks)lib/ui/bookmarks/widget/reading_screen.dart(1 hunks)lib/ui/bookmarks/widget/unarchived_screen.dart(1 hunks)lib/ui/core/main_layout.dart(4 hunks)lib/ui/core/ui/bookmark_list_fab.dart(3 hunks)lib/ui/daily_read/widgets/daily_read_screen.dart(2 hunks)test/ui/daily_read/widgets/daily_read_screen_test.dart(1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
lib/ui/**/!(theme).dart
📄 CodeRabbit inference engine (.trae/rules/project_rules.md)
UI 样式必须使用主题,不允许硬编码颜色、字号等
Files:
lib/ui/core/ui/bookmark_list_fab.dartlib/ui/bookmarks/widget/reading_screen.dartlib/ui/bookmarks/widget/archived_screen.dartlib/ui/daily_read/widgets/daily_read_screen.dartlib/ui/bookmarks/widget/marked_screen.dartlib/ui/core/main_layout.dartlib/ui/bookmarks/widget/bookmark_list_screen.dartlib/ui/bookmarks/widget/unarchived_screen.dart
lib/ui/**/*.dart
📄 CodeRabbit inference engine (.trae/rules/project_rules.md)
lib/ui/**/*.dart: View 只处理展示与交互,构造函数仅接受 key 与 viewModel,不包含业务逻辑;用户交互通过 Command 执行
Command 监听器:在 initState 中订阅 results/errors,dispose 中取消订阅,并使用 mounted 检查
错误状态统一使用 ErrorPage 组件,优先使用工厂方法(如 ErrorPage.fromException),并遵循主题样式与可用性要求
Files:
lib/ui/core/ui/bookmark_list_fab.dartlib/ui/bookmarks/widget/reading_screen.dartlib/ui/bookmarks/widget/archived_screen.dartlib/ui/daily_read/widgets/daily_read_screen.dartlib/ui/bookmarks/widget/marked_screen.dartlib/ui/core/main_layout.dartlib/ui/bookmarks/widget/bookmark_list_screen.dartlib/ui/bookmarks/widget/unarchived_screen.dart
lib/routing/{router.dart,routes.dart}
📄 CodeRabbit inference engine (.trae/rules/project_rules.md)
使用 go_router 管理路由配置与路由定义
Files:
lib/routing/router.dart
🧠 Learnings (6)
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to test/**/*.{dart} : 应编写测试覆盖:Repository 层与 ViewModel 层 100% 覆盖,关键 Widget/UI、Command 流程与 Result 逻辑具备测试
Applied to files:
test/ui/daily_read/widgets/daily_read_screen_test.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/config/dependencies.dart : 使用 provider 进行依赖注入管理(构造函数注入 Repository 等)
Applied to files:
lib/ui/daily_read/widgets/daily_read_screen.dartlib/ui/core/main_layout.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/*.dart : View 只处理展示与交互,构造函数仅接受 key 与 viewModel,不包含业务逻辑;用户交互通过 Command 执行
Applied to files:
lib/ui/daily_read/widgets/daily_read_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/view_models/**/*.dart : ViewModel 必须继承 ChangeNotifier,使用 flutter_command 的 Command 暴露异步操作,并在构造函数初始化 Commands
Applied to files:
lib/ui/daily_read/widgets/daily_read_screen.dartlib/ui/bookmarks/widget/marked_screen.dartlib/ui/core/main_layout.dartlib/ui/bookmarks/widget/unarchived_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/core/ui/error_page.dart : 提供统一错误页面组件 ErrorPage 并位于 lib/ui/core/ui/error_page.dart
Applied to files:
lib/ui/bookmarks/widget/bookmark_list_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/*.dart : 错误状态统一使用 ErrorPage 组件,优先使用工厂方法(如 ErrorPage.fromException),并遵循主题样式与可用性要求
Applied to files:
lib/ui/bookmarks/widget/bookmark_list_screen.dart
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Test
🔇 Additional comments (16)
lib/ui/bookmarks/widget/bookmark_list_screen.dart (1)
47-48: 新增 scrollController 参数的 API 设计合理,但需注意生命周期与一致性引入可选的外部 ScrollController 能与 MainLayout/FAB 共享滚动源,方向正确。后续请确保当父组件替换 controller 时,本组件能正确切换与解绑监听(见后续针对 didChangeDependencies 的评论)。
是否存在父组件在运行期切换 scrollController 的场景?若有,请参见下方建议在 didUpdateWidget 中处理切换逻辑。
Also applies to: 52-53
lib/routing/router.dart (1)
61-87: 对设置页的特殊回退逻辑保持了与主导航一致性,LGTMPopScope + 强制跳回首页的处理明确,避免在设置页留下空栈。
test/ui/daily_read/widgets/daily_read_screen_test.dart (1)
51-54: 测试装配简化为单一 Provider,方向正确使用 ChangeNotifierProvider.value 注入 mock ViewModel 与通过构造注入 viewModel 的新接口匹配良好。
lib/ui/bookmarks/widget/marked_screen.dart (2)
16-22: 局部自管 ScrollController 的生命周期处理妥当initState 创建、dispose 释放,避免了历史 Provider 方案中的竞态。与 BookmarkListScreen 共享 controller 以统一 FAB 行为,方向正确。
32-46: MainLayout 参数校验通过,无需移除showFabMainLayout 构造函数依然定义了
final bool showFab(可选且有默认值)final ScrollController? scrollController你在各书签页面均已显式传入:
showFab: true- 相同的
_scrollController并在
BookmarkListScreen中同步使用,行为和编译均正常。lib/ui/daily_read/widgets/daily_read_screen.dart (1)
253-284: MainLayout 包裹与 CommandBuilder 状态切换逻辑清晰,符合 PR 目标
- MainLayout 接入本地 ScrollController,showFab 打开,符合 FAB 滚动联动方案。
- whileExecuting/onError/onData 分流合理,ErrorPage 使用规范。
LGTM。
lib/ui/bookmarks/widget/archived_screen.dart (3)
6-13: 从 StatelessWidget 切换为 StatefulWidget 的封装合理该改动为页面内管理 ScrollController 创造条件,符合“页面级管理”的架构方向。
15-22: ScrollController 生命周期管理规范initState 创建、dispose 释放,简洁到位,与其它页面保持一致。
32-46: MainLayout 与 BookmarkListScreen 共享同一个 ScrollController,FAB 行为可预期
- title/showFab/scrollController 的传递完整;
- BookmarkListScreen 复用同一 controller,避免 FAB 与列表不同步的边缘态。
lib/ui/bookmarks/widget/reading_screen.dart (3)
6-13: 形态切换为 StatefulWidget 用于注入 ScrollController:合理且必要与 PR 其它书签列表页面一致,便于维护。
15-22: ScrollController 按规范创建/释放实现无多余逻辑,清晰。
32-46: 主布局和列表共享 ScrollController 的接线正确FAB 滚动联动在该页面应可稳定复现,文案与主题使用也符合规范。
lib/ui/bookmarks/widget/unarchived_screen.dart (3)
6-13: 切换为 StatefulWidget 以承载页面级 ScrollController:一致性良好与 archived/reading 等页面同一模式,便于后续统一维护。
15-22: ScrollController 生命周期处理得当无资源泄漏风险。
32-46: MainLayout/BookmarkListScreen 共享控制器,FAB 与列表联动可靠命名与文案规范,未出现硬编码色值。
lib/ui/core/main_layout.dart (1)
122-179: Scaffold 与 Drawer/导航逻辑清晰,FAB 装配方式符合新架构
- AppBar/Drawer 的分支判断可读性较好;
- FAB 条件渲染依赖 showFab 与 scrollController,一致性良好。
LGTM。
修复了 FloatingActionButton 在从未读页切换到每日阅读页后, 下滑隐藏功能失效的问题。 主要变更: - 重构 FAB 管理架构,从全局 Provider 模式改为页面级管理 - 修复 ScrollController 绑定时机问题,确保滚动监听器正常工作 - 为所有书签列表页面(未读、阅读中、已归档、收藏)添加 FAB 支持 - 优化 ScrollController 生命周期管理 影响页面: - 每日阅读页面 - 未读书签页面 - 阅读中书签页面 - 已归档书签页面 - 收藏书签页面 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
ea05b6b to
e15e4fb
Compare
修复书签列表页面和FAB组件中滚动控制器的监听管理问题 确保在组件更新和销毁时正确解绑监听器 添加所有权标志来正确管理控制器生命周期
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (3)
lib/ui/core/ui/bookmark_list_fab.dart (3)
53-58: didUpdateWidget 未解绑旧 controller,存在监听泄漏与错误回调风险当父组件替换 ScrollController 时,需要先从旧实例移除监听,再给新实例添加。否则可能触发已卸载 FAB 的 setState。建议就地修复:
@override void didUpdateWidget(BookmarkListFab oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.scrollController != widget.scrollController) { - // ScrollController 变化时重置状态 - _lastScrollPosition = 0; - setState(() { - _isVisible = true; - }); - _animationController.forward(); + // 先解绑旧,再绑定新 + oldWidget.scrollController?.removeListener(_onScroll); + widget.scrollController?.addListener(_onScroll); + // 重置滚动状态与动画 + setState(() { + _lastScrollPosition = 0; + _isVisible = true; + }); + _animationController.forward(); } }
62-66: dispose 未移除滚动监听,可能导致 “setState() called after dispose()”FAB 销毁后若外部 ScrollController 仍存活,将继续回调已销毁状态。需显式移除监听。
@override void dispose() { + // 解绑监听,防止内存泄漏与已销毁状态的 setState + widget.scrollController?.removeListener(_onScroll); _animationController.dispose(); super.dispose(); }
123-127: 在 build 中反复 remove/add 监听是反模式,应改用生命周期管理build 可能高频触发,反复解绑/绑定影响性能且时序难控。将首次绑定放在 initState,变更时在 didUpdateWidget 处理,销毁时在 dispose 解绑。就地移除本段代码:
- // 简单直接地绑定监听器 - if (widget.scrollController != null) { - widget.scrollController!.removeListener(_onScroll); - widget.scrollController!.addListener(_onScroll); - }并在 initState 首次绑定(非选区,示意):
@override void initState() { super.initState(); _animationController = AnimationController(duration: const Duration(milliseconds: 200), vsync: this); _animation = CurvedAnimation(parent: _animationController, curve: Curves.easeInOut); _animationController.forward(); widget.scrollController?.addListener(_onScroll); }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (10)
lib/routing/router.dart(1 hunks)lib/ui/bookmarks/widget/archived_screen.dart(1 hunks)lib/ui/bookmarks/widget/bookmark_list_screen.dart(2 hunks)lib/ui/bookmarks/widget/marked_screen.dart(1 hunks)lib/ui/bookmarks/widget/reading_screen.dart(1 hunks)lib/ui/bookmarks/widget/unarchived_screen.dart(1 hunks)lib/ui/core/main_layout.dart(4 hunks)lib/ui/core/ui/bookmark_list_fab.dart(3 hunks)lib/ui/daily_read/widgets/daily_read_screen.dart(2 hunks)test/ui/daily_read/widgets/daily_read_screen_test.dart(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (7)
- test/ui/daily_read/widgets/daily_read_screen_test.dart
- lib/ui/bookmarks/widget/marked_screen.dart
- lib/ui/bookmarks/widget/archived_screen.dart
- lib/ui/bookmarks/widget/unarchived_screen.dart
- lib/ui/core/main_layout.dart
- lib/routing/router.dart
- lib/ui/bookmarks/widget/bookmark_list_screen.dart
🧰 Additional context used
📓 Path-based instructions (2)
lib/ui/**/!(theme).dart
📄 CodeRabbit inference engine (.trae/rules/project_rules.md)
UI 样式必须使用主题,不允许硬编码颜色、字号等
Files:
lib/ui/core/ui/bookmark_list_fab.dartlib/ui/daily_read/widgets/daily_read_screen.dartlib/ui/bookmarks/widget/reading_screen.dart
lib/ui/**/*.dart
📄 CodeRabbit inference engine (.trae/rules/project_rules.md)
lib/ui/**/*.dart: View 只处理展示与交互,构造函数仅接受 key 与 viewModel,不包含业务逻辑;用户交互通过 Command 执行
Command 监听器:在 initState 中订阅 results/errors,dispose 中取消订阅,并使用 mounted 检查
错误状态统一使用 ErrorPage 组件,优先使用工厂方法(如 ErrorPage.fromException),并遵循主题样式与可用性要求
Files:
lib/ui/core/ui/bookmark_list_fab.dartlib/ui/daily_read/widgets/daily_read_screen.dartlib/ui/bookmarks/widget/reading_screen.dart
🧠 Learnings (4)
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/*.dart : Command 监听器:在 initState 中订阅 results/errors,dispose 中取消订阅,并使用 mounted 检查
Applied to files:
lib/ui/core/ui/bookmark_list_fab.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/view_models/**/*.dart : ViewModel 必须继承 ChangeNotifier,使用 flutter_command 的 Command 暴露异步操作,并在构造函数初始化 Commands
Applied to files:
lib/ui/daily_read/widgets/daily_read_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/config/dependencies.dart : 使用 provider 进行依赖注入管理(构造函数注入 Repository 等)
Applied to files:
lib/ui/daily_read/widgets/daily_read_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/*.dart : View 只处理展示与交互,构造函数仅接受 key 与 viewModel,不包含业务逻辑;用户交互通过 Command 执行
Applied to files:
lib/ui/daily_read/widgets/daily_read_screen.dart
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Test
🔇 Additional comments (4)
lib/ui/daily_read/widgets/daily_read_screen.dart (1)
253-284: MainLayout 内联包裹 + 统一传入 scrollController 的做法很稳标题、showFab 与同一 controller 透传,契合此次从全局 Provider 下沉到页面级管理的目标,有助于 FAB 在页面切换后的稳定性与一致性。
lib/ui/bookmarks/widget/reading_screen.dart (2)
15-28: 本地持有 ScrollController 的 Stateful 化改造正确,生命周期处理到位initState 创建、dispose 释放,避免外部依赖与全局状态耦合,符合此次架构收敛方向。
32-47: 同一 ScrollController 传递给 MainLayout 与 BookmarkListScreen,FAB 行为可预测该 wiring 保障 FAB 监听到真实滚动来源,切页后隐藏/显示逻辑可持续工作。
lib/ui/core/ui/bookmark_list_fab.dart (1)
78-83: 细节赞:微小滚动阈值与最后位置更新避免抖动提高到 5.0 并在 early-return 前更新 _lastScrollPosition,能有效减少动画抖动。
在daily_read_screen_test和reading_screen_test测试文件中添加了对MainAppViewModel和AboutViewModel的mock支持,以支持更全面的测试场景
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #86 +/- ##
==========================================
+ Coverage 42.70% 43.38% +0.68%
==========================================
Files 87 87
Lines 5943 5981 +38
==========================================
+ Hits 2538 2595 +57
+ Misses 3405 3386 -19
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
将测试中的日志配置改为使用测试专用的SimpleTestPrinter和MultiOutput,避免测试时产生不必要的输出。同时添加setupTestGroupLogging()来统一管理测试组的日志设置。
确保在组件销毁时正确释放滚动控制器和所有错误订阅 添加对组件挂载状态的检查以避免在未挂载时更新UI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🧹 Nitpick comments (9)
test/unit/domain/use_cases/app_update_use_case_test.dart (1)
309-319: 重复的“静默 Logger”构造可抽到 helper,减少样板建议在 test/helpers/test_logger_helper.dart 中新增 createSilentTestLogger(),此处直接调用,统一配置,避免各处手写。
可参考:
// test/helpers/test_logger_helper.dart Logger createSilentTestLogger() => Logger( printer: SimpleTestPrinter(), output: MultiOutput([]), level: Level.off, );lib/ui/core/ui/bookmark_list_fab.dart (1)
5-12: 文档注释与实现不一致(仍提 Provider)实现已改为通过外部传入的 ScrollController,建议同步更新注释,避免误导。
-/// - 通过Provider获取ScrollController +/// - 通过外部传入的 ScrollController 驱动滚动隐藏/显示lib/ui/bookmarks/widget/bookmark_list_screen.dart (3)
47-53: 构造函数新增 scrollController 参数,需与项目规范对齐规范要求 “View 构造函数仅接受 key 与 viewModel”。该新增参数为纯 UI 依赖,且与本 PR 架构目标一致,但建议在项目规则中明确此类例外(如:可接受的 UI 级依赖:ScrollController、FocusNode 等),避免后续 PR 产生分歧。
需要我提交一段项目规则补充说明到 .trae/rules/project_rules.md 吗?
137-142: 分页触底缺少节流/重入保护,可能重复触发 loadNextPage快速滚动到尾部时会高频进入该分支。建议在执行中或无更多数据时直接返回。
- if (currentScrollPosition >= - _scrollController!.position.maxScrollExtent - 200) { - widget.viewModel.loadNextPage(); - } + if (currentScrollPosition >= + _scrollController!.position.maxScrollExtent - 200) { + // 防重入:执行中或无更多数据则跳过 + final isLoadingMore = widget.viewModel.loadMore.isExecuting.value; + if (isLoadingMore || !widget.viewModel.hasMoreData) return; + widget.viewModel.loadNextPage(); + }
68-79: 清理导航回调以避免持有已卸载 contextinitState 中设置了 navigateToDetail 回调,但 dispose 未清理,若 ViewModel 复用可能残留无效闭包。
void dispose() { _scrollController?.removeListener(_onScroll); // 只有自己创建的 ScrollController 才需要 dispose if (_ownsController) { _scrollController?.dispose(); } + // 清理回调,避免持有已卸载的 context + widget.viewModel.setNavigateToDetailCallback((_) {}); _deleteSuccessSubscription.cancel(); super.dispose(); }Also applies to: 121-129
lib/ui/daily_read/widgets/daily_read_screen.dart (1)
70-117: 错误订阅完备且带 mounted 检查:LGTM;可考虑消除重复样板三处 errors 监听结构一致,可抽一个私有辅助方法以减少重复(可选)。
示例:
void _listenError<T>(Command<T, dynamic> cmd, String logMsg, String snackMsg) { cmd.errors.where((e) => e != null).listen((error, _) { if (!mounted) return; appLogger.e(logMsg, error: error); SnackBarHelper.showError(context, snackMsg); }); }test/ui/daily_read/widgets/daily_read_screen_test.mocks.dart (1)
332-410: 不同测试套件的 mock 策略不一致(是否抛出缺失桩)此处的 NiceMock 未启用缺失桩抛错,而
reading_screen_test.mocks.dart中的同类 mock 使用了throwOnMissingStub。建议在注解处统一策略,避免测试行为不一致导致的隐性问题。test/ui/daily_read/widgets/daily_read_screen_test.dart (2)
17-21: 统一 mock 生成策略以提升用例健壮性(可选)建议在
@GenerateNiceMocks中为所有 MockSpec 指定onMissingStub: OnMissingStub.throw,与另一个测试套件保持一致,避免漏 stub 时悄然返回默认值。可参考:
@GenerateNiceMocks([ - MockSpec<DailyReadViewModel>(), - MockSpec<MainAppViewModel>(), - MockSpec<AboutViewModel>(), + MockSpec<DailyReadViewModel>(onMissingStub: OnMissingStub.throw), + MockSpec<MainAppViewModel>(onMissingStub: OnMissingStub.throw), + MockSpec<AboutViewModel>(onMissingStub: OnMissingStub.throw), ])
12-14: 补充与 FAB 滚动隐藏/页面切换相关的用例(可选)为更贴合本 PR 修复目标,建议增加一条 Widget 测试:在两个包含各自
ScrollController与 FAB 的页面之间切换,模拟滚动并断言 FAB 的显隐状态在返回前页后仍能正确响应。可在本文件新增用例(示意):
testWidgets('FAB 在页面切换后仍能随滚动隐藏/显示', (tester) async { // 构建包含两个路由的 MaterialApp,并为每页各自提供 ScrollController 与 FAB // push -> scroll down (expect: FAB 不可见) // pop -> 再次 scroll up (expect: FAB 可见) // 断言使用 find.byType(FloatingActionButton) 与 offstage/opacity/visibility 判定 });
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (8)
lib/ui/bookmarks/widget/bookmark_list_screen.dart(3 hunks)lib/ui/core/ui/bookmark_list_fab.dart(2 hunks)lib/ui/daily_read/widgets/daily_read_screen.dart(5 hunks)test/ui/bookmarks/widget/reading_screen_test.dart(3 hunks)test/ui/bookmarks/widget/reading_screen_test.mocks.dart(2 hunks)test/ui/daily_read/widgets/daily_read_screen_test.dart(3 hunks)test/ui/daily_read/widgets/daily_read_screen_test.mocks.dart(2 hunks)test/unit/domain/use_cases/app_update_use_case_test.dart(3 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
lib/ui/**/!(theme).dart
📄 CodeRabbit inference engine (.trae/rules/project_rules.md)
UI 样式必须使用主题,不允许硬编码颜色、字号等
Files:
lib/ui/bookmarks/widget/bookmark_list_screen.dartlib/ui/core/ui/bookmark_list_fab.dartlib/ui/daily_read/widgets/daily_read_screen.dart
lib/ui/**/*.dart
📄 CodeRabbit inference engine (.trae/rules/project_rules.md)
lib/ui/**/*.dart: View 只处理展示与交互,构造函数仅接受 key 与 viewModel,不包含业务逻辑;用户交互通过 Command 执行
Command 监听器:在 initState 中订阅 results/errors,dispose 中取消订阅,并使用 mounted 检查
错误状态统一使用 ErrorPage 组件,优先使用工厂方法(如 ErrorPage.fromException),并遵循主题样式与可用性要求
Files:
lib/ui/bookmarks/widget/bookmark_list_screen.dartlib/ui/core/ui/bookmark_list_fab.dartlib/ui/daily_read/widgets/daily_read_screen.dart
🧠 Learnings (6)
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to test/**/*.{dart} : 应编写测试覆盖:Repository 层与 ViewModel 层 100% 覆盖,关键 Widget/UI、Command 流程与 Result 逻辑具备测试
Applied to files:
test/ui/bookmarks/widget/reading_screen_test.darttest/ui/bookmarks/widget/reading_screen_test.mocks.darttest/ui/daily_read/widgets/daily_read_screen_test.mocks.darttest/ui/daily_read/widgets/daily_read_screen_test.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/view_models/**/*.dart : ViewModel 必须继承 ChangeNotifier,使用 flutter_command 的 Command 暴露异步操作,并在构造函数初始化 Commands
Applied to files:
test/ui/bookmarks/widget/reading_screen_test.darttest/ui/bookmarks/widget/reading_screen_test.mocks.darttest/ui/daily_read/widgets/daily_read_screen_test.mocks.dartlib/ui/daily_read/widgets/daily_read_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/core/ui/error_page.dart : 提供统一错误页面组件 ErrorPage 并位于 lib/ui/core/ui/error_page.dart
Applied to files:
lib/ui/bookmarks/widget/bookmark_list_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/*.dart : 错误状态统一使用 ErrorPage 组件,优先使用工厂方法(如 ErrorPage.fromException),并遵循主题样式与可用性要求
Applied to files:
lib/ui/bookmarks/widget/bookmark_list_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/*.dart : Command 监听器:在 initState 中订阅 results/errors,dispose 中取消订阅,并使用 mounted 检查
Applied to files:
test/ui/daily_read/widgets/daily_read_screen_test.mocks.dartlib/ui/core/ui/bookmark_list_fab.dartlib/ui/daily_read/widgets/daily_read_screen.dart
📚 Learning: 2025-08-25T09:44:33.606Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-25T09:44:33.606Z
Learning: Applies to lib/ui/**/*.dart : View 只处理展示与交互,构造函数仅接受 key 与 viewModel,不包含业务逻辑;用户交互通过 Command 执行
Applied to files:
lib/ui/daily_read/widgets/daily_read_screen.dart
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Test
🔇 Additional comments (19)
test/unit/domain/use_cases/app_update_use_case_test.dart (2)
11-11: 测试日志初始化:LGTM引入 test_logger_helper 并在分组前初始化日志,符合单测静默化诉求。
Also applies to: 29-30
36-41: 确认失败场景下仍能看到必要日志当前 Logger 使用 Level.off 且 MultiOutput([]) 会完全禁用输出。请确认 setupTestGroupLogging() 的失败回放机制不依赖 Logger 本身;否则失败时也可能拿不到日志。
是否需要改为 Level.warning(或结合自定义 LogFilter)以仅在较高等级输出?
lib/ui/core/ui/bookmark_list_fab.dart (4)
49-50: 在 initState 绑定滚动监听:LGTM从 build 期迁移到生命周期方法,避免重复绑定与性能抖动。
56-63: didUpdateWidget 解绑旧 controller 并重置状态:LGTM正确处理 controller 切换与可见性复位,规避监听泄漏。
68-70: dispose 中解绑监听:LGTM避免 “setState after dispose” 与潜在泄漏。
84-89: 滚动阈值与微滚动去抖:LGTM阈值提升到 5.0 并在微小滚动仅更新游标,可减少动画抖动。
lib/ui/bookmarks/widget/bookmark_list_screen.dart (3)
97-103: 首次绑定 controller 并标记所有权:LGTM只在 _scrollController 为空时创建/绑定,避免重复 addListener。
106-119: didUpdateWidget 中的解绑/重新绑定与自有控制权处理:LGTM覆盖 controller 切换的关键路径,避免悬挂监听与双重回调。
121-129: 按自有控制权释放 controller:LGTM避免从外部传入的 controller 被误 dispose。
lib/ui/daily_read/widgets/daily_read_screen.dart (2)
30-35: 在 initState 创建 late final ScrollController 并统一释放:LGTM符合“页面自管理 controller”的重构目标,配合 MainLayout/FAB 共享一份滚动源。
Also applies to: 43-45
269-301: MainLayout 直连 scrollController 并开启 FAB:LGTM与本 PR 的“页面级管理”策略一致。
test/ui/bookmarks/widget/reading_screen_test.dart (4)
11-13: 新增 MainAppViewModel/AboutViewModel mocks:LGTM与 MainLayout 的新依赖保持一致,避免构建时缺依赖。
Also applies to: 16-16
61-67: 基础桩配置完整:LGTMshareTextStream 与 updateInfo 的默认桩能满足构建路径。
71-83: MultiProvider 包装被测 Widget:LGTM与生产代码依赖图一致,提升用例稳定性。
157-159: 先设置 bookmarks 后构建:LGTM避免竞态导致的初次渲染不一致。
test/ui/daily_read/widgets/daily_read_screen_test.dart (1)
73-78: Provider 注入改造与 PR 目标一致,LGTM从移除 ScrollControllerProvider 到按页面注入 MainAppViewModel / AboutViewModel 的改动合理,未见问题。
test/ui/bookmarks/widget/reading_screen_test.mocks.dart (3)
9-21: 导入与类型依赖齐全,符合生成规范引入 ThemeMode、UpdateInfo 与内部 dummies 用于默认返回值,满足测试场景需要。
318-397: MockMainAppViewModel 定义完善,严格模式更易暴露漏桩问题启用
throwOnMissingStub有助于发现未 stub 的调用,保持现状即可。
399-522: MockAboutViewModel 定义完整,LGTM涵盖下载/安装命令及状态字段,满足 UI 测试依赖。
| // Mock AboutViewModel | ||
| when(mockAboutViewModel.updateInfo).thenReturn(null); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
修复对 AboutViewModel 不存在成员的 stub
mockAboutViewModel.updateInfo 在生成的 mock 中不存在。请改为 stub 已有成员或移除该行。
应用如下 diff:
- // Mock AboutViewModel
- when(mockAboutViewModel.updateInfo).thenReturn(null);
+ // Mock AboutViewModel(示例:根据需要 stub 已存在的成员)
+ when(mockAboutViewModel.version).thenReturn('test-version');
+ when(mockAboutViewModel.load).thenReturn(
+ Command.createSyncNoParam<dynamic>(() => null),
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Mock AboutViewModel | |
| when(mockAboutViewModel.updateInfo).thenReturn(null); | |
| }); | |
| // Mock AboutViewModel(示例:根据需要 stub 已存在的成员) | |
| when(mockAboutViewModel.version).thenReturn('test-version'); | |
| when(mockAboutViewModel.load).thenReturn( | |
| Command.createSyncNoParam<dynamic>(() => null), | |
| ); | |
| }); |
🤖 Prompt for AI Agents
In test/ui/daily_read/widgets/daily_read_screen_test.dart around lines 62-64,
the test stubs mockAboutViewModel.updateInfo which does not exist on the
generated mock; remove that stub line or replace it with a stub of an actual
member that exists on the generated AboutViewModel mock (inspect the generated
mock to find the correct method/property name) and update the
when(...).thenReturn(...) call accordingly.
| /// A class which mocks [AboutViewModel]. | ||
| /// | ||
| /// See the documentation for Mockito's code generation for more information. | ||
| class MockAboutViewModel extends _i1.Mock implements _i11.AboutViewModel { | ||
| @override | ||
| _i2.Command<dynamic, dynamic> get load => (super.noSuchMethod( | ||
| Invocation.getter(#load), | ||
| returnValue: _FakeCommand_0<dynamic, dynamic>( | ||
| this, | ||
| Invocation.getter(#load), | ||
| ), | ||
| returnValueForMissingStub: _FakeCommand_0<dynamic, dynamic>( | ||
| this, | ||
| Invocation.getter(#load), | ||
| ), | ||
| ) as _i2.Command<dynamic, dynamic>); | ||
|
|
||
| @override | ||
| String get version => (super.noSuchMethod( | ||
| Invocation.getter(#version), | ||
| returnValue: _i12.dummyValue<String>( | ||
| this, | ||
| Invocation.getter(#version), | ||
| ), | ||
| returnValueForMissingStub: _i12.dummyValue<String>( | ||
| this, | ||
| Invocation.getter(#version), | ||
| ), | ||
| ) as String); | ||
|
|
||
| @override | ||
| double get downloadProgress => (super.noSuchMethod( | ||
| Invocation.getter(#downloadProgress), | ||
| returnValue: 0.0, | ||
| returnValueForMissingStub: 0.0, | ||
| ) as double); | ||
|
|
||
| @override | ||
| bool get isDownloading => (super.noSuchMethod( | ||
| Invocation.getter(#isDownloading), | ||
| returnValue: false, | ||
| returnValueForMissingStub: false, | ||
| ) as bool); | ||
|
|
||
| @override | ||
| bool get isInstalling => (super.noSuchMethod( | ||
| Invocation.getter(#isInstalling), | ||
| returnValue: false, | ||
| returnValueForMissingStub: false, | ||
| ) as bool); | ||
|
|
||
| @override | ||
| _i2.Command<_i13.UpdateInfo, void> get downloadAndInstallUpdateCommand => | ||
| (super.noSuchMethod( | ||
| Invocation.getter(#downloadAndInstallUpdateCommand), | ||
| returnValue: _FakeCommand_0<_i13.UpdateInfo, void>( | ||
| this, | ||
| Invocation.getter(#downloadAndInstallUpdateCommand), | ||
| ), | ||
| returnValueForMissingStub: _FakeCommand_0<_i13.UpdateInfo, void>( | ||
| this, | ||
| Invocation.getter(#downloadAndInstallUpdateCommand), | ||
| ), | ||
| ) as _i2.Command<_i13.UpdateInfo, void>); | ||
|
|
||
| @override | ||
| _i2.Command<_i13.UpdateInfo, String> get downloadUpdateCommand => | ||
| (super.noSuchMethod( | ||
| Invocation.getter(#downloadUpdateCommand), | ||
| returnValue: _FakeCommand_0<_i13.UpdateInfo, String>( | ||
| this, | ||
| Invocation.getter(#downloadUpdateCommand), | ||
| ), | ||
| returnValueForMissingStub: _FakeCommand_0<_i13.UpdateInfo, String>( | ||
| this, | ||
| Invocation.getter(#downloadUpdateCommand), | ||
| ), | ||
| ) as _i2.Command<_i13.UpdateInfo, String>); | ||
|
|
||
| @override | ||
| _i2.Command<String, void> get installUpdateCommand => (super.noSuchMethod( | ||
| Invocation.getter(#installUpdateCommand), | ||
| returnValue: _FakeCommand_0<String, void>( | ||
| this, | ||
| Invocation.getter(#installUpdateCommand), | ||
| ), | ||
| returnValueForMissingStub: _FakeCommand_0<String, void>( | ||
| this, | ||
| Invocation.getter(#installUpdateCommand), | ||
| ), | ||
| ) as _i2.Command<String, void>); | ||
|
|
||
| @override | ||
| set load(_i2.Command<dynamic, dynamic>? _load) => super.noSuchMethod( | ||
| Invocation.setter( | ||
| #load, | ||
| _load, | ||
| ), | ||
| returnValueForMissingStub: null, | ||
| ); | ||
|
|
||
| @override | ||
| bool get hasListeners => (super.noSuchMethod( | ||
| Invocation.getter(#hasListeners), | ||
| returnValue: false, | ||
| returnValueForMissingStub: false, | ||
| ) as bool); | ||
|
|
||
| @override | ||
| void addListener(_i6.VoidCallback? listener) => super.noSuchMethod( | ||
| Invocation.method( | ||
| #addListener, | ||
| [listener], | ||
| ), | ||
| returnValueForMissingStub: null, | ||
| ); | ||
|
|
||
| @override | ||
| void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod( | ||
| Invocation.method( | ||
| #removeListener, | ||
| [listener], | ||
| ), | ||
| returnValueForMissingStub: null, | ||
| ); | ||
|
|
||
| @override | ||
| void dispose() => super.noSuchMethod( | ||
| Invocation.method( | ||
| #dispose, | ||
| [], | ||
| ), | ||
| returnValueForMissingStub: null, | ||
| ); | ||
|
|
||
| @override | ||
| void notifyListeners() => super.noSuchMethod( | ||
| Invocation.method( | ||
| #notifyListeners, | ||
| [], | ||
| ), | ||
| returnValueForMissingStub: null, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
MockAboutViewModel 未包含 updateInfo,和测试桩冲突
生成的 MockAboutViewModel 没有 updateInfo getter;但测试中对 mockAboutViewModel.updateInfo 进行了 stub,会导致编译/运行期错误。请在测试中移除该 stub,或改为 stub 已存在的成员(如 version、load),或在真实 AboutViewModel 中新增 updateInfo 并重新生成 mocks。
建议在仓库根目录执行下列脚本确认接口中是否存在 updateInfo:
🏁 Script executed:
#!/bin/bash
# 查看 AboutViewModel 声明及是否含有 updateInfo
rg -nP '(^|\s)(abstract\s+)?class\s+AboutViewModel\b' -C2
rg -n '\bupdateInfo\b' -C2Length of output: 24654
🏁 Script executed:
#!/bin/bash
# 确认 Daily Read widget 的 MockAboutViewModel 中是否包含 updateInfo getter
rg -n 'updateInfo' -C2 test/ui/daily_read/widgets/daily_read_screen_test.mocks.dartLength of output: 83
修复 MockAboutViewModel 缺失 updateInfo 导致测试编译失败
经确认,真实的 AboutViewModel 在 lib/ui/settings/view_models/about_viewmodel.dart 中确实声明了
UpdateInfo? _updateInfo;
UpdateInfo? get updateInfo => _updateInfo;但生成在 test/ui/daily_read/widgets/daily_read_screen_test.mocks.dart 的 MockAboutViewModel 并未包含 updateInfo 的重写,导致如下测试代码无法编译:
when(mockAboutViewModel.updateInfo).thenReturn(null);请在以下两种方案中择一进行修复:
- 方案一:在项目根目录重新生成 mocks,确保
AboutViewModel的所有成员(含updateInfo)都被正确生成 - 方案二:临时移除或改用已有成员(如
version、load)来替换测试中的updateInfostub,待 mocks 配置修正后再恢复
定位:
lib/ui/settings/view_models/about_viewmodel.dart(定义updateInfo)test/ui/daily_read/widgets/daily_read_screen_test.mocks.dart(缺失该 getter)test/ui/daily_read/widgets/daily_read_screen_test.dart:63(stubupdateInfo)
请尽快补齐 mock 或更新测试逻辑,确保整个测试套件能够通过编译并正常运行。
🤖 Prompt for AI Agents
In test/ui/daily_read/widgets/daily_read_screen_test.mocks.dart around lines 412
to 555, the generated MockAboutViewModel is missing the updateInfo getter which
causes tests that stub mockAboutViewModel.updateInfo to fail; fix by either
re-running the mock generation tool (e.g., run build_runner to regenerate mocks
so AboutViewModel's updateInfo getter is included) or as a temporary measure
update the test at test/ui/daily_read/widgets/daily_read_screen_test.dart line
~63 to stop stubbing updateInfo (use an existing stubbed member like
version/load or remove the stub) until mocks are regenerated. Ensure after
regenerating or editing tests you run the test compile to confirm the missing
getter is restored or the test adjusted.
概述
修复了 FloatingActionButton 在页面切换后滚动隐藏功能失效的问题。
Closes #85
问题背景
FAB 的下滑隐藏功能在从未读页切换到每日阅读页后会失效,影响用户体验的一致性。
解决方案
🔧 架构重构
从全局 Provider 模式改为页面级管理
ScrollControllerProvider全局共享 ScrollController🎯 关键修改
移除全局 Provider 依赖
ScrollControllerProvider类MainLayout直接接受scrollController参数优化 ScrollController 绑定
build方法中直接绑定监听器统一页面架构
技术细节
修改的文件
核心组件
lib/ui/core/ui/bookmark_list_fab.dart- FAB 组件重构lib/ui/core/main_layout.dart- 移除 Provider,支持直接传参页面层
lib/ui/daily_read/widgets/daily_read_screen.dart- 自管理 FABlib/ui/bookmarks/widget/unarchived_screen.dart- 添加 FAB 支持lib/ui/bookmarks/widget/reading_screen.dart- 添加 FAB 支持lib/ui/bookmarks/widget/archived_screen.dart- 添加 FAB 支持lib/ui/bookmarks/widget/marked_screen.dart- 添加 FAB 支持架构支持
lib/ui/bookmarks/widget/bookmark_list_screen.dart- 支持外部 ScrollControllerlib/routing/router.dart- 更新路由配置,避免双重包装关键代码实现
FAB 组件监听器绑定
页面级管理模式
测试验证
✅ 功能验证
✅ 代码质量
影响评估
🎉 正面影响
检查清单
Summary by CodeRabbit