Skip to content

feat(更新): 优化应用内更新交互体验#75

Merged
shadowfish07 merged 14 commits intomainfrom
feature/74-auto-update
Aug 19, 2025
Merged

feat(更新): 优化应用内更新交互体验#75
shadowfish07 merged 14 commits intomainfrom
feature/74-auto-update

Conversation

@shadowfish07
Copy link
Owner

@shadowfish07 shadowfish07 commented Aug 17, 2025

Summary

Fixes #74

本次更新重构了“关于”页面中的应用更新功能,旨在提供更流畅、更现代化的用户体验。主要变更包括:

  • 集成 GitHub Release 更新日志:在新版本卡片中直接展示从 GitHub Release 读取的 Markdown 格式更新日志,让用户可以快速了解新版本的变化。
  • 简化更新流程:移除了更新前的确认对话框,用户点击“立即更新”后会直接开始下载和安装流程,操作更直接。
  • 优化交互按钮
    • 取消了原有的“手动下载”按钮。
    • 新增了一个“GitHub”按钮,方便用户直接跳转到对应的 GitHub Release 页面查看完整信息或手动下载。
  • 依赖更新:添加 flutter_markdown 依赖以支持在应用内渲染更新日志。

Test plan

  • 单元测试已更新并通过。
  • 在模拟器上验证更新流程,确认“立即更新”和“跳转 GitHub”功能正常。
  • 验证更新日志的 Markdown 格式是否正确渲染。

🤖 Generated with Claude Code

Summary by CodeRabbit

  • 新功能
    • 应用内更新:自动检查远程发布,在关于页显示版本与 Markdown 发布说明,支持下载、进度展示与安装,关于页与抽屉可触达更新流程与安装提示。
  • 重构
    • 多个设置页面改为嵌套路由与无 AppBar 的滚动布局;主界面新增更新徽章与 SnackBar,并统一抽屉导航行为。
  • 平台/权限
    • Android 增加安装包权限与外部 URL 查询支持;Windows 增加权限插件注册以支持权限调用。
  • 依赖
    • 新增版本解析、HTTP 下载、文件打开、权限与 Markdown 等运行时依赖。
  • 测试
    • 增加更新、下载与安装相关单元测试并在测试中静默日志输出。
  • 其它
    • 移除用于 PR 审查的自动化工作流配置。

实现应用版本更新检查功能,包括:
1. 添加 package_info_plus 和 version 依赖
2. 创建 UpdateService 检查 GitHub 最新版本
3. 添加 UpdateRepository 处理更新逻辑
4. 在 MainAppViewModel 中集成更新检查
5. 在设置界面显示更新提示
6. 添加相关单元测试

关闭测试日志以减少干扰,优化测试断言方式
- 将关于页面等设置子页面改为嵌套路由结构
- 提取公共导航方法到_navigateToRoute
- 移除页面中重复的Scaffold包装
- 优化路由标题匹配逻辑,优先使用fullPath
修复url_launcher功能所需的Android intent声明,并改进链接打开的错误处理
添加自动更新功能,包括下载、安装和权限处理
- 新增下载服务和安装服务
- 重构更新逻辑到AboutViewModel
- 添加权限处理和进度显示
- 更新AndroidManifest添加所需权限
- 添加相关测试用例
添加flutter_markdown依赖以支持更新日志的Markdown渲染
扩展UpdateInfo模型包含releaseNotes和htmlUrl字段
移除更新确认弹窗,改为直接执行更新操作
在关于页面添加更新日志显示区域
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 17, 2025

Note

Other AI code review bot(s) detected

CodeRabbit 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

新增基于 GitHub Releases 的应用更新机制:在启动时检测更新并暴露 UpdateInfo,支持通过 GitHub 下载 APK 并在 Android 上请求权限、下载与安装;引入服务、仓库、用例、ViewModel、UI 集成、原生权限声明与单元测试。

Changes

Cohort / File(s) Summary
Android 清单
android/app/src/main/AndroidManifest.xml
添加 xmlns:tools 命名空间、加入 android.permission.REQUEST_INSTALL_PACKAGES 权限,并在 <queries> 中增加对 http/https VIEW intents。
依赖与原生插件注册
pubspec.yaml, windows/flutter/generated_plugin_registrant.cc, windows/flutter/generated_plugins.cmake
新增依赖(package_info_plus、version、dio、open_filex、permission_handler、flutter_markdown);Windows 插件注册与 CMake 列表加入 permission_handler_windows
模型、服务与异常类型
lib/domain/models/update/update_info.dart, lib/data/service/update_service.dart, lib/data/service/download_service.dart, lib/data/service/app_installer_service.dart
新增 UpdateInfo 模型;UpdateService(查询 GitHub Releases);DownloadService(Dio 下载、进度、文件校验);AppInstallerService(APK 安装、权限与文件权限处理、InstallException)。
仓库与用例层
lib/data/repository/update/update_repository.dart, lib/domain/use_cases/app_update_use_case.dart
新增 UpdateRepository(封装 UpdateService 返回);AppUpdateUseCase(编排下载与安装、独立下载/安装接口、AppUpdateException、获取文件大小)。
依赖注入与 ViewModel 注册
lib/config/dependencies.dart, lib/ui/settings/view_models/about_viewmodel.dart
在依赖注入中注册 UpdateService/DownloadService/AppInstallerService/UpdateRepository/AppUpdateUseCase;AboutViewModel 改为构造注入并暴露检查/下载/安装命令及下载进度与安装状态。
UI 集成(主布局 / 设置 / 关于)
lib/ui/core/main_layout.dart, lib/ui/settings/widgets/about_page.dart, lib/ui/settings/widgets/settings_screen.dart
主布局监听 AboutViewModel 并显示徽章与 SnackBar;关于页展示更新卡(版本、发布说明 Markdown、下载/安装进度);设置页关于项动态显示“发现新版本”文案与徽章。
路由与页面结构
lib/routing/router.dart, lib/routing/routes.dart
将若干关于相关页面移入 Settings 子路由;新增 aboutRelative 并调整 about 常量;路由标题/匹配改为使用 state.fullPath ?? state.matchedLocation
设置页布局重构
lib/ui/api_config/widgets/api_config_page.dart, lib/ui/settings/widgets/*.dart
多个设置页面移除各自 Scaffold/AppBar,改为直接返回滚动或监听内容,保留交互逻辑并调整布局层级与导航行为。
测试与 Mock
test/unit/..., test/ui/...
新增 UpdateService/UpdateRepository/AppInstallerService/DownloadService/AboutViewModel 等单元测试与 Mockito mocks;若干测试中禁用日志输出或调整断言风格。
杂项
lib/main_viewmodel.dart, CLAUDE.md, .github/workflows/gemini-pr-review.yml
少量格式化与文案空格调整;移除 Gemini PR Review workflow 文件。

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant App as 主布局 / App
  participant VM as AboutViewModel
  participant Repo as UpdateRepository
  participant Svc as UpdateService
  participant GH as GitHub API

  App->>VM: 订阅 updateInfo
  VM->>Repo: checkForUpdate()
  Repo->>Svc: checkForUpdate()
  Svc->>GH: GET /repos/.../releases/latest
  GH-->>Svc: 返回 release JSON
  Svc-->>Repo: UpdateInfo 或 null
  Repo-->>VM: Success/Failure
  VM-->>App: notifyListeners()(触发徽章 / SnackBar)
Loading
sequenceDiagram
  autonumber
  participant User as 用户
  participant About as 关于页
  participant VM as AboutViewModel
  participant UC as AppUpdateUseCase
  participant DL as DownloadService
  participant Inst as AppInstallerService
  participant OS as Android OS

  User->>About: 点击“立即更新”
  About->>VM: 执行 downloadAndInstallUpdateCommand(updateInfo)
  VM->>UC: downloadAndInstallUpdate(updateInfo)
  UC->>DL: downloadFile(url, filename, onProgress)
  DL-->>DL: 写入 APK 文件
  DL-->>UC: 返回 filePath
  UC->>Inst: installApk(filePath)
  Inst->>OS: 发起安装(OpenFilex / Intent)
  OS-->>Inst: 安装结果
  Inst-->>UC: Success / Failure
  UC-->>VM: 返回结果与进度
  VM-->>About: 更新 UI(进度 / 安装中 / 完成 / 错误)
Loading

Assessment against linked issues

Objective Addressed Explanation
启动时检测更新并在入口处提示(菜单按钮红点、设置入口红点、设置-关于入口红点与文案) [#74] AboutViewModel 会检测并设置状态,主布局与设置页显示徽章/文案;未明确在应用左上角菜单按钮处注入红点渲染逻辑或示例代码。
通过 GitHub Releases 检测新版本并获取发布信息 [#74]
在关于页提示存在新版本并展示发布说明(含链接/Markdown) [#74]
支持从 GitHub 下载 APK 并触发系统安装(含权限处理) [#74]

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
路由重构为 Settings 子路由(lib/routing/router.dart, lib/routing/routes.dart 该变更为页面组织/导航重构,并非 #74 要求的更新检测或安装功能必需。
多个设置页面移除 Scaffold/AppBar(lib/ui/settings/widgets/*.dart, lib/ui/api_config/widgets/api_config_page.dart 这是界面布局重构,与自动更新检测与安装流程无直接关联。
Windows 插件注册加入 permission_handler_windows(windows/... 自动更新目标为 Android APK,Windows 插件新增与 APK 下载/安装逻辑无直接关系。

我是小兔巡版本,仓库 Release 风轻扬。
下载进度细如光,安装呼唤系统窗。
抽屉红点轻轻亮,关于页里新版本香。
(=^.^=) 🥕

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 3dd82b3 and 7df79da.

📒 Files selected for processing (1)
  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.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
✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/74-auto-update

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@gemini-code-assist gemini-code-assist bot left a 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!

此拉取请求旨在全面优化 Readeck APP 的应用内更新体验。核心目标是提供一个更直观、更现代化的更新机制,让用户能够轻松获取最新版本。通过集成 GitHub Release 的更新日志、简化更新流程和优化交互按钮,新版本将显著提升用户在应用内管理和获取更新的便捷性。此外,还引入了新的服务层和业务逻辑层,为未来的更新功能扩展奠定了坚实基础。

Highlights

  • 优化应用内更新交互: 重构了应用内更新功能,现在可以直接从 GitHub Release 获取更新日志并展示,为用户提供更现代、更流畅的更新体验。
  • 简化更新流程: 简化了更新流程,移除了更新前的确认对话框,用户点击“立即更新”后会直接开始下载和安装,操作更加直接高效。
  • 交互按钮优化: 优化了更新相关的交互按钮,取消了原有的“手动下载”按钮,并新增了“GitHub”按钮,方便用户直接跳转到对应的 GitHub Release 页面。
  • 集成 Markdown 更新日志: 引入了 flutter_markdown 依赖,以支持在应用内直接渲染 Markdown 格式的更新日志,提升了信息展示的丰富性。
  • 新增更新服务与用例: 新增了 UpdateServiceDownloadServiceAppInstallerService 等核心服务,以及 AppUpdateUseCase 业务逻辑层,构建了完整的应用更新架构。
  • Android 权限管理: 为 Android 平台添加了必要的权限,包括文件写入和应用安装权限,确保应用能够顺利下载和安装更新包。
  • 路由重构与更新通知: 调整了应用路由结构,将设置相关页面(包括“关于”页面)嵌套在统一的设置路由下,并实现了新版本可用时的通知提示(SnackBar 和设置入口徽章)。
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

  1. 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.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

本次更新重构了应用内更新功能,交互体验和代码结构都得到了显著提升,特别是通过引入专门的 Service 和 UseCase 来分层处理更新逻辑,并增加了完整的单元测试,这非常棒。不过,在权限声明和处理方面发现了一些关键问题,需要修正以确保功能的稳定性和安全性。

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🔭 Outside diff range comments (1)
lib/ui/api_config/widgets/api_config_page.dart (1)

16-20: 未释放 TextEditingController,存在内存泄漏风险

_baseHostController_tokenControllerState.dispose() 中需调用 dispose() 释放资源。

可在 build 方法之后新增:

   class _ApiConfigPageState extends State<ApiConfigPage> {
@@
   @override
   Widget build(BuildContext context) {
     return Padding(
       padding: const EdgeInsets.all(16.0),
       child: Form(
         key: _formKey,
-        child: Column(
+        child: Column(
@@
       ),
     );
   }
+
+  @override
+  void dispose() {
+    _baseHostController.dispose();
+    _tokenController.dispose();
+    super.dispose();
+  }
 }
🧹 Nitpick comments (43)
test/unit/data/repository/bookmark/bookmark_repository_test.dart (1)

28-33: 建议集中化测试日志初始化以去重

多个测试文件内重复初始化 appLogger。可考虑在测试公共入口(如 test/test_util.dart 或 test/_setup.dart)提供 initTestLogger(Level.off) 并在各套件 setUpAll 调用,便于统一调整与维护。

如需,我可以补充一个轻量的 test 工具方法并在现有测试中替换调用。

lib/main_viewmodel.dart (3)

29-40: 保护性处理 themeModeIndex 防止越界

当前通过 ThemeMode.values[themeModeIndex] 取值,若上游返回异常索引会抛出异常。建议做越界保护或降级为系统主题,增强健壮性。

可参考如下最小改动(伪代码,展示意图):

ThemeMode _resolveThemeMode(int idx) {
  return (idx >= 0 && idx < ThemeMode.values.length)
      ? ThemeMode.values[idx]
      : ThemeMode.system;
}

void _load() {
  final idx = _settingsRepository.getThemeMode();
  _themeMode = _resolveThemeMode(idx);
  notifyListeners();
}

void _onSettingsChanged(void _) {
  final idx = _settingsRepository.getThemeMode();
  final newMode = _resolveThemeMode(idx);
  if (_themeMode != newMode) {
    _themeMode = newMode;
    notifyListeners();
  }
}

8-16: 提高可测试性:将 ShareIntentService 注入而非内部实例化

当前直接在 ViewModel 内 new ShareIntentService(),不利于单元测试替换。建议通过构造函数注入(可保留默认值以兼容现有调用)。

可参考:

class MainAppViewModel extends ChangeNotifier {
  MainAppViewModel(
    this._settingsRepository, {
    ShareIntentService? shareIntentService,
  }) : _shareIntentService = shareIntentService ?? ShareIntentService() {
    _load();
    _settingsSubscription = _settingsRepository.settingsChanged.listen(_onSettingsChanged);
    _shareIntentService.initialize();
  }

  final SettingsRepository _settingsRepository;
  final ShareIntentService _shareIntentService;
  ...
}

Also applies to: 18-20


36-44: 按项目约定补充关键路径日志(appLogger)

根据项目约定(ViewModel 使用全局 appLogger 记录重要操作),建议在主题切换与分享文本更新等关键路径加入简要日志,便于线上排障与行为追踪。

可参考:

void _onSettingsChanged(void _) {
  final idx = _settingsRepository.getThemeMode();
  final newMode = _resolveThemeMode(idx);
  if (_themeMode != newMode) {
    appLogger.i('Theme mode changed: $_themeMode -> $newMode');
    _themeMode = newMode;
    notifyListeners();
  }
}

void setPendingSharedText(String? text) {
  appLogger.d('Set pending shared text: ${text != null ? "non-empty" : "null"}');
  _pendingSharedText = text;
  notifyListeners();
}

Also applies to: 46-54

lib/ui/settings/widgets/model_selection_screen.dart (1)

102-120: 短列表时无法下拉刷新,建议为列表指定 AlwaysScrollableScrollPhysics

当列表项很少时,默认物理效果可能无法触发下拉刷新。为 ListView.separated 添加 AlwaysScrollableScrollPhysics,确保任意长度都可下拉刷新。

建议修改如下:

-                return ListView.separated(
+                return ListView.separated(
+                  physics: const AlwaysScrollableScrollPhysics(),
                   itemCount: viewModel.availableModels.length,
lib/ui/api_config/widgets/api_config_page.dart (2)

100-107: 避免硬编码字号,统一通过主题提供样式

存在 TextStyle(fontSize: 16/14) 的硬编码,不符合“样式通过主题提供”的规范。建议改用 Theme.of(context).textTheme.* 并按需 copyWith

示例:

-              child: const Text(
-                '保存配置',
-                style: TextStyle(fontSize: 16),
-              ),
+              child: Text(
+                '保存配置',
+                style: Theme.of(context).textTheme.titleMedium,
+              ),
@@
-                    const Text(
-                      '使用说明',
-                      style: TextStyle(
-                        fontWeight: FontWeight.bold,
-                        fontSize: 16,
-                      ),
-                    ),
+                    Text(
+                      '使用说明',
+                      style: Theme.of(context)
+                          .textTheme
+                          .titleMedium
+                          ?.copyWith(fontWeight: FontWeight.bold),
+                    ),
@@
-                    const Text(
-                      '1. 在Readeck网页版中,进入个人资料 > 令牌页面\n'
-                      '2. 创建一个新的API令牌\n'
-                      '3. 将服务器地址和令牌填入上方表单\n'
-                      '4. 点击保存配置',
-                      style: TextStyle(fontSize: 14),
-                    ),
+                    Text(
+                      '1. 在Readeck网页版中,进入个人资料 > 令牌页面\n'
+                      '2. 创建一个新的API令牌\n'
+                      '3. 将服务器地址和令牌填入上方表单\n'
+                      '4. 点击保存配置',
+                      style: Theme.of(context).textTheme.bodyMedium,
+                    ),

Also applies to: 123-138


83-99: 输入建议进行标准化处理(trim/规整 URL)

为减少用户输入异常(前后空格、尾部斜杠等)导致的保存/网络问题,建议保存前对 host/token 进行 trim(),并可考虑统一移除 host 末尾 /

可在保存前处理:

-                try {
-                  await widget.viewModel.save.executeWithFuture(
-                      (_baseHostController.text, _tokenController.text));
+                try {
+                  final host = _baseHostController.text.trim();
+                  final token = _tokenController.text.trim();
+                  await widget.viewModel.save.executeWithFuture((host, token));
lib/ui/settings/widgets/ai_tag_settings_screen.dart (1)

103-199: 布局与主题使用一致,交互通过 Command 执行,整体实现稳健

ListenableBuilder 驱动渲染,点击后通过 saveAiTagTargetLanguage.execute 提交,卡片标题/图标等均来自主题,符合 UI 层规范。若后续希望统一间距/内边距,可考虑采用统一的设计令牌。

lib/ui/settings/widgets/translation_settings_screen.dart (2)

200-236: 多处硬编码的间距与内边距不符合 UI 指南

本文件存在多处硬编码的 16.0、12.0 等间距(例如 EdgeInsets.all(16.0)、SizedBox(height: 12/8))。根据 lib/ui 指南,“所有样式均通过主题提供,不得硬编码颜色、字号、间距等”。建议:

  • 将页面/卡片/区块间距统一抽取到 ThemeExtension(如 AppSpacing)或已有的设计系统组件;
  • 使用 Theme 的 textTheme/cardTheme/listTileTheme 等替代手动 paddings 和 SizedBox 间隔;
  • 若项目已有统一的 PagePadding/SectionHeader 等组件,复用之以保持一致性。

如需,我可以协助起草一个 Spacing ThemeExtension 并替换本段落的硬编码间距。


165-171: 与其他设置页的页面内边距不一致,建议统一页面级 padding 策略

本页根为 ListView(未设置 padding),而 AI 设置页根为带有 const EdgeInsets.all(16) 的 SingleChildScrollView。为保证视觉一致性,建议:

  • 要么为本 ListView 设置统一的页面级 padding(通过主题/统一容器,而非硬编码数值);
  • 要么和其他设置页保持相同的 Scaffold-free + ScrollView + 统一 PagePadding 容器结构。

请确认 Settings 下其他子页面的新结构,是否有约定的统一页面 padding 容器,避免各页出现不同的左右留白与段落距。

Also applies to: 199-204

lib/ui/settings/widgets/ai_settings_screen.dart (3)

116-126: API Key 输入框建议禁用拼写纠正/建议,并优化提交行为

API Key 属于敏感 token,不应启用输入建议与自动纠正;同时可将“完成”事件与保存行为绑定,提升可用性。

应用以下修改以改善输入体验:

 TextFormField(
   controller: _apiKeyController,
   decoration: const InputDecoration(
     labelText: 'OpenRouter API 密钥',
     hintText: '请输入您的 API 密钥',
     border: OutlineInputBorder(),
   ),
   obscureText: true,
+  enableSuggestions: false,
+  autocorrect: false,
+  textInputAction: TextInputAction.done,
   onChanged: widget.viewModel.textChangedCommand.call,
-  onFieldSubmitted: (_) => _saveApiKey(),
+  onFieldSubmitted: (_) => _saveApiKey(),
+  onEditingComplete: _saveApiKey,
 )

181-185: 导航返回后建议增加 mounted 判断以规避边界场景

当页面在目标路由期间被销毁时(极端导航/状态场景),返回后继续访问 widget 可能产生边界问题。建议在 await 返回后加 mounted 守卫。

 onTap: () async {
   await context.push(Routes.modelSelection);
-  // 从模型选择页面返回后,重新加载选中的模型
-  widget.viewModel.loadSelectedModel.execute();
+  // 从模型选择页面返回后,重新加载选中的模型
+  if (!mounted) return;
+  widget.viewModel.loadSelectedModel.execute();
 },

93-131: 本页亦存在多处硬编码间距(16/12/8)与排版间距,建议主题化

和 TranslationSettingsScreen 一致,建议将间距抽象为主题/扩展,并替换卡片内外的 EdgeInsets/SizedBox 硬编码,同时复用统一的卡片头部组件(图标+标题)以去重。

我可以整理一个示例的 ThemeExtension(AppSpacing)与 SectionHeader 组件,统一替换以上多处卡片标题与间距写法。

Also applies to: 135-171, 195-232, 236-271, 273-312

android/app/src/main/AndroidManifest.xml (1)

4-4: WRITE_EXTERNAL_STORAGE 在高版本已弃用,建议使用分区存储/应用私有目录替代

Android 10+ 推荐使用分区存储;Android 13+ 已引入更细粒度媒体权限。下载 APK 建议存储于应用内部或 app-specific 外部目录,并通过 FileProvider 共享给安装器,无需 WRITE_EXTERNAL_STORAGE。

请确认下载服务是否已经使用应用私有目录(如 getExternalFilesDir 或 cacheDir)并通过内容 URI 触发安装;若是,则可移除该权限以降低合规风险。

lib/data/service/app_installer_service.dart (2)

75-91: 在 Android 上执行 chmod 的价值有限且可能在部分设备/版本无效

移动沙箱下,APK 保存在应用私有目录并通过 FileProvider 暴露即可,通常无需修改文件权限;执行外部命令也可能在部分设备受限。建议移除此步骤或加上“可配置开关/严格按异常降级”的保护,以降低不确定性。

我可以提交一个版本将该方法改为 no-op,并在日志中解释理由。


98-120: 权限请求失败时直接返回 true 可能导致误判

当前 catch 分支返回 true,可能在插件初始化异常时误以为已有权限。更稳妥的做法是返回 false,并在调用端统一走“尝试调起安装器/再提示去设置”的兜底路径。

   } catch (e) {
     _logger.e('请求安装权限失败,可能是插件未正确初始化', error: e);
-    // 如果权限请求失败,尝试直接安装,让系统处理权限
-    _logger.i('将尝试直接安装,让系统处理权限授权');
-    return true;
+    _logger.i('权限检查失败,将在安装阶段提示用户前往系统设置授权');
+    return false;
   }
test/unit/view_models/main_viewmodel_test.dart (2)

8-8: 避免在单元测试中直接导入 main.dart 减少副作用

导入 main.dart 可能引入路由/Provider初始化等副作用。建议将全局 logger 抽到独立的 logging 文件(例如 lib/config/logging.dart)并在测试中仅导入该文件,或为 appLogger 提供可注入/可覆盖的入口,减少对 main.dart 的依赖。

我可以帮你起一个 logging.dart 的最小改造方案,是否需要我补一份 diff?


36-43: 将占位测试标记为跳过或重命名,避免误导

该用例名称与断言不匹配(未测试“设置变更时更新主题模式”)。建议将其标记为跳过,并在描述中说明原因,避免后续维护者混淆。

应用以下 diff(仅在测试声明上添加 skip):

-    test('updates theme mode when settings change', () async {
+    test('updates theme mode when settings change', () async, skip: '更新相关逻辑已迁移/不在 MainAppViewModel 验证') {
lib/config/dependencies.dart (1)

56-65: 为 Provider 指定显式泛型并避免泛型擦除的 context.read()

当前写法依赖类型推断,阅读和重构时不够直观。建议显式标注泛型并在 read 时注明类型,降低动态类型导致的隐患。

建议改为:

-    Provider(create: (context) => UpdateService()),
-    Provider(create: (context) => UpdateRepository(context.read())),
-    Provider(create: (context) => DownloadService()),
-    Provider(create: (context) => AppInstallerService()),
-    Provider(
-        create: (context) => AppUpdateUseCase(
-              downloadService: context.read(),
-              installerService: context.read(),
-            )),
+    Provider<UpdateService>(create: (context) => UpdateService()),
+    Provider<UpdateRepository>(create: (context) => UpdateRepository(context.read<UpdateService>())),
+    Provider<DownloadService>(create: (context) => DownloadService()),
+    Provider<AppInstallerService>(create: (context) => AppInstallerService()),
+    Provider<AppUpdateUseCase>(
+      create: (context) => AppUpdateUseCase(
+        downloadService: context.read<DownloadService>(),
+        installerService: context.read<AppInstallerService>(),
+      ),
+    ),
lib/ui/settings/widgets/settings_screen.dart (1)

112-125: 避免硬编码间距,改用主题/设计令牌提供的间距值

根据项目规范,UI 中不应硬编码间距。当前使用 const SizedBox(width: 8) 建议改为从 Theme 扩展或统一的间距常量获取,便于全局一致调整。

应用以下最小改造(依赖已有 ThemeExtension 或统一常量;若暂无可先占位):

-                      const SizedBox(width: 8),
+                      SizedBox(width: Theme.of(context).extension<AppSpacing>()?.sm ?? 8),

如项目尚未提供间距扩展,可新增一个 ThemeExtension,例如:

@immutable
class AppSpacing extends ThemeExtension<AppSpacing> {
  final double xs, sm, md, lg;
  const AppSpacing({this.xs = 4, this.sm = 8, this.md = 16, this.lg = 24});
  @override
  AppSpacing copyWith({double? xs, double? sm, double? md, double? lg}) =>
      AppSpacing(xs: xs ?? this.xs, sm: sm ?? this.sm, md: md ?? this.md, lg: lg ?? this.lg);
  @override
  AppSpacing lerp(ThemeExtension<AppSpacing>? other, double t) => this;
}

并在主题里注入该扩展。

test/unit/data/service/update_service_test.dart (3)

21-45: 新版本可用路径覆盖到位,可补充调用校验

场景覆盖合理。可考虑补充一次对 http.get 调用次数的 verify,确保只触发一次网络请求,增强测试健壮性。

可在断言后追加:

verify(mockHttpClient.get(any)).called(1);

47-68: 同版本/更低版本返回空路径正确,可扩展边界用例

逻辑符合预期。建议补充无 assets 或 assets 非 apk 的返回为空用例,覆盖更多边界。

我可以补一条“assets 为空返回 null”的测试用例,是否需要我直接给出完整 diff?


70-85: HTTP 错误返回 null 的行为明确

对 404 处理为 null 合理。与上方建议一致,可附加 verify 确保只调用一次。

lib/data/repository/update/update_repository.dart (1)

9-20: 用 Failure 表达“没有新版本”语义不够贴切;建议使用领域化异常并准备缓存/重试

  • 语义:将“无可用更新”视为错误(Failure(Exception("No update available")))会模糊业务语义。至少应使用领域化异常,或提供一个显式返回“无更新”的 API(例如返回 Result<UpdateInfo?, Exception> 或补充 hasUpdate())。
  • 错误类型:建议引入自定义异常以替代裸 Exception,便于上层区分网络错误 vs 无更新。
  • 健壮性:按规范补齐最小缓存与必要重试(如瞬时网络抖动重试 2~3 次),避免频繁打 GitHub API,提升体验。

可先做轻量调整为领域异常,后续演进缓存/重试。示例最小改动(仅本段位差异):

-      if (updateInfo != null) {
-        return Success(updateInfo);
-      } else {
-        return Failure(Exception("No update available"));
-      }
+      if (updateInfo != null) {
+        return Success(updateInfo);
+      } else {
+        return Failure(NoUpdateAvailableException());
+      }

并在合适位置新增异常类型(示例,放在 domain/errors 下更佳):

class NoUpdateAvailableException implements Exception {
  final String message;
  NoUpdateAvailableException([this.message = 'No update available']);
  @override
  String toString() => 'NoUpdateAvailableException: $message';
}
test/unit/data/repository/update/update_repository_test.dart (2)

20-35: 用例覆盖到位(有更新场景),建议补充一次调用校验

当前仅断言返回类型与值;可顺带校验 UpdateService.checkForUpdate() 被调用 1 次,增强行为约束:

       final result = await updateRepository.checkForUpdate();

       expect(result, isA<rd.Success>());
       expect(result.getOrNull(), updateInfo);
+      verify(mockUpdateService.checkForUpdate()).called(1);

45-51: 异常场景覆盖 OK,可考虑断言异常类型

在采用领域异常后,可将断言细化为:

expect(result.exceptionOrNull(), isA<NoUpdateAvailableException>());
test/unit/view_models/about_viewmodel_test.dart (2)

46-50: 避免使用固定延时,改为等待事件队列或具体信号以提升稳定性

固定 Future.delayed(100ms) 可能导致 CI 环境下偶发性抖动。建议:

  • 方案 A(最小改动):使用 await pumpEventQueue();
  • 方案 B:若 AboutViewModel 暴露初始化完成的 Future/通知,直接等待该信号
  • 方案 C:用 Mockito 的 untilCalled 等待 checkForUpdate() 被调用

示例(方案 A):

-      // Allow time for the async command to complete
-      await Future.delayed(const Duration(milliseconds: 100));
+      // Wait for async init to complete deterministically
+      await pumpEventQueue();

同样适用于本文件其它两处 Future.delayed


14-21: 关闭日志与 Dummy 提供 OK,可以再加一条“更新流程”用例

当前覆盖了初始化检查与版本读取。建议补充“点击立即更新”路径,验证:

  • 调用 downloadAndInstallUpdate
  • 进度回调触发(若 UI 需展示);
  • 失败时错误状态传播。

我可以按现有 mocks 帮你起草此用例。

lib/routing/router.dart (1)

211-250: 依赖读取使用无类型 read() 可读性略差,建议显式标注类型参数(可后续统一)

例如:

  • ApiConfigViewModel(context.read<SettingsRepository>());
  • 其它若依赖多项(AI/翻译/模型等)也建议按实际类型显式标注,减少后续重构风险。

本项为风格一致性建议,可在后续 PR 统一处理。

lib/ui/core/main_layout.dart (1)

154-165: 满足需求:在左上角菜单按钮显示更新红点提示

Issue 要求“左上角菜单按钮显示红点提示”。当前仅在“设置”条目上展示 Badge。建议在菜单 Icon 上叠加一个小红点。

-                        return IconButton(
-                          icon: const Icon(Icons.menu),
+                        return IconButton(
+                          icon: Stack(
+                            clipBehavior: Clip.none,
+                            children: [
+                              const Icon(Icons.menu),
+                              if (_hasUpdate)
+                                const Positioned(
+                                  right: -2,
+                                  top: -2,
+                                  child: Badge(), // 仅展示小红点
+                                ),
+                            ],
+                          ),
                           onPressed: () {
                             Scaffold.of(context).openDrawer();
                           },
                         );
lib/data/service/update_service.dart (1)

10-12: 仓库地址硬编码不利于多环境/分叉场景

将 _repo/'latest' API 地址硬编码到 Service 不利于测试与多环境(如预发布、镜像源、Fork)。建议通过构造参数或配置中心(例如依赖注入读取 Config/Env)传入,默认值保留当前仓库。

lib/ui/settings/widgets/about_page.dart (3)

242-249: 文案规范:将“Github”修正为“GitHub”

品牌大小写需保持一致。

-                                  label: const Text('Github'),
+                                  label: const Text('GitHub'),

21-35: 遵循 UI 规范:避免硬编码颜色/间距/尺寸

根据项目规则(lib/ui/**/*.dart),样式需通过主题提供,避免硬编码。此处 BoxShadow 使用了 Colors.black,且布局中大量使用 const SizedBox 固定数值(如 32、24、12),图标容器宽高也为固定值(120)。建议:

  • 使用 Theme.of(context).shadowColor 替代硬编码黑色;
  • 将常用间距/圆角/尺寸抽象为 ThemeExtension(如 AppSpacing、AppRadii、AppSizes),或通过主题的尺寸系统统一管理;
  • 需要投影时优先使用 Material/Card 的 elevation,由主题控制阴影强度。

示例最小改动(仅示意颜色来源):

-                    BoxShadow(
-                      color: Colors.black.withValues(alpha: 0.1),
+                    BoxShadow(
+                      color: Theme.of(context).shadowColor.withValues(alpha: 0.1),
                       blurRadius: 10,
                       offset: const Offset(0, 4),
                     ),

如果需要,我可以帮你抽出一套 ThemeExtension(Spacing/Radii/Sizes)并替换当前硬编码用法。


465-477: 当外部浏览器打开失败时,建议给用户可见反馈

目前 catch 分支仅 debugPrint,用户无感知。建议使用 ScaffoldMessenger 提示,如 SnackBar,或在 UI 上暴露错误状态。

我可以补一个轻量的反馈实现(检测当前是否有 ScaffoldMessenger)并回退到默认模式。

lib/domain/use_cases/app_update_use_case.dart (4)

13-20: 命名建议与项目规范保持一致

规范建议 UseCase 命名为 {Action}UseCase。当前 AppUpdateUseCase 可考虑更贴近动作的命名,例如 UpdateAppUseCase 或 InstallAppUpdateUseCase(若聚焦下载+安装流程)。


24-94: 权限检查与安装前置校验可提炼为私有方法,减少重复与分支复杂度

downloadAndInstallUpdate 与 installUpdate 都包含 isSupportedPlatform/权限检查等重复逻辑。建议提炼私有方法(如 _ensureInstallable() 和 _ensurePermissionGranted()),缩短主流程、便于单元测试与复用。

如需,我可以提交一个小型重构 PR,将通用校验封装并补充相应单测。


96-130: 错误传播一致性

downloadUpdate 在捕获异常时包装为 AppUpdateException 返回 Failure,正常情况下则透传底层错误(downloadResult.exceptionOrNull)。为便于调用方处理,建议统一用 AppUpdateException 包裹错误信息与上下文(如 URL、文件名)。


186-194: getUpdateFileSize 失败时静默返回 0 可能掩盖问题

为避免误导 UI(0 可能被解释为“未知”或“极小”),建议:

  • 返回 Result,错误分支包含异常信息;或
  • 同步记录更详细的上下文(URL)并在上层区分“未知大小”与“真实 0”。

可按现有模式先增加更多日志上下文。

lib/data/service/download_service.dart (4)

19-23: 建议支持取消下载(提升交互体验)

为配合“简化更新流程/优化交互”的目标,给 downloadFile 增加 CancelToken,便于 UI 侧随时取消下载,避免卡死或误触后的等待。

可直接应用以下变更:

   Future<Result<String>> downloadFile(
     String url,
     String fileName, {
     Function(int received, int total)? onProgress,
+    CancelToken? cancelToken,
   }) async {
@@
       await _dio.download(
         url,
         filePath,
         onReceiveProgress: (received, total) {
           onProgress?.call(received, total);
           if (total > 0) {
             final progress = (received / total * 100).toStringAsFixed(1);
             _logger.d('下载进度: $progress% ($received/$total 字节)');
           }
         },
+        cancelToken: cancelToken,
       );

Also applies to: 47-57


27-37: 使用 path.join 构建路径以确保跨平台正确性

当前使用字符串拼接路径在多平台(尤其 Windows)可能出现分隔符问题。建议引入 package:path 并用 path.join 规范化。

+import 'package:path/path.dart' as p;
@@
-      final downloadDirectory = Directory('${directory.path}/downloads');
+      final downloadDirectory = Directory(p.join(directory.path, 'downloads'));
@@
-      final filePath = '${downloadDirectory.path}/$fileName';
+      final filePath = p.join(downloadDirectory.path, fileName);

91-99: GitHub Releases/重定向环境下的 HEAD 可靠性建议

GitHub 资源经常 302 跳转到 CDN,部分场景 HEAD 可能未返回 content-length。除了已加 followRedirects 外,建议增加兜底:若无 content-length,尝试 GET + Range: bytes=0-0 读取 content-range 解析大小。

伪代码示例(供参考,放在 getFileSizecontent-length 为空分支):

final rangeResp = await _dio.get(
  url,
  options: Options(
    headers: {'Range': 'bytes=0-0'},
    responseType: ResponseType.bytes,
    followRedirects: true,
    validateStatus: (s) => s != null && s < 400,
  ),
);
final cr = rangeResp.headers.value('content-range'); // e.g. bytes 0-0/123456
if (cr != null && cr.contains('/')) {
  final total = int.tryParse(cr.split('/').last);
  if (total != null) return Success(total);
}
return Failure(DownloadException('无法从 content-range 推断文件大小'));

11-14: 关于在 Service 中持有 Logger 的规范确认

项目规范强调 Service 仅持有私有 Dio/SharedPreferences 引用。当前持有 Logger 实例略超出约束。建议确认团队约定:若需日志,是否改为使用全局 logger 或在调用侧记录。

可选调整方向:

  • 使用全局 appLogger(若可在 data 层访问)
  • 或通过构造注入 logger,但避免将其视为 Service 的核心依赖
lib/ui/settings/view_models/about_viewmodel.dart (1)

57-61: 为检查更新命令补充错误监听,确保异常可见

当前仅监听了 results。建议同时订阅 thrownExceptionserrors,把错误打到全局日志,避免静默失败。

可在紧随其后追加:

_checkUpdateCommand.thrownExceptions.listen((error, _) {
  appLogger.e('检查更新失败: $error');
});
📜 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.

📥 Commits

Reviewing files that changed from the base of the PR and between baaf293 and d248a0a.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (34)
  • android/app/src/main/AndroidManifest.xml (2 hunks)
  • lib/config/dependencies.dart (2 hunks)
  • lib/data/repository/update/update_repository.dart (1 hunks)
  • lib/data/service/app_installer_service.dart (1 hunks)
  • lib/data/service/download_service.dart (1 hunks)
  • lib/data/service/update_service.dart (1 hunks)
  • lib/domain/use_cases/app_update_use_case.dart (1 hunks)
  • lib/main_viewmodel.dart (1 hunks)
  • lib/routing/router.dart (4 hunks)
  • lib/routing/routes.dart (1 hunks)
  • lib/ui/api_config/widgets/api_config_page.dart (1 hunks)
  • lib/ui/core/main_layout.dart (4 hunks)
  • lib/ui/settings/view_models/about_viewmodel.dart (1 hunks)
  • lib/ui/settings/widgets/about_page.dart (4 hunks)
  • lib/ui/settings/widgets/ai_settings_screen.dart (1 hunks)
  • lib/ui/settings/widgets/ai_tag_settings_screen.dart (1 hunks)
  • lib/ui/settings/widgets/model_selection_screen.dart (1 hunks)
  • lib/ui/settings/widgets/settings_screen.dart (2 hunks)
  • lib/ui/settings/widgets/translation_settings_screen.dart (1 hunks)
  • pubspec.yaml (1 hunks)
  • test/ui/settings/view_models/ai_tag_settings_viewmodel_simple_test.dart (1 hunks)
  • test/unit/data/repository/ai_tag_recommendation/ai_tag_recommendation_repository_simple_test.dart (1 hunks)
  • test/unit/data/repository/bookmark/bookmark_repository_test.dart (1 hunks)
  • test/unit/data/repository/update/update_repository_test.dart (1 hunks)
  • test/unit/data/repository/update/update_repository_test.mocks.dart (1 hunks)
  • test/unit/data/service/update_service_test.dart (1 hunks)
  • test/unit/data/service/update_service_test.mocks.dart (1 hunks)
  • test/unit/data/service/web_content_service_simple_test.dart (1 hunks)
  • test/unit/view_models/about_viewmodel_test.dart (1 hunks)
  • test/unit/view_models/about_viewmodel_test.mocks.dart (1 hunks)
  • test/unit/view_models/main_viewmodel_test.dart (1 hunks)
  • test/unit/view_models/main_viewmodel_test.mocks.dart (1 hunks)
  • windows/flutter/generated_plugin_registrant.cc (1 hunks)
  • windows/flutter/generated_plugins.cmake (1 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
lib/routing/{router,routes}.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

路由定义与配置应集中在 routing/router.dart 与 routing/routes.dart 中

Files:

  • lib/routing/routes.dart
  • lib/routing/router.dart
lib/data/service/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/data/service/**/*.dart: Service 为无状态类,返回 Result,专注单一数据源,并仅持有私有 Dio/SharedPreferences 引用
命名规范:Service 命名为 {Purpose}Service

Files:

  • lib/data/service/update_service.dart
  • lib/data/service/app_installer_service.dart
  • lib/data/service/download_service.dart
lib/domain/use_cases/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

命名规范:UseCase 命名为 {Action}UseCase

Files:

  • lib/domain/use_cases/app_update_use_case.dart
lib/data/repository/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/data/repository/**/*.dart: Repository 返回 Result,实现缓存、错误处理(含必要重试),并进行 API→领域模型转换
Repository 不得继承 ChangeNotifier;数据变更通过 StreamController 对外通知
全局共享数据必须统一由 Repository 管理,作为单一数据源供多个 ViewModel 共享
命名规范:Repository 命名为 {Domain}Repository

Files:

  • lib/data/repository/update/update_repository.dart
lib/ui/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/ui/**/*.dart: 所有样式均通过主题提供,不得硬编码颜色、字号、间距等
View 构造函数仅接收 key 与 viewModel;不包含业务逻辑;用户交互通过 Command 执行
在处理 Command 结果时,优先使用监听器(在 initState 订阅 results/errors 并在 dispose 取消),避免在 Builder 回调中做副作用
View 不得直接使用 Repository(应通过 ViewModel 间接使用)
命名规范:页面组件命名为 {Feature}Screen

Files:

  • lib/ui/settings/widgets/model_selection_screen.dart
  • lib/ui/core/main_layout.dart
  • lib/ui/settings/widgets/settings_screen.dart
  • lib/ui/settings/widgets/ai_tag_settings_screen.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
  • lib/ui/settings/widgets/about_page.dart
  • lib/ui/api_config/widgets/api_config_page.dart
  • lib/ui/settings/widgets/translation_settings_screen.dart
  • lib/ui/settings/widgets/ai_settings_screen.dart
lib/**/view_models/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/**/view_models/**/*.dart: ViewModel 只能依赖 Repository,不得直接依赖 Service
ViewModel 必须继承 ChangeNotifier
ViewModel 对外异步操作需以 flutter_command 的 Command 暴露
ViewModel 使用全局 appLogger 记录重要操作与状态变化
ViewModel 之间不得互相引用或持有彼此实例,需通过 Repository/流进行通信
ViewModel 中处理 Result 时使用 result_dart 的便捷方法(isSuccess/getOrNull/exceptionOrNull),避免使用 fold()
命名规范:ViewModel 命名为 {Feature}ViewModel

Files:

  • lib/ui/settings/view_models/about_viewmodel.dart
🧠 Learnings (15)
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 必须继承 ChangeNotifier

Applied to files:

  • lib/main_viewmodel.dart
  • test/unit/view_models/about_viewmodel_test.dart
  • lib/ui/settings/widgets/model_selection_screen.dart
  • test/unit/view_models/about_viewmodel_test.mocks.dart
  • test/unit/view_models/main_viewmodel_test.dart
  • lib/ui/core/main_layout.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/data/repository/**/*.dart : 全局共享数据必须统一由 Repository 管理,作为单一数据源供多个 ViewModel 共享

Applied to files:

  • lib/main_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/data/repository/**/*.dart : Repository 不得继承 ChangeNotifier;数据变更通过 StreamController 对外通知

Applied to files:

  • lib/main_viewmodel.dart
  • test/unit/data/repository/update/update_repository_test.dart
  • lib/data/repository/update/update_repository.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 使用全局 appLogger 记录重要操作与状态变化

Applied to files:

  • test/unit/data/repository/bookmark/bookmark_repository_test.dart
  • lib/ui/core/main_layout.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/routing/{router,routes}.dart : 路由定义与配置应集中在 routing/router.dart 与 routing/routes.dart 中

Applied to files:

  • lib/routing/routes.dart
  • lib/routing/router.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/data/repository/**/*.dart : Repository 返回 Result<T>,实现缓存、错误处理(含必要重试),并进行 API→领域模型转换

Applied to files:

  • lib/data/repository/update/update_repository.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/ui/**/*.dart : View 构造函数仅接收 key 与 viewModel;不包含业务逻辑;用户交互通过 Command 执行

Applied to files:

  • lib/ui/settings/widgets/model_selection_screen.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 对外异步操作需以 flutter_command 的 Command 暴露

Applied to files:

  • lib/ui/settings/widgets/model_selection_screen.dart
  • lib/ui/core/main_layout.dart
  • lib/ui/settings/widgets/settings_screen.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/ui/**/*.dart : 在处理 Command 结果时,优先使用监听器(在 initState 订阅 results/errors 并在 dispose 取消),避免在 Builder 回调中做副作用

Applied to files:

  • lib/ui/settings/widgets/model_selection_screen.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 之间不得互相引用或持有彼此实例,需通过 Repository/流进行通信

Applied to files:

  • lib/ui/core/main_layout.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/ui/**/*.dart : View 不得直接使用 Repository(应通过 ViewModel 间接使用)

Applied to files:

  • lib/ui/core/main_layout.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : 命名规范:ViewModel 命名为 {Feature}ViewModel

Applied to files:

  • lib/ui/core/main_layout.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 只能依赖 Repository,不得直接依赖 Service

Applied to files:

  • lib/ui/core/main_layout.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 中处理 Result 时使用 result_dart 的便捷方法(isSuccess/getOrNull/exceptionOrNull),避免使用 fold()

Applied to files:

  • lib/ui/core/main_layout.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/data/service/**/*.dart : Service 为无状态类,返回 Result<T>,专注单一数据源,并仅持有私有 Dio/SharedPreferences 引用

Applied to files:

  • lib/data/service/download_service.dart
🧬 Code Graph Analysis (1)
windows/flutter/generated_plugin_registrant.cc (1)
linux/flutter/generated_plugin_registrant.cc (1)
  • fl_register_plugins (12-19)
⏰ 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). (2)
  • GitHub Check: review-pr
  • GitHub Check: Test
🔇 Additional comments (35)
windows/flutter/generated_plugins.cmake (1)

6-6: 确认:依赖声明和使用均正确,无需手动编辑 generated_plugins.cmake

  • pubspec.yaml (/pubspec.yaml) 中已声明 permission_handler: ^11.0.1,未显式依赖 permission_handler_windows
  • Dart 代码(lib/data/service/app_installer_service.dart)已正确 import 'package:permission_handler/permission_handler.dart'

上述条目均由 flutter pub get 自动生成,请继续通过修改 pubspec.yaml 管理依赖,避免手动编辑 windows/flutter/generated_plugins.cmake

windows/flutter/generated_plugin_registrant.cc (2)

9-9: 头文件包含正确、与插件名匹配

#include <permission_handler_windows/permission_handler_windows_plugin.h> 路径与插件名称一致,符合 Flutter Windows 插件的生成/注册约定。


15-16: 插件注册调用正确

PermissionHandlerWindowsPluginRegisterWithRegistrar(registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); 的符号名与字符串标识一致,注册顺序也无需强制要求,LGTM。

test/unit/data/repository/bookmark/bookmark_repository_test.dart (1)

30-30: 测试日志静默处理合理 — LGTM

将 appLogger 设为 Level.off 可有效降低测试噪声,同时避免未初始化导致的 LateInitializationError,和本 PR 其他测试文件的做法保持一致。

lib/main_viewmodel.dart (1)

3-3: 仅格式化变更,无功能影响 — LGTM

test/unit/data/repository/ai_tag_recommendation/ai_tag_recommendation_repository_simple_test.dart (1)

22-28: 测试日志静默处理 — LGTM

将 Logger.level 调整为 Level.off,测试输出更干净;PrettyPrinter 保留但不会实际输出,影响可忽略。

test/unit/data/service/web_content_service_simple_test.dart (1)

19-26: 测试日志静默处理 — LGTM

与仓库其他测试保持一致,降低测试日志噪声,便于聚焦断言结果。

pubspec.yaml (1)

37-42: 为应用内更新功能引入依赖 — LGTM

新增 package_info_plus/version/dio/open_filex/permission_handler/flutter_markdown 与本 PR 的更新检测、下载、安装、Markdown 渲染需求一致,版本范围合理。

lib/routing/routes.dart (1)

17-18: 关于路由嵌套调整合理,命名清晰

about 路由改为 /$settingsRelative/$aboutRelative 并补充 aboutRelative 常量,和本 PR 的 Settings 子路由重构一致,便于集中管理路径与相对段名。

test/ui/settings/view_models/ai_tag_settings_viewmodel_simple_test.dart (1)

139-142: 用 Future 异常断言替代轮询/错误属性检查是更简洁稳定的写法

改用 executeWithFuturethrowsA 断言失败分支,同时验证状态未改变与未通知监听者,覆盖点明确,测试更健壮。

Also applies to: 145-146

lib/ui/settings/widgets/ai_tag_settings_screen.dart (1)

20-45: 在 initState 订阅 Command 结果/错误并在 dispose 取消,符合交互规范

成功/失败分别通过 SnackBar 反馈,生命周期管理到位,符合“在 initState 订阅 results/errors 并在 dispose 取消”的准则。

lib/ui/settings/widgets/translation_settings_screen.dart (1)

163-171: 移除 Scaffold 并以根级 ListenableBuilder 驱动重建,方向正确

根节点直接监听 viewModel,配合在 initState 中的订阅/在 dispose 中取消订阅,符合本项目“在监听器中处理命令结果、避免在 builder 中做副作用”的约束。

lib/ui/settings/widgets/ai_settings_screen.dart (2)

79-90: Scaffold-free 重构与页面说明文字样式使用主题,方向正确

移除 Scaffold,直接以可滚动内容呈现,页首说明文字使用主题的 textTheme 与 colorScheme,无额外副作用,符合 View 约束。


136-171: 根据 hasApiKey 动态着色的处理清晰、无副作用

通过 ListenableBuilder 读取 hasApiKey 并动态使用 colorScheme 的 primary/onSurfaceVariant,符合可访问性与主题规范。

android/app/src/main/AndroidManifest.xml (2)

5-5: 保留 REQUEST_INSTALL_PACKAGES 即可满足未知来源安装需求

与 AppInstallerService 采用的“调起系统安装器(OpenFilex.open)”流程匹配,REQUEST_INSTALL_PACKAGES 是必要且充分的。


62-70: queries 节点中新增 http/https VIEW 意图匹配 url_launcher 需求,赞同

这有助于在 Android 11+ 的包可见性限制下正常解析外部浏览器/处理器。

lib/data/service/app_installer_service.dart (1)

36-41: 安装流程与 OpenFilex 结果映射清晰

  • 先校验平台与文件存在/大小;
  • 请求安装权限;
  • 使用 OpenFilex 调起系统安装器并按 ResultType 映射错误信息。
    整体流程合理,错误信息也足够明确。

Also applies to: 48-68

test/unit/view_models/main_viewmodel_test.mocks.dart (1)

31-345: 生成的 Mockito 存根文件无需人工调整

覆盖 SettingsRepository 的接口完整,供测试注入使用。请保持由生成流程维护,避免手改导致难以复现。

test/unit/view_models/main_viewmodel_test.dart (1)

24-27: Mock 行为覆盖到位,初始化路径合理

对 settingsChanged 使用空流、getThemeMode 返回 0 与期望的 ThemeMode.system 映射一致,初始化验证清晰。

lib/config/dependencies.dart (1)

21-26: 新增依赖注入导入项与现有结构契合,依赖关系清晰

UpdateService/Repository、DownloadService、AppInstallerService 与 AppUpdateUseCase 的引入合理,职责分离。

lib/ui/settings/widgets/settings_screen.dart (2)

104-111: “关于”条目副标题与更新信息联动逻辑清晰

通过 Consumer 读取 AboutViewModel.updateInfo 动态展示“发现新版本 {version}”,符合交互预期。


1-1: 引入 provider 依赖用于 Consumer 使用,合理

新增 import 与下方 Consumer 配合良好。

test/unit/data/repository/update/update_repository_test.mocks.dart (1)

25-41: Mock 生成代码合理,满足仓库层测试需求

MockUpdateService 的行为与测试场景匹配,使用 throwOnMissingStub 有助于及早暴露未 stub 路径。

test/unit/view_models/about_viewmodel_test.mocks.dart (2)

30-55: 生成的 Mockito mocks 符合预期,可保留在仓库中

签名与仓储/用例匹配,便于单元测试隔离依赖。


57-141: LGTM:用例方法覆盖完整

覆盖了下载、安装、查询大小等方法的桩实现,满足当前测试需求。

test/unit/data/repository/update/update_repository_test.dart (1)

37-43: “无更新”被视为 Failure 的行为已被测试覆盖

与当前实现一致。若后续采用“领域异常”或“空值成功”的语义模型,记得同步更新断言。

能否确认是否计划引入领域化异常(如 NoUpdateAvailableException)?若是,我可以同步生成改造后的测试断言。

lib/routing/router.dart (2)

83-96: 返回设置页时的返回行为处理合理

针对 Routes.settings 根页拦截返回并跳转首页的交互符合预期。


70-72: 已确认 go_router 版本支持 GoRouterState.fullPath
pubspec.yaml 中声明了 ^14.6.2,pubspec.lock 已锁定到 14.8.1,fullPath 自 14.x 即可用,无需做额外兼容处理。

lib/ui/core/main_layout.dart (3)

61-66: 确认 Provider 注入层级,避免 initState 中的 context.read 取不到依赖

在 initState 中通过 context.read() 与 context.read() 读取依赖,需要确保它们在 MainLayout 之上的 Widget 树中已正确注入。请确认 DI 配置与路由装配时机,避免运行期抛出 ProviderNotFoundException。


120-130: Drawer 关闭后再导航的处理得当,避免过渡卡顿

使用 addPostFrameCallback 延后导航,体验更顺滑。实现可复用,LGTM。


189-192: 设置项的 Badge 提示逻辑清晰

_trailing: _hasUpdate ? const Badge() : null 符合需求,简单直接。

test/unit/data/service/update_service_test.mocks.dart (1)

1-284: 自动生成的 Mock 文件无需人工修改,LGTM

文件头已声明由 Mockito 生成,接口覆盖完备,满足测试注入需要。

lib/data/service/download_service.dart (1)

24-74: 下载流程与校验逻辑整体设计合理,细节完备

  • 目录存在性检查与旧文件清理完善
  • 下载进度透传与日志细粒度合适
  • 下载后文件存在性与非空校验严谨
  • 错误捕获与栈记录完整
lib/ui/settings/view_models/about_viewmodel.dart (2)

44-50: 整体设计 LGTM:命令封装与状态暴露清晰,契合交互目标

  • 通过 flutter_command 暴露异步操作,UI 绑定简单
  • 下载/安装状态与进度字段清楚,通知时机合理
  • 错误向上抛出以便 UI 订阅 errors 流处理提示
  • 构造即触发检查更新,符合“应用启动即检测”的目标

Also applies to: 62-79, 92-123, 124-144


118-122: 已确认 result_dart 版本支持 getOrThrow

pubspec.yaml 中声明的 result_dart: ^2.1.0,自 2.0.0 版本起即包含 getOrThrow() 方法;代码库(包括单元测试)中已有多处成功调用示例,可放心使用此 API。

添加对asset名称和下载链接的类型检查,防止潜在的类型错误
更新测试用例以包含asset名称字段
@codecov
Copy link

codecov bot commented Aug 17, 2025

Codecov Report

❌ Patch coverage is 13.79310% with 700 lines in your changes missing coverage. Please review.
✅ Project coverage is 31.15%. Comparing base (9de0d01) to head (7df79da).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
lib/ui/settings/widgets/about_page.dart 0.00% 177 Missing ⚠️
lib/ui/settings/widgets/ai_settings_screen.dart 0.00% 96 Missing ⚠️
lib/ui/settings/view_models/about_viewmodel.dart 26.76% 52 Missing ⚠️
...ib/ui/settings/widgets/model_selection_screen.dart 0.00% 52 Missing ⚠️
lib/domain/use_cases/app_update_use_case.dart 32.00% 51 Missing ⚠️
lib/data/service/app_installer_service.dart 31.34% 46 Missing ⚠️
...ib/ui/settings/widgets/ai_tag_settings_screen.dart 0.00% 42 Missing ⚠️
lib/routing/router.dart 0.00% 35 Missing ⚠️
.../settings/widgets/translation_settings_screen.dart 0.00% 35 Missing ⚠️
lib/ui/api_config/widgets/api_config_page.dart 0.00% 34 Missing ⚠️
... and 4 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #75      +/-   ##
==========================================
+ Coverage   29.85%   31.15%   +1.29%     
==========================================
  Files          78       84       +6     
  Lines        5004     5415     +411     
==========================================
+ Hits         1494     1687     +193     
- Misses       3510     3728     +218     
Flag Coverage Δ
unittests 31.15% <13.79%> (+1.29%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
test/unit/data/service/update_service_test.dart (4)

40-41: 为 http.get 打桩时匹配 headers(User-Agent/Accept 等),避免未来出现 MissingStubError

如果 UpdateService 在真实实现里为 GitHub API 请求设置了 headers(例如必须的 User-Agent),当前的 when(mockHttpClient.get(any)) 可能不会匹配到实际调用,导致测试抛出 MissingStubError。建议:

  • 显式使用 any()(类型更明确);
  • 同时匹配可选的 headers: anyNamed('headers'),增强兼容性。

建议改动如下:

-      when(mockHttpClient.get(any)).thenAnswer(
+      when(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers'))).thenAnswer(
           (_) async => http.Response(jsonEncode(responsePayload), 200))
-      when(mockHttpClient.get(any)).thenAnswer(
+      when(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers'))).thenAnswer(
           (_) async => http.Response(jsonEncode(responsePayload), 200))
-      when(mockHttpClient.get(any))
+      when(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers')))
           .thenAnswer((_) async => http.Response('Not Found', 404))

Also applies to: 68-69, 85-86


45-48: 对 http.get 的调用次数做校验,增强断言力度

补充 verify 可防止实现层出现重复/多余的请求而未被发现。

应用如下增量(每个用例都加一次):

用例1:

       expect(updateInfo.downloadUrl, 'http://example.com/download');
+      verify(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers'))).called(1);

用例2:

       expect(updateInfo, isNull);
+      verify(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers'))).called(1);

用例3:

       expect(updateInfo, isNull);
+      verify(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers'))).called(1);

45-47: 避免在测试中使用非空断言 !,用 ?. 更稳妥

在断言前已验证非空,但保持无 ! 风格可读性更高,且在失败栈中信息更清晰。

-      expect(updateInfo!.version, '0.5.0');
-      expect(updateInfo.downloadUrl, 'http://example.com/download');
+      expect(updateInfo?.version, '0.5.0');
+      expect(updateInfo?.downloadUrl, 'http://example.com/download');

31-39: 补充边界与分支用例:assets 为空/无 apk、远端版本更旧、忽略非正式版本

当前用例覆盖“有更新”“同版本/无更新”“HTTP 错误”。建议再补:

  • 远端 assets 为空或没有 .apk 时返回 null;
  • 远端版本更旧(例如 v0.4.9 本地 0.5.0)返回 null;
  • 若实现中会过滤 prerelease/draft,则相应用例验证忽略这些版本。

可参考新增用例(示意):

test('returns null when assets are empty or contain no apk', () async {
  PackageInfo.setMockInitialValues(
    appName: 'readeck_app',
    packageName: 'com.example.readeck_app',
    version: '0.4.1',
    buildNumber: '1',
    buildSignature: '',
  );

  final payloadNoAssets = {'tag_name': 'v0.5.0', 'assets': []};
  when(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers')))
      .thenAnswer((_) async => http.Response(jsonEncode(payloadNoAssets), 200));

  final updateInfo1 = await updateService.checkForUpdate();
  expect(updateInfo1, isNull);

  final payloadNoApk = {
    'tag_name': 'v0.5.0',
    'assets': [
      {'name': 'notes.txt', 'browser_download_url': 'https://example.com/file.txt'}
    ]
  };
  when(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers')))
      .thenAnswer((_) async => http.Response(jsonEncode(payloadNoApk), 200));

  final updateInfo2 = await updateService.checkForUpdate();
  expect(updateInfo2, isNull);
});

test('returns null when remote version is older than current', () async {
  PackageInfo.setMockInitialValues(
    appName: 'readeck_app',
    packageName: 'com.example.readeck_app',
    version: '0.5.1',
    buildNumber: '1',
    buildSignature: '',
  );

  final responsePayload = {
    'tag_name': 'v0.5.0',
    'assets': [
      {'name': 'app.apk', 'browser_download_url': 'https://example.com/app.apk'}
    ]
  };
  when(mockHttpClient.get(any<Uri>(), headers: anyNamed('headers')))
      .thenAnswer((_) async => http.Response(jsonEncode(responsePayload), 200));

  final updateInfo = await updateService.checkForUpdate();
  expect(updateInfo, isNull);
});

Also applies to: 59-67

📜 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d248a0a and 377b9e8.

📒 Files selected for processing (2)
  • lib/data/service/update_service.dart (1 hunks)
  • test/unit/data/service/update_service_test.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/data/service/update_service.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 (1)
test/unit/data/service/update_service_test.dart (1)

11-19: 单测基本结构与依赖注入方式正确,可维护

通过构造函数注入 http.Client 并配合 Mockito 生成 Mock,隔离网络依赖的做法很干净,有利于扩展和复用。

移除AndroidManifest.xml中不再需要的WRITE_EXTERNAL_STORAGE和INSTALL_PACKAGES权限
简化AppInstallerService中的权限处理逻辑,移除直接安装和打开应用设置的冗余代码
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 (1)
lib/data/service/app_installer_service.dart (1)

99-118: 异常分支返回值已修正为 false,避免误导调用方 —— LGTM

_requestInstallPermissionhasInstallPermission 的 catch 分支返回 false,已修复先前评审提出的风险点,符合预期。

Also applies to: 120-134

🧹 Nitpick comments (5)
lib/data/service/app_installer_service.dart (5)

47-49: 明确指定 APK MIME Type,提升兼容性(部分设备/ROM 依赖 MIME 路由到安装器)

在部分厂商 ROM 或旧系统上,未显式传入 application/vnd.android.package-archive 可能无法正确调起安装器。建议传入 MIME。

-      final result = await OpenFilex.open(filePath);
+      final result = await OpenFilex.open(
+        filePath,
+        type: 'application/vnd.android.package-archive',
+      );

75-91: 在 Android 上调用 Process.run('chmod', ...) 基本不可用,建议改为 no-op 日志

dart:ioProcess 在移动端(Android/iOS)通常不可用,会抛出 UnsupportedError。虽然这里被 try/catch 吞掉,但每次都会异常,属于无效开销。open_filex 通过 FileProvider 暴露 content:// URI,通常无需额外修改权限。

建议将方法改为 no-op 并保留一条 verbose 日志:

   Future<void> _ensureFilePermissions(String filePath) async {
-    try {
-      // 在Android上,使用chmod命令设置文件权限
-      if (Platform.isAndroid) {
-        final result = await Process.run('chmod', ['644', filePath]);
-        if (result.exitCode == 0) {
-          _logger.i('文件权限设置成功: $filePath');
-        } else {
-          _logger.w('文件权限设置失败: ${result.stderr}');
-        }
-      }
-    } catch (e) {
-      _logger.w('设置文件权限时出现错误: $e');
-      // 不抛出异常,继续安装流程
-    }
+    // 移动端无需显式 chmod;open_filex 使用 FileProvider 即可处理可读访问。
+    _logger.v('跳过修改文件权限: $filePath');
   }

93-97: 与 Service 返回 Result 的团队规范略有偏差(仅建议,非阻断)

根据团队规范“Service 为无状态类,返回 Result…”,requestInstallPermission() 目前直接返回 bool。为对齐一致性,可考虑改为 Future<Result<bool>>,由调用方通过 fold/onSuccess 处理,或保持现状但在 UseCase 层统一 Result 化。


36-41: 可补充“引导至系统设置”的便捷方法,完善失败分支处理

当未授予安装权限时你返回 Failure 并提示用户去设置开启。为了减少上层耦合,可在本 Service 内提供一个辅助方法(不与库函数同名,避免遮蔽)来直接打开系统设置。

可新增方法(支持在当前文件任意合适位置添加):

/// 打开系统设置(用于引导用户手动授权“安装未知应用”)
Future<bool> openInstallPermissionSettings() async {
  try {
    // permission_handler 的顶级函数
    return await openAppSettings();
  } catch (e) {
    _logger.e('打开系统设置失败', error: e);
    return false;
  }
}

136-150: 跨平台扩展名映射说明(微建议)

getInstallFileExtension() 返回 Linux 为 deb,但实际发行版与安装包类型多样(deb/rpm/AppImage 等)。当前实现足够满足本 PR(Android)诉求;若后续扩展桌面平台,建议将该映射下沉到平台配置或通过构建配置注入。

📜 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 377b9e8 and 0ba3f17.

📒 Files selected for processing (2)
  • android/app/src/main/AndroidManifest.xml (2 hunks)
  • lib/data/service/app_installer_service.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • android/app/src/main/AndroidManifest.xml
🧰 Additional context used
📓 Path-based instructions (1)
lib/data/service/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/data/service/**/*.dart: Service 为无状态类,返回 Result,专注单一数据源,并仅持有私有 Dio/SharedPreferences 引用
命名规范:Service 命名为 {Purpose}Service

Files:

  • lib/data/service/app_installer_service.dart

refactor(about): 使用Provider重构About页面状态管理
refactor(logging): 移除Logger依赖改用全局appLogger
当已有模型数据时不再显示全屏加载,而是直接展示模型列表
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🔭 Outside diff range comments (1)
lib/ui/settings/view_models/model_selection_viewmodel.dart (1)

81-97: 未等待异步保存且抛出字符串异常,可能导致未捕获异常与状态不一致

  • selectModel 内直接调用 _saveSelectedModelawait,若保存失败将以未捕获异常的形式在微任务中抛出,同时 UI 已将选中态更新,导致“已选中但未保存成功”的状态不一致。
  • _saveSelectedModel 使用 throw '...' 抛出字符串,违背最佳实践(应抛出 Exception/自定义异常)。

建议:

  • selectModel 改为 Future<void>await 保存;保存失败时回滚 _selectedModelId 并记录日志。
  • 将字符串异常改为 Exception

可直接应用以下修改:

   void selectModel(OpenRouterModel model) {
-    _selectedModelId = model.id;
-    notifyListeners();
-    // 自动保存选中的模型
-    _saveSelectedModel(model.id);
+    // 使保存过程可等待,并在失败时回滚
+  Future<void> selectModel(OpenRouterModel model) async {
+    final prev = _selectedModelId;
+    _selectedModelId = model.id;
+    notifyListeners();
+    try {
+      // 自动保存选中的模型
+      await _saveSelectedModel(model.id);
+    } catch (e, s) {
+      appLogger.e('保存选中的模型失败,回滚到之前的选择', error: e, stackTrace: s);
+      _selectedModelId = prev;
+      notifyListeners();
+      rethrow;
+    }
   }
@@
     } else {
       appLogger.e('保存选中的模型失败', error: result.exceptionOrNull()!);
-      throw '保存选中的模型失败';
+      throw Exception('保存选中的模型失败');
     }
♻️ Duplicate comments (3)
lib/ui/settings/widgets/model_selection_screen.dart (1)

25-33: 占位态为非可滚动组件,导致无法触发下拉刷新(已在先前评审指出)

RefreshIndicator 需要可滚动子树。whileExecuting 的空数据分支、onError、以及空列表态均返回 Center,手势无法下传到可滚动组件,刷新指示器无法拉起。

将这些分支的内容用 ListView(physics: AlwaysScrollableScrollPhysics()) 包裹,确保任意状态都可下拉刷新。

可参考以下修改:

           whileExecuting: (context, _, __) {
             // 如果已有数据,显示数据内容而不是全屏Loading
             if (viewModel.availableModels.isNotEmpty) {
               return _buildModelsList();
             }
             // 无数据时显示Loading
-            return const Center(child: Loading(text: '正在加载模型列表...'));
+            return ListView(
+              physics: const AlwaysScrollableScrollPhysics(),
+              children: const [
+                SizedBox(height: 120),
+                Center(child: Loading(text: '正在加载模型列表...')),
+              ],
+            );
           },
-          onError: (context, error, _, __) => Center(
-            child: Card(
-              child: Padding(
-                padding: const EdgeInsets.all(24.0),
-                child: Column(
-                  mainAxisSize: MainAxisSize.min,
-                  children: [
+          onError: (context, error, _, __) => ListView(
+                physics: const AlwaysScrollableScrollPhysics(),
+                children: [
+                  const SizedBox(height: 80),
+                  Center(
+                    child: Card(
+                      child: Padding(
+                        padding: const EdgeInsets.all(24.0),
+                        child: Column(
+                          mainAxisSize: MainAxisSize.min,
+                          children: [
                             ...
-                    ),
-                  ],
-                ),
-              ),
-            ),
-          ),
+                          ],
+                        ),
+                      ),
+                    ),
+                  ),
+                ],
+              ),

以及空列表态:

-        if (viewModel.availableModels.isEmpty) {
-          return Center(
-            child: Card(
-              child: Padding(
-                padding: const EdgeInsets.all(24.0),
-                child: Column(
-                  mainAxisSize: MainAxisSize.min,
-                  children: [
+        if (viewModel.availableModels.isEmpty) {
+          return ListView(
+            physics: const AlwaysScrollableScrollPhysics(),
+            children: [
+              const SizedBox(height: 80),
+              Center(
+                child: Card(
+                  child: Padding(
+                    padding: const EdgeInsets.all(24.0),
+                    child: Column(
+                      mainAxisSize: MainAxisSize.min,
+                      children: [
                         ...
-                    ),
-                  ],
-                ),
-              ),
-            ),
-          );
+                      ],
+                    ),
+                  ),
+                ),
+              ),
+            ],
+          );
         }

Also applies to: 33-66, 77-111

lib/routing/router.dart (1)

203-217: 已修复:About 页在路由处提供 ViewModel,避免 ProviderNotFound

此处改为路由级注入 AboutViewModel 并传入页面,解决了先前“依赖上游全局注入导致潜在崩溃”的问题,且与其它设置子页风格一致。

lib/data/service/app_installer_service.dart (1)

52-55: Result 的成功返回值构造不正确

result_dartSuccess 通常不应使用 const,且对于 Result<void> 应返回 Success(null)。当前 const Success(unit) 既与签名不符,也可能因 const 导致编译失败。

请改为:

-        return const Success(unit);
+        return Success(null);
🧹 Nitpick comments (10)
lib/ui/settings/widgets/model_selection_screen.dart (1)

124-127: 交互建议:仅在保存成功后返回上一页,并处理失败提示

若采纳 ViewModel 中将 selectModel 改为异步并在失败时抛出异常,UI 侧可在 onTapawait 后再 pop(),并在失败时给出用户提示,避免“保存失败但已返回”的不一致。

-              onTap: () {
-                viewModel.selectModel(model);
-                Navigator.of(context).pop();
-              },
+              onTap: () async {
+                try {
+                  await viewModel.selectModel(model);
+                  if (context.mounted) Navigator.of(context).pop();
+                } catch (_) {
+                  ScaffoldMessenger.of(context).showSnackBar(
+                    const SnackBar(content: Text('保存选择失败,请重试')),
+                  );
+                }
+              },
lib/domain/use_cases/app_update_use_case.dart (5)

31-35: 平台判断存在重复与不一致,建议收敛到单一入口

前面已通过 InstallerService 的 isSupportedPlatform() 做了平台支持校验,后面又用 Platform.isAndroid 二次判断,存在重复且潜在不一致。建议仅依赖 InstallerService 的平台判断,或将平台分支完全下沉到 InstallerService,UseCase 只处理业务编排。

可选最小改动(去除后段的 Platform.isAndroid 分支,统一用 InstallerService):

-      // 安装更新(目前只支持Android APK)
-      if (Platform.isAndroid) {
-        _logger.i('开始安装APK');
-        final installResult = await _installerService.installApk(filePath);
-
-        if (installResult.isError()) {
-          final error = installResult.exceptionOrNull()!;
-          _logger.e('安装APK失败: $error');
-          return Failure(AppUpdateException('安装失败: ${error.toString()}'));
-        }
-
-        _logger.i('APK安装成功');
-        return const Success(());
-      } else {
-        // 其他平台的处理逻辑可以在这里添加
-        return Failure(AppUpdateException('当前平台尚不支持自动安装'));
-      }
+      // 安装更新(由 InstallerService 负责平台分发)
+      _logger.i('开始安装更新包');
+      final installResult = await _installerService.installApk(filePath);
+      if (installResult.isError()) {
+        final error = installResult.exceptionOrNull()!;
+        _logger.e('安装失败: $error');
+        return Failure(AppUpdateException('安装失败: ${error.toString()}'));
+      }
+      _logger.i('安装成功');
+      return const Success(());

Also applies to: 73-89


51-55: 下载文件名未做字符净化,可能引发文件系统不兼容

updateInfo.version 若包含斜杠、空格、中文或其它特殊字符,在部分机型/ROM 上可能导致下载或安装失败。建议对文件名进行 slug 化处理。

-      final fileExtension = _installerService.getInstallFileExtension();
-      final fileName = 'readeck_app_${updateInfo.version}.$fileExtension';
+      final fileExtension = _installerService.getInstallFileExtension();
+      final safeVersion = updateInfo.version
+          .replaceAll(RegExp(r'[^0-9A-Za-z\._-]'), '_');
+      final fileName = 'readeck_app_${safeVersion}.$fileExtension';

Also applies to: 106-109


184-193: getUpdateFileSize 将异常吞掉返回 0,建议改为显式结果类型

返回 0 会与真实“文件大小为 0”混淆,且丢失错误语义。建议返回 Result<int>,或最起码返回 -1 明确表示失败。

如可调整签名,推荐:

-  Future<int> getUpdateFileSize(UpdateInfo updateInfo) async {
-    try {
-      return await _downloadService.getFileSize(updateInfo.downloadUrl);
-    } catch (e) {
-      _logger.w('获取更新文件大小失败: $e');
-      return 0;
-    }
-  }
+  Future<Result<int>> getUpdateFileSize(UpdateInfo updateInfo) async {
+    try {
+      final size = await _downloadService.getFileSize(updateInfo.downloadUrl);
+      return Success(size);
+    } catch (e, st) {
+      _logger.w('获取更新文件大小失败: $e', error: e, stackTrace: st);
+      return Failure(AppUpdateException('获取更新文件大小失败: ${e.toString()}'));
+    }
+  }

13-20: UseCase 命名未严格符合规范,可考虑微调或拆分

规范为“{Action}UseCase”。AppUpdateUseCase 更像名词短语,可考虑:

  • UpdateAppUseCase(保留单一入口),或
  • 拆分为 DownloadUpdateUseCaseInstallUpdateUseCaseDownloadAndInstallUpdateUseCase,单一职责更清晰。

13-20: Logger 的注入方式请确认是否复用全局实例

当前在未显式注入时创建新的 Logger() 实例。请确保在 DI 中传入统一的全局 logger,以便日志集中管理;否则会在不同 UseCase 实例间产生分散的 logger。

lib/ui/settings/view_models/about_viewmodel.dart (4)

80-85: 命令体内直接 throw 可能导致 UI 未捕获异常

Command 的执行体里 throw,会让 execute() 返回的 Future 以错误结束,若上层未捕获,可能引发 UI 崩溃。建议通过 Command 的结果/错误流传递错误,而不是显式 throw

-      if (result.isError()) {
-        appLogger.e('更新失败: ${result.exceptionOrNull()}');
-        notifyListeners();
-        throw result.exceptionOrNull()!;
-      }
+      if (result.isError()) {
+        appLogger.e('更新失败: ${result.exceptionOrNull()}');
+        notifyListeners();
+        return;
+      }

110-115: 建议避免在命令体内 rethrow,保持一致的错误传递语义

与上条同理,推荐通过命令状态/错误流传递错误,避免 throw

-      if (result.isError()) {
-        appLogger.e('下载失败: ${result.exceptionOrNull()}');
-        notifyListeners();
-        throw result.exceptionOrNull()!;
-      }
+      if (result.isError()) {
+        appLogger.e('下载失败: ${result.exceptionOrNull()}');
+        notifyListeners();
+        return '';
+      }

133-137: 避免在安装命令中直接抛出异常

建议同样通过命令错误流传递,或返回后由上层统一监听处理。

-      if (result.isError()) {
-        appLogger.e('安装失败: ${result.exceptionOrNull()}');
-        notifyListeners();
-        throw result.exceptionOrNull()!;
-      }
+      if (result.isError()) {
+        appLogger.e('安装失败: ${result.exceptionOrNull()}');
+        notifyListeners();
+        return;
+      }

148-167: 版本号解析建议使用 package_info_plus 更稳健

直接解析 pubspec.yaml 在发布/混淆场景下可能不可靠。建议改用 package_info_plusPackageInfo.fromPlatform() 获取版本号。

如需,我可以补充替换实现及依赖声明。

📜 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 0ba3f17 and d619b83.

📒 Files selected for processing (15)
  • CLAUDE.md (1 hunks)
  • lib/data/repository/update/update_repository.dart (1 hunks)
  • lib/data/service/app_installer_service.dart (1 hunks)
  • lib/data/service/update_service.dart (1 hunks)
  • lib/domain/models/update/update_info.dart (1 hunks)
  • lib/domain/use_cases/app_update_use_case.dart (1 hunks)
  • lib/routing/router.dart (4 hunks)
  • lib/ui/api_config/widgets/api_config_page.dart (1 hunks)
  • lib/ui/settings/view_models/about_viewmodel.dart (1 hunks)
  • lib/ui/settings/view_models/model_selection_viewmodel.dart (1 hunks)
  • lib/ui/settings/widgets/model_selection_screen.dart (1 hunks)
  • test/unit/data/repository/update/update_repository_test.dart (1 hunks)
  • test/unit/data/repository/update/update_repository_test.mocks.dart (1 hunks)
  • test/unit/view_models/about_viewmodel_test.dart (1 hunks)
  • test/unit/view_models/about_viewmodel_test.mocks.dart (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • CLAUDE.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • test/unit/data/repository/update/update_repository_test.mocks.dart
  • lib/data/repository/update/update_repository.dart
  • test/unit/data/repository/update/update_repository_test.dart
  • test/unit/view_models/about_viewmodel_test.dart
  • test/unit/view_models/about_viewmodel_test.mocks.dart
  • lib/data/service/update_service.dart
  • lib/ui/api_config/widgets/api_config_page.dart
🧰 Additional context used
📓 Path-based instructions (6)
lib/domain/models/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/domain/models/**/*.dart: 所有领域模型使用 freezed 定义不可变数据模型
命名规范:领域模型命名为 {Entity} 或 {Entity}ApiModel

Files:

  • lib/domain/models/update/update_info.dart
lib/ui/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/ui/**/*.dart: 所有样式均通过主题提供,不得硬编码颜色、字号、间距等
View 构造函数仅接收 key 与 viewModel;不包含业务逻辑;用户交互通过 Command 执行
在处理 Command 结果时,优先使用监听器(在 initState 订阅 results/errors 并在 dispose 取消),避免在 Builder 回调中做副作用
View 不得直接使用 Repository(应通过 ViewModel 间接使用)
命名规范:页面组件命名为 {Feature}Screen

Files:

  • lib/ui/settings/view_models/model_selection_viewmodel.dart
  • lib/ui/settings/widgets/model_selection_screen.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
lib/**/view_models/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/**/view_models/**/*.dart: ViewModel 只能依赖 Repository,不得直接依赖 Service
ViewModel 必须继承 ChangeNotifier
ViewModel 对外异步操作需以 flutter_command 的 Command 暴露
ViewModel 使用全局 appLogger 记录重要操作与状态变化
ViewModel 之间不得互相引用或持有彼此实例,需通过 Repository/流进行通信
ViewModel 中处理 Result 时使用 result_dart 的便捷方法(isSuccess/getOrNull/exceptionOrNull),避免使用 fold()
命名规范:ViewModel 命名为 {Feature}ViewModel

Files:

  • lib/ui/settings/view_models/model_selection_viewmodel.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
lib/data/service/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

lib/data/service/**/*.dart: Service 为无状态类,返回 Result,专注单一数据源,并仅持有私有 Dio/SharedPreferences 引用
命名规范:Service 命名为 {Purpose}Service

Files:

  • lib/data/service/app_installer_service.dart
lib/domain/use_cases/**/*.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

命名规范:UseCase 命名为 {Action}UseCase

Files:

  • lib/domain/use_cases/app_update_use_case.dart
lib/routing/{router,routes}.dart

📄 CodeRabbit Inference Engine (.trae/rules/project_rules.md)

路由定义与配置应集中在 routing/router.dart 与 routing/routes.dart 中

Files:

  • lib/routing/router.dart
🧠 Learnings (14)
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 对外异步操作需以 flutter_command 的 Command 暴露

Applied to files:

  • lib/ui/settings/view_models/model_selection_viewmodel.dart
  • lib/ui/settings/widgets/model_selection_screen.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 必须继承 ChangeNotifier

Applied to files:

  • lib/ui/settings/widgets/model_selection_screen.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
  • lib/routing/router.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/ui/**/*.dart : View 构造函数仅接收 key 与 viewModel;不包含业务逻辑;用户交互通过 Command 执行

Applied to files:

  • lib/ui/settings/widgets/model_selection_screen.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/ui/**/*.dart : 在处理 Command 结果时,优先使用监听器(在 initState 订阅 results/errors 并在 dispose 取消),避免在 Builder 回调中做副作用

Applied to files:

  • lib/ui/settings/widgets/model_selection_screen.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 中处理 Result 时使用 result_dart 的便捷方法(isSuccess/getOrNull/exceptionOrNull),避免使用 fold()

Applied to files:

  • lib/data/service/app_installer_service.dart
  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/data/service/**/*.dart : Service 为无状态类,返回 Result<T>,专注单一数据源,并仅持有私有 Dio/SharedPreferences 引用

Applied to files:

  • lib/data/service/app_installer_service.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 使用全局 appLogger 记录重要操作与状态变化

Applied to files:

  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 之间不得互相引用或持有彼此实例,需通过 Repository/流进行通信

Applied to files:

  • lib/ui/settings/view_models/about_viewmodel.dart
  • lib/routing/router.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : 命名规范:ViewModel 命名为 {Feature}ViewModel

Applied to files:

  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/ui/**/*.dart : View 不得直接使用 Repository(应通过 ViewModel 间接使用)

Applied to files:

  • lib/ui/settings/view_models/about_viewmodel.dart
  • lib/routing/router.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/data/repository/**/*.dart : 全局共享数据必须统一由 Repository 管理,作为单一数据源供多个 ViewModel 共享

Applied to files:

  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 只能依赖 Repository,不得直接依赖 Service

Applied to files:

  • lib/ui/settings/view_models/about_viewmodel.dart
  • lib/routing/router.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/data/repository/**/*.dart : Repository 不得继承 ChangeNotifier;数据变更通过 StreamController 对外通知

Applied to files:

  • lib/ui/settings/view_models/about_viewmodel.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/routing/{router,routes}.dart : 路由定义与配置应集中在 routing/router.dart 与 routing/routes.dart 中

Applied to files:

  • lib/routing/router.dart
🔇 Additional comments (3)
lib/ui/settings/view_models/model_selection_viewmodel.dart (1)

55-57: 初始化 Command 的方式更稳健,赞一个

includeLastResultInCommandResults: true 搭配 initialValue: [] 能保证 Builder 初次渲染有数据源且能追溯到最近一次结果,符合本项目的 Command 使用约定。

lib/routing/router.dart (1)

71-81: 基于 fullPath 的标题与 FAB 判定更稳妥

使用 state.fullPath ?? state.matchedLocation 处理嵌套路由场景更准确,判定与标题映射逻辑清晰。

Also applies to: 84-85

lib/ui/settings/view_models/about_viewmodel.dart (1)

11-17: 依赖关系符合“仅依赖 Repository(不直接依赖 Service)”,但请确认是否允许直接依赖 UseCase

规范明确禁止依赖 Service,未明确限制 UseCase。当前直接依赖 AppUpdateUseCase,从分层角度是合理的,但请确认团队共识。

Comment on lines +1 to +13
class UpdateInfo {
final String version;
final String downloadUrl;
final String releaseNotes;
final String htmlUrl;

UpdateInfo({
required this.version,
required this.downloadUrl,
required this.releaseNotes,
required this.htmlUrl,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

领域模型未使用 freezed,不符合项目约定

项目规范要求 domain 模型用 freezed 定义不可变数据模型,便于获得等值、拷贝、序列化等能力。建议改为 freezed 声明,并添加 fromJson 支持。

参考实现(需要同时生成代码:flutter pub run build_runner build --delete-conflicting-outputs):

-class UpdateInfo {
-  final String version;
-  final String downloadUrl;
-  final String releaseNotes;
-  final String htmlUrl;
-
-  UpdateInfo({
-    required this.version,
-    required this.downloadUrl,
-    required this.releaseNotes,
-    required this.htmlUrl,
-  });
-}
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'update_info.freezed.dart';
+part 'update_info.g.dart';
+
+@freezed
+class UpdateInfo with _$UpdateInfo {
+  const factory UpdateInfo({
+    required String version,
+    required String downloadUrl,
+    required String releaseNotes,
+    required String htmlUrl,
+  }) = _UpdateInfo;
+
+  factory UpdateInfo.fromJson(Map<String, dynamic> json) =>
+      _$UpdateInfoFromJson(json);
+}
📝 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.

Suggested change
class UpdateInfo {
final String version;
final String downloadUrl;
final String releaseNotes;
final String htmlUrl;
UpdateInfo({
required this.version,
required this.downloadUrl,
required this.releaseNotes,
required this.htmlUrl,
});
}
import 'package:freezed_annotation/freezed_annotation.dart';
part 'update_info.freezed.dart';
part 'update_info.g.dart';
@freezed
class UpdateInfo with _$UpdateInfo {
const factory UpdateInfo({
required String version,
required String downloadUrl,
required String releaseNotes,
required String htmlUrl,
}) = _UpdateInfo;
factory UpdateInfo.fromJson(Map<String, dynamic> json) =>
_$UpdateInfoFromJson(json);
}
🤖 Prompt for AI Agents
In lib/domain/models/update/update_info.dart around lines 1 to 13, the
UpdateInfo domain model is a plain class but must follow project convention to
use freezed for immutable, equatable, and serializable models; replace the class
with a freezed data class (annotated with @freezed), add a factory constructor
and a factory UpdateInfo.fromJson(Map<String, dynamic> json) =>
_$UpdateInfoFromJson(json) to enable JSON (de)serialization, add the necessary
part directives for generated files, and run code generation (flutter pub run
build_runner build --delete-conflicting-outputs) to produce the *.g.dart and
*.freezed.dart files.

Comment on lines +36 to +49
// 检查是否有安装权限,如果没有则尝试请求
final hasInstallPermission =
await _installerService.hasInstallPermission();
if (!hasInstallPermission) {
_logger.w('没有安装权限,尝试请求权限');
// 尝试请求权限,该方法内部会处理权限请求
final permissionGranted =
await _installerService.requestInstallPermission();
if (!permissionGranted) {
return Failure(
AppUpdateException('需要安装权限才能自动更新应用。请在设置中授权"安装未知应用"权限。'));
}
_logger.i('安装权限请求成功');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

安装权限检查逻辑重复,建议抽取为私有方法

两处完全相同的安装权限检查与请求逻辑可抽取为 _ensureInstallPermission(),降低重复与维护成本。

在两处调用位置应用以下最小替换:

-      // 检查是否有安装权限,如果没有则尝试请求
-      final hasInstallPermission =
-          await _installerService.hasInstallPermission();
-      if (!hasInstallPermission) {
-        _logger.w('没有安装权限,尝试请求权限');
-        // 尝试请求权限,该方法内部会处理权限请求
-        final permissionGranted =
-            await _installerService.requestInstallPermission();
-        if (!permissionGranted) {
-          return Failure(
-              AppUpdateException('需要安装权限才能自动更新应用。请在设置中授权"安装未知应用"权限。'));
-        }
-        _logger.i('安装权限请求成功');
-      }
+      final perm = await _ensureInstallPermission();
+      if (perm.isError()) return perm;

在文件内(类的私有区域)新增该工具方法(附加代码块,需放置在类内任意私有区域):

Future<Result<void>> _ensureInstallPermission() async {
  final hasInstallPermission = await _installerService.hasInstallPermission();
  if (hasInstallPermission) return const Success(unit);

  _logger.w('没有安装权限,尝试请求权限');
  final permissionGranted = await _installerService.requestInstallPermission();
  if (!permissionGranted) {
    return Failure(
      AppUpdateException('需要安装权限才能自动更新应用。请在设置中授权"安装未知应用"权限。'),
    );
  }
  _logger.i('安装权限请求成功');
  return const Success(unit);
}

Also applies to: 149-162

🤖 Prompt for AI Agents
In lib/domain/use_cases/app_update_use_case.dart around lines 36-49 (and
similarly 149-162), the install-permission check and request logic is
duplicated; extract it into a private helper _ensureInstallPermission() inside
the class and replace both duplicated blocks with a call to that helper.
Implement _ensureInstallPermission() to call
_installerService.hasInstallPermission(), return success immediately if true,
otherwise log a warning, call _installerService.requestInstallPermission(),
return a Failure(AppUpdateException(...)) if the request is denied, log the
success when granted, and return success; update the two call sites to await the
helper and propagate the Failure if returned.

Comment on lines +117 to +121
if (downloadResult.isError()) {
_logger.e('下载更新文件失败');
return Failure(downloadResult.exceptionOrNull()!);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

错误类型不统一:建议统一包装为 AppUpdateException

当前直接向上抛了底层异常对象,和其他分支使用 AppUpdateException 不一致,增加 UI 层处理复杂度。建议统一为领域异常。

-      if (downloadResult.isError()) {
-        _logger.e('下载更新文件失败');
-        return Failure(downloadResult.exceptionOrNull()!);
-      }
+      if (downloadResult.isError()) {
+        final error = downloadResult.exceptionOrNull()!;
+        _logger.e('下载更新文件失败: $error');
+        return Failure(AppUpdateException('下载更新文件失败: ${error.toString()}'));
+      }

我们需要先确认 AppUpdateException 在代码库中的定义及使用情况,以确保建议的一致性。


🏁 Script executed:

#!/bin/bash
set -e

echo "1. 查找 AppUpdateException 类定义"
rg -nP 'class\s+AppUpdateException\b' --glob '*.dart'

echo "2. 查找项目中对 AppUpdateException 的引用"
rg -n 'AppUpdateException' --glob '*.dart'

Length of output: 2043


统一使用 AppUpdateException 处理下载失败

请将该分支中直接返回底层异常的逻辑改为统一包装为 AppUpdateException,以保持与其他错误分支一致,简化 UI 层处理复杂度。

· 位置:lib/domain/use_cases/app_update_use_case.dart 第 117–121 行
· 建议变更:

-      if (downloadResult.isError()) {
-        _logger.e('下载更新文件失败');
-        return Failure(downloadResult.exceptionOrNull()!);
-      }
+      if (downloadResult.isError()) {
+        final error = downloadResult.exceptionOrNull()!;
+        _logger.e('下载更新文件失败: $error');
+        return Failure(AppUpdateException('下载更新文件失败: ${error.toString()}'));
+      }
📝 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.

Suggested change
if (downloadResult.isError()) {
_logger.e('下载更新文件失败');
return Failure(downloadResult.exceptionOrNull()!);
}
if (downloadResult.isError()) {
final error = downloadResult.exceptionOrNull()!;
_logger.e('下载更新文件失败: $error');
return Failure(AppUpdateException('下载更新文件失败: ${error.toString()}'));
}
🤖 Prompt for AI Agents
In lib/domain/use_cases/app_update_use_case.dart around lines 117–121, the
download error branch currently returns the raw underlying exception; change it
to wrap that exception in an AppUpdateException and return
Failure(AppUpdateException(...)) so the error shape is consistent with other
branches. Extract the underlying exception from
downloadResult.exceptionOrNull(), create an AppUpdateException that includes a
clear message (e.g. "下载更新文件失败") and the original exception as the cause, return
Failure(AppUpdateException(...)), and add/import the AppUpdateException symbol
if not already imported.

Comment on lines +168 to +171
if (installResult.isError()) {
_logger.e('安装APK失败');
return Failure(installResult.exceptionOrNull()!);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

错误类型不统一:installUpdate 分支直接返回底层异常

与其他流程相同,建议统一返回 AppUpdateException,避免上层分支判断多种异常类型。

-      if (installResult.isError()) {
-        _logger.e('安装APK失败');
-        return Failure(installResult.exceptionOrNull()!);
-      }
+      if (installResult.isError()) {
+        final error = installResult.exceptionOrNull()!;
+        _logger.e('安装APK失败: $error');
+        return Failure(AppUpdateException('安装失败: ${error.toString()}'));
+      }
📝 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.

Suggested change
if (installResult.isError()) {
_logger.e('安装APK失败');
return Failure(installResult.exceptionOrNull()!);
}
if (installResult.isError()) {
final error = installResult.exceptionOrNull()!;
_logger.e('安装APK失败: $error');
return Failure(AppUpdateException('安装失败: ${error.toString()}'));
}
🤖 Prompt for AI Agents
In lib/domain/use_cases/app_update_use_case.dart around lines 168 to 171, the
installUpdate branch currently returns the raw lower-level exception
(installResult.exceptionOrNull()), causing inconsistent error types; change it
to wrap the underlying exception in an AppUpdateException and return
Failure(AppUpdateException(...)) instead — construct AppUpdateException with a
clear message and pass the original exception (and stack trace if available) as
the cause so callers always receive AppUpdateException for update failures.

Comment on lines +235 to +242
path: Routes.modelSelectionRelative,
builder: (context, state) {
final viewModel = ModelSelectionViewModel(
context.read(), context.read());
return ModelSelectionScreen(
viewModel: viewModel,
);
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

为 ModelSelectionScreen 提供 Provider,确保 ViewModel 生命周期正确释放

当前直接构造 ModelSelectionViewModel 并传入页面,但未托管其 dispose()(VM 内部会释放 Command)。建议与其他页面一致,用 ChangeNotifierProvider 包裹,自动管理生命周期。

-                      builder: (context, state) {
-                        final viewModel = ModelSelectionViewModel(
-                            context.read(), context.read());
-                        return ModelSelectionScreen(
-                          viewModel: viewModel,
-                        );
-                      },
+                      builder: (context, state) {
+                        return ChangeNotifierProvider(
+                          create: (context) =>
+                              ModelSelectionViewModel(context.read(), context.read()),
+                          child: Consumer<ModelSelectionViewModel>(
+                            builder: (context, vm, _) =>
+                                ModelSelectionScreen(viewModel: vm),
+                          ),
+                        );
+                      },
📝 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.

Suggested change
path: Routes.modelSelectionRelative,
builder: (context, state) {
final viewModel = ModelSelectionViewModel(
context.read(), context.read());
return ModelSelectionScreen(
viewModel: viewModel,
);
},
path: Routes.modelSelectionRelative,
builder: (context, state) {
return ChangeNotifierProvider(
create: (context) =>
ModelSelectionViewModel(context.read(), context.read()),
child: Consumer<ModelSelectionViewModel>(
builder: (context, vm, _) =>
ModelSelectionScreen(viewModel: vm),
),
);
},
🤖 Prompt for AI Agents
In lib/routing/router.dart around lines 235 to 242, the route builder constructs
ModelSelectionViewModel directly and passes it to ModelSelectionScreen so the
ViewModel.dispose() is never managed; wrap the screen with a
ChangeNotifierProvider that creates the ModelSelectionViewModel (using
context.read() for dependencies) and pass the screen as the provider's child so
Provider will call dispose() automatically; keep the ModelSelectionScreen
receiving its ViewModel from Provider (or via context.watch()/read() inside the
screen) rather than having the route directly construct and pass the VM.

Comment on lines +50 to +59
_checkUpdateCommand = Command.createAsyncNoParam<UpdateInfo?>(() async {
final result = await _updateRepository.checkForUpdate();
return result.getOrNull();
}, initialValue: null);

_checkUpdateCommand.results.listen((commandResult, _) {
_updateInfo = commandResult.data;
notifyListeners();
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

订阅未释放,存在潜在内存泄漏风险

_checkUpdateCommand.results.listen(...) 的订阅未保存与取消。建议持有订阅并在 dispose() 中取消;同时对各 Command 调用 dispose() 释放内部 StreamController。

最小改动示例:

+import 'dart:async';
@@
-    _checkUpdateCommand.results.listen((commandResult, _) {
+    _checkUpdateSubscription = _checkUpdateCommand.results.listen((commandResult, _) {
       _updateInfo = commandResult.data;
       notifyListeners();
     });

并在类中新增订阅字段与 dispose()(附加代码,需放置在类内):

late final StreamSubscription _checkUpdateSubscription;

@override
void dispose() {
  _checkUpdateSubscription.cancel();
  // 逐一释放 Commands
  load.dispose();
  _checkUpdateCommand.dispose();
  _downloadAndInstallUpdateCommand.dispose();
  _downloadUpdateCommand.dispose();
  _installUpdateCommand.dispose();
  super.dispose();
}
🤖 Prompt for AI Agents
In lib/ui/settings/view_models/about_viewmodel.dart around lines 50 to 59, the
StreamSubscription returned by _checkUpdateCommand.results.listen(...) is not
preserved or cancelled, causing a potential memory leak; capture that
subscription into a class-level field (e.g. late final StreamSubscription
_checkUpdateSubscription) when you call listen, and cancel it in the view
model's override dispose() method; additionally call dispose() on each Command
instance (load, _checkUpdateCommand, _downloadAndInstallUpdateCommand,
_downloadUpdateCommand, _installUpdateCommand) inside dispose() before calling
super.dispose() so all internal StreamControllers are released.

Comment on lines +23 to +25
child: CommandBuilder<void, void>(
command: viewModel.loadModels,
whileExecuting: (context, _, __) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

CommandBuilder 泛型与实际 Command 不匹配,可能导致类型错误

viewModel.loadModels 的类型为 Command<void, List<OpenRouterModel>>,这里写成了 CommandBuilder<void, void>,应改为 CommandBuilder<void, List<OpenRouterModel>>

-        child: CommandBuilder<void, void>(
+        child: CommandBuilder<void, List<OpenRouterModel>>(
           command: viewModel.loadModels,
📝 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.

Suggested change
child: CommandBuilder<void, void>(
command: viewModel.loadModels,
whileExecuting: (context, _, __) {
child: CommandBuilder<void, List<OpenRouterModel>>(
command: viewModel.loadModels,
whileExecuting: (context, _, __) {
🤖 Prompt for AI Agents
In lib/ui/settings/widgets/model_selection_screen.dart around lines 23 to 25,
the CommandBuilder generic types are incorrect: it currently uses
CommandBuilder<void, void> while viewModel.loadModels is Command<void,
List<OpenRouterModel>>; change the CommandBuilder generic to
CommandBuilder<void, List<OpenRouterModel>> so the types match the actual
Command signature and avoid type errors.

添加了多个单元测试文件,包括:
1. AppInstallerService测试 - 验证平台支持、异常处理和权限检查
2. DownloadService测试 - 测试文件下载、异常处理和进度回调
3. AppUpdateUseCase测试 - 验证更新流程和错误处理
4. ModelSelectionViewModel测试 - 测试模型选择和排序逻辑
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (30)
test/unit/data/service/app_installer_service_test.dart (9)

11-13: 在测试环境静默日志,减少 CI 噪音

默认 Logger 会输出日志,建议在测试中将级别降为 Level.nothing。

-      service = AppInstallerService(logger: Logger());
+      service = AppInstallerService(logger: Logger(level: Level.nothing));

15-33: 当前仅验证“运行平台”的扩展名,建议可注入 Platform 抽象以实现跨平台覆盖

此用例只断言当前运行环境的分支,无法在 CI 中稳定覆盖多平台逻辑。建议为 AppInstallerService 引入可注入的 Platform 封装(或在构造函数中传入 PlatformAdapter),在测试中以虚拟平台参数化测试所有分支(Android/iOS/macOS/Windows/Linux/unknown)。

如需,我可以起草一个 PlatformAdapter 接口及相应的测试改造示例。


55-55: 断言风格建议:使用 isTrue/isFalse 提升可读性与失败信息质量

将布尔断言切换为 isTrue/isFalse,可得到更清晰的失败提示。

-          expect(result.isError(), true);
+          expect(result.isError(), isTrue);
-          expect(hasPermission, false);
+          expect(hasPermission, isFalse);
-          expect(granted, false);
+          expect(granted, isFalse);
-        expect(result.isError(), true);
+        expect(result.isError(), isTrue);
-          expect(result.isError(), true);
+          expect(result.isError(), isTrue);
-        expect(result.isError(), true);
+        expect(result.isError(), isTrue);

Also applies to: 66-66, 74-74, 88-88, 108-108, 142-142


57-57: 冗余断言:已做强制类型转换后无需再使用 isA

变量已通过 “as InstallException” 强转,此时再用 isA() 断言是多余的,可移除以简化测试。

-          expect(exception, isA<InstallException>());

Also applies to: 90-90, 110-110


80-84: 移除冗余平台分支并忽略未使用参数

该 group 已使用 skip: !Platform.isAndroid;此处的 if (!Platform.isAndroid) return 冗余。同时可将未使用的 tester 参数替换为下划线,避免潜在的未使用参数告警。

-      testWidgets('installApk should check file existence', (tester) async {
-        if (!Platform.isAndroid) {
-          return; // 跳过非Android平台的测试
-        }
+      testWidgets('installApk should check file existence', (_) async {

95-98: 移除冗余平台分支(group 已跳过非 Android)

与上一个用例同理,这里的平台判断分支可以去掉。

-        if (!Platform.isAndroid) {
-          return;
-        }
+        // Android-only group (skip: !Platform.isAndroid) 已保证平台条件

99-107: 临时文件命名存在冲突风险,建议使用时间戳确保唯一

在并发或重复运行测试时,固定文件名可能冲突。可基于微秒时间戳生成唯一文件名。

-        final tempDir = Directory.systemTemp;
-        final tempFile = File('${tempDir.path}/tiny_test.apk');
+        final tempDir = Directory.systemTemp;
+        final tempFile =
+            File('${tempDir.path}/tiny_${DateTime.now().microsecondsSinceEpoch}.apk');

121-133: 权限测试与前文用例存在语义重叠,可合并精简

此前已覆盖非 Android 平台的权限为 false 场景;这里的 returnsNormally 仅验证不抛异常。建议考虑与前面“非 Android 权限返回 false”的用例合并,或在此明确区分 Android/非 Android 的期望值,避免重复。


135-144: 使用 test 的 skip 参数替代手动 return,保持风格一致

与 Android 专属用例组的写法保持一致,直接对该用例使用 skip: !Platform.isAndroid,并移除函数体内的 early-return。

-      test('should handle empty file path gracefully', () async {
-        if (!Platform.isAndroid) {
-          return;
-        }
+      test('should handle empty file path gracefully', () async {
         final result = await service.installApk('');
         expect(result.isError(), true);
-      });
+      }, skip: !Platform.isAndroid);
test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart (7)

32-41: 分组名可更具体,避免与 VM 测试混淆

该分组仅验证“列表为空”等基础断言,建议命名为“模型列表基础断言(不依赖 VM)”之类,避免与后续真正的 ViewModel 行为测试混淆。

-    group('基础功能测试', () {
+    group('模型列表基础断言(不依赖 VM)', () {

53-61: 使用更语义化的匹配器以提升可读性

与其断言 expect(model1.id != model2.id, true),更推荐使用 isNot(equals(...)),语义更清晰。

-        expect(model1.id != model2.id, true);
-        expect(model2.id != model3.id, true);
-        expect(model1.contextLength != model2.contextLength, true);
+        expect(model1.id, isNot(equals(model2.id)));
+        expect(model2.id, isNot(equals(model3.id)));
+        expect(model1.contextLength, isNot(equals(model2.contextLength)));

74-85: 多处“前置移动”重排逻辑重复,可抽出私有辅助函数

相同的“按 ID 移动到首位”逻辑在多处测试重复。考虑在测试文件内提取为局部函数,减少重复并降低出错风险。

示例(可放在文件内 main() 顶层下方):

OpenRouterModel? moveToFrontById(List<OpenRouterModel> models, String id) {
  final selected = models.where((m) => m.id == id).firstOrNull;
  if (selected == null) return null;
  models.removeWhere((m) => m.id == id);
  models.insert(0, selected);
  return selected;
}

然后在测试中替换为:

final selected = moveToFrontById(models, selectedModelId);

Also applies to: 91-101, 120-131, 133-145, 220-231


156-166: 校验默认值前需确认领域模型的实际默认定义

这里假设 OpenRouterModel 的 contextLength 默认值为 0、pricing 为 null。若领域模型稍后调整默认值(例如 contextLength 默认为 4096 或提供非空默认 Pricing),该断言会产生脆弱性。

建议:

  • 与领域模型定义保持同步(prefer: 读取常量或工厂方法生成的“默认模型”再断言),或
  • 放宽为“非负数/可空”的结构性断言。

168-196: 可考虑将 fullModel 设为 const(若构造器支持),提升不可变性与分析友好度

如果 OpenRouterModel/ModelPricing/ModelArchitecture 提供 const 构造器,这里可用 const,利于静态分析与速度;否则保持现状。

-        const fullModel = OpenRouterModel(
+        const fullModel = OpenRouterModel(
           id: 'full',
           name: 'Full Model',
           description: 'A complete model',
           contextLength: 8192,
           created: 1234567890,
-          pricing: ModelPricing(
+          pricing: ModelPricing(
             prompt: '0.001',
             completion: '0.002',
             image: '0.003',
             request: '0.004',
           ),
-          architecture: ModelArchitecture(
+          architecture: ModelArchitecture(
             inputModalities: ['text'],
             outputModalities: ['text'],
             tokenizer: 'gpt-4',
           ),
         );

若相关类型无 const 构造器,请忽略本建议。


198-219: “性能测试”建议限制规模或使用超时标记以防 CI 波动

构造 1000 个对象在多数 CI 环境问题不大,但性能类测试更易受环境波动影响。建议:

  • 添加较短超时(如 Timeout(Duration(seconds: 5))),或
  • 标记为非阻塞/较低优先级,或
  • 将更大规模的性能评估迁移到基准测试而非单元测试。

64-73: 推荐补充“排序稳定性/幂等性”断言

除了当前的前置移动断言,可补充:

  • 重复选择同一 ID 时结果不变(幂等性)。
  • 选中首位元素再次前置不改变顺序(稳定性)。
    这能更贴近真实 ViewModel 的需求。

Also applies to: 91-104

test/unit/data/service/download_service_test.dart (8)

1-6: 避免真实网络依赖:为 DownloadService 注入带短超时的 Dio,并可选引入 MockAdapter

当前测试依赖真实网络错误来覆盖失败路径,易产生偶发失败和超时。建议:

  • 统一为 DownloadService 注入带短超时的 Dio(降低等待时间)。
  • 如需覆盖成功路径与更可控的失败场景,建议引入 dio_http_mock_adapter(dev_dependency)做 HTTP mocking,彻底消除对网络的依赖。

如采用 MockAdapter,需在此处补充依赖与导入:

+import 'package:dio/dio.dart';
+// 可选:如采用 HTTP mocking
+// import 'package:dio_http_mock_adapter/dio_http_mock_adapter.dart';

10-12: 在 setUp 中注入带短超时的 Dio,减少长时间阻塞

为避免 DNS/TLS 超时导致的测试缓慢或波动,建议注入带较短 connect/receive 超时的 Dio:

 setUp(() {
-  service = DownloadService();
+  final dio = Dio(
+    BaseOptions(
+      connectTimeout: const Duration(milliseconds: 300),
+      receiveTimeout: const Duration(milliseconds: 300),
+    ),
+  );
+  service = DownloadService(dio: dio);
 });

若采用 MockAdapter,可在此处挂载 adapter 并按用例配置期望响应。


59-62: 断言异常“文案”会导致脆弱性,建议仅断言异常类型

异常信息容易随着本地化或实现细节改变而变化。建议移除对具体文案的断言,仅保留类型校验:

 final exception = result.exceptionOrNull() as DownloadException;
 expect(exception, isA<DownloadException>());
-expect(exception.message, contains('下载失败'));

64-71: 将不存在的域名替换为保留域名 example.invalid,失败更确定、失败更快

使用保留 TLD(RFC 2606)的 example.invalid 可避免 DNS 偶发解析、跳转等问题,失败更可控:

-const url = 'https://nonexistent-domain-12345.com/file.apk';
+const url = 'http://example.invalid/file.apk';

同类场景建议全文件一致替换(也可统一为 http 降低 TLS 握手时间)。


77-97: 进度回调用例也建议使用 example.invalid,保持一致与确定性

同上,建议替换测试 URL 以保证失败路径稳定:

-const url = 'https://nonexistent-domain.com/file.apk';
+const url = 'http://example.invalid/file.apk';

此外,建议补充一条“成功下载时进度回调应被调用”的正向用例(见下方建议)。


116-123: 小拼写/一致性:这里的扩展名建议统一为 .apk

避免歧义,统一使用 apk 扩展名:

-const fileName = 'test.apv';
+const fileName = 'test.apk';

126-147: getFileSize 的失败路径同样建议改用 example.invalid

保持一致、提升测试确定性与速度:

-const url = 'https://nonexistent-domain.com/file.apk';
+const url = 'http://example.invalid/file.apk';

51-75: 补齐“成功路径”测试,提升覆盖与可信度(建议使用 HTTP mocking)

目前仅覆盖失败分支,建议增加:

  • 成功下载:返回 Success(filePath),目标文件存在且非空。
  • 成功下载触发进度回调:onProgress 至少被调用一次且 total >= received。
  • getFileSize 成功:返回 > 0。

如采用 dio_http_mock_adapter,可参考如下示例(片段,仅供思路):

final dio = Dio();
final adapter = DioAdapter(dio: dio);
adapter.onHead(
  RegExp(r'.*\.apk$'),
  (server) => server.reply(200, '', headers: {'content-length': '1024'}),
);
adapter.onGet(
  RegExp(r'.*\.apk$'),
  (server) => server.reply(200, List<int>.filled(1024, 1), headers: {
    Headers.contentTypeHeader: 'application/vnd.android.package-archive',
  }),
);
// 将 dio 注入 DownloadService(dio: dio)

如需彻底去插件依赖,可考虑让 DownloadService 支持注入保存目录(测试时指向系统临时目录),或在测试中替换 path_provider 的平台实现为 Fake。

test/unit/domain/use_cases/app_update_use_case_test.dart (6)

1-6: 为用例注入带短超时的 Dio,并考虑通过 MockAdapter 模拟网络

与 DownloadService 的测试类似,建议:

  • 在此文件导入 Dio,并为 DownloadService 注入短超时 Dio;
  • 如需正向/失败路径的确定性,使用 dio_http_mock_adapter 模拟 HEAD/GET 响应。

示例导入:

+import 'package:dio/dio.dart';
+// 可选:如采用 HTTP mocking
+// import 'package:dio_http_mock_adapter/dio_http_mock_adapter.dart';

12-20: setUp 中注入带短超时的 Dio,减少网络失败测试的等待时间

为稳定性与速度,建议如下改造:

 setUp(() {
-  final downloadService = DownloadService();
+  final dio = Dio(
+    BaseOptions(
+      connectTimeout: const Duration(milliseconds: 300),
+      receiveTimeout: const Duration(milliseconds: 300),
+    ),
+  );
+  final downloadService = DownloadService(dio: dio);
   final installerService = AppInstallerService();
 
   useCase = AppUpdateUseCase(
     downloadService: downloadService,
     installerService: installerService,
   );
 });

如采用 HTTP mocking,可在此处配置 adapter 并按用例行为返回期望响应。


60-73: 将无效域名替换为 example.invalid,使失败更确定

建议替换为保留域名,避免 DNS/TLS 带来的不确定性:

-          downloadUrl: 'https://nonexistent-domain-12345.com/app.apk',
+          downloadUrl: 'http://example.invalid/app.apk',

同类用例建议统一替换。


99-102: 避免断言本地化文案,建议仅断言异常类型或错误码

直接断言“更新文件不存在”这类中文文案会与本地化/实现强耦合,建议改为仅断言异常类型:

-        final exception = result.exceptionOrNull() as AppUpdateException;
-        expect(exception.message, contains('更新文件不存在'));
+        expect(result.exceptionOrNull(), isA<AppUpdateException>());

若需要更强语义,建议为 AppUpdateException 增加错误码/分类字段,并在测试中断言之。


150-163: 补充“成功路径”与进度回调被触发的用例,完善覆盖

当前仅覆盖失败与“进度不应触发”。建议新增:

  • downloadUpdate 成功(使用 HTTP mocking 返回固定字节流);
  • downloadAndInstallUpdate 成功(使用 Fake 安装服务返回成功),并断言最终为 Success;
  • 成功下载时,onProgress 至少被调用一次。

可引入一个简单的 Fake 安装服务(示例):

class FakeAppInstallerService extends AppInstallerService {
  @override
  Future<Result<Unit>> installApk(String filePath) async {
    return const Success(unit);
  }
}

并在 setUp 中注入。


21-27: 测试数据中的 URL 建议也统一使用 example.invalid,避免实际外部访问

这里的 testUpdateInfo.downloadUrl 指向 example.com,虽然会失败,但仍可能造成 DNS/TLS 等不确定成本。建议同样改为:

-        downloadUrl: 'https://example.com/app.apk',
+        downloadUrl: 'http://example.invalid/app.apk',
📜 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d619b83 and a3686bd.

📒 Files selected for processing (5)
  • test/unit/data/service/app_installer_service_test.dart (1 hunks)
  • test/unit/data/service/download_service_test.dart (1 hunks)
  • test/unit/domain/use_cases/app_update_use_case_test.dart (1 hunks)
  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart (1 hunks)
  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.mocks.dart (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.mocks.dart
🧰 Additional context used
🧠 Learnings (7)
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 必须继承 ChangeNotifier

Applied to files:

  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Repository 与 ViewModel 层须达到 100% 测试覆盖,关键 Widget 需有 UI 测试,并包含 Command 流程与 Result 处理测试

Applied to files:

  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : 命名规范:ViewModel 命名为 {Feature}ViewModel

Applied to files:

  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 中处理 Result 时使用 result_dart 的便捷方法(isSuccess/getOrNull/exceptionOrNull),避免使用 fold()

Applied to files:

  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 之间不得互相引用或持有彼此实例,需通过 Repository/流进行通信

Applied to files:

  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 使用全局 appLogger 记录重要操作与状态变化

Applied to files:

  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart
📚 Learning: 2025-08-17T13:26:48.214Z
Learnt from: CR
PR: shadowfish07/ReadeckAPP#0
File: .trae/rules/project_rules.md:0-0
Timestamp: 2025-08-17T13:26:48.214Z
Learning: Applies to lib/**/view_models/**/*.dart : ViewModel 对外异步操作需以 flutter_command 的 Command 暴露

Applied to files:

  • test/unit/ui/settings/view_models/model_selection_viewmodel_test.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 (5)
test/unit/data/service/app_installer_service_test.dart (1)

40-47: 异常格式化测试设计合理,覆盖到位

对 InstallException 的 message 与 toString 行为的验证清晰、可维护。

test/unit/ui/settings/view_models/model_selection_viewmodel_test.dart (1)

11-29: ModelPricing 字段类型请与领域模型保持一致(字符串 vs 数值)

测试中使用了字符串形式的价格(如 '0.001')。若领域模型将其定义为 num/double(更利于计算),这里应改为数值字面量;反之亦可。请确认 domain 定义,避免类型不匹配导致测试编译失败。

Also applies to: 169-186

test/unit/data/service/download_service_test.dart (1)

24-35: 文件存在性单测用例设计合理

  • 使用系统临时目录创建并清理临时文件,验证 fileExists 的正向路径,覆盖合理。
test/unit/domain/use_cases/app_update_use_case_test.dart (2)

181-193: 空发布说明是否应当视为错误?请确认产品/领域约束

“发布说明为空即错误”可能过于严格,会导致某些 Release 缺少说明时无法更新。建议:

  • 确认领域规则:releaseNotes 是否必填?
  • 若不是强约束,建议放宽此断言或转由 UI 层处理显示(留空、占位文案)。

如确认必填,建议在模型层以类型/注解/校验显式声明,并在 use case 中集中校验。


210-226: 集成用例基本合理

  • 多实例独立性校验清晰直接,无需强制调整。

shadowfish07 and others added 2 commits August 19, 2025 11:15
- 添加缺失的mock配置和依赖注入
- 修复异步命令执行的时序问题
- 增加mock对象的正确初始化和清理
- 完善测试覆盖包括初始化、模型选择、排序逻辑和边界条件
- 修复tearDown方法避免重复dispose错误

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- 添加Command.globalExceptionHandler设置以正确处理命令执行错误
- 优化测试日志配置,减少测试输出噪音
- 修复"should handle loadModels command errors"测试用例
- 确保所有16个测试用例正常通过

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@shadowfish07 shadowfish07 merged commit faf2ad2 into main Aug 19, 2025
4 checks passed
@shadowfish07 shadowfish07 deleted the feature/74-auto-update branch August 19, 2025 04:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

支持通过 github release 自动更新

1 participant