diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3582dce..014973a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,9 @@ jobs: artifact_name: release-Android artifact_path: build/app/outputs/flutter-apk/*.apk - target: Windows - os: windows-latest + # Keep the VS 2022 toolchain for Windows plugins that still use + # . + os: windows-2022 artifact_name: release-Windows artifact_path: | build/windows/outputs/*.zip @@ -34,7 +36,8 @@ jobs: date: ${{ steps.get_version.outputs.date}} runs-on: ${{ matrix.os }} env: - FLUTTER_VERSION: 3.27.3 + # Satisfies pubspec.lock without the newer IconData final-class change. + FLUTTER_VERSION: 3.41.5 steps: # Checkout branch - name: Checkout @@ -72,6 +75,12 @@ jobs: flutter pub get dart run intl_utils:generate + - name: Generate awesome_chewie localizations + working-directory: third-party/chewie + run: | + flutter pub get + flutter gen-l10n + # Get app version - name: Get app version id: get_version @@ -102,6 +111,10 @@ jobs: $dllDir = "tools\windows_dll" Copy-Item -Filter *.dll -Path $dllDir\* -Destination $SrcDir -Force + if (-not (Test-Path "$SrcDir\sqlite3.dll")) { + Write-Error "sqlite3.dll is missing from $SrcDir - the packaged app would start but never render." + exit 1 + } New-Item -Path $DestDir -ItemType Directory Copy-Item $SrcDir\* -Recurse $DestDir diff --git a/.gitignore b/.gitignore index c344ed7a..a0b0dfe0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ app.*.map.json **/Deprecated/ **/generated/ +**/build/ loftify.db diff --git a/Final_Visual_Redesign.md b/Final_Visual_Redesign.md new file mode 100644 index 00000000..2615c2d2 --- /dev/null +++ b/Final_Visual_Redesign.md @@ -0,0 +1,256 @@ +# Loftify 最终视觉重构总纲 + +> 状态:锁定为整个项目的最后一项重点工作。 +> 启动条件:功能、交互、内容、稳定性与可延期边界全部确认后方可开始。 +> 关联任务:`Todo.md` 中的 `REDESIGN-00`。 +> 适用范围:Android、Windows 及项目支持的其他响应式尺寸;浅色、深色与自定义主题。 + +## 1. 项目定位与重构目标 + +Loftify 是面向 LOFTER 内容生态与用户习惯的独立第三方客户端,核心场景包括作品浏览、图文阅读、标签探索、内容收藏、作者关注与社区互动。最终设计需要同时满足: + +- 熟悉但不照搬:保留 LOFTER 用户对作品、标签、作者与互动关系的认知连续性,但不复制官方客户端,也不制造官方身份错觉。 +- 内容优先:摄影、绘画、同人、文学、手作等作品是视觉主角,界面框架降低存在感,不与内容竞争注意力。 +- 文艺但克制:体现创作社区的情绪价值与温度,避免矫饰、廉价渐变、高饱和大色块和无意义装饰。 +- 精致且可持续:建立可复用、可测量、可扩展的设计系统,不再逐页凭感觉调整颜色、圆角和间距。 +- 完整保留业务:不得遗漏功能、删减内容、改变业务含义或用装饰性入口代替真实能力。 + +最终结果应是一款成熟、现代、具有自身辨识度的第三方客户端:用户首先感受到作品与内容,其次才感受到界面的存在。 + +## 2. 严格启动门槛 + +以下条件未全部满足时,不得开始页面视觉改造: + +- [ ] `Todo.md` 中所有不依赖官方接口的功能与体验任务已完成并验收。 +- [ ] 依赖官方接口的划线评论、评论扩展等任务已明确实现条件,或由用户确认登记延期。 +- [ ] P0、P1 无未关闭回归;主要页面不存在持续加载、崩溃、跳位、内容裁切或明显手势冲突。 +- [ ] 首页、详情、灯箱、标签、搜索、个人主页、收藏、历史与设置的信息和入口清单已冻结。 +- [ ] 关键流程已有自动化测试,重构前主要页面已有浅色 / 深色、手机 / 平板 / 桌面基线截图。 +- [ ] Lucide 图标迁移与首页毛玻璃主导航已作为独立功能完成,不在最终重构中边设计边补功能。 +- [ ] 当前工作区已提交且可构建,可随时对照或回退到重构前基线。 + +## 3. 不可违反的边界 + +- 不冒充 LOFTER 官方客户端,不复制官方商标性布局、专属视觉资产或容易造成官方误认的表达。 +- 不删除、隐藏、合并或更改任何已有功能的业务含义;视觉分组变化必须保留可发现性。 +- 不以“更简洁”为由移除信息,不以“更现代”为由增加没有真实功能的入口。 +- 不把最终重构缩减为换主题色、调圆角、换字体、统一卡片或替换图标。 +- 不在各页面直接硬编码一套相似但不同的样式;先建立语义 Token 和公共组件,再迁移页面。 +- 不混用多套图标、阴影、圆角、间距或动效语言;受控例外必须登记原因、范围和退出条件。 +- 不为了动效展示牺牲阅读、性能、可访问性或操作效率。 + +## 4. 重构前全局审计 + +开始设计前先产出审计文档和截图矩阵,至少覆盖: + +### 4.1 字体与信息层级 + +- 字体家族、字号、字重、行高、字距和文字颜色的现有使用分布。 +- 标题、正文、作者、元信息、标签、按钮、提示与错误文案之间的层级冲突。 +- 中文、繁体中文和英文下的换行、截断、基线与可读性问题。 +- 长文字正文宽度、段落间距和前景对比度是否适合持续阅读。 + +### 4.2 组件与布局 + +- 按钮、输入框、搜索框、标签、开关、卡片、菜单、导航、Tab、Panel、Dialog 与空状态的重复实现。 +- 控件高度、点击区域、圆角、边框、内外间距、分割线和阴影的不一致项。 +- 页面栅格、边距、内容最大宽度、图片比例、卡片节奏与视觉重心问题。 +- 手机、横屏、平板与桌面之间突然跳变、内容挤压和操作错位问题。 + +### 4.3 图标与动效 + +- Lucide 之外的静态界面图标、旧位图和重复 SVG 清单。 +- 图标视觉尺寸、线宽感、颜色、对齐和选中态表达差异。 +- Lottie、页面过渡、加载、反馈和手势动画的节奏、时长与曲线差异。 +- 无意义、冗长、遮挡阅读或导致持续重绘的动画。 + +### 4.4 内容呈现 + +- 作品卡片的封面比例、标题行数、作者信息、标签、发布时间、热度和互动按钮是否争夺注意力。 +- 图片、插画、摄影、多图、长文、短文和视频在不同密度页面中的展示质量。 +- 标签发现、筛选、选中、管理和失效状态是否清晰统一。 +- 评论与社区互动的信息层级、阅读顺序和操作入口是否自然。 + +## 5. 设计系统交付物 + +设计系统必须先于全页面迁移完成,并以语义命名而非页面命名组织。 + +### 5.1 色彩体系 + +建立浅色、深色和自定义主题的完整语义色表: + +- 页面底色、内容底色、悬浮层、遮罩、输入层、选中层和禁用层。 +- 主文字、次文字、弱化文字、反色文字、链接与占位文字。 +- 品牌强调、互动选中、成功、警告、错误和信息状态。 +- 边框、分割线、焦点环、阴影与毛玻璃染色。 + +基础色应低噪声、中性、耐看并带适度社区温度;强调色只服务于关键操作和状态。必须验证正文、弱文字、按钮和图标的对比度,禁止依赖颜色作为唯一状态信号。 + +### 5.2 字体层级 + +制定显示标题、页面标题、区块标题、卡片标题、正文、长文正文、辅助信息、标签和按钮文字的: + +- 字体家族与回退顺序。 +- 字号、字重、行高、字距和最大行数。 +- 中文 / 英文 / 数字混排规则。 +- 响应式缩放边界与系统大字体策略。 + +长文需单独定义舒适正文宽度、段落间距、引用、链接、图片说明和列表样式,不能直接复用普通卡片正文。 + +### 5.3 间距、栅格与响应式 + +- 建立有限且明确的间距阶梯,覆盖图标间距、控件内边距、卡片间距、页面边距和区块节奏。 +- 定义手机、宽手机、平板、横屏和桌面的断点、列数、沟槽、最大内容宽度和侧栏规则。 +- 建立内容密度等级,让首页流、搜索列表、个人主页和设置页可以共享规则但保持合理密度差异。 +- 所有布局必须支持安全区、系统手势区、键盘、长文本和动态字体。 + +### 5.4 圆角、边框与阴影 + +- 建立小控件、中型容器、卡片、Panel、Dialog 和全屏浮层的圆角阶梯。 +- 区分结构边界、交互焦点和状态警告,避免所有区域都依赖描边。 +- 阴影只表达真实层级,使用低透明、柔和扩散和有限层数;深色模式不能简单复用黑色阴影。 +- 毛玻璃、透明层和纯色层必须有确定的层级及性能降级方案。 + +### 5.5 图标规范 + +- 默认静态界面图标统一使用 Lucide。 +- 统一常用尺寸、视觉修正、颜色、点击区域、文字基线和状态透明度。 +- 面性状态优先通过主题色、底板、容器形态、圆点或强调层表达,不重新混入 Material / Cupertino 面性图标。 +- Lottie 动画保留;其静态占位、失败与无障碍替代仍遵循 Lucide 规范。 +- 品牌标识、用户内容和受控配对 SVG 必须登记为例外,不得借例外扩散第二套图标语言。 + +### 5.6 动效规范 + +- 定义快速反馈、状态切换、Panel、页面过渡、内容进入和手势跟随的时长与曲线。 +- 动效只解释空间关系、操作结果或状态变化;禁止循环装饰和冗长等待。 +- 支持系统减少动画偏好;60/90/120Hz 下时长与速度一致。 +- 模糊、阴影和大图变换必须验证重绘范围和帧时间。 + +## 6. 公共组件及完整状态 + +以下组件必须统一,并覆盖默认、悬停、按下、聚焦、选中、禁用、加载、成功、警告、错误和无内容等适用状态: + +- 主按钮、次按钮、文字按钮、图标按钮、危险操作和组合操作。 +- 输入框、搜索框、验证码、下拉菜单、行内选择与筛选器。 +- 标签、筛选标签、状态徽标、角标和可管理标签。 +- 开关、单选、复选、滑块、进度条和刷新控件。 +- 内容卡片、作者卡片、列表项、设置项和管理项。 +- AppBar、主导航、Tab、侧栏、悬浮操作条和面包屑。 +- BottomSheet、Panel、Dialog、ContextMenu、Tooltip 与 Toast。 +- 图片查看器、视频控件、评论区、加载、空状态、错误和重试。 + +每个公共组件需要记录:用途、禁止用途、尺寸、Token、状态、交互、语义标签、键盘行为、响应式变化和测试用例。 + +## 7. 内容类型设计原则 + +### 7.1 图片、插画与摄影 + +- 封面比例稳定,裁切策略明确,禁止压缩变形。 +- 多图顺序、页码、预览与沉浸式灯箱关系清晰。 +- 界面控件减少对画面的遮挡,必要操作可渐隐但必须可发现。 + +### 7.2 长短文字作品 + +- 长文优先保证阅读宽度、字号、行高、段落、引用与图片说明。 +- 短文卡片只保留必要信息,避免标签、作者、数据和按钮同时高亮。 +- 正文与互动区建立清晰结束边界,不让悬浮操作遮挡内容。 + +### 7.3 标签与兴趣发现 + +- 标签展示、点击、筛选、选中、屏蔽与管理使用同一语义体系。 +- 热门、普通、已选、禁用和风险标签不能只靠颜色区分。 +- 标签详情、搜索和首页入口保持认知连续性。 + +### 7.4 高频互动 + +点赞、推荐、收藏、评论、关注、分享和下载必须易识别,但只允许关键当前状态使用强调色;数字、状态变化、触觉与动画反馈保持一致,不制造彩色按钮墙。 + +## 8. 页面级迁移范围 + +按“基础 Token → 公共组件 → 代表页面 → 全量页面”的顺序迁移,不逐页另起炉灶。 + +1. 首页信息流与主导航:建立内容密度、卡片节奏、毛玻璃层级和选中状态基准。 +2. 作品详情与长文阅读:验证内容优先、正文可读性、互动区和切篇体验。 +3. 图片灯箱与视频:验证沉浸、手势、控件渐隐和横屏布局。 +4. 标签页与搜索结果:验证筛选、Tab、列表密度和发现路径。 +5. 作者主页:验证资料头、内容 Tab、关注与更多操作层级。 +6. 收藏、喜欢、推荐、合集、粮单与历史:验证批量操作、状态和分组一致性。 +7. 评论区与社区互动:验证作者、回复、时间、操作与多级内容的阅读顺序。 +8. 设置、管理、登录、验证码和异常页:验证表单、列表项、状态反馈及品牌独立性。 + +每迁移一组页面,都必须与未迁移页面检查视觉断层;不得留下长期存在的半新半旧状态。 + +## 9. 实施阶段 + +### 阶段 A:审计与方向确认 + +- 产出现状问题清单、组件清单、页面截图矩阵和信息架构图。 +- 提出至少两套完整视觉方向,比较内容沉浸、辨识度、实现成本和可访问性。 +- 选定方向后冻结原则,不在编码过程中反复凭感觉改风格。 + +### 阶段 B:设计 Token 与组件样板 + +- 实现颜色、字体、间距、栅格、圆角、边框、阴影、图标和动效 Token。 +- 用真实中文、英文、长标题、大图、长文和异常数据构建组件样板。 +- 完成深浅色、响应式、键盘和无障碍状态验证。 + +### 阶段 C:代表页面试点 + +- 选择首页、作品详情、标签详情、作者主页和设置页作为密度不同的试点。 +- 完整实现而非只做静态稿;保留真实数据、滚动、加载、失败、空态和操作反馈。 +- 对照产品定位与设计系统至少进行两轮自检和调整。 + +### 阶段 D:全量迁移 + +- 按页面族批量迁移,删除旧样式和重复组件。 +- 每完成一个页面族立即执行自动化、截图、双端运行与 Git 提交。 +- 记录受控例外和未决项,禁止用临时硬编码赶进度。 + +### 阶段 E:多轮质量收口 + +- 第一轮:设计一致性、信息层级、内容沉浸和明显视觉断层。 +- 第二轮:可读性、可访问性、状态反馈和功能可发现性。 +- 第三轮:响应式、动态字体、中英文、深浅色和性能。 +- 第四轮:逐像素细节,包括基线、图标对齐、点击区域、裁切、留白、列表节奏和视觉重心。 +- 最后一轮:对照重构前功能与入口清单,证明无信息、功能、数据和交互遗漏。 + +## 10. 验收矩阵 + +### 10.1 必测页面 + +- 首页各 Tab、搜索与搜索结果、动态、我的。 +- 图片、文章、视频、合集、粮单和标签详情。 +- 作者主页、粉丝 / 关注、收藏、喜欢、推荐、历史。 +- 评论 Panel、所有底部 Panel、更多菜单、分享、下载与管理页。 +- 登录、验证码、锁屏、设置、主题、语言、字体与错误页。 + +### 10.2 必测状态 + +- 首次加载、刷新、分页、空数据、部分失败、整页失败、重试。 +- 默认、悬停、按下、聚焦、选中、禁用、成功、警告和错误。 +- 浅色、深色、自定义主题;简体、繁体、英文;系统字体与自定义字体。 +- 小屏手机、宽手机、平板竖屏 / 横屏、桌面最小窗口与大窗口。 +- 键盘、安全区、系统返回手势、减少动画、降低透明度和高对比度。 +- 60/90/120Hz、长列表、大图、多图、长文和高密度评论。 + +### 10.3 完成证据 + +- 设计 Token 清单和组件目录。 +- 重构前后主要页面截图矩阵。 +- 自动化测试、静态检查和 Debug 构建结果。 +- Android 模拟器、Android 真机与 Windows 的 Hot Reload / Hot Restart 记录。 +- 主要流程功能对照清单与无遗漏结论。 +- 仍保留的受控例外清单及理由。 + +## 11. 最终完成标准 + +只有同时满足以下条件,`REDESIGN-00` 才能勾选完成: + +- 所有主要页面共享明确统一的视觉语言,不存在明显临时样式、旧组件残留或页面间断层。 +- 内容层级、阅读体验、图片展示、标签发现与高频互动符合 LOFTER 内容生态,但保持第三方产品独立身份。 +- 颜色、字体、间距、栅格、圆角、边框、阴影、图标和动效均由可持续设计系统驱动。 +- 所有核心组件及适用状态完整,深浅色、语言、响应式和可访问性验证通过。 +- 重构前已有信息、功能、数据和交互逻辑完整保留,测试与人工对照均未发现遗漏。 +- 多轮自检中不再存在明显拥挤、零碎、突兀、廉价、抢夺内容注意力或与整体风格不一致的部分。 + +在达到这些标准之前,不得以“初版已完成”结束任务。 diff --git a/Icon_Migration_Map.md b/Icon_Migration_Map.md new file mode 100644 index 00000000..a94407bc --- /dev/null +++ b/Icon_Migration_Map.md @@ -0,0 +1,248 @@ +# Loftify Lucide 图标迁移清单与语义映射 + +更新时间:2026-08-24 +对应任务:`ICON-01` + +## 1. 范围与原则 + +本清单覆盖 `lib/**/*.dart`、公共组件以及本地 `third-party/chewie/lib/**/*.dart`。统计对象包括 Material `Icons.*`、Cupertino `CupertinoIcons.*`、现有 `LucideIcons.*` 和 `assets/icon/*.png` 静态界面图标。 + +以下内容不作为普通界面图标迁移: + +- `assets/lottie/*.json` 的 40 个自定义状态动画,包括加载、点赞、推荐、关注、明暗切换和庆祝动画; +- Loftify Logo、托盘 Logo、默认头像等产品标识或内容占位; +- `assets/illust/*`、`assets/mess/*` 等插画与内容装饰; +- 用户头像、帖子图片、装扮、远程 SVG 等用户或业务内容; +- 操作系统原生窗口按钮中不可替换的系统行为,但本地 `awesome_chewie` 自绘窗口按钮仍纳入迁移。 + +默认迁移规则:使用 Lucide 线性图标;选中、已点赞、已收藏等状态仍使用同一个 Lucide 轮廓,通过主题色、字重感、浅色底板、圆点或容器表达,不再切换到 Material 面性图标。只有后续 `ICON-03` 登记的受控 SVG 例外可以保留配对图形。 + +## 2. 2026-08-24 基线 + +| 范围 | Material | Cupertino | Lucide | 涉及文件 | +| --- | ---: | ---: | ---: | ---: | +| Loftify 业务与公共组件 `lib` | 254 处 / 115 个符号 | 0 | 22 处 / 14 个符号 | Material 40 个文件;Lucide 4 个文件 | +| 本地 `awesome_chewie` | 61 处 / 44 个符号 | 4 处 / 3 个符号 | 77 处 / 35 个符号 | Material 26 个文件;Cupertino 3 个文件;Lucide 24 个文件 | +| 合计 | 315 处,跨范围去重后 146 个 Material 符号 | 4 处 / 3 个符号 | 99 处 / 45 个符号 | — | + +旧的 `assets/icon` 目录曾包含 51 张 PNG 界面图标(不含 `code.txt` 和 `run.py`)。`ICON-04` 迁移和回归完成后,运行路径已无引用;`ICON-05` 已删除整套染色位图、废弃常量和生成辅助文件。 + +迁移热点按引用数量排序: + +1. `post_detail_screen.dart`:24 处; +2. `video_detail_screen.dart`、`user_detail_screen.dart`:各 23 处; +3. `about_setting_screen.dart`:15 处; +4. `general_post_item_builder.dart`:13 处; +5. `mine_screen.dart`:12 处; +6. `main_screen.dart`:11 处; +7. `setting_screen.dart`:9 处; +8. `panel_screen.dart`、`tag_detail_screen.dart`:各 8 处。 + +## 3. Material / Cupertino 到 Lucide 的语义映射 + +表中的目标名称必须存在于本地 `lucide_icons` 包。实际迁移时页面不直接引用目标常量,而应在 `ICON-02` 建立的统一语义组件中取用。 + +| 旧图标 | 语义键 | Lucide 目标 | 状态说明 | +| --- | --- | --- | --- | +| `Icons.access_alarm` | `alarm` | `alarmClock` | — | +| `Icons.access_time` | `time` | `clock` | — | +| `Icons.account_box` | `accountCard` | `squareUserRound` | — | +| `Icons.account_circle` | `accountCircle` | `circleUserRound` | — | +| `Icons.person_outline_rounded`, `Icons.person_rounded` | `person` | `userRound` | 选中态只改变颜色和底板 | +| `Icons.person_add_alt_1_rounded` | `personAdd` | `userPlus` | — | +| `Icons.add_circle` | `addCircle` | `circlePlus` | — | +| `Icons.add_rounded` | `add` | `plus` | — | +| `Icons.remove_rounded`, `Icons.horizontal_rule_rounded` | `remove` | `minus` | — | +| `Icons.alternate_email_rounded` | `at` | `atSign` | — | +| `Icons.mail_outline_rounded` | `mail` | `mail` | — | +| `Icons.phone_android_rounded` | `phone` | `smartphone` | — | +| `Icons.password_rounded` | `password` | `keyRound` | — | +| `Icons.card_membership_rounded` | `identityCard` | `badge` | 登录方式语义,不表达支付卡 | +| `Icons.credit_card` | `paymentCard` | `creditCard` | — | +| `Icons.verified_outlined` | `verified` | `badgeCheck` | — | +| `Icons.delete_outline_rounded` | `delete` | `trash2` | 危险色由操作语义提供 | +| `Icons.arrow_back`, `Icons.arrow_back_rounded` | `back` | `arrowLeft` | — | +| `Icons.arrow_downward`, `Icons.arrow_downward_rounded` | `arrowDown` | `arrowDown` | — | +| `Icons.arrow_forward`, `Icons.arrow_forward_rounded`, `Icons.arrow_right` | `forward` | `arrowRight` | — | +| `Icons.arrow_upward`, `Icons.arrow_upward_rounded` | `arrowUp` | `arrowUp` | — | +| `Icons.keyboard_arrow_down_rounded`, `Icons.expand_more` | `expand` | `chevronDown` | — | +| `Icons.keyboard_arrow_up_rounded` | `collapse` | `chevronUp` | — | +| `Icons.keyboard_arrow_left_rounded` | `previous` | `chevronLeft` | — | +| `Icons.keyboard_arrow_right_rounded` | `next` | `chevronRight` | — | +| `Icons.keyboard_double_arrow_left_rounded` | `previousPage` | `chevronsLeft` | — | +| `Icons.keyboard_double_arrow_right_rounded` | `nextPage` | `chevronsRight` | — | +| `Icons.article_outlined` | `article` | `fileText` | — | +| `Icons.edit_note_rounded` | `editNote` | `notebookPen` | — | +| `Icons.format_quote` | `quote` | `quote` | — | +| `Icons.copyright_rounded` | `copyright` | `copyright` | — | +| `Icons.copy_rounded` | `copy` | `copy` | 成功态可切换 `copyCheck` | +| `Icons.save_rounded` | `save` | `save` | — | +| `Icons.open_in_browser_rounded`, `Icons.open_in_new_rounded` | `externalLink` | `externalLink` | — | +| `Icons.share_rounded` | `share` | `share2` | — | +| `Icons.auto_awesome_outlined` | `sparkles` | `sparkles` | — | +| `Icons.auto_fix_high_outlined` | `magic` | `wandSparkles` | — | +| `Icons.color_lens_outlined` | `palette` | `palette` | — | +| `Icons.format_color_reset_rounded` | `clearStyle` | `eraser` | — | +| `Icons.merge_type_outlined` | `merge` | `gitMerge` | — | +| `Icons.commit_outlined` | `commit` | `gitCommitHorizontal` | — | +| `Icons.bug_report_outlined` | `bug` | `bug` | — | +| `Icons.block_rounded` | `blocked` | `ban` | 警告色由语义颜色提供 | +| `Icons.shield_outlined` | `shield` | `shield` | — | +| `Icons.flag_outlined` | `report` | `flag` | — | +| `Icons.error`, `Icons.error_outline`, `Icons.error_outline_rounded` | `error` | `circleAlert` | 错误色由状态提供 | +| `Icons.warning_amber_rounded` | `warning` | `triangleAlert` | — | +| `Icons.info_outline_rounded` | `info` | `info` | — | +| `Icons.contact_support_outlined` | `help` | `circleHelp` | — | +| `Icons.bookmark_outline_rounded`, `Icons.bookmark_rounded`, `Icons.bookmark_added_rounded`, `Icons.bookmark_add_outlined` | `bookmark` | `bookmark` | 收藏状态不换面性图标 | +| `Icons.bookmarks_outlined` | `bookmarks` | `libraryBig` | — | +| `Icons.favorite_border_rounded`, `Icons.favorite_rounded` | `favorite` | `heart` | 点赞状态不换面性图标 | +| `Icons.thumb_up`, `Icons.thumb_up_alt`, `Icons.thumb_up_off_alt`, `Icons.thumb_up_outlined`, `Icons.thumb_up_rounded` | `recommend` | `thumbsUp` | 推荐状态不换面性图标 | +| `Icons.star`, `Icons.star_border`, `Icons.star_border_purple500_rounded`, `Icons.star_border_rounded`, `Icons.star_rate_rounded`, `Icons.star_rounded` | `star` | `star` | 选中态用主题色或浅底板 | +| `Icons.star_half` | `starHalf` | `starHalf` | 仅评分显示允许半星 | +| `Icons.check_box`, `Icons.check_box_outline_blank`, `Icons.check_box_outline_blank_rounded` | `checkbox` | `square` / `squareCheckBig` | 使用同一网格的选中状态 | +| `Icons.check_circle_outline_rounded`, `Icons.check_circle_rounded` | `successCircle` | `circleCheck` | — | +| `Icons.check_rounded`, `Icons.done`, `Icons.done_rounded` | `check` | `check` | — | +| `Icons.done_all_rounded` | `checkAll` | `checkCheck` | — | +| `Icons.circle_outlined` | `circle` | `circle` | — | +| `Icons.clear`, `Icons.clear_rounded`, `Icons.close_rounded` | `close` | `x` | — | +| `Icons.broken_image`, `Icons.broken_image_outlined` | `imageError` | `imageOff` | — | +| `Icons.image_outlined` | `image` | `image` | — | +| `Icons.download_rounded` | `download` | `download` | — | +| `Icons.downloading_rounded` | `downloading` | `download` | 进度由环形进度层表达 | +| `Icons.download_done_rounded` | `downloaded` | `fileCheck` | — | +| `Icons.play_arrow_rounded` | `play` | `play` | — | +| `Icons.playlist_play_rounded` | `playlist` | `listVideo` | — | +| `Icons.video_settings_rounded` | `videoSettings` | `settings2` | — | +| `Icons.subtitles_rounded` | `captions` | `captions` | — | +| `Icons.volume_off_rounded` | `muted` | `volumeX` | — | +| `Icons.volume_up_rounded` | `volume` | `volume2` | — | +| `Icons.fullscreen_rounded` | `fullscreen` | `maximize` | — | +| `Icons.fullscreen_exit_rounded` | `exitFullscreen` | `minimize` | — | +| `Icons.repeat_rounded` | `repeat` | `repeat2` | — | +| `Icons.explore_outlined`, `Icons.explore_rounded` | `explore` | `compass` | 选中态不换面性图标 | +| `Icons.home_filled` | `home` | `house` | — | +| `Icons.search_rounded`, `Icons.manage_search_rounded` | `search` | `search` | 选中态通过颜色和底板表达 | +| `Icons.settings`, `Icons.settings_outlined`, `Icons.settings_rounded` | `settings` | `settings` | — | +| `Icons.notifications_on_outlined` | `notifications` | `bell` | 未读状态使用角标 | +| `Icons.history_rounded`, `Icons.history_toggle_off_rounded` | `history` | `history` | 暂停记录用状态角标或文案 | +| `Icons.logout_rounded`, `Icons.exit_to_app_rounded` | `logout` | `logOut` | — | +| `Icons.menu` | `menu` | `menu` | — | +| `Icons.more_horiz_rounded` | `moreHorizontal` | `ellipsis` | — | +| `Icons.more_vert_rounded` | `moreVertical` | `ellipsisVertical` | — | +| `Icons.message` | `message` | `messageCircle` | — | +| `Icons.mode_comment_outlined`, `Icons.mode_comment_rounded` | `comment` | `messageCircle` | 评论状态不换面性图标 | +| `Icons.comment_bank_outlined` | `comments` | `messagesSquare` | — | +| `Icons.inbox_outlined` | `inbox` | `inbox` | — | +| `Icons.refresh_rounded` | `refresh` | `refreshCw` | 动画旋转由统一组件控制 | +| `Icons.filter_alt_rounded` | `filter` | `listFilter` | 激活时显示主题色圆点 | +| `Icons.tag_rounded` | `tag` | `tag` | — | +| `Icons.language_outlined` | `language` | `languages` | — | +| `Icons.group_outlined` | `group` | `users` | — | +| `Icons.local_fire_department_rounded` | `hot` | `flame` | 热度级别不更换图形 | +| `Icons.grain_rounded` | `grainList` | `wheat` | LOFTER 粮单语义 | +| `Icons.workspace_premium_rounded` | `premium` | `crown` | — | +| `Icons.shopping_bag_outlined` | `shop` | `shoppingBag` | — | +| `Icons.shopping_cart` | `cart` | `shoppingCart` | — | +| `Icons.rate_review_outlined` | `review` | `messageSquareText` | — | +| `Icons.telegram_outlined` | `send` | `send` | 不表示 Telegram 品牌 | +| `Icons.push_pin_outlined`, `Icons.push_pin_rounded` | `pin` | `pin` | 固定状态不换面性图标 | +| `Icons.change_history` | `changeHistory` | `triangle` | 仅保留几何语义 | +| `Icons.view_agenda_outlined` | `listLayout` | `layoutList` | — | +| `Icons.view_module_outlined` | `gridLayout` | `layoutGrid` | — | +| `Icons.view_carousel_outlined` | `carouselLayout` | `galleryHorizontal` | — | +| `Icons.visibility_outlined` | `visible` | `eye` | — | +| `Icons.visibility_off_outlined` | `hidden` | `eyeOff` | — | +| `Icons.egg_rounded` | `easterEgg` | `egg` | — | +| `CupertinoIcons.archivebox` | `archive` | `archive` | — | +| `CupertinoIcons.checkmark_alt` | `check` | `check` | — | +| `CupertinoIcons.square_on_square` | `copy` | `copy` | — | + +## 4. PNG 静态界面图标映射 + +| 旧资源组 | 当前使用 | 语义键 | Lucide 目标 | 处理 | +| --- | --- | --- | --- | --- | +| `collection_dark/light/primary/white.png` | 是 | `collection` | `libraryBig` | 颜色由主题提供,删除四套染色位图 | +| `confirm.png` | 否 | `confirm` | `check` | 迁移后删除 | +| `download_white.png` | 否 | `download` | `download` | 颜色由前景色提供 | +| `dress_dark/light.png` | 是 | `dress` | `shirt` | 删除深浅两套位图 | +| `dynamic_dark/light(_selected).png` | 否 | `activityFeed` | `rss` | 选中态不换图 | +| `favorite_dark/light.png` | 否 | `favorite` | `heart` | — | +| `grain_white.png` | 是 | `grainList` | `wheat` | — | +| `home_dark/light(_selected).png` | 否 | `home` | `compass` | 与现有“探索首页”语义一致 | +| `hot.png`, `hotless.png`, `hottest.png`, `hot_white.png` | 是 | `hot` | `flame` | 强度通过颜色、标签和数值表达 | +| `info.png` | 否 | `info` | `info` | — | +| `like_dark/light/filled.png` | 仅注释 | `favorite` | `heart` | 保留 Lottie 点赞动画,删除静态位图 | +| `link_dark/grey/light/primary/white.png` | 否 | `link` | `link` | — | +| `mine_dark/light(_selected).png` | 否 | `person` | `userRound` | — | +| `order_down/up_dark/light.png` | 是 | `sortDirection` | `arrowDownUp` | 方向由旋转或状态参数表达 | +| `pin_dark/light.png` | 否 | `pin` | `pin` | — | +| `search_dark/grey/light.png` | 是 | `search` | `search` | — | +| `setting_dark/light.png` | 是 | `settings` | `settings` | — | +| `tag_dark/grey/light/white.png` | 是 | `tag` | `tag` | — | + +`assets/icon/code.txt` 和 `assets/icon/run.py` 是旧资源生成辅助文件,不属于运行时资源,已随 `ICON-05` 一并清理。`assets/illust` 和 `assets/mess` 不在本表中,它们是插画与内容装饰,最终视觉重构前不得因图标迁移误删。 + +## 5. 当前已合规的 Lucide 使用 + +当前代码已使用 45 个不同的 Lucide 符号,主要覆盖下载管理、设置入口以及 `awesome_chewie` 的输入框、菜单、空状态和通用操作。迁移时优先复用现有语义: + +`arrowLeft`, `check`, `checkCheck`, `chevronDown`, `chevronLeft`, `chevronRight`, `chevronUp`, `circleCheck`, `circleX`, `cloudAlert`, `copy`, `copyCheck`, `download`, `ellipsisVertical`, `externalLink`, `eye`, `eyeOff`, `file`, `flaskConical`, `globe`, `hash`, `house`, `image`, `imageOff`, `inbox`, `info`, `link`, `paintbrushVertical`, `pause`, `play`, `plus`, `refreshCw`, `rotateCcw`, `save`, `search`, `searchSlash`, `settings`, `settings2`, `share2`, `square`, `textCursorInput`, `trash2`, `triangleAlert`, `video`, `x`。 + +这些直接引用将在 `ICON-02` 一并收口到统一组件,避免“虽然都是 Lucide,但尺寸、颜色和点击区域仍由页面各自决定”。 + +## 6. 统一组件规范 + +`ICON-02` 已建立分层入口:业务代码只通过 `LoftifyIcons` 使用产品语义,复用组件通过 `ChewieIcons` 使用基础语义,并由 `ChewieIcon` 和 `ChewieIconButton` 统一渲染。业务页面不得再直接导入 `lucide_icons`;后续迁移时先在对应语义层登记,再替换页面中的旧图标。 + +| 项目 | 统一规则 | +| --- | --- | +| 视觉尺寸 | 小型辅助图标 16;常规操作 20;大图标 24。特殊内容允许显式覆盖,但不得同时改变点击区域。 | +| 点击区域 | 所有可点击图标最小 44×44 逻辑像素;紧凑列表只缩小字形和间距,不缩小热区。 | +| 颜色 | 默认继承当前 `IconTheme` 或 `onSurfaceVariant`;选中态使用主题色,禁用态统一为当前前景色 38% 不透明度。 | +| 状态 | 选中态保持同一字形,以主题色和 12% 主题色底板表达;悬停、聚焦、按下叠层分别为 8%、10%、12%。 | +| 形状 | 普通按钮透明;柔和按钮使用低对比表面色;描边按钮使用 0.8 细边框;默认圆角 10。 | +| 对齐 | 图标以自身光学尺寸居中;确有字形偏差时只通过 `opticalOffset` 微调,不改变布局盒和热区。 | +| 无障碍 | 可点击图标必须提供 tooltip 或语义标签;禁用、选中和长按状态由统一按钮暴露。 | + +上述尺寸、透明度和状态参数集中在 `ChewieIconThemeData`,并注册到明暗主题中;不得在页面重新复制一套状态颜色。自动化测试会阻止业务页面绕过 `LoftifyIcons` 直接导入 Lucide 字体。 + +## 7. 面性图标与状态表达 + +持久状态和动作状态必须分开处理: + +- 点赞、推荐、收藏、关注、筛选、导航选中等“同一对象的持久状态”,始终保留同一个 Lucide 字形。默认态使用次级前景色;选中态依次使用主题色、12% 主题色底板或小圆点增强辨识,不能切换为另一套填充图标。 +- 播放/暂停、静音/有声、展开/收起、全屏/退出全屏等“当前可执行动作已经变化”的场景,可以切换 Lucide 字形。这是动作语义变化,不是面性选中态。 +- 成功、警告、错误、加载等反馈使用对应语义图标、颜色和动画;不得用实心版本冒充更强状态。Lottie 继续承担原有加载与反馈动画,静态无障碍替代才使用 Lucide。 +- 图标状态的优先级为禁用 > 错误/警告 > 选中 > 默认;悬停、聚焦和按下只添加临时叠层,不改变持久状态的图形和含义。 +- 业务语义名称不得包含 `filled`、`outlined`、`active`、`inactive`、`selected` 或 `unselected`。同一语义只登记一次,状态交给统一组件处理。 + +### 7.1 受控配对 SVG 例外 + +只有 Lucide 轮廓在 16/20/24 三档尺寸下都无法清晰表达含义,且主题色、底板、圆点、标签仍不足以解决时,才允许制作配对 SVG。例外必须同时满足:24×24 网格、统一 2px 视觉描边、相同基线和包围盒、支持主题色着色、具备语义标签,并在本节登记文件、使用页面、原因和移除条件。 + +当前受控配对 SVG 例外:**无**。 + +`assets/logo.svg` 是产品品牌标识,不属于界面图标,也不进入配对 SVG 例外表。自动化测试会拒绝未登记的新增 SVG 界面资源;如确需新增,必须在设计复核后同步更新本节和测试白名单。 + +## 8. 迁移批次与验收口径 + +1. 基础组件:先完成 `awesome_chewie`、统一语义组件、输入、菜单、Entry/Setting Item、空状态和 AppBar。 +2. 全局导航:迁移 `main_screen.dart`、`panel_screen.dart`、Mine 与设置导航,为 `NAVGLASS-*` 提供稳定图标基础。 +3. 内容浏览:迁移帖子卡片、帖子详情、标签、合集、粮单、搜索和用户页。 +4. 媒体与状态:迁移视频控件、下载状态、图片失败状态;Lottie 动画继续保留。 +5. 清理:仓库扫描不得再出现未登记的 `Icons.*`、`CupertinoIcons.*` 或运行时 `assets/icon/*.png` 引用。 + +每一批都必须验证:浅色/深色、选中/未选中、禁用/错误/加载状态,中文/英文长文案,手机/平板/桌面尺寸,以及至少 44×44 逻辑像素的触控区域。图标视觉尺寸不能直接等同于点击区域。 + +## 9. 自动盘点命令 + +后续验收使用以下规则复查,不依赖人工目测: + +```text +rg -o "\bIcons\.[A-Za-z0-9_]+" lib third-party/chewie/lib -g "*.dart" +rg -o "\bCupertinoIcons\.[A-Za-z0-9_]+" lib third-party/chewie/lib -g "*.dart" +rg -o "\bLucideIcons\.[A-Za-z0-9_]+" lib third-party/chewie/lib -g "*.dart" +rg "AssetUtil\.[A-Za-z0-9_]+|assets/icon/" lib -g "*.dart" +``` + +允许结果只能是本文件明确登记的例外;新增页面不得扩大遗留图标数量。 diff --git a/Todo.md b/Todo.md new file mode 100644 index 00000000..ed60871e --- /dev/null +++ b/Todo.md @@ -0,0 +1,759 @@ +# Loftify 待办与验收清单 + +> 最近整理:2026-09-27 +> 参考基线:CloudOTP / awe_chewie +> 当前主线:全软件视觉重构阶段持续验收;个人主页更多菜单已完成紧凑化,近期真机反馈重新打开个人主页刷新体验问题。依赖官方接口的划线评论等能力继续暂缓。 + +## 任务总览 + +- 功能与体验任务:130 项;已完成 124 项,待完成 6 项。 +- 重复验收项:8 项;它们是每轮交付都要执行的流程,不计入功能任务完成率。 +- 当前焦点:`PROFILE-04` 的触发阈值冲突已修复,仍需在真机核实不同个人主页和各 Tab 的请求完成、动画收起及折叠状态。划线评论及其他依赖官方接口的能力继续暂缓。 +- 紧接处理:完成主页嵌套下拉刷新端到端复验,再继续阶段 C 第二轮极端尺寸自检和阶段 D 页面族迁移。 +- 当前设备:Android 16 真机 `986008e8`(1200×2670 / 120Hz)已连接;按用户要求不再使用 MuMu 或桌面端验收。更多菜单新版已覆盖安装并在真机上验收。 + +### 未完成任务分布 + +| 优先级 | 待完成 | 范围 | +| --- | ---: | --- | +| P0 | 0 | 本轮视觉回归已清零 | +| P1 | 1 | 个人主页嵌套刷新 | +| P2 | 4 | 划线评论(依赖官方接口,暂缓) | +| 最终阶段 | 1 | 全软件视觉设计系统与页面重构 | + +### 2026-08-24 实机视觉回归校准 + +- [x] `REDESIGN-R01` 将全局 Material 字体映射恢复到原应用的紧凑基线;设计 Token 只供明确迁移的新组件使用,正文阅读字号单独生效,禁止让旧页面整体放大。 +- [x] `REDESIGN-R02` 恢复并精修帖子详情页 AppBar 合集入口,以及正文底部的合集、粮单、标签等原有轻量控件;不得用统一大卡片或大按钮覆盖原有信息密度与业务层级。 +- [x] `REDESIGN-R03` 逐项恢复被错误移除的卡片、列表项、Panel 与交互控件背景,浅色模式维持白色页面与柔和浅灰内容层级,不能出现控件边界消失。 +- [x] `REDESIGN-R04` 重新设计“我的”主导航页面的信息层级和内容中心;装扮入口移入内容中心,保留已有下载管理及全部业务入口。 +- [x] `REDESIGN-R05` 四个主导航不再使用完整横向 AppBar,改为相互一致的悬浮顶部控件:首页左侧用户头像并跳转“我的”;搜索左侧用户头像、右侧搜索按钮;关注使用悬浮 Tab 栏;“我的”左侧通知、右侧仅保留设置和深浅色切换。 +- [x] `REDESIGN-R06` 首页悬浮栏支持沉浸式滚动:未滚动时用安全区与顶部 padding 保持原有首屏观感;滚动后内容可自然扩展到状态栏区域,悬浮控件原位可读且不遮挡内容。 +- [x] `REDESIGN-R07` 重做底部导航 Lottie:未选中为克制线性轮廓,选中通过自然动画过渡到清晰的面性状态,四个图标均应有足够可感知但不过度的动态反馈。 +- [x] `REDESIGN-R08` 优化底部导航随滚动显示 / 隐藏的位移、透明度、缩放和曲线衔接,快速反向滚动不跳变,减少动态模式保持即时稳定。 +- [x] `REDESIGN-R09` 修复下拉刷新层级与尺寸回归:动画不能像被列表盖住;指示器尺寸、触发距离和拖拽缩放必须协调,保持上一版从小到大显现及回弹体验。 +- [x] `REDESIGN-R10` 普通页面 AppBar 的返回、更多、下载等独立图标按钮统一使用圆形点击面,不再默认使用矩形圆角;明确传入圆角的行内工具按钮保持原布局语义,横屏返回按钮遵循同一规则。 + +### 当前迭代看板 + +| 状态 | 任务 | 本轮目标 | +| --- | --- | --- | +| 阶段完成 | `BASE-*`、`UI-*`、`L10N-01` | 基础组件、主题、本地化、加载动画和全局页面视觉已收口 | +| 阶段完成 | `POST-01`~`POST-07` | 帖子卡片、1~9 图、异常内容回退和布局切换已通过自动化与模拟器验收 | +| 本轮完成 | `PROFILE-01`~`PROFILE-03` | 个人主页信息层级、统一卡片、背景衔接和吸顶视觉已通过自动化与模拟器验收 | +| 本轮完成 | `DETAIL-01`~`DETAIL-04` | 评论层级、详情底栏、详情页基础视觉和局部错误回退已完成自动化与模拟器验收 | +| 待真机复验 | `PROFILE-04` | 既有嵌套滚动与刷新实现曾通过模拟器验收,但真机内容区下拉仍无法可靠触发刷新 | +| 本轮完成 | `DETAIL-05`、`SCROLL-01`~`SCROLL-04`、`PROFILE-MORE-01` | 详情滚动和刷新回归、个人主页更多菜单真机布局验收 | +| 本轮完成 | `LIST-02`、`LIST-04`、`LIST-05`、`LIKES-01`~`LIKES-03` | 消除重复分页与滚动监听,完成长列表懒构建、位置恢复、异常卡片和喜欢日期分组同步 | +| 本轮完成 | `LIST-01`、`LIST-03` | 搜索与动态流完成公共分页迁移,图片按显示尺寸等比解码,离屏列表与图片状态按需回收 | +| 本轮完成 | `IMAGE-01`~`IMAGE-05` | 原图链路、相邻页预热、失败重试、灯箱翻页缩放、下拉退出和取消回弹已通过双端验收 | +| 本轮完成 | `VIDEO-01`~`VIDEO-06` | 播放控制、资源复用、沉浸式布局、作者页手势、操作菜单、边缘倍速与弹幕状态已完成代码和组件验证 | +| 本轮完成 | `CAPTCHA-01`~`CAPTCHA-03` | 滑动验证码的响应式缩放、触摸轨迹和服务端坐标已完成接口、组件和双端运行验证 | +| 本轮完成 | `REFRESH-01`~`REFRESH-03` | 显示模式依赖、60/90/120Hz 等时动画和 Android Surface 帧率请求已完成代码、自动化及双端验证 | +| 本轮完成 | `TABSTATE-01`~`TABSTATE-04` | 稳定标识、筛选与 Tab 持久化、懒加载边界和无效状态回退已完成自动化及双端验证 | +| 本轮完成 | `BATCH-01`~`BATCH-04` | 五类内容集合的选择、资源解析、批量入队、去重、失败提示与重试已完成自动化和 MuMu 实机验收 | +| 本轮完成 | `DYNAMIC-01` | 重构前基线巡检发现并修复动态合集卡片的窄屏 / 大字体底部溢出 | +| 本轮完成 | `REDESIGN-A` | 完成全局样式与组件审计、19 张截图矩阵、信息入口冻结、两套视觉方向比较并选定“静谧内容工作室” | +| 本轮完成 | `REDESIGN-B1` | 落地全局设计 Token、宽度分级和可访问 Entry / Section 样板,并迁移“我的”内容中心与设置总页 | +| 本轮完成 | `REDESIGN-B2` | 统一按钮、输入、标签、卡片、菜单、Panel 与全局状态反馈,并完成主题、语言、可访问性和跨平台状态矩阵 | +| 本轮完成 | `REDESIGN-C1` | 首页内容栅格、瀑布流卡片与宽屏版心完成首轮代表页迁移,并通过真实数据和双端构建验收 | +| 本轮完成 | `REDESIGN-C2` | 作品详情阅读版心、信息层级和右侧悬浮操作条完成代表页迁移,并通过真实长文与双端构建验收 | +| 本轮完成 | `REDESIGN-C3` | 标签详情头部、发现入口、筛选与内容栅格完成代表页迁移,并通过真实标签、自动刷新和双端构建验收 | +| 本轮完成 | `REDESIGN-C4` | 作者主页身份、统计、关注、代表作与响应式头部完成代表页迁移,并通过真实作者、Tab 刷新和双端构建验收 | +| 本轮完成 | `REDESIGN-C5` | 设置页分组、Entry、行内选择、分割线、响应式与 AppBar 完成代表页迁移,并通过双 Android 设备验证 | +| 本轮完成 | `REDESIGN-C6-P1` | 五类代表页第一轮设计连续性与内容沉浸自检完成,详情内容归属、设置状态色和范围变化滚动物理断层已修复 | +| 本轮完成 | `REDESIGN-C6-P2B2B1` | 在不降低真机安全设置的前提下建立当前 Flutter 构建渲染截图通道,并取得浅色简中真实数据证据 | +| 本轮完成 | `REDESIGN-C6-P2B2B2` | 修复默认及自定义明亮强调色错误使用低对比白色前景,统一新旧主题组件的可读前景算法 | +| 本轮完成 | `REDESIGN-C6-P2B2B3` | 修复持久化繁中 / 英文在冷启动时界面语言与日期格式语言可能不一致;真机语言矩阵仍待 USB 恢复后验收 | +| 本轮完成 | `REDESIGN-C6-P2B2B4` | 系统返回只在根路由被阻止时委托内部 Panel,避免根路由已返回后再次清理导航状态 | +| 本轮完成 | `REDESIGN-C6-P2B2B5` | 修复标签、合集胶囊、选中菜单和 tonal 按钮共用的柔和选中底板前景对比不足 | +| 本轮完成 | `REDESIGN-C6-P2B2B6` | 统一修复成功、警告、危险语义色在页面、抬升面和柔和状态底板上的前景对比不足 | +| 本轮完成 | `REDESIGN-C6-P2B2B7` | 修复输入提示、空态辅助信息等低强调文字在浅色页面和柔和表面上的对比不足 | +| 本轮完成 | `REDESIGN-C6-P2B2B8` | 补齐 Android 小屏真实异步加载、错误、重试加载与成功状态替换回归,旧错误反馈不会残留 | +| 本轮完成 | `REDESIGN-C6-P2B2B9` | 状态淡出阶段立即停止旧操作命中,避免 Android 快速连点重复发起重试请求 | +| 本轮完成 | `REDESIGN-C6-P2B2B10` | 清理繁中通知空态与文章加载态遗留的简体文案,并增加本地化资源回归 | +| 本轮完成 | `REDESIGN-C6-P2B2B11` | 补齐低强调文字在弱化表面的 AA 对比约束,修复浅深主题下输入提示与禁用信息偏淡 | +| 本轮完成 | `REDESIGN-C6-P2B2B12` | 拆分品牌强调底色与可读强调前景,修复亮主题色作为文字、图标、焦点环和进度条时对比不足 | +| 本轮完成 | `REDESIGN-C6-P2B2B13` | 分离装饰分割线与可操作强边界,确保浅深主题下输入、次按钮和把手边界达到 3:1 | +| 本轮完成 | `REDESIGN-C6-P2B2B14` | 修复个人主页拉黑状态操作在图片头部与浅色背景上依赖透明红底白字而可读性不足的问题 | +| 本轮完成 | `REDESIGN-C6-P2B2B15` | 为个人主页头图建立稳定遮罩,确保最亮封面上的 AppBar、昵称与辅助信息仍清晰可读 | +| 本轮完成 | `REDESIGN-C6-P2B2B16` | 将稳定头图遮罩扩展到合集与粮单详情,统一白色标题、统计与辅助信息在任意封面上的对比 | +| 本轮完成 | `REDESIGN-C6-P2B2B17` | 为所有图片角标设置最低可读遮罩,避免计数、类型与热度标签在明亮封面上失去对比 | +| 本轮完成 | `REDESIGN-C6-P2B2B18` | 为装扮壁纸预览的白色日期与时间增加局部顶部遮罩,兼顾明亮壁纸可读性与图片原貌 | +| 本轮完成 | `REDESIGN-C6-P2B2B19` | 将装扮详情迁移到统一 Panel 和响应式按钮,修复短屏大字体下预览与底部操作溢出 | +| 本轮完成 | `REDESIGN-C6-P2B2B20` | 将装扮详情单图 / 全部下载从 Lottie 对话框改为真实字节线性进度,并阻止重复任务 | +| 本轮完成 | `REDESIGN-C6-P2B2B21` | 将表情包与装扮部件下载统一为真实字节进度,按内容密度分别使用线性进度与圆形进度 | +| 本轮完成 | `REDESIGN-C6-P2B2B22` | 统一四种登录页面的响应式单滚动链,修复短屏、键盘与大字体下登录方式覆盖表单和不可达 | +| 本轮完成 | `REDESIGN-C6-P2B2B23` | 将评论区迁移到统一 Panel,固定标题层并让评论列表随短屏、键盘与大字体安全收缩滚动 | +| 本轮完成 | `REDESIGN-C6-P2B2B24` | 将帖子收藏夹选择迁移到统一 Panel,修复短屏大字体下新建与确认操作挤压列表的问题 | +| 本轮完成 | `REDESIGN-C6-P2B2B25` | 将合集目录迁移到统一 Panel,并按真实格宽恢复手机三列封面与宽屏响应式栅格 | +| 本轮完成 | `REDESIGN-C6-P2B2B26` | 统一五个作品归档网格的真实单元格宽度,修复固定 160px 封面尺寸与实际列宽不一致 | +| 本轮完成 | `REDESIGN-C6-P2B2B27` | 将真实单元格宽度扩展到收藏夹详情、喜欢与历史,统一普通列表和 Sliver 分组网格 | +| 本轮完成 | `REDESIGN-C6-P2B2B28` | 重构我的收藏夹列表卡片,恢复浅色表面与封面 cover,并让窄屏大字体下的管理操作安全换行 | +| 本轮完成 | `REDESIGN-C6-P2B2B29` | 统一粉丝 / 关注用户卡与关注按钮,补齐 48px 点击区、无水波纹状态和窄屏大字体重排 | +| 本轮完成 | `REDESIGN-C6-P2B2B30` | 修复下载管理父任务与独立任务在 280dp 英文 2 倍字体下的状态、进度和控制按钮横向溢出 | +| 本轮完成 | `REDESIGN-C6-P2B2B31` | 修复下载任务详情在 280dp 英文 2 倍字体下的整组状态、批量操作与资源任务横向溢出 | +| 本轮完成 | `REDESIGN-C6-P2B2B32` | 修复批量下载确认页在 280dp 英文 2 倍字体下的选择区垂直溢出与底部双按钮横向溢出 | +| 本轮完成 | `REDESIGN-C6-P2B2B33` | 优化通知消息卡在极窄屏与大字体下的正文宽度,将预览图响应式移到正文下方 | +| 本轮完成 | `REDESIGN-C6-P2B2B34` | 修复手势锁创建 / 验证页在短屏大字体下将正方形九宫格压扁为 280×80 的触摸几何错误 | +| 本轮完成 | `REDESIGN-C6-P2B2B35` | 将文件命名字段说明在极窄屏与大字体下从三列碎片表格重排为可读的纵向信息卡 | +| 本轮完成 | `REDESIGN-C6-P2B2B36` | 修复主题预览卡在 2 倍字体下的底部溢出,并将强调色色点统一为 48px 点击目标 | +| 本轮完成 | `REDESIGN-C6-P2B2B37` | 将移动设置总页迁移到 BaseSettingScreen,移除 AppBar 底部分割线与占位空按钮 | +| 本轮完成 | `REDESIGN-C6-P2B2B38` | 统一字体卡整卡选择语义,修复 2 倍字号预览溢出并将删除入口补齐为 48px 点击目标 | +| 本轮完成 | `REDESIGN-C6-P2B2B39` | 统一行内下拉选项的 48px 整行交互与选中语义,移除重复的收缩 Checkbox 事件目标 | +| 本轮完成 | `REDESIGN-C6-P2B2B40` | 统一字体 / 主题选择卡的 Lucide 勾选状态、整卡语义与透明区域命中行为 | +| 本轮完成 | `NAV-STYLE-*`、`REFRESH-04`~`REFRESH-06` 等 | 跨页面自检发现的主导航、刷新、通知占位、标签入口与底栏回归已清零 | +| 本轮完成 | `DOWNLOAD-GROUP-01`~`DOWNLOAD-GROUP-04` | 批量下载父任务、来源类型、任务详情、整组控制和原内容跳转已完成自动化与真机验收 | + +### 2026-08-25 跨页面体验需求 + +- [x] `NAV-STYLE-01` 收紧首页瀑布流在手机宽度下的左右留白,同时保留卡片间距、圆角、宽屏版心和安全点击边界。 +- [x] `NAV-STYLE-02` 首页悬浮栏改为左侧头像、中间软件名称、右侧通知;搜索页改为左侧头像和默认常驻展开的搜索栏,不再展示关闭按钮。 +- [x] `NAV-STYLE-03` 动态页悬浮 Tab 左侧增加账号头像并可进入“我的”,同时保持四个 Tab 的均分、指示器和切换动画。 +- [x] `NAV-STYLE-04` “我的”页移除左上通知,只保留右侧主题和设置;通知统一从首页右侧入口进入。 +- [x] `NAV-HEADER-01` 四个主导航顶栏改为参与页面布局并真实占据安全区与控件高度,不再覆盖列表内容;首页顶栏作为瀑布流首个滚动区块,向上滚动时与内容一起自然离场,搜索、动态和“我的”顶栏则稳定占据各自内容上方。 +- [x] `NOTICE-01` 修复通知页全部 Tab 在首次加载、无数据和占位数据时的滚动内容高度,避免像旧“我的推荐”一样被视窗裁剪。 +- [x] `REFRESH-04` 放大通用下拉 / 上拉 Lottie 动画与稳定态占位,重新协调触发距离、最大回弹和图形半径。 +- [x] `REFRESH-05` 统一拖拽进度与 Lottie 缩放比例,动画必须从小到大自然显现且不能被列表内容遮盖。 +- [x] `REFRESH-06` 四个主导航的刷新指示器必须从悬浮栏下方的内容起点显现,不得从状态栏或悬浮控件背后出现。 +- [x] `DYNAMIC-02` 优化动态页“合集”Tab 卡片四周外边距、卡片间距和分组节奏,避免相邻内容挤在一起。 +- [x] `NAV-OPTION-01` 新增“导航栏展示风格”设置:图文、仅图标、仅文字;默认仅图标并持久化,切换后即时刷新。 +- [x] `NAV-ANIM-01` 修复四个主导航滚动过程中底部导航显隐偶发失效,统一多滚动控制器注册、反向滚动恢复和 Tab 切换状态。 +- [x] `TAG-STYLE-01` 恢复并优化标签详情“合集粮单 / 相关标签 / 相关装扮”等 Banner 的原有信息层级、封面比例和横向节奏,避免统一卡片化后变得臃肿。 + +上述回归完成后重新执行既有 `REDESIGN-C6` 的两轮跨页面一致性与内容沉浸自检,不能以旧版本截图替代最终验收。 + +`NAV-STYLE-02`~`NAV-STYLE-04` 完成记录(2026-08-25):四个主导航悬浮栏已按最终信息架构收口:首页使用“账号头像 / Loftify / 通知”三段式独立胶囊,搜索页使用头像与常驻展开搜索框且移除关闭入口,动态页在等分四 Tab 左侧补齐账号头像,“我的”只保留右侧主题与设置胶囊。头像入口继续统一跳转“我的”,通知统一从首页进入,原 Tab 指示器、搜索提交、主题切换与设置跳转逻辑保持不变。新增中心标题单行与胶囊回归,相关 7 项组件测试通过,目标静态检查无 error / warning。MuMu Android 12 已以真实首页、搜索、动态和“我的”完成浅色逐页检查;截图保存在 `build/screenshots/loftify_nav_home_new.png`、`build/screenshots/loftify_nav_search_verified.png`、`build/screenshots/loftify_nav_dynamic_new.png` 与 `build/screenshots/loftify_nav_mine_verified.png`。 + +`REFRESH-04`~`REFRESH-06` 完成记录(2026-08-29):通用 Lottie 下拉动画由 30px 放大到 40px,上拉由 28px 放大到 36px,并同步将触发距离、最大回弹和稳定态半径调整为 56 / 84 / 20 与 52 / 76 / 18;嵌套刷新使用 36px 视觉尺寸,避免主列表与个人页 Tab 出现两套比例。拖拽态缩放改为严格跟随 `offset / triggerOffset`,同时保留“绘制尺寸不得超过已揭示空隙”的裁切保护,动画从小到大与手势同速且不会被列表边缘覆盖。首页、动态各 Tab 与“我的”使用专用 62px 视觉偏移,刷新动画在悬浮顶栏下方的首屏留白中显现,滚动内容仍可延伸到状态栏。14 项加载与悬浮导航组件测试通过,目标静态检查无 error / warning。Android 16 真机 `986008e8` 已以 1200×2670 / 120Hz 完成 Debug 覆盖安装、真实登录数据启动、下拉手势、Hot Reload 与 Hot Restart;进程保持前台,近期日志未发现布局溢出、未处理异常或销毁后更新。 + +`NAV-STYLE-01`、`NAV-STYLE-05` 完成记录(2026-08-29):首页瀑布流新增独立的高密度内容留白规则,手机宽度下由原先 12 / 16px 收紧为 8 / 10px,卡片间 gutter、14px 圆角、宽屏 24px 留白和 1440px 最大版心保持不变,其他阅读页与标签页不受影响。首页中间“Loftify”标题已移除毛玻璃胶囊、边框、背景和阴影,只保留单行标题及语义头部;左右头像与通知继续使用独立圆形悬浮面。13 项主题栅格与悬浮导航组件测试通过,目标静态检查无 error / warning(仅保留首页既有类型推断 info)。Android 16 真机 `986008e8` 已完成 Hot Reload,并切回首页验证 1200×2670 / 120Hz 布局;近期日志未发现布局溢出或未处理异常。 + +`DYNAMIC-02` 完成记录(2026-08-29):动态页“合集”Tab 的已订阅与猜你喜欢列表统一补齐页面级留白;手机使用 12px 水平、6px 垂直外边距及 10px 卡片间距,宽屏使用 16px 水平外边距及 12px 卡片间距,原 560px 最大卡片宽度、内容结构、点击语义和业务跳转保持不变。3 项合集卡片与栅格专项测试通过,目标静态检查无 error / warning(仅保留动态页既有类型推断 info)。Android 16 真机 `986008e8` 已完成 Hot Reload,并进入真实动态合集 Tab 检查 1200×2670 / 120Hz 布局;页面进程正常,近期日志未发现布局溢出或未处理异常。 + +`NOTICE-01` 完成记录(2026-08-29):通知页全部 8 个 Tab 统一使用铺满可用视窗的可滚动占位,首次加载、空数据和短内容状态不再因默认 `shrinkWrap` 只占据局部高度;空态仍具备 `AlwaysScrollableScrollPhysics`,无内容时也能直接触发原下拉刷新。390×568 短屏专项组件测试验证占位完整铺满且文案可见,目标静态检查无 error / warning(仅保留通知页既有类型推断 info)。Android 16 真机 `986008e8` 已完成 Hot Reload,真实通知页“全部”接口成功返回并连续切换 3 个 Tab;近期日志未发现布局溢出、未处理异常或销毁后更新。 + +`NAV-HEADER-01` 完成记录(2026-08-29):公共主导航顶栏由 `Positioned` 覆盖层改为包含状态栏安全区、6px 顶部节奏、48px 控件区和 8px 内容间隔的真实布局区块。首页改用单一 `CustomScrollView`,顶栏作为首个 Sliver 与瀑布流共享滚动控制器,上滑时同步离场且作品随后自然进入状态栏区域;搜索、动态和“我的”使用 `Column + Expanded` 将顶栏稳定放在内容上方,不再依靠空白占位补偿。动态四 Tab 与“我的”的刷新区域随之从顶栏下方开始,移除旧悬浮层专用 62px 指示器偏移,原入口、Tab、搜索、主题、设置和底栏滚动联动保持不变。23 项导航与动态栅格测试通过,目标静态检查无 error / warning(仅保留既有 info)。Android 16 真机 `986008e8` 已重新执行完整 Gradle Debug 编译、APK 覆盖安装、四导航切换、首页双向滚动和 Hot Restart;1200×2670 / 120Hz 进程正常,近期日志未发现布局溢出、滚动控制器冲突或未处理异常。 + +`NAV-OPTION-01` 完成记录(2026-08-29):外观设置新增“图文 / 仅图标 / 仅文字”三种底部导航展示风格,默认使用仅图标并通过 Hive 持久化;公共 Provider 与主面板选择器会在切换后即时重建底栏,无需重启应用。仅文字模式继续保留通知角标和完整语义标签,图文模式在固定导航高度内将系统字体缩放限制为 1.4 倍,避免窄屏与超大字体造成底部溢出。中英简繁四套资源已同步;18 项底栏专项测试覆盖 280px 窄屏、2 倍字体、三种风格、默认值、未知值回退、角标和可访问语义,全部通过,目标静态检查无新增 error / warning。 + +`NAV-ANIM-01` 完成记录(2026-08-29):底栏显隐监听由“每次遍历全部滚动控制器”改为只响应本次实际产生滚动的控制器,未激活页面或 Tab 停在顶部、残留反向方向时不再覆盖当前列表的隐藏动作;控制器注册按身份去重并在切换时完整移除旧监听,避免重复回调和生命周期泄漏。主面板只注册当前导航页的滚动源,动态页只注册当前 Tab;页面或 Tab 切换时底栏与横屏悬浮按钮都会主动恢复,并在下一帧重绑新滚动源。22 项专项测试覆盖多控制器互不干扰、旧监听释放、活跃源切换后恢复、快速反向、减少动画和四导航接线,全部通过;目标静态检查无新增 error / warning(动态页仅保留既有类型推断 info),Android Debug 完整构建成功。Android 16 真机 `986008e8` 已完成首页 / 动态、多 Tab、连续上下滚动、Hot Reload 与 Hot Restart;1200×2670 / 120Hz 前台运行正常,近期日志未发现布局溢出、滚动控制器冲突或未处理异常。 + +`TAG-STYLE-01` 完成记录(2026-08-29):标签详情“合集粮单 / 相关标签 / 相关装扮”发现入口恢复原插画 Banner 的紧凑信息层级,移除统一大卡片带来的额外描边、阴影、半透明覆盖与渐变;标题保持轻量辅助层级,具体内容恢复为单行主信息。Banner 默认约 183×70,并始终按原资源 170:65 的比例随大字体受控增长;文本缩放上限为 1.5 倍并预留 2px 字体栅格安全量,避免 320px 窄屏和 2 倍系统字体下出现裁切。横向入口间距由 12px 收紧至 8px,区块底部留白由 24px 收紧至 12px,外侧仍对齐响应式内容版心;原深浅色资源、`BoxFit.cover`、点击语义与业务跳转完整保留。4 项组件测试覆盖浅色 2 倍字体、深色、插画比例、无描边/阴影、点击和语义,全部通过;目标静态检查零问题,Android Debug 完整构建成功。Android 16 真机 `986008e8` 已通过深链进入真实 `OC` 标签详情,运行树确认 2 个 Banner 均为 183.1×70、无描边,横向往返滑动、Hot Reload 与 Hot Restart 正常,近期日志未发现布局溢出或未处理异常。 + +`SETTING-IMAGE-01` 完成记录(2026-08-29):图片设置页移除重复的“下载管理”Entry 及不再需要的页面、图标依赖,只保留与图片下载行为直接相关的保存路径和文件命名格式;下载管理页面、持久化任务与“我的 → 内容中心”入口均未修改。2 项入口结构回归验证内容中心仍持有唯一业务入口、图标和页面跳转,同时图片设置不再引用下载管理且保留路径 / 文件名设置;目标静态检查零问题,Android Debug 完整构建成功。Android 16 真机 `986008e8` 已进入真实“设置 → 图片设置”,运行树确认页面存在图片/视频下载路径与文件命名格式,当前页面子树中不存在“下载管理”或下载管理页面;Hot Reload 与 Hot Restart 均成功,近期日志未发现布局溢出或未处理异常。 + +`APPBAR-SIZE-01` 完成记录(2026-08-29):公共 AppBar 图标按钮将可见圆面、图标和语义点击区域拆分为独立尺寸;普通、响应式与折叠 AppBar 统一使用 34px 圆面和 18px 图标,同时保留不小于 44×44 的完整点击区域。显式传入较大图标时会在 AppBar 作用域内按光学尺寸收口,独立行内按钮、导航页胶囊和业务语义不受影响;边框、选中、禁用、高对比度与深浅主题状态继续绘制在可见圆面上。18 项图标与 AppBar 组件测试全部通过,目标静态检查零问题,Android Debug APK 完整构建成功。Android 16 真机 `986008e8` 已完成新版覆盖安装、真实登录数据启动、设置页入口操作、Hot Reload 与 Hot Restart;1200×2670 / 120Hz 前台运行正常,近期日志未发现布局溢出、断言或未处理异常。 + +`DETAIL-DOWNLOAD-01` 完成记录(2026-08-29):帖子详情 AppBar 下载按钮移除不确定的通用加载表现,改为固定 20px 视口内的真实环形下载进度;中心保留下载语义图标,进度轨道、主题强调色、圆角端点及深浅色 / 高对比度配色不会挤压 44×44 点击区域。单图直接读取实际传输字节,多图通过独立进度累加器等权汇总每项实际字节进度,尚未开始且总字节未知的资源仍会预留份额,避免首个文件完成时错误显示 100%。完成态使用成功色对号,失败态使用危险色警告并可立即点击重试,下载中与成功态阻止重复入队;所有状态均暴露本地化语义和百分比,切换帖子后旧回调不会污染新页面。30 项下载、详情组件与 Lucide 规范回归全部通过,目标静态检查无新增 error / warning(详情页仅保留既有 info),Android Debug APK 完整构建成功。Android 16 真机 `986008e8` 已在真实帖子详情触发下载并确认“下载完成 → 下载”稳定恢复,Hot Reload 与 Hot Restart 成功;1200×2670 / 120Hz 前台运行正常,近期日志未发现布局溢出、断言或未处理异常。 + +`DOWNLOAD-GROUP-01`、`DOWNLOAD-GROUP-02` 完成记录(2026-08-29):下载队列新增独立父级批次模型,同一次帖子“保存全部”、合集、粮单、我的喜欢、我的推荐或收藏夹批量保存只在下载管理首页展示一条父任务,具体图片 / 视频作为子资源保留且不再平铺;原有单项下载和历史无批次记录继续兼容展示。父任务持久化稳定的来源 `type + sourceId`、标题、封面与跳转元数据,类型覆盖 `postAll / collection / grain / likes / recommendations / favoriteFolder / other`,并保存子任务 ID、请求数、不可用数和跳过数;重启恢复、资源 URL 去重、失败重排队、整体进度、进行中 / 暂停 / 完成 / 部分失败 / 失败 / 取消状态均由子任务实时汇总。清理历史会保留仍被其他父任务引用的资源,避免共享去重资源被误删。中英简繁新增父任务类型、部分完成和资源进度文案;47 项下载管理、批量选择、详情下载、Lucide 规范与本地化资源回归全部通过,目标静态检查无新增 error / warning(旧页面仅保留既有 info),Android Debug APK 完整构建成功。Android 16 真机 `986008e8` 已在真实帖子创建 `postAll` 父任务,下载管理仅展示“帖子标题 / 帖子全部资源 / 1 / 1 项资源 · 100%”;Hot Restart 后父任务、类型和进度均成功恢复,Hot Reload 正常,近期日志未发现布局溢出、断言或未处理异常。 + +`DOWNLOAD-GROUP-03`、`DOWNLOAD-GROUP-04` 完成记录(2026-08-29):下载管理父任务现可进入独立详情页,内容来源、请求 / 跳过 / 不可用统计、整体状态与字节进度、子资源列表、单项状态与进度均实时随下载管理器更新;整组支持暂停、继续、取消和失败项重试,子资源按等待、下载、暂停、完成、失败、取消状态提供对应单项操作,完成资源继续保留在父任务记录中。详情页新增“查看原内容”,按 `postAll / collection / grain / likes / recommendations / favoriteFolder` 的持久化元数据准确构造原帖子、合集、粮单、喜欢、推荐或收藏夹页面;旧记录或来源信息失效时仅显示局部提示,详情与下载记录不受影响。合集、喜欢与推荐的后续批次补齐作者及入口元数据,帖子十进制业务 ID 在跳转前正确转换为详情接口所需十六进制 Meta。真机跳转首次暴露的帖子详情首帧尺寸读取断言已通过帧后合并可见性同步修复,复测不再出现断言。中英简繁新增 11 项详情文案;32 项下载管理、来源解析、窄屏详情、帖子详情组件与本地化回归全部通过,320×568 窄屏无溢出,目标静态检查无新增 error / warning(详情页仅保留 47 项既有 info),Android Debug APK 完整构建成功。Android 16 真机 `986008e8` 已以真实 `postAll` 父任务验证详情、子资源、整体进度和“查看原内容 → 原帖子”;验收截图保存在 `build/download_group_detail.png` 与 `build/download_original_target.png`,Hot Reload、Hot Restart 均成功,恢复用户防截屏设置后近期日志未发现布局溢出、断言或未处理异常。 + +### 2026-08-25 下载管理与局部视觉补充 + +- [x] `DOWNLOAD-GROUP-01` 将“帖子保存所有”、合集、粮单、我的喜欢、我的推荐、我的收藏等一次批量保存操作合并为一条父级下载任务;下载管理首页不得再把同一批次中的每张图片 / 每个视频平铺为互不相关的顶层任务。 +- [x] `DOWNLOAD-GROUP-02` 为聚合任务建立稳定的来源 `type` 与来源标识,至少区分帖子全部资源、合集、粮单、喜欢、推荐、收藏及后续可扩展类型;持久化、恢复、去重、通知和状态统计均不得丢失类型。 +- [x] `DOWNLOAD-GROUP-03` 点击父级任务进入下载任务详情,展示来源摘要、子资源列表、总体与单项进度、成功 / 等待 / 暂停 / 失败状态,并支持对整组或失败子项执行暂停、继续、取消和重试。 +- [x] `DOWNLOAD-GROUP-04` 下载任务详情提供“查看原内容”入口,并依据任务类型准确跳转到原帖子、原合集、原粮单或对应内容列表;来源失效时给出局部错误提示,不能导致下载记录不可查看。 +- [x] `NAV-STYLE-05` 首页中间的“Loftify”应用名称取消胶囊背景、毛玻璃、边框和阴影,只保留克制的单行文字;左右头像与通知按钮继续使用独立悬浮圆形控件。 +- [x] `SETTING-IMAGE-01` 从图片设置页面移除下载管理入口;下载管理只保留在“我的 → 内容中心”,不得删除管理页面、任务数据或其他业务入口。 +- [x] `APPBAR-SIZE-01` 缩小普通页面 AppBar 圆形按钮的可见圆面和图标视觉尺寸,避免显得笨重;实际语义点击区域仍不得小于 44×44,返回、更多、下载等按钮保持统一光学对齐。 +- [x] `DETAIL-DOWNLOAD-01` 优化帖子详情 AppBar 下载按钮的下载中反馈:使用清晰的环形 / 进度式状态表达实际进度和完成结果,避免把普通 Lottie 加载动画当作下载进度;失败可重试且不能挤压 AppBar 布局。 + +## 0. 使用规则 + +- `[x]`:代码已落地,并至少完成静态检查、自动化测试或设备验证中的一项有效验证。 +- `[ ]`:尚未完成,或虽有实现但仍缺少关键场景验证。 +- “进行中”只记录在迭代看板中;未达到完整验收条件前,正文任务仍保持 `[ ]`。 +- P0:影响现有页面正确性、一致性或稳定性的回归问题。 +- P1:影响核心使用体验、性能和设备适配的问题。 +- P2:不阻塞现有功能的新能力。 +- 每轮只勾选本轮真正完成并验证的原子任务;设备未覆盖的内容必须保留在验收记录中。 +- 对齐 CloudOTP / awe_chewie 时,以可复用的组件逻辑、主题语义和交互规则为基线,不复制与 Loftify 无关的业务逻辑。 +- 同一问题若同时涉及基础组件和具体页面,先修基础组件,再做页面级回归;禁止只在单个页面追加临时样式。 + +## 1. 已建立的设计与工程基线 + +后续页面必须复用这些基础实现,不再新增同用途的独立样式或交互。 + +- [x] `BASE-01` 浅色模式下,页面、AppBar、状态栏区域和底部导航栏使用白色背景;卡片使用浅灰层级。 +- [x] `BASE-02` 统一 EntryItem、CheckboxItem、CaptionItem、InputItem、登录输入框和行内下拉菜单。 +- [x] `BASE-03` 统一基础组件的圆角、图标底板、间距、分割线、选中状态和 Material 水波纹。 +- [x] `BASE-04` 统一 AppBar、状态栏颜色延伸和 Cupertino 页面过渡。 +- [x] `BASE-05` 统一 Toast、Dialog、BottomSheet 和 ContextMenu 的基础视觉样式;所有底部 Panel 强制裁切统一顶部圆角与安全区。 +- [x] `BASE-06` 恢复全局自定义 Lottie 加载、下拉刷新和上拉加载动画。 +- [x] `BASE-07` 对齐主题色、深浅色模式、字体、语言切换及本地持久化。 +- [x] `BASE-08` 修复繁体中文本地化键中的不可见字符,本地化生成无缺失项。 +- [x] `BASE-09` 升级 Android / Flutter 相关依赖,并完成 Android Debug APK 构建。 +- [x] `BASE-10` 已在 Android 模拟器和真机完成启动、Hot Reload、Hot Restart 基线验证。 + +## 2. P0:现有功能回归与视觉统一 + +### 2.1 全局组件和页面巡检 + +- [x] `UI-01` 在 `UI-03`~`UI-08` 完成后执行最终扫尾:清查尚未迁移的页面、卡片、Panel、列表项和弹出菜单,移除重复实现及无必要的硬编码颜色、圆角、间距和字体。 +- [x] `UI-02` 统一 Tab 的单行文字、选中状态、背景、指示器、点击反馈和自适应滚动;窄屏不得换行或挤压。 +- [x] `UI-03` 统一更多菜单、长按菜单和行内菜单的图标、分割线、圆角、水波纹与安全区。 +- [x] `UI-04` 逐页核对 AppBar 与状态栏颜色延伸,避免顶部白边、色块断层或返回按钮样式不一致。 +- [x] `UI-05` 逐页核对浅色模式背景;页面、底部导航栏和主要滚动区域不得出现非设计性的灰底。 +- [x] `UI-06` 检查深色模式、横屏、平板和桌面布局,避免局部白块、颜色断层、文字换行和控件溢出。 +- [x] `UI-07` 检查主题、字体和语言切换后的即时刷新与重启恢复,避免局部仍使用旧配置。 +- [x] `UI-08` 检查所有加载入口,确保使用统一 Lottie 动画,并覆盖首次加载、刷新、分页、空状态、失败和重试状态。 +- [x] `L10N-01` 完成真实英文资源:716 个业务文案已完成真实英文翻译、领域词人工校对和本地化代码生成;四套资源键与占位符一致,英文页面未发现文字溢出。 +- [x] `DYNAMIC-01` 修复动态页“合集”双列卡片在窄屏、长摘要、更新徽标或大字体下的底部溢出;卡片高度必须由封面与信息列中较高者自然决定,不得依赖固定高度裁切内容。 + +`DYNAMIC-01` 验证记录(2026-08-24):最终视觉重构前的 MuMu 基线巡检在真实“动态 → 合集”数据中发现多张卡片出现 1~2px 底部溢出。订阅合集和猜你喜欢合集现已复用无固定高度的响应式卡片框架,以 100px 封面作为最小高度,标题、三行摘要、标签、更新徽标、操作按钮和系统大字体可推动整卡自然增长。新增 560px 标准宽度与 280px / 2 倍文字专项测试;全量 205 项测试通过,目标静态检查无 error / warning,Android / Windows Debug 构建成功。MuMu Android 12 已完成真实双列合集、Hot Reload 与 Hot Restart 验证,黄色溢出条消失且内容顺序、点击与继续阅读入口保持不变;修复前后截图保存在 `docs/redesign/baseline/android-phone-light-dynamic-overflow-before.png` 与 `docs/redesign/baseline/android-phone-light-dynamic.png`。 + +`UI-02` 验证记录:公共 Tab 已统一为页面背景、主题文字色、无水波纹点击反馈和主题色 UnderlineIndicator;4 个以上 Tab 或大字号环境自动横向滚动,文字固定单行。搜索、搜索结果、动态、标签详情、个人主页、系统通知、装扮和粮单页面已收口到同一组件。3 个组件测试、Android Debug 构建、模拟器 Hot Reload / Hot Restart 及搜索结果 6 Tab 点击验证均通过;本轮真机因未连接待补验。 + +`UI-03` 验证记录:桌面菜单已增加状态栏、导航栏、键盘等安全边界和最大宽度约束;移动端菜单支持长列表滚动、标题、分割线和底部安全区;禁用菜单不再响应点击或长按。行内下拉支持窄屏纵向排布、单行标签、Material 水波纹和上下方自适应展开,并修复控制器监听释放、语言切换后等值选中项显示旧标签及 `initialItem` 断言。8 个菜单测试与 3 个 Tab 测试通过,本轮文件无新增静态错误或警告,Android Debug APK 构建通过。Android 17 模拟器已完成 Hot Restart、Hot Reload、菜单打开态、English 切换及恢复“跟随系统”验证,运行日志未发现断言、溢出、未处理异常或销毁后更新;真机当前未连接,仍需在后续综合回归中补验。 + +`UI-04` 验证记录:业务页面 AppBar 已确认统一经过公共组件;普通、半透明和图片型头部会按实际合成背景自动选择状态栏图标明暗,图片头部默认使用浅色图标,返回按钮颜色同步。Android 已启用 edge-to-edge,状态栏与导航栏保持透明并跟随主题;平板横屏补齐顶部安全区,个人页移除重复 SafeArea,面板恢复和安全模式不再把浅色状态栏写死。新增 5 个 AppBar / 系统栏组件测试,连同菜单与 Tab 共 16 个通用组件测试全部通过;本轮文件静态检查、差异检查及 Android Debug APK 构建均通过。Android 17 模拟器已完成 Hot Restart、Hot Reload、浅色主页与设置页、深色主页及图片头部页面验证,状态栏无白边或断色,运行日志未发现断言、溢出、未处理异常或销毁后更新;真机当前未连接,仍需在后续综合回归中补验。 + +`UI-05` 验证记录:浅色主题的 Scaffold、Material 2 / Material 3 导航栏统一使用纯白页面底色,包括自定义浅色主题;`canvasColor` 与 `cardColor` 继续用于卡片、输入框、搜索框和占位图等有层级含义的组件。首页横竖屏底层、公共 `getBackground` 与主底部导航均使用页面背景;合集、粮单和帖子详情的底部操作栏已从卡片灰底迁移为页面底色。新增 4 个主题与导航背景测试,连同既有 AppBar、菜单和 Tab 共 20 个通用组件测试全部通过,差异检查及 Android Debug APK 构建通过;相关页面文件仅保留既有静态提示,本轮未新增问题。Android 17 模拟器已完成 Hot Restart、Hot Reload、首页 / 搜索 / 动态 / 我的、设置、柔和光明主题、极简白恢复及粮单详情底栏验证,页面、主滚动区、底部导航和手势区均连续为白色,运行日志未发现断言、溢出、未处理异常或销毁后更新;真机当前未连接,仍需在后续综合回归中补验。 + +`UI-06` 验证记录:移动端方向策略现在会在窗口尺寸或显示密度变化后重新计算;手机继续锁定竖屏,达到 `600×900dp` 平板断点后主动解除方向锁,修复由手机尺寸切换到平板横屏时应用仅占左侧、右侧出现大面积黑区的问题。桌面启动和运行期统一采用公共 `800×640` 最小窗口约束,消除首次启动、窗口缩放和恢复尺寸之间的限制不一致;帖子详情的双栏阈值已改为独立命名,避免再被误用为窗口约束。另修复平板 / 桌面侧栏主题按钮只显示水波纹却不执行回调的问题。新增 3 个响应式断点测试和 1 个动态工具按钮测试,连同既有主题、加载、AppBar、菜单和 Tab 共 27 个通用组件测试全部通过;目标文件静态检查无 warning 或 error,仅保留既有 info 提示,差异检查通过。Android Debug APK 与 Windows Debug 编译 / 启动通过;Android 17 模拟器完成手机竖屏、平板竖屏 / 横屏、深浅色、首页 / 搜索 / 动态 / 我的 / 设置、侧栏主题切换、Hot Restart 与 Hot Reload 验证,Windows 完成最小窗口下验证码登录、三列首页、深浅色侧栏切换、Hot Restart 与 Hot Reload 验证,未发现布局溢出、颜色断层或运行异常。模拟器密度、自动旋转、方向和浅色状态均已恢复;Android 真机当前未连接,仍需在后续综合回归中补验。 + +`UI-07` 验证记录:系统语言和系统明暗监听已上移到应用根节点,登录前、锁屏和业务页共享同一刷新链路,并同步 `Intl.defaultLocale`;语言解析优先匹配地区、再回退到语言。字体选择改为等待资源加载成功后再持久化和刷新根主题,内置字体从远程缓存加载,自定义字体从本地文件恢复,失败不会覆盖当前选择;字体设置摘要与全局页面同步更新。归档年月改用当前语言资源,并修正生成器按参数名排序后原调用传反年月的问题。新增 5 个外观状态测试,本地化测试同时加强为键、占位符及顺序一致性校验;连同既有通用组件共 35 项测试全部通过,目标静态检查零问题,Android Debug APK 构建成功。Android 17 模拟器完成浅色 / 深色、跟随系统 / 简体中文 / English、系统字体 / MiSans 的即时切换,验证统一 Lottie 字体下载进度,并确认“简体中文 + 深色 + MiSans”组合在 Hot Restart 后同时恢复;Android 与 Windows 调试会话均完成 Hot Restart 和 Hot Reload。最终模拟器已恢复“跟随系统 + 浅色 + 系统字体”,运行日志未发现布局溢出、未处理异常或销毁后更新;Android 真机当前未连接,仍需在综合回归中补验。 + +`UI-08` 验证记录:Lottie 加载构建器已在应用启动早期注册,并依据当前页面的实际明暗主题选择资源,避免自动锁定、主题切换或重启阶段回落为系统加载圈。首次加载、下拉刷新、分页加载、通用加载与失败重试容器、图片预览、视频下载和异步下拉搜索均接入统一动画;视频下载百分比继续保留,加载容器默认背景改为透明以延续页面底色。业务页面和公共组件的活动路径已无直接系统进度圈。新增 3 个加载动画组件测试,连同既有主题、AppBar、菜单和 Tab 共 23 个通用组件测试全部通过;目标文件静态检查无错误或警告,差异检查及 Android Debug APK 构建通过。Android 17 模拟器已完成浅色与深色下拉刷新动画、Hot Restart、Hot Reload 及浅色默认状态恢复验证,运行日志未发现断言、溢出、未处理异常或销毁后更新;真机当前未连接,仍需在后续综合回归中补验。 + +`L10N-01` 验证记录:英文资源的 716 个业务文案已全部转换为真实英文,并对登录、动态流、收藏夹、粮单、用户关系、设置、版权与解锁道具等领域词进行人工校对;CloudOTP 复用文案中的品牌残留已清除,仅在彩蛋的真实开发经历中保留项目名。四套 ARB 资源均为 716 个业务键,占位符及顺序完全一致;英文仅保留字体预览所需的非拉丁示例。`flutter gen-l10n` 与目标静态检查通过;新增 3 个本地化资源回归测试,连同既有通用组件测试共 30 项全部通过,Android Debug APK 构建成功。Android 17 模拟器完成“跟随系统 / 简体中文 / English”即时切换、英文重启恢复、我的页、设置页、语言行内菜单和长说明换行验证,并执行 Hot Restart 与 Hot Reload;运行日志未发现断言、溢出、未处理异常或销毁后更新,最终已恢复“跟随系统”。Windows 调试会话完成 Hot Restart 与 Hot Reload,最小窗口继续正常运行;Android 真机当前未连接,英文真机布局仍需在后续综合回归中补验。 + +`UI-01` 验证记录:页面层直接使用系统 AppBar、Card、ListTile、系统弹窗、系统下拉菜单和原生页面路由的残留已清零;帖子更多操作迁移到响应式 `BottomSheetBuilder`,手机使用统一底部弹层,宽屏使用统一对话框。Panel 初始页接入公共淡入路由,底部导航、Tab、次要文本、箭头、关注按钮、收藏夹标题和手势锁状态改用主题文字层级与语义色;三个搜索入口移除重复灰底并复用公共搜索栏背景。`GeneralPostItem` 独立为无页面循环依赖的数据模型,消除 Windows 分析器的重复类型边界。相关本地化、通用组件和帖子组件共 41 项测试全部通过,整组目标静态检查为 0 error(仅保留 2 个既有未使用字段 warning),差异检查及 Android Debug APK 构建通过。Android 17 模拟器完成浅色 / 深色搜索页、底部导航、帖子更多弹层、Hot Reload 与 Hot Restart 验证,Windows 调试会话同样完成 Hot Reload 与 Hot Restart;运行日志未发现布局溢出、未处理异常、销毁后更新或空值崩溃,模拟器最终恢复浅色。Android 真机当前未连接,仍需在后续综合回归中补验。 + +验收:同类组件在不同页面上具有一致的高度、内边距、圆角、分割线、字体和交互反馈;主题、语言、字体或窗口尺寸变化后无明显断层和溢出。 + +### 2.2 帖子卡片与图片排版 + +- [x] `POST-01` 修复纯文字帖子卡片丢失圆角的问题。 +- [x] `POST-02` 统一瀑布流、Grid、图文和纯文字帖子卡片的 12px 圆角、裁切与点击边界;瀑布流保持无水波纹。 +- [x] `POST-03` 优化 1~9 张图片的比例、间距和圆角,并重构 7 图、8 图拼图结构。 +- [x] `POST-04` 修复帖子组件复用后仍持有旧 Item 和旧图片比例的问题。 +- [x] `POST-05` 修复缺失媒体或异常图片比例导致的卡片崩溃,提供安全回退。 +- [x] `POST-06` 覆盖 1~9 图、超长标题、无标题、无标签、缺失媒体、异常比例和加载失败的视觉边界测试。 +- [x] `POST-07` 检查卡片在列表首尾、单列、双列、瀑布流以及布局切换后的圆角和高度稳定性。 + +`POST-06`、`POST-07` 验证记录:瀑布流、Grid、Tile 三类卡片统一了缺失媒体且无正文时的局部错误卡片;滚动列表中的 HTML 摘要关闭整卡文本选区,详情正文仍保留选择能力,消除了窗口尺寸变化时的 SelectionArea 布局断言。Tooltip 的全局指针监听会随组件停用和恢复正确注册,并迁移到新的主题约束模型,避免热更新或窗口变化期间创建重复动画控制器。组件测试覆盖 1~9 图四角圆角、有限尺寸、7→8 图更新、超长标题、无标题、无标签、缺失媒体、异常比例、图片失败、同 Key 数据替换以及列表首尾点击边界;本地化、通用组件和帖子组件共 49 项测试全部通过,目标静态检查零问题,Android Debug APK 构建成功。Android 17 模拟器完成单列列表、Grid、瀑布流、布局往返切换、平板多列横屏、滚动、恢复手机竖屏、Hot Reload 与 Hot Restart 验证,关键运行异常计数均为 0;截图保存在 `build/post07-feed-list.png`、`build/post07-tag-grid-loaded.png`、`build/post07-tag-waterfall-restored.png`、`build/post07-tablet-landscape-clean.png` 和 `build/post07-final-restored-clean.png`。Windows 调试会话完成 Hot Reload 与 Hot Restart;Android 真机当前未连接,仍需在后续综合回归中补验。 + +验收:各种帖子类型均无直角泄漏、裁切错误、溢出、高度跳变或错误复用;异常内容只影响当前卡片,不得使列表或详情页崩溃。 + +### 2.3 个人主页 + +- [x] `PROFILE-01` 重构信息层级、数据概览和操作入口。 +- [x] `PROFILE-02` 使用统一的 Panel / Caption 卡片,并正确处理列表首尾圆角。 +- [x] `PROFILE-03` 统一页面背景、滚动区域、吸顶区域和卡片之间的视觉衔接。 +- [ ] `PROFILE-04` 修复嵌套滚动、圆角裁切、回弹、刷新和吸顶手势冲突;触发阈值冲突已修复,真机动画完成和多 Tab 仍需复验。 +- [x] `PROFILE-MORE-01` 将个人主页更多操作改为紧凑横向图标网格,保留全部原入口和回调;隐私 / 屏蔽操作单独成组,完成短屏大字体和真机布局验收。深色真机外观留待全局视觉回归复查。 +- [x] `PROFILE-SUPPORTER-01` 支持者列表对齐关注/粉丝列表的白底紧凑行、轻量按下反馈与自适应文字布局;保留贡献数与整行跳转,真机核对真实账号。 + +`PROFILE-01`~`PROFILE-03` 验证记录:个人主页重排为头像与身份摘要、元数据、更多信息、四段数据概览和全宽主操作的稳定层级;本人页面使用“在浏览器中打开”,其他用户正确显示关注状态。数据概览抽为基于 `ContainerItem` 的 `ProfileOverviewCard`,统一 14px 圆角、分隔线、语义色和 Material 水波纹,长标题保持单行省略;中日韩沿用“万”单位,其他语言使用 K / M / B 紧凑数字,避免英文大数字挤压。装扮展示同样收口到统一卡片和点击边界,并补齐稳定 Hero / Tab 标识、异步 mounted 保护及控制器释放。图片头部增加主题占位与渐变遮罩,AppBar 颜色连续扩展至状态栏;展开头部、白色内容区和吸顶 Tab 在手机竖屏、平板横屏与折叠滚动状态下无颜色断层。新增 1 个个人主页组件测试;本地化、通用组件、帖子组件和个人主页共 50 项测试全部通过,本轮 3 个目标文件静态检查零问题,Android Debug APK 构建成功;全仓库分析仍保留 2716 项旧代码与内置第三方目录的既存提示。Android 17 模拟器完成本人 / 其他用户、关注态、超大统计数字、手机竖屏、平板横屏、吸顶滚动、Hot Reload 与 Hot Restart 验证,关键运行异常计数均为 0;截图保存在 `build/profile-own-portrait.png`、`build/profile-own-collapsed.png`、`build/profile-tablet-landscape.png`、`build/profile-tablet-landscape-collapsed.png` 和 `build/profile-other-compact.png`。Windows 调试会话完成 Hot Reload 与 Hot Restart;Android 真机当前未连接,仍需在后续综合回归中补验。 + +`PROFILE-04` 验证记录:个人主页的四个内容 Tab 改为单一嵌套滚动链,子列表直接复用主滚动控制器,嵌套场景不再同时执行手动首次刷新与 `refreshOnStart`;分页只保留 EasyRefresh 入口,并补齐控制器释放和无更多数据判断。Android 17 模拟器完成资料头展开 / 折叠、吸顶 Tab、连续快速滑动及“文章 / 喜欢 / 推荐 / 合集”往返切换;“喜欢”和“推荐”首次进入各仅发起 1 次请求,未发现跳位、重复加载或布局异常。截图保存在 `build/scroll-profile-loaded.png`、`build/scroll-profile-collapsed.png` 和 `build/scroll-profile-tabs.png`;Android 真机当前未连接,仍需补验物理触控与高刷新率回弹。 + +验收:连续滚动无卡顿、错位和异常裁切;刷新、吸顶、Tab 切换与返回行为稳定;浅色模式背景连续为白色。 + +### 2.4 帖子详情与评论区 + +- [x] `DETAIL-01` 重构评论区的层级、头像、正文、时间、操作按钮和多级回复布局。 +- [x] `DETAIL-02` 优化底部操作栏的安全区、按钮间距、键盘弹出和输入状态。 +- [x] `DETAIL-03` 统一文章、图片、视频和合集详情页的 AppBar、页面背景与过渡效果。 +- [x] `DETAIL-04` 修复文章详情解析失败时的整页异常,提供局部错误提示和重试入口。 +- [x] `DETAIL-05` 排查并优化文章详情滚动掉帧、内容重排和高度跳变。 + +`DETAIL-01`~`DETAIL-04` 验证记录:一级评论与多级回复统一为可复用 `CommentItem` 卡片,头像、作者与回复关系、作者/置顶标识、正文、时间地点和点赞操作使用同一层级规则;评论列表移除嵌套 `ListView`,弹层支持刷新、分页、局部失败重试和底部安全区。文章详情、合集与粮单底栏统一为白色 `DetailBottomBar`,四个文章动作等宽分配,长英文标签不再粘连或越界;正文不再延伸到底栏背后,评论跳转和弹层输入环境会随系统安全区与键盘可用高度调整。文章核心数据会先独立完成渲染,礼物、评论等辅助请求失败不再拖垮整页;正文 HTML 解析异常收口为内容区局部提示与重试入口,宽松数值、布尔值、空评论作者等服务端数据也增加安全解析。文章、图片、视频、合集与粮单继续复用公共 AppBar、语义背景和 Cupertino 过渡,合集/粮单吸顶区改为稳定标识;视频页保留符合媒体场景的黑色沉浸式系统栏。新增 5 个详情组件与模型回归测试,连同本地化、通用组件、帖子组件和个人主页共 55 项测试全部通过;详情相关 10 组路径静态检查为 0 error、0 warning、0 info,Android Debug APK 构建成功。Android 17 模拟器完成手机竖屏、平板密度、正文滚动、评论定位、全量评论弹层、Hot Reload 与 Hot Restart 验证,关键运行异常计数均为 0;截图保存在 `build/detail-current.png`、`build/detail-tablet.png`、`build/detail-tablet-comments.png` 和 `build/detail-tablet-sheet.png`。Windows 调试会话完成 Hot Reload 与 Hot Restart;Android 真机当前未连接,本轮详情页真机键盘、安全区与物理横屏仍需在综合回归中补验。 + +`DETAIL-05` 验证记录:文章正文的 HTML / 纯文本提取从 `build` 移至内容变化时执行,解析结果和渲染组件会跨父级无关刷新复用;正文区增加独立重绘边界,手机与桌面详情的整页大 `Column` 改为按段构建的 Sliver / List 子项。推荐分页只在请求成功后推进页码,空页与全重复页会正确结束加载,移除滚动监听和 EasyRefresh 的双重触发。新增正文解析缓存回归测试;Android 17 模拟器对长文章连续执行 6 次快速滑动,正文、彩蛋区和合集导航均无高度跳变,未处理异常、布局溢出、延迟初始化和销毁后更新计数均为 0;截图保存在 `build/long-article-top.png` 和 `build/long-article-fast-scroll.png`。 + +`DETAIL-05` 补充回归(待真机手感确认):帖子详情底部操作改为右下角白底竖向悬浮条,带边框、阴影及向右进出动画;向下滚动隐藏、向上滚动恢复。合集 / 粮单、标签、点赞推荐操作区或评论区任一进入视窗时会优先隐藏。显隐状态通过独立监听器只重建悬浮条,不再刷新长文章列表;视窗检测分别挂在原有列表子项上,不改变评论跳转后的 Sliver 层级与滚动锚点,用于修复跳到评论后向上滚动瞬间回到文章顶部的问题。Android 模拟器与 Android 16 真机已完成 Hot Reload,仍待物理触控复测后关闭本条。 + +验收:长文本、多级回复、键盘弹出、横屏及解析失败时无溢出或整页崩溃;底部操作栏不遮挡内容,返回后列表状态保持正确。 + +### 2.5 Lottie 刷新动画与复杂滚动 + +- [x] `SCROLL-01` 调整 Lottie 下拉动画层级,使动画与列表连续移动,不被列表内容覆盖。 +- [x] `SCROLL-02` 处理帖子详情、个人主页、标签详情和合集 Panel 的嵌套滚动冲突。 +- [x] `SCROLL-03` 统一刷新触发阈值、回弹距离、加载状态和手势优先级。 +- [x] `SCROLL-04` 避免重复刷新、刷新卡住、快速滑动误触发及动画提前消失。 +- [x] `SCROLL-05` 缩小上下拉 Lottie,按下拉进度从小到大显现并让列表同步让位;分页在距底部约一屏内自动预取,无需拖到底部。 + +`SCROLL-01`~`SCROLL-05` 验证记录:公共 Lottie Header / Footer 统一为内容上方层级、受控触发距离、有限回弹和弹簧复位;动画尺寸缩小到 32~36px,轻拉时按进度从小到大、由淡到实显现,列表同步让出真实空间。Footer 在距底部 240px 时自动预取下一页,请求进行中不会重复触发,保留触底动画作为加载状态而非唯一触发入口。个人主页、标签详情、合集 / 粮单详情和合集选择弹层移除重复的 `LoadMoreNotification`、固定高度内层列表及双重首次刷新。标签瀑布流降低超大缓存范围并改为按需构建;合集 / 粮单详情改为单个 `CustomScrollView`,并新增公共 `SliverEmptyPlaceholder`,修复空态列表请求 Viewport 固有尺寸导致整页空白的问题。刷新、预取、Panel 和视频合并回归共 28 项全部通过;目标静态检查无 error / warning,Android Debug APK 构建成功,模拟器与真机均完成 Hot Reload 和 Hot Restart,运行日志无 Flutter 未处理异常。 + +验收:下拉和分页动画完整可见;快速、连续或取消手势不会重复发起请求,也不会留下无法结束的加载状态。 + +## 3. P1:稳定性、性能与设备适配 + +### 3.1 列表 / Grid / 瀑布流 + +- [x] `LIST-01` 统一普通帖子流、Grid 和瀑布流的公共列表逻辑,减少重复状态管理。 +- [x] `LIST-02` 修复滚动时的错位、闪烁、重复布局和高度跳变。 +- [x] `LIST-03` 优化大量帖子下的构建次数、图片解码、缓存、内存占用和组件复用。 +- [x] `LIST-04` 检查刷新、分页、返回页面和切换布局后的滚动位置恢复。 +- [x] `LIST-05` 对单条内容解析失败提供卡片级回退,避免整列或整页渲染失败。 + +验收:长列表持续滚动无明显掉帧或闪烁;分页无重复内容;单条异常不影响其他内容;返回页面时位置和已加载内容保持稳定。 + +`LIST-01`~`LIST-05` 验证记录:首页、搜索六类结果和动态四个页签统一接入公共分页控制器,刷新会失效旧请求,游标仅在成功后提交,相邻页按业务 ID 去重,辅助元数据使用统一合并策略;单条解析失败只跳过异常卡片。搜索输入与建议请求加入代次检查,快速改词不会让旧响应覆盖新结果。普通流、Grid、瀑布流和日期分组均使用 Builder / Sliver 懒构建、稳定 Key 和一屏预取,收藏夹详情移除嵌套完整 Grid,所有 `cacheExtent: 9999` 已清零;帖子卡片与九宫格不再永久保活离屏状态。网络缩略图按控件宽度和设备像素密度解码,单边约束保留源图比例,最大解码边为 4096;SVG 类型缓存限制为 200 项,常见带查询参数的图片不再额外发送 HEAD 请求。头像、封面和帖子缩略图显式使用 `BoxFit.cover`,头像框、透明装扮部件和徽标显式使用 `BoxFit.contain`,避免依赖默认值或发生挤压变形。72 项自动化测试通过,相关静态检查无 error / warning。模拟器完成搜索深度滚动、All → Article → All 与动态 Follow → Tag → Follow 位置恢复;Android 模拟器和真机均完成 Hot Reload、Hot Restart 与图片长列表浏览,真机连续浏览前后 PSS 从 707277 KB 降至 695060 KB,未出现持续增长,关键运行异常计数为 0。验收截图保存在 `build/list-p2-search-result.png`、`build/list-p2-search-restored.png`、`build/list-p2-dynamic-restored.png`、`build/real-device-start.png` 与 `build/real-device-aspect-fixed.png`。 + +### 3.2 “我的喜欢”日期分组 Grid + +- [x] `LIKES-01` 统一“日期分组 Header + Grid”的布局、Key 和复用逻辑。 +- [x] `LIKES-02` 修复刷新、取消喜欢和批量增删后的旧数据、空分组与布局异常。 +- [x] `LIKES-03` 保证日期分组、条目数量与服务端结果同步。 + +验收:取消喜欢后条目立即消失,空日期分组自动移除;刷新后不恢复旧条目,也不产生重复内容。 + +`LIKES-01`~`LIKES-03` 验证记录:“我的喜欢”从外层 `ListView` 嵌套多个完整 `GridView` 改为单一 `CustomScrollView`,日期 Header 与 `SliverGrid` 按可视范围懒构建,帖子使用稳定 ID Key。分页偏移与本地条目数量解耦,刷新替换旧数据,分页去重;从卡片或详情页取消喜欢后会回传状态并立即移除条目,同步减少服务端总数和对应月份计数,空月份自动删除,批量清理失效内容复用同一逻辑。月份解析不再用重复计数值反查索引,保证相同月计数仍落在真实月份;新增归档排序、重复计数、批量移除、游标失败回滚、分页去重和监听释放测试。全量 66 项测试通过,相关静态检查 0 error、0 warning(仅保留既有类型标注 info),最终 Android Debug APK 构建成功。Android 17 模拟器完成多年份 Grid 初载、深度滚动、动态页签位置恢复、Hot Restart 与两轮 Hot Reload,关键运行异常计数为 0;截图保存在 `build/list-p1-home.png`、`build/list-p1-dynamic.png`、`build/list-p1-follow-restored.png`、`build/list-p1-likes.png` 和 `build/list-p1-likes-scrolled.png`。Windows 调试会话完成 Hot Restart 与 Hot Reload;Android 真机仍未连接,取消喜欢的线上写操作为避免修改账号数据未在设备上实际触发,已由状态与归档单元测试覆盖,真机物理触控和高刷新率体验仍需补验。 + +### 3.3 图片加载与灯箱 + +- [x] `IMAGE-01` 修复图片失败重试在组件销毁后继续更新状态导致的崩溃,并限制自动重试节奏。 +- [x] `IMAGE-02` 分析图片加载链路,优化缩略图、原图、预加载、缓存和占位图策略。 +- [x] `IMAGE-03` 优化灯箱打开、关闭、左右翻页和缩放体验。 +- [x] `IMAGE-04` 支持向下拖拽退出,并同步位移、缩放、透明度和背景变化。 +- [x] `IMAGE-05` 完善取消手势后的回弹,解决拖拽退出与缩放、翻页手势冲突。 + +验收:缩略图优先显示且不重复闪烁;失败可重试;拖拽、缩放和翻页互不误触;退出动画连续无跳帧。 + +`IMAGE-01`~`IMAGE-05` 验证记录:帖子列表继续按显示尺寸请求缩略图,灯箱使用去除裁切参数后的原图地址;打开灯箱后仅预热当前页前后各一张原图到磁盘缓存,避免把整组大图同时解码进内存。灯箱与公共图片加载均保留自定义 Lottie 占位,原图失败显示本地化错误和手动重试入口,切换图片时使用无缝播放避免空白闪烁。灯箱补齐分页、缩放状态和图片控制器的完整释放,修复主色异步回写顺序及下载状态延迟回调在退出后继续工作的生命周期问题。手机端照片路由改为透明全屏层;单指向下拖动会同步更新内容位移、缩放、透明度与背景遮罩,达到距离或速度阈值后连续退出,未达到阈值则使用弹性曲线回到原位。横向动作优先交给分页,双指或双击缩放状态禁止触发退出,多指加入会立即取消下拉候选,避免缩放、翻页与退出互相误触;快速甩动另设 72 逻辑像素的最低位移,防止短距离手势意外关闭。新增灯箱阈值、视觉进度、透明路由和缩略地址解析测试,全量 77 项自动化测试通过;灯箱核心文件静态检查零问题,扩展业务入口无新增 error / warning(仅旧业务文件保留既有 info),Android Debug APK 构建成功。Android 17 模拟器和 Android 真机均完成原图加载、Lottie 占位、短拉回弹、长拉退出;真机额外完成三图左右翻页、双击放大后拖动不退出、恢复缩放后退出,以及双端 Hot Reload / Hot Restart。关键运行异常计数为 0;验收截图保存在 `build/image-viewer-emulator-short-rebound.png`、`build/image-viewer-emulator-drag-midpoint.png`、`build/image-viewer-emulator-long-dismiss.png`、`build/image-viewer-real-short-rebound.png`、`build/image-viewer-real-zoomed-drag.png` 和 `build/image-viewer-real-long-dismiss.png`。 + +### 3.4 视频播放 + +- [x] `VIDEO-01` 系统排查播放、暂停、进度拖动、全屏、横竖屏、静音和后台切换。 +- [x] `VIDEO-02` 修复列表复用、自动播放、资源释放和多个播放器同时工作的异常。 +- [x] `VIDEO-03` 统一加载、错误、重试和下载状态的视觉反馈。 +- [x] `VIDEO-04` 优化沉浸式进度条、横屏排版、底部文字区、操作数字缩写和作者主页滑动过渡。 +- [x] `VIDEO-05` 统一长按操作面板,持久化自动连播与弹幕、保持列表内播放速度,并保证作者页系统返回只关闭作者页。 +- [x] `VIDEO-06` 在视频左右边缘长按临时 2x 播放、松手恢复原倍速;弹幕按安全轨道错峰排列,播放暂停时同步冻结并从原位置恢复。 + +验收:重复进入退出、切换前后台和旋转屏幕后播放器状态正确,无黑屏、串音、重复播放或资源泄漏。 + +`VIDEO-01`~`VIDEO-06` 验证记录:视频列表仅允许当前页播放器继续播放,页面复用、初始化失败、重试和释放均收口资源;自动连播切换到下一条时不再因作者组件 Key 变化重建列表并闪回第一条。进度条改为沉浸式样式,长按后扩大触控区并支持连续拖动;全屏横屏重新排列底部文字、作者头像和右侧动作,关注后的头像不再显示重复状态标记,点赞等大数值使用 `2.9w` 一类单行缩写。左滑作者主页使用跟手动画,返回时由页面本地历史优先收回作者层,系统返回手势不再退出视频页。长按菜单移除 Grid 默认边距造成的空白,统一圆角、按钮、选中态与播放速度区;自动连播和弹幕写入本地配置,Hot Restart 后仍恢复,倍速在当前视频列表内切换页面时保持。新增左右边缘 16%(52~92dp)临时 2x 长按区,松手或取消时恢复原倍速;弹幕使用 4~5 条安全轨道与错峰时间槽,暂停后冻结当前位置。视频控制器测试 12 项全部通过,边缘倍速、同轨避让和暂停同步已有独立回归覆盖;Android 模拟器和真机均已运行本轮 Debug 构建并完成 Hot Reload / Hot Restart,未发现连接丢失以外的 Flutter 运行异常。 + +### 3.5 合集 / 粮单上下篇切换 + +- [x] `SWIPE-01` 在详情页支持左右滑动切换合集或粮单的上一篇、下一篇。 +- [x] `SWIPE-02` 设计边界阻尼、切换阈值、方向提示和页面过渡动画。 +- [x] `SWIPE-03` 解决横向切换与正文纵向滚动、图片灯箱手势之间的冲突。 +- [x] `SWIPE-04` 预加载相邻内容,并处理首篇、末篇和加载失败状态。 + +验收:只有达到明确阈值才触发切换;首末边界有清晰反馈;切换后标题、正文和操作状态对应正确内容。 + +`SWIPE-01`~`SWIPE-04` 完成记录(2026-08-24):合集详情与粮单阅读序列支持左右拖拽切换、边界阻尼、页面跟手位移、切换加载态、相邻内容预取及失败回退;没有合集 / 粮单上下文的独立帖子只保留阻尼回弹,不显示“第一篇 / 最后一篇”文字或松手提示。首末篇文字、轻震和松手反馈统一使用正常切换上下篇的动态提交距离,边界提示与上下篇提示复用同一缩放淡入容器。新增手势竞技场分流:多图区域中部横滑只翻图,屏幕左右 28dp 边缘稳定负责切篇;边缘手势会优先于长文 `SelectionArea`,正文中部仍可选择文字,纵向滚动继续交给阅读列表,灯箱独立路由不受详情页手势影响。MuMu Android 12 已验证两图帖子从 1/2 翻到 2/2 而不误切文章、公开粮单中上一篇 / 下一篇双向连续切换、公开合集 43/44 → 42/44 → 43/44 → 44/44,以及末篇继续拖拽只回弹不越界;每次切换后标题、作者、正文、合集序号和互动数据对应新帖子。15 项手势 / 序列专项测试及 195 项全量测试通过,新增文件静态检查零问题,Android/Windows Debug 构建成功,MuMu Hot Reload / Hot Restart 无编译或框架异常;独立 Android 真机当前未连接,仍保留后续综合触感复核项。 + +### 3.6 滑动验证块 + +- [x] `CAPTCHA-01` 修复不同屏幕尺寸、分辨率和显示缩放下的位置与尺寸误差。 +- [x] `CAPTCHA-02` 覆盖小屏、平板、横屏和高 DPI 场景。 +- [x] `CAPTCHA-03` 确保滑块轨迹、触控范围、图片缩放和实际验证距离一致。 + +`CAPTCHA-01`~`CAPTCHA-03` 验证记录:验证码会先解码服务端背景图和拼图层的真实像素尺寸,再以统一进度分别换算滑块逻辑轨迹、拼图显示轨迹和服务端原图 `xPos`,移除固定 `1.1` 缩放及“控件位移直接当原图坐标”的误差。真实接口抽样返回背景 `320×160`、拼图层 `82×160`,现实现会随每次挑战的真实尺寸计算,不依赖固定素材规格;宽屏不放大原图,窄屏保持比例缩小,安全区和低高度使用可滚动布局。滑块从按下位置开始累计拖动,首段触控 slop 不再造成手指、滑块和提交坐标错位;请求代次、图片解码资源、延迟状态和退出后的异步回写也已收口。6 项几何与组件测试覆盖 280px 小屏、4×DPI 手机、1024×600 横屏平板、极端约束、轨迹端点和实际提交坐标,全部通过;目标静态检查零问题,Android Debug APK 构建成功,模拟器与真机均完成 Hot Reload / Hot Restart,运行日志无 Flutter 异常。 + +验收:各设备上的视觉缺口、滑块终点和提交坐标一致,无明显偏移或触控死区。 + +### 3.7 高刷新率设备 + +- [x] `REFRESH-01` 排查 90Hz、120Hz 及更高刷新率下的动画和滑动问题。 +- [x] `REFRESH-02` 清除依赖固定 60Hz 的帧率、速度或动画时长计算。 +- [x] `REFRESH-03` 优化列表、灯箱、Lottie、页面过渡和手势动画的流畅度。 + +`REFRESH-01`~`REFRESH-03` 验证记录:`flutter_displaymode` 与本地 `awesome_chewie` 已同步升级到 `0.7.0`;显示模式使用稳定签名持久化,并允许 60 与 59.94Hz 等同一物理模式的微小浮点漂移,避免系统升级后把用户保存的 60Hz 误恢复成最高刷新率。Android 除设置 `preferredDisplayModeId` 外,会在 Android 11+ 对 Flutter 主 Surface 设置精确刷新率提示,低版本仍使用窗口 `preferredRefreshRate`,跟随系统时用 0 清除提示,应用恢复前台会重新应用。全仓动画与滑动计算未发现按“每帧固定增量”或固定 60Hz 推进的业务路径;页面过渡、弹幕、Lottie、灯箱和刷新均基于真实经过时长或手势逻辑距离。新增 60/90/120Hz 等时页面过渡与弹幕位移回归,连同显示模式、加载、灯箱和视频共 34 项测试全部通过;目标静态检查零问题,Android Debug APK 构建成功,模拟器与真机均完成完整安装、Hot Reload 和 Hot Restart。真机窗口与系统 App Request 均确认请求 120Hz,但该 MIUI 系统同时施加 `PRIORITY_MIUI_REFRESH_RATE` 最大 60Hz、`mIgnorePreferredRefreshRate=true` 的厂商策略,因此本机最终活动模式仍为 60Hz;应用侧请求链路有效,实际 90/120Hz 物理显示仍需在未对应用限帧的设备上复核。 + +验收:60Hz、90Hz 和 120Hz 下动画速度一致,无异常加速、减速、周期抖动或显著掉帧。 + +### 3.8 标签页状态记忆 + +- [x] `TABSTATE-01` 保存标签页最后使用的筛选、排序和 Tab。 +- [x] `TABSTATE-02` 再次进入时恢复选择、列表内容和必要的滚动位置。 +- [x] `TABSTATE-03` 明确页面生命周期内记忆与 App 重启后持久化的边界。 +- [x] `TABSTATE-04` 数据源变化或保存状态失效时安全回退到默认值。 + +`TABSTATE-01`~`TABSTATE-04` 验证记录:搜索榜单、搜索结果、动态页、标签详情、标签合集 / 粮单、个人主页、系统通知和装扮页均改用稳定业务标识保存选中项,数据源重排、翻译变化或旧索引失效时会安全回退并迁移旧状态。标签详情额外持久化最新 / 最热筛选参数;用户可见选择跨 App 重启恢复,已加载响应和滚动位置只保留在当前页面生命周期,避免恢复陈旧列表。各 Tab 首次进入按需加载,失败后允许重试;Tab 文字字号、字重和颜色跟随指示器进度连续过渡。横向手势刚指向相邻页就登记预加载,不再等待滑到中点或切页结束;标签详情与搜索结果会等待目标 EasyRefresh 获得实际纵向滚动位置后立即拉出 Lottie 刷新头并执行同一请求,首次进入、重复点击当前 Tab 与筛选变更不再静默请求。搜索结果 6 Tab 与个人主页 Tab 按整行等宽分布。个人主页撤销外层手势遥控刷新,改为各子列表使用 34px 触发阈值的原生 Lottie 刷新头,刷新结束由同一状态机管理;正文顶部通过实际裁剪保留 20px 圆角,避免被 TabBar 的不透明 Material 覆盖。作品、推荐、合集和粮单的空数据视图使用完整非收缩视口,避免占位被裁切。粉丝、关注及搜索用户列表迁移为懒构建圆角用户卡片,统一头像、昵称、ID、简介、关系按钮、边框与主题点击反馈,并移除重复触底入口。新增等宽 6 Tab 与 320px 长文本用户卡片回归;全量 125 项测试通过,目标静态检查无新增 error / warning,Android Debug APK 构建成功。Android 17 模拟器与 Android 16 真机均完成 Hot Reload、Hot Restart,重启后的新日志未发现未处理异常或布局溢出。 + +验收:返回标签页时选择不丢失;恢复过程不重复请求或展示错误筛选结果;无效状态可自动降级。 + +## 4. P2:新交互与下载能力 + +### 4.1 划线评论 + +- [ ] `ANNOTATION-01` 支持选中正文文本并针对选区发表评论。 +- [ ] `ANNOTATION-02` 建立评论与原文文本区间、内容版本之间的关联。 +- [ ] `ANNOTATION-03` 设计划线高亮、评论展示和点击评论定位原文的交互。 +- [ ] `ANNOTATION-04` 处理原文修改、选区失效、重叠划线和跨段选择。 + +验收:评论能够稳定定位原文;原文变化后不会错误绑定其他内容,并有明确的失效提示。 + +### 4.2 批量下载 + +- [x] `BATCH-01` 支持批量下载“我的喜欢”“我的推荐”“我的收藏”。 +- [x] `BATCH-02` 支持批量下载合集、粮单等内容集合。 +- [x] `BATCH-03` 支持全选、部分选择、下载进度、失败提示和失败重试。 +- [x] `BATCH-04` 对重复资源去重,并明确已下载文件的覆盖或跳过规则。 + +`BATCH-01`~`BATCH-04` 完成记录(2026-08-24):我的喜欢、我的推荐、我的收藏、合集和粮单均接入同一批量下载页;部分选择可即时调整,全选会安全遍历全部分页并在数据不再增长时停止。资源解析覆盖原图、文章正文图片和视频源,单篇解析失败不会阻断其余内容;解析及入队进度、失败数量、重试入口和下载管理入口均有明确反馈。批次内与已有任务按资源地址去重:等待中、下载中和已完成任务明确跳过,失败、暂停或取消任务重置后重新排队,不覆盖已下载文件。资源解析、任务队列和 320×568 窄屏交互均有专项回归,全量 203 项测试通过,Android / Windows Debug 构建成功。MuMu Android 12 已完成 Hot Reload、Hot Restart、部分选择、全选和真实资源入队验证;一次真实批次成功加入 7 项、跳过 0 项,并正确报告 1 篇无法下载,下载管理页同步显示任务进度和控制操作。 + +验收:选中结果与任务数量一致;部分任务失败不影响其他任务;重复内容不会重复下载。 + +### 4.3 下载任务系统 + +- [x] `DOWNLOAD-01` 建立统一下载任务模型和管理页面。 +- [x] `DOWNLOAD-02` 展示等待中、下载中、已完成、失败和已取消状态。 +- [x] `DOWNLOAD-03` 支持暂停、继续、取消、重试和清理记录。 +- [x] `DOWNLOAD-04` 处理并发数量、后台下载、网络切换和断点续传。 +- [x] `DOWNLOAD-05` 处理文件命名、重复文件、临时文件清理和空间不足。 + +进展(2026-08-24):统一任务底座已覆盖图片、视频和通用文件入口,支持任务持久化、并发队列、暂停 / 继续 / 取消 / 重试、失败恢复、断点文件保留、重复 URL 去重、文件名冲突递增、临时文件清理和可读错误提示;Android 已接入 WorkManager 系统后台队列和前台下载通知,网络类失败会在网络恢复后自动重排队,且不会擅自恢复用户手动暂停的任务。下载管理已作为用户内容资产入口加入“我的 → 内容中心”,原图片设置内的快捷入口继续保留,两处复用同一管理页面和任务状态。MuMu Android 12 已用真实帖子完成图片和视频下载验收:飞行模式下系统任务保持 `CONNECTIVITY` 等待约束,应用退到后台并恢复网络后出现 Loftify 下载通知,任务继续并将 4,259,137 字节视频写入系统 Movies 目录,返回应用后记录为 100% 完成;随后通过 hot restart / hot reload 确认状态持久化和运行稳定。 + +验收:App 重启后任务状态可恢复;网络中断后可继续;取消任务不残留临时文件;文件冲突有确定规则。 + +### 4.4 全局 Lucide 图标迁移 + +- [x] `ICON-01` 全仓盘点静态界面图标并建立语义映射表,覆盖业务代码、公共组件和本地 `awesome_chewie`;Lottie 动画、产品品牌标识和用户内容不作为普通界面图标替换。详见 [`Icon_Migration_Map.md`](Icon_Migration_Map.md)。 +- [x] `ICON-02` 建立统一的 Lucide 图标组件与规范,收口默认尺寸、视觉尺寸、颜色、点击区域、基线对齐和禁用状态,禁止页面继续直接混用 Material、Cupertino 或其他图标集。 +- [x] `ICON-03` 制定面性图标处理规则:默认使用 Lucide 线性图标;选中态优先通过主题色、字重感、浅色底板、圆点或容器形态表达,不用另一套面性图标破坏一致性;确有轮廓识别不足的极少数场景,使用基于同一网格的本地配对 SVG,并登记为受控例外。 +- [x] `ICON-04` 分阶段迁移导航、AppBar、列表项、设置、菜单、对话框、空状态、帖子操作、媒体控件和下载状态;Lottie 动画保留,但其静态占位、失败和无障碍替代图标仍使用 Lucide。 +- [x] `ICON-05` 删除可替换的旧图标依赖和重复资源,补充语义、尺寸、选中态、深浅色、高对比度及中英文布局回归,确保图标含义、可点击范围和业务状态不因迁移改变。 + +进展(2026-08-24):已完成业务层、公共组件和本地 `awesome_chewie` 的全量静态盘点,共登记 315 处 Material 引用(跨范围 146 个不同符号)、4 处 Cupertino、99 处现有 Lucide 和 51 张旧 PNG 界面图标;映射表逐项覆盖全部遗留符号,并明确排除 40 个 Lottie、Logo、插画、用户内容和远程 SVG。后续迁移以 [`Icon_Migration_Map.md`](Icon_Migration_Map.md) 为唯一语义基线,新增页面不得扩大遗留数量。已新增 `ChewieIconThemeData`、`ChewieIcon`、`ChewieIconButton` 和业务语义层 `LoftifyIcons`,统一 16/20/24 视觉尺寸、至少 44×44 点击区域、主题色选中态、38% 禁用态、光学对齐及明暗主题状态;业务页面直接导入 Lucide 字体已由组件测试拦截,设置导航、EntryItem 与下载管理作为首批接入样例已在 MuMu 的明暗主题中通过验证。面性状态已明确为“同一语义、同一字形”,只通过主题色、底板或圆点表达;动作语义发生变化时才允许切换 Lucide 字形。当前没有受控配对 SVG 例外,测试会阻止视觉变体命名和未登记 SVG 资源进入业务层。 + +`ICON-04` 分批进展(2026-08-24):全局导航与 AppBar 第一批已完成。桌面/平板侧栏、移动底栏、头像菜单、装扮、通知、设置入口和普通/Sliver AppBar 返回按钮已切换到统一 Lucide 语义;导航选中前后保留同一字形,只通过主题色和容器状态变化,AppBar 返回操作统一为至少 44×44 点击区域。相关静态守卫会阻止全局导航重新引入 Material 图标、旧 PNG 图标或选中态换图;MuMu 横屏浅色模式已完成 Hot Restart、页面切换和嵌套设置页验证。后续继续迁移列表项、菜单、对话框、空状态、内容操作与媒体控件,完成全部批次后再勾选 `ICON-04`。 + +`ICON-04` 分批进展(2026-08-24):`awesome_chewie` 基础控件第二批已完成。新增组件语义层 `ChewieIcons`,并迁移 AppBar、窗口按钮、评分、数字输入、下拉搜索、右键/底部菜单、图片失败与重试、展开箭头以及 EasyRefresh/Lottie 刷新兜底状态;复用组件运行时代码已无 Material/Cupertino 图标引用,文档示例不计入运行时。评分选中态和置顶状态保持同一 Lucide 字形,反馈只通过颜色或旋转变化;自动化扫描会阻止基础组件重新混入旧图标集。下一批继续处理 Loftify 业务列表、设置、菜单、对话框和空状态。 + +`ICON-04` 分批进展(2026-08-24):设置与登录输入第三批已完成。所有 `Screens/Setting` 页面、设置管理 Item 和 `LoginInputItem` 已改用 `LoftifyIcons` 语义,覆盖设置分类、关于/外链、主题重置、文件名保存/刷新、屏蔽标签增删以及输入框清空/密码显隐;登录输入尾部操作同时补齐统一 20px 字形和至少 44×44 点击区域。静态守卫会阻止这些范围重新引入 Material/Cupertino 图标,MuMu 深色横屏已验证设置首页和关于页的图标层级、外链标识与页面跳转。下一批继续迁移通用业务 Item、内容卡片操作、菜单与对话框。 + +`ICON-04` 分批进展(2026-08-24):通用业务 Item 与内容卡片操作第四批已完成。`ItemBuilder`、`LoftifyItemBuilder` 的 Entry 默认图标、标签类型、搜索入口、标题尾部操作、评论展开、标签搜索以及点赞/推荐按钮已统一使用业务 Lucide 语义;搜索按钮补齐至少 44×44 点击区域,点赞和推荐的选中前后保持同一字形并仅通过状态色表达。旧的热度/搜索 PNG 接口图标不再由这两项基础组件加载,标签排行底纹等内容装饰继续保留。静态守卫会阻止该范围重新引入 Material/Cupertino 或已淘汰的接口位图;MuMu 深色横屏已验证热榜标签、搜索入口与首页内容卡片。下一批继续迁移业务菜单、对话框与空状态。 + +`ICON-04` 分批进展(2026-08-24):业务底部面板第五批已完成。`Widgets/BottomSheet` 中的合集排序、装扮预览翻页与单张/批量下载、标签筛选重置、滑块验证码重试/关闭/刷新/拖拽提示、收藏夹选择以及通用选项勾选均已切换到 `LoftifyIcons`;排序方向通过同一图形旋转表达,收藏夹选中前后保持同一圆形字形并通过主题状态反馈。翻页、验证码关闭与刷新操作补齐至少 44×44 点击区域,旧排序 PNG 不再加载;面板内原有 Lottie 加载保持不变。静态守卫会阻止全部业务 BottomSheet 重新引入 Material/Cupertino 或旧排序位图;MuMu 深色横屏已验证筛选面板圆角、按钮层级和重置图标。下一批继续迁移业务页面菜单、对话框与空状态。 + +`ICON-04` 分批进展(2026-08-24):内容管理页面菜单第六批已完成。我的推荐、我的喜欢、历史、收藏夹列表和支持者页面的更多、清理、删除、历史开关、新建/编辑收藏夹与支持者标识已统一为业务 Lucide 语义;AppBar 操作使用至少 44×44 点击区域,清空与删除菜单明确使用主题错误色,历史开关继续保持单一字形。静态守卫会阻止该范围重新引入 Material/Cupertino;MuMu 深色横屏已验证我的喜欢页面的悬浮更多按钮、菜单圆角、分割层级和红色危险操作。下一批继续迁移个人主页菜单及其他内容详情页操作。 + +`ICON-04` 分批进展(2026-08-24):个人主页第七批已完成。主题背景、商店、头像框、备注、拉黑/屏蔽、复制/浏览器/分享、更多、简介展开、关注、热度明细、收藏、评论、代表作、热度与合集标识均已统一为业务 Lucide 语义;更多操作补齐至少 44×44 点击区域,关注与已关注保持同一字形,仅在拉黑动作下切换为明确的禁止语义,危险菜单继续使用红色错误态。旧合集白色 PNG 接口图标已移除,主页插画和用户内容不受影响。静态守卫会阻止个人主页重新引入 Material/Cupertino 或旧合集位图;MuMu 深色横屏已验证主页顶部圆角正文、统计区和更多菜单的图标、分组与圆角。下一批继续迁移帖子/合集/粮单/标签详情操作。 + +`ICON-04` 分批进展(2026-08-24):标签、合集与粮单详情第八批已完成。详情 AppBar 更多、复制/浏览器/分享、正倒序、标签标题、筛选、标签内搜索、列表/网格切换、合集/粮单/热度角标与排行榜均已统一为业务 Lucide 语义;排序沿用同一图形旋转,布局切换仅在实际动作/模式变化时切换 `layoutList` 与 `layoutGrid`。排行榜前三名改为同一火焰图形配合递减层级和明确名次,不再加载三套热度位图;旧排序、标签、搜索、合集与粮单接口 PNG 已从这些页面移除,入口插画继续保留。静态守卫会阻止该范围重新引入旧图标;MuMu 深色横屏已验证标签页 44×44 操作区、列表/网格即时切换及合集粮单排行。下一批继续迁移帖子详情、帖子卡片与媒体操作。 + +`ICON-04` 分批进展(2026-08-24):帖子信息流卡片与正文异常状态第九批已完成。瀑布流和列表卡片的无效内容、推荐来源、更多、复制、查看原文、浏览器、分享、喜欢、推荐与评论图标,以及正文渲染失败和重试状态均已统一为业务 Lucide 语义;喜欢和推荐的未选中/选中状态保持同一字形,仅通过颜色表达状态,避免面性图标跳变。静态守卫会阻止该范围重新引入 Material/Cupertino 图标。下一批继续迁移帖子详情页操作与下载状态。 + +`ICON-04` 分批进展(2026-08-24):帖子详情页操作与下载状态第十批已完成。上下篇拖拽提示、图片翻页、粮单/合集、版权/创作标记/转载来源、收藏、评论、下载成功/失败、更多及复制/原文/浏览器/分享菜单均已统一为业务 Lucide 语义;收藏前后保持同一书签字形并使用主题色反馈,下载继续使用原有圆形进度条而非 Lottie,成功与失败使用主题语义色。AppBar 下载和更多操作补齐至少 44×44 点击区域,旧合集明暗 PNG 不再加载;现有喜欢/推荐 Lottie 与切篇动画保持不变。静态守卫会阻止帖子详情重新引入 Material/Cupertino 或旧合集接口位图;MuMu 深色横屏已验证详情作者栏、下载/更多操作、上下篇提示基础样式、竖向悬浮条和更多菜单。下一批继续迁移登录、导航与媒体页面的剩余图标。 + +`ICON-04` 分批进展(2026-08-24):登录输入与登录方式第十一批已完成。密码、短信验证码、LOFTER ID 和邮箱登录页面的关闭、手机号、图片/短信验证码、密码、LOFTER ID、邮箱及其他登录方式入口均已统一为业务 Lucide 语义;字段图标按真实输入含义重新映射,密码字段不再误用“已验证”图标。登录输入组件原有清除、密码可见性、焦点、自动填充、提交和验证码逻辑保持不变,静态守卫会阻止登录页面重新引入 Material/Cupertino 图标。下一批继续迁移首页动态、搜索、我的页面与媒体控制图标。 + +`ICON-04` 分批进展(2026-08-24):首页核心导航页面第十二批已完成。首页/动态页的刷新与回顶、动态标签与订阅状态、搜索历史清理/换一换/趋势、我的页面个人入口、内容与创作中心、退出、装扮、通知和设置图标均已统一为业务 Lucide 语义;合集订阅前后保持同一书签字形,首页深浅主题 Lottie 切换保持不变。我的页面 AppBar 操作统一为至少 44×44 点击区域,旧装扮/设置/标签接口 PNG 不再加载,静态守卫会阻止核心导航页面重新引入 Material/Cupertino 或这些旧位图;MuMu 深色横屏已验证首页刷新/回顶、我的内容入口和全局侧栏,设备强制横屏导致竖屏 AppBar 改由布局测试与双端构建覆盖。下一批继续迁移视频、装扮与其他剩余页面图标。 + +`ICON-04` 分批进展(2026-08-24):视频播放与长按面板第十三批已完成。加载失败/重试、中央播放提示、返回、静音、全屏、播放选项、连播、分享、弹幕、下载、选择标记、喜欢、推荐、评论与关注入口均已统一为业务 Lucide 语义;喜欢、推荐、连播和弹幕的持久状态保持同一主字形,仅通过主题色与选择标记反馈,播放/静音/全屏只在实际动作变化时切换字形。顶部控制补齐至少 44×44 点击区域,长按面板下载中状态改为进度环,侧边下载继续显示实际百分比;共享响应式 Panel 现在按实际内容收缩并保持完整圆角,不再在横屏短菜单下方保留固定高度空白。原有视频加载动画、弹幕暂停同步、播放速度、状态记忆和横屏布局逻辑保持不变。静态守卫会阻止视频页重新引入 Material/Cupertino 图标或下载伪动画;MuMu 横屏已验证真实视频播放页、顶部/侧边控件与长按菜单的布局和交互。下一批完成装扮页面等最后一批旧图标并复核全局遗留。 + +`ICON-04` 完成记录(2026-08-24):装扮预览卡片的进入提示和装扮详情下载入口已完成最后一批 Lucide 迁移,装扮卡片整体点击语义和下载按钮原有至少 44×44 点击区域保持不变;同时修复装扮详情刷新方法丢弃 `IndicatorResult` 导致刷新状态无法可靠结束的问题。自动化守卫覆盖最后两处遗留,业务运行时 `lib/` 已无 Material/Cupertino 图标引用;所有明确保留的 Lottie、品牌标识、用户内容和内容装饰均未被误替换。MuMu 横屏已验证装扮列表卡片、装扮详情加载完成及各部件下载入口。后续由 `ICON-05` 清理不再使用的旧图标依赖与重复资源,并补齐全局资源级验收。 + +`ICON-05` 完成记录(2026-08-24):已删除无运行时引用的 51 张旧染色 PNG、`code.txt`/`run.py` 辅助文件、`AssetUtil` 废弃常量及 `pubspec.yaml` 资源声明,验证码几何测试改为运行时生成精确 72×72 的中性图片,不再依赖界面图标夹具。全局静态守卫会同时扫描 Loftify 与本地 `awesome_chewie` 运行时代码、资源目录和清单,阻止 Material/Cupertino 字形或 `assets/icon` 回流;深浅主题下的选中态、禁用态、44×44 点击区域与中英文语义均有回归,高对比度模式额外增强选中描边/底板和禁用前景。`cupertino_icons` 仅由 HTML 视频链路的第三方 `chewie` 包传递引入,并非可安全移除的直接界面依赖;Lottie、品牌、插画和内容装饰资源完整保留。 + +验收:除明确登记的 Lottie、品牌标识、用户内容和受控配对 SVG 外,界面不再混用其他图标体系;同类图标的尺寸、色彩、视觉重量、对齐和状态表达一致,迁移前后功能入口与业务语义完整保留。 + +### 4.5 首页毛玻璃主导航 + +- [x] `NAVGLASS-01` 为手机底部主导航建立可复用毛玻璃容器,统一模糊半径、透明度、背景染色、顶部边界、圆角、安全区和阴影;内容应自然延伸到导航下方但不能被操作区遮挡。 +- [x] `NAVGLASS-02` 完成浅色、深色、自定义主题、纯黑 / 纯白主题下的语义适配,并为系统“降低透明度”、低性能设备和不支持模糊的平台提供无视觉断层的实色降级方案。 +- [x] `NAVGLASS-03` 统一首页各导航项的 Lucide 图标、文字、选中态、未读角标、点击 / 按下反馈和页面切换动效;避免高饱和大色块、厚重阴影和水波纹噪声。 +- [x] `NAVGLASS-04` 验证长列表滚动、键盘弹出、横竖屏、平板 / 桌面导航形态、系统手势区和 60/90/120Hz 性能;模糊层不得造成持续重绘、明显掉帧、文字对比不足或触控穿透。 + +验收:主导航在不同主题和设备上保持轻盈、现代且可读,滚动内容与玻璃层的空间关系自然,选中状态清晰但不喧宾夺主;关闭透明效果或性能降级后仍保持完整层级和可用性。 + +`NAVGLASS-01` 完成记录(2026-08-24):手机主导航已替换为可复用的浮动毛玻璃容器,统一使用 18px 模糊、半透明主题 Surface、24px 圆角、0.8px 柔和边界、轻量阴影和系统手势安全区;主页面继续使用 `extendBody`,长列表会自然延伸到玻璃层后方,同时底栏自身高度与底部留白仍保护四个操作区。四个 Lucide 入口补回单行文字,选中态使用克制的主题色浅底与描边,按下仅做轻微缩放且不产生水波纹;高对比度、减少动画、辅助导航、Web 或显式关闭模糊时会自动使用完整实色 Surface。6 项专项测试覆盖 280px 宽屏幕、1.4 倍字体、安全区、模糊/实色分支、语义选中态、99+ 角标和触控回调;全量 183 项测试、Android/Windows Debug 构建均通过,MuMu 浅色搜索长列表已完成 Hot Reload、Hot Restart、页面切换和内容透过层级检查。 + +`NAVGLASS-02` 完成记录(2026-08-24):玻璃底栏直接使用当前 `ColorScheme.surface/primary/onSurfaceVariant/error`,内置极简白、柔和浅色、GitHub 浅色、极简黑、蓝铁、GitHub 深色及自定义强调色均跟随主题语义,不再写死白底或青色。系统减少动态效果、辅助导航、高对比度或 iOS Reduce Motion、Web 平台会自动停用模糊;“外观 → 移动端设置”新增可持久化的“降低透明效果”开关,低性能设备可主动切换为同圆角、边界、阴影与层级的完整实色 Surface。中英文及繁体文案已同步;MuMu 实测开关即时生效、Hot Restart 后状态保持,极简黑与极简白对比度正常,最后恢复浅色玻璃模式。主题、降级、偏好接线与本地化回归连同全量 185 项测试通过,Android/Windows Debug 构建成功。 + +`NAVGLASS-03` 完成记录(2026-08-24):首页、搜索、动态和我的四个入口保持同一 Lucide 字形,统一 22px 视觉尺寸、单行文字、主题色选中底板/描边与未选中前景;按下使用 90ms 轻量缩放,点击回调立即触发,不再被双击识别窗口延迟,也没有 InkWell 水波纹。页面栈继续保留四页状态,主导航切换改为 220ms `easeOutCubic` 过渡;系统减少动态效果或辅助导航时使用即时跳转。通用角标仅在业务提供真实未读数时展示,支持 1~99、99+ 截断、错误色语义与读屏原始数量,不伪造占位数字。专项回归覆盖点击即时性、按压反馈、稳定字形、未读语义和动效降级;全量 186 项测试、Android/Windows Debug 构建通过,MuMu 已完成首页/搜索/我的切换、Hot Reload 与 Hot Restart,未发现输入延迟、触控穿透或状态错位。 + +`NAVGLASS-04` 完成记录(2026-08-24):手机竖屏继续使用毛玻璃底栏,平板横屏与桌面保持侧栏形态;底栏遵循系统手势安全区,并在键盘产生底部 `viewInsets` 时主动退出布局。真实长列表接入滚动方向联动,向内容深处滚动时隐藏、反向滚动时恢复;MuMu Android 12(1080×1920)已验证瀑布流内容延伸、隐藏和恢复过程。专项组件测试覆盖滚动联动、键盘避让策略、横竖屏 / 宽屏分支、安全区、底栏拦截点击而不穿透正文、玻璃层稳定后不存在持续调度帧,以及 60/90/120Hz 下相同的时间制动效;MuMu 使用实体键盘模式,无法弹出软键盘,因此软键盘实机外观留待独立真机补验,当前以真实 `viewInsets` 回归验证行为。全量 190 项测试、Android/Windows Debug 构建通过,MuMu 已完成 Hot Reload 与 Hot Restart,未发现编译错误、框架异常或明显掉帧。 + +## 5. 推荐执行顺序 + +1. `LIST-*`、`LIKES-*`、`IMAGE-*`、`VIDEO-*`:稳定列表与媒体体验。 +2. `CAPTCHA-*`、`REFRESH-*`、`TABSTATE-*`:完成设备适配和状态恢复。 +3. `SWIPE-*`:完成合集 / 粮单切篇及手势回归。 +4. `DOWNLOAD-*`:先建立统一下载任务底座。 +5. `BATCH-*`:复用下载任务底座实现各业务入口的批量下载。 +6. `ICON-*`:完成全局 Lucide 图标语义映射、面性状态策略和分阶段迁移。 +7. `NAVGLASS-*`:在统一图标体系上升级首页主导航,并完成模糊性能与降级验收。 +8. `ANNOTATION-*`:依赖官方接口,当前允许暂缓;接口条件明确后再决定实现或登记延期。 +9. `REDESIGN-00`:上述功能、交互、内容与延期边界全部确认后,才允许启动最终视觉重构。 + +依赖关系: + +- `SCROLL-*` 已完成;后续列表、图片和横向切换必须继续复用现有单滚动链与刷新状态机,不得重新引入双重分页触发。 +- `SWIPE-*` 依赖详情页滚动与图片灯箱手势稳定。 +- `BATCH-*` 依赖 `DOWNLOAD-*` 的任务模型、持久化和失败恢复能力;实现时应先完成下载任务底座。 +- `ICON-*` 是 `NAVGLASS-*` 与最终视觉重构的图标基础;面性状态不得通过重新混入其他图标集解决。 +- `NAVGLASS-*` 应先作为独立功能完成性能和可访问性验证,最终重构阶段只允许在既有语义上继续精修。 +- `REDESIGN-00` 是严格的最后阶段,不得与功能开发并行,也不得为了视觉效果改写、删除或弱化现有业务。 + +## 6. 最终阶段:全软件视觉重构 + +- [ ] `REDESIGN-00` 按 [`Final_Visual_Redesign.md`](Final_Visual_Redesign.md) 执行完整设计系统与全页面视觉重构。只有其启动门槛全部满足后才能开始;必须经过多轮页面巡检与双端验收,不得以换色、统一圆角或少量组件翻新代替。 + +阶段里程碑(用于追踪 `REDESIGN-00` 内部进度,不单独计入任务总数): + +- [x] `REDESIGN-A` 完成现状问题、组件分散度、页面截图矩阵和信息架构审计;比较至少两套完整方向并冻结唯一视觉方向。详见 [`Visual_Redesign_Audit.md`](docs/redesign/Visual_Redesign_Audit.md)。 +- [x] `REDESIGN-B` 实现颜色、字体、间距、栅格、圆角、边框、阴影、图标状态、密度和动效 Token;建立真实内容组件样板并通过深浅色、响应式和可访问性验证。 + - [x] `REDESIGN-B1` 建立应用层 ThemeExtension 与颜色、字体、间距、栅格、圆角、边框、阴影、图标状态、密度、动效 Token;实现可增长的 Section / Entry Item 样板并接入真实页面。 + - [x] `REDESIGN-B2` 完成按钮、输入、标签、卡片、菜单、Panel、空态、错误和 Lottie 加载样板,以及繁中、英文、高对比度、减少动画与跨平台状态矩阵。 + - [x] `REDESIGN-B2A` 统一 Button、TextField、Tag 与 ChoiceGroup 的尺寸、语义色、状态、触控、长文案和响应式规则;接入四种登录流程和标签详情筛选 Panel,并完成深浅色、高对比度与减少动态验证。 + - [x] `REDESIGN-B2B` 重构现有 Lottie 资产与运行组件:统一虚拟画布、有效内容区、光学校准、留白、主题语义色、循环 / 单次播放及减少动态降级;原关键帧、缓动和动作含义不得丢失。首页主导航动画使用统一 22px 光学尺寸,未选中帧必须清晰,选中时只播放一次且不得延迟页面切换。 + - [x] `REDESIGN-B2C` 完成卡片、菜单、通用 Panel、空态、错误和其余加载样板,并补齐繁中、英文、跨平台与完整状态矩阵。 +- [ ] `REDESIGN-C` 完成首页、作品详情、标签详情、作者主页和设置页五类代表页面试点,并至少进行两轮一致性与内容沉浸自检。 + - [x] `REDESIGN-C1` 完成首页代表页:统一响应式版心、瀑布流栅格、媒体裁切、文字卡片与元数据层级,并保留悬浮顶部胶囊、下拉刷新、分页和全部业务入口。 + - [x] `REDESIGN-C2` 完成作品详情代表页:建立响应式阅读版心与统一信息轴,保留媒体沉浸展示,重构右侧悬浮操作条并完整保留加载、上下篇、评论和合集 / 粮单业务逻辑。 + - [x] `REDESIGN-C3` 完成标签详情代表页:统一响应式标签头部、统计、发现入口、筛选条和内容栅格,保留原有丝滑 Tab、自动刷新、筛选记忆与嵌套滚动逻辑。 + - [x] `REDESIGN-C4` 完成作者主页代表页:统一响应式身份、统计、关注与代表作层级,保留主题背景、正文顶部圆角、等宽 Tab、刷新和全部作者操作。 + - [x] `REDESIGN-C5` 完成设置页代表页。 + - [ ] `REDESIGN-C6` 至少完成两轮跨页面一致性与内容沉浸自检。 + - [x] `REDESIGN-C6-P1` 第一轮检查设计系统连续性、内容沉浸和真实交互;修复发现的问题并完成代表页交叉回归、Android 真机与 Windows 构建验证。详见 [`Stage_C_Self_Check.md`](docs/redesign/Stage_C_Self_Check.md)。 + - [ ] `REDESIGN-C6-P2` 第二轮独立检查可读性、状态反馈、深浅色、多语言、大字体与极端尺寸;补齐当前最终构建截图后才能完成阶段 C。 + - [x] `REDESIGN-C6-P2A` 完成 Android 紧凑屏与大字体 AppBar 审查:修复 320dp / 2 倍英文下帖子详情“合集胶囊 + 下载 + 更多”横向溢出,以及平板横屏长标题挤出操作区的问题;标题改为剩余空间内单行省略,文本胶囊按屏宽和字号自适应收窄,点击区与完整语义标签保持不变。 + - [ ] `REDESIGN-C6-P2B` 继续完成 Android 深浅色、自定义主题、简中 / 繁中 / 英文、空态 / 错误 / 加载、系统返回以及当前最终构建截图验收。 + - [x] `REDESIGN-C6-P2B1` 修复短屏 / 分屏 / 键盘占高时错误、警告和成功状态的大字体溢出:受限高度下内容可内部滚动并保持重试入口可达;EasyRefresh / Sliver 空态继续使用原外层滚动,不新增手势冲突。 + - [ ] `REDESIGN-C6-P2B2` 完成剩余主题、三语言、真实加载 / 错误切换、系统返回和当前构建截图验收。 + - [x] `REDESIGN-C6-P2B2A` 修复纯图标加载态的无名称实时区域:视觉隐藏标题时仍保留本地化语义标签;可见标题与说明由状态容器统一播报一次,重试按钮保持独立可操作语义。 + - [ ] `REDESIGN-C6-P2B2B` 继续完成主题、三语言、真实状态切换、系统返回和当前构建截图。 +- [ ] `REDESIGN-D` 按页面族迁移全部剩余页面,删除旧样式和重复组件;每一族独立完成测试、截图、双端运行与提交。 +- [ ] `REDESIGN-E` 完成设计一致性、信息层级、可访问性、响应式、语言、主题、性能和逐像素细节的多轮收口,以及重构前后功能入口无遗漏对照。 + +`REDESIGN-A` 完成记录(2026-08-24):以 `dev/redesign@c9d72e3` 为重构前代码基线,扫描 62 个 Screen 与 30 个业务 Widget,并量化业务层 281 处直接颜色表达、140 处圆角、329 处 EdgeInsets 和 63 处动画时长;公共 `awesome_chewie` 运行层同样登记组件与样式分散度。MuMu 现场采集首页、搜索、动态、我的的浅色基线及首页、我的的深色基线,并归档既有平板横竖屏、Windows、详情、标签、个人主页、设置、登录、评论 Panel 和视频截图,共 19 张。方向比较后冻结“静谧内容工作室”:浅色页面与主导航保持纯白、现有青绿作为克制强调、作品提供主要色彩,以三档内容密度、统一版心、内容驱动高度和有限 Token 阶梯建立自身辨识度;不采用暖纸主底、装饰性衬线或仿官方布局。信息与入口清单已经冻结,阶段 B 不得越过 Token 和组件样板直接逐页换肤。 + +`REDESIGN-B1` 完成记录(2026-08-24):新增应用层 `LoftifyDesignThemeData`,由现有 `ChewieThemeColorData` 派生语义色并完整保留用户自定义强调色和字体;冻结排版角色、2~40 间距阶梯、Section 10px 顶距、6 / 10 / 14 / 20 / 24 圆角、0.6 / 1 / 2 边界、两级阴影、48px 最小点击区域、三档内容密度、90~280ms 动效以及 compact / medium / expanded / large 宽度分级。全局 ThemeData 同步收口 AppBar 无分割线、浅色纯白页面与导航、输入、按钮、Dialog、BottomSheet、菜单和 Chip 的基础语义;Hot Reload 期间旧主题树缺少扩展时会安全降级,不再触发断言。首批 `LoftifySection` / `LoftifyEntryItem` 支持内容驱动高度、展开收起、长标题与说明、Lottie 加载、选中、禁用、成功、警告、错误、鼠标 / 键盘 / 按压反馈,并已迁移“我的”内容中心、我的创作和设置总页,入口与业务跳转未删减。新增 8 项 Token / 栅格测试和 3 项组件测试;全量 216 项测试通过,本轮文件定向静态检查零问题,Android Debug APK 与 Windows Profile 构建成功(Windows Debug 输出被用户正在运行的 Loftify 进程占用,未强制结束该进程)。MuMu Android 12 已完成浅色 / 深色“我的”、设置及首页 / 搜索 / 动态巡检,280px / 2 倍英文长文案由组件测试覆盖;Hot Reload、Hot Restart 均成功且运行日志无断言、溢出或未处理异常。独立 Android 真机仍未连接;繁中、完整英文、高对比度、减少动画和其余组件状态矩阵留在 `REDESIGN-B2`,因此 `REDESIGN-B` 继续保持未完成。 + +`REDESIGN-B2A` 完成记录(2026-08-24):新增同源 `LoftifyButton`、`LoftifyTextField`、`LoftifyTag` 与 `LoftifyChoiceGroup`,统一 48px 最小触控区、三档尺寸、主 / 次 / 柔和 / 幽灵 / 危险按钮、焦点 / 悬停 / 按下 / 选中 / 禁用 / 加载 / 成功 / 警告 / 错误状态,以及可增长的长文案和窄屏换行规则;加载继续使用自定义 Lottie,不回退为系统进度圈。四种登录流程复用新输入与按钮,原自动填充、密码显隐、验证码和提交逻辑保持不变;标签详情筛选 Panel 移除易漂移的独立选择控制器,改由业务参数单向驱动,短标签优先行内排列,长英文与大字体可滚动,短屏上固定重置 / 确认区仍完整可见。新增 9 项组件与真实 Panel 回归,既有登录测试同步升级;全量 225 项测试、目标静态检查、差异检查和 Android Debug APK 构建全部通过。MuMu Android 12 已完成真实标签筛选 Panel 的浅色、深色、选中态、滚动、Hot Reload 与 Hot Restart 验证,运行日志未发现断言、布局溢出或未处理异常;独立 Android 真机当前未连接。Lottie 资产归一化与导航动画进入 `REDESIGN-B2B`,卡片及其余状态矩阵保留在 `REDESIGN-B2C`,因此 `REDESIGN-B` 继续保持未完成。 + +`REDESIGN-B2B` 完成记录(2026-08-24):全部 43 个 Lottie JSON 已登记原始画布与有效内容区,新增统一虚拟光学视口,调用方可像普通图标一样直接指定 20 / 22 / 24 / 32px,不再受原 JSON 画布比例、标签留白或大效果区影响;原始图层、关键帧、缓动、震动、循环与庆祝效果均未重写。加载动画由整体染色改为强调色弧线、次强调色弧线与中性底层的分层语义配色,浅色与深色下均不再出现整块绿色。首页四个主导航增加统一 48×48 源画布、22px 光学尺寸的 Lottie:未选中时停在清晰轮廓帧,点击后页面立即切换并仅播放一次,离开时复位;减少动态时直接落在目标帧。新增资源完整性、光学尺寸、分层配色、减少动态与导航单次播放回归;全量 229 项测试、目标静态检查与 Android Debug APK 构建通过。MuMu Android 12 已完成导航选中 / 复位、加载动画浅色 / 深色、Hot Restart 与 Hot Reload 验证;独立 Android 真机当前未连接。卡片、菜单、通用 Panel 和其余状态矩阵进入 `REDESIGN-B2C`,因此 `REDESIGN-B2` 与 `REDESIGN-B` 继续保持未完成。 + +`REDESIGN-B2C` 完成记录(2026-08-24):新增同源 `LoftifyCard`、`LoftifyMenuItem`、`LoftifyPanel` 与 `LoftifyStateView`,卡片覆盖平面、描边、浮起、柔和四种层级和正常、选中、成功、警告、错误、禁用状态;菜单统一图标、说明、选中、危险色和无水波纹反馈;移动端 Panel 保留统一顶部圆角与拖拽把手,宽屏浮层使用完整圆角和轻量阴影。全局 Chewie 加载、空态和错误入口通过可选应用级构建器复用同一视觉,加载仍使用自定义 Lottie,原重试、滚动控制器、滚动物理和内边距语义保持不变;动态合集、个人资料统计、装扮预览、通用选项、筛选和屏蔽 Panel 已接入真实样板。新增状态矩阵覆盖繁中、英文、280px 窄屏、2 倍字体、浅深色、高对比度、减少动画、Android 与 Windows;全量 244 项测试、目标静态检查、差异检查和 Android Debug APK 构建全部通过。MuMu Android 12 已完成真实首页、“我的”、装扮列表、Hot Reload 与 Hot Restart 验证,运行日志未发现断言、布局溢出或未处理异常;截图保存在 `build/screenshots/loftify_b2c_home.png`、`build/screenshots/loftify_b2c_mine.png` 与 `build/screenshots/loftify_b2c_dress.png`。普通页面 AppBar 返回等操作继续使用圆形点击面,导航页悬浮操作继续使用带柔和边界和阴影的全圆角胶囊;独立 Android 真机当前未连接。至此 `REDESIGN-B2` 与 `REDESIGN-B` 完成,下一阶段进入五类代表页面试点。 + +`REDESIGN-C1` 完成记录(2026-08-24):首页内容区改为由设计 Token 驱动的响应式栅格,并在宽屏以 1440px 最大版心居中;瀑布流卡片不再按设备半屏宽度推算媒体尺寸,而是读取所在真实列宽,修复平板与 Windows 多列布局中的封面比例和内容挤压。图片与视频继续使用 `BoxFit.cover`,边界描边改为覆盖绘制,不再侵占媒体尺寸;媒体卡片统一 14px 圆角,异常内容保持局部回退。文字帖仅正文区域使用柔和灰底,作者、标签和互动元数据留在页面底色上;标题、摘要、作者和计数使用冻结排版层级,更多菜单保留无水波纹语义点击。新增真实列宽几何回归及 Token 检查;全量 245 项测试通过,目标静态检查无 error / warning(仅保留首页两处既有 info),Android Debug APK 与 Windows Profile 构建通过。MuMu Android 12 已完成真实首页两轮视觉检查、滚动、更多 Panel、下拉刷新、Hot Reload 与 Hot Restart,运行日志未发现断言、布局溢出或未处理异常;截图保存在 `build/screenshots/loftify_c_home_pass1.png`、`build/screenshots/loftify_c_home_pass2.png`、`build/screenshots/loftify_c_home_menu2.png` 与 `build/screenshots/loftify_c_home_refresh.png`。普通页面 AppBar 仍使用圆形独立按钮,导航页悬浮控件仍使用带柔和阴影的胶囊,两类规则没有交叉覆盖;独立 Android 真机当前未连接。 + +`REDESIGN-C2` 完成记录(2026-08-24):新增响应式阅读版心,正文在手机使用 12 / 16px 分级留白、宽屏限制为 720px,并以 17px 字号和 1.8 行高提供稳定的长文阅读节奏;HTML 选择、下载、解析与局部重试逻辑保持不变。作者、正文、合集 / 粮单、标签、标记、互动、评论与相关推荐标题统一落在同一内容轴上,图片和视频仍以内容优先方式使用可用宽度。帖子操作区改为右侧竖向悬浮条:浅色使用白色抬升表面,深色使用深色抬升表面,并统一边界、阴影、圆角、触控和计数排版;原有滚动显隐、评论区 / 底部控件入屏隐藏、点赞、推荐、下载、评论跳转与上下篇状态均完整保留。AppBar 继续保留轻量合集胶囊,返回、更多等独立操作继续使用圆形点击面。新增阅读宽度、窄屏留白、正文排版及悬浮条深浅色回归;独立全量 247 项测试通过,目标静态检查无 error / warning(旧详情屏仅保留既有 info),Android Debug APK 与 Windows Profile 构建通过。MuMu Android 12 已在真实长文章中完成顶部布局、滚动显隐、评论跳转、Hot Reload 与 Hot Restart 验证,运行日志未发现断言、布局溢出或未处理异常;截图保存在 `build/screenshots/loftify_c2_detail_top2.png`、`build/screenshots/loftify_c2_detail_hidden.png`、`build/screenshots/loftify_c2_detail_shown.png` 与 `build/screenshots/loftify_c2_detail_comments.png`。独立 Android 真机当前未连接。 + +`REDESIGN-C3` 完成记录(2026-08-24):标签头部改为由设计 Token 驱动的响应式信息区,统一标签图标、标题、作品 / 参与统计及关注状态;窄屏和 2 倍字体会安全改为纵向排列,关注按钮使用克制的柔和 / 次级状态,不以大面积实色抢夺内容焦点。三个发现入口复用统一描边卡片与原有插画,正常字号保持紧凑,长本地化文案和大字体会按内容增长;页面内容以 1440px 最大版心居中。Tab 继续使用原有 `UnderlinedTabIndicator`、主题强调色、无水波纹与控制器进度驱动的字体动画,首次进入、重复点击及筛选变化仍会立即拉出自定义 Lottie 刷新动画,不改动既有懒加载时序。筛选吸顶区使用 64px 稳定高度容纳 40px 控件,修复文字裁切并保留 Tab / Segment / 筛选参数跨重启记忆;瀑布流与方格列表改用真实单元格宽度和响应式 300px 最大列宽,消除旧 160px 固定宽度导致的封面挤压。新增窄屏、大字体、关注状态和发现卡片回归;独立全量 250 项测试通过,目标静态检查无问题,Android Debug APK 与 Windows Profile 构建通过。MuMu Android 12 已在真实“原神”标签中验证两列内容栅格、Tab 自动刷新动画、周榜筛选记忆、筛选 Panel、吸顶状态、Hot Reload 与 Hot Restart,运行日志未发现断言、布局溢出或未处理异常;截图保存在 `build/screenshots/loftify_c3_tag_pass2.png`、`build/screenshots/loftify_c3_tag_refresh.png`、`build/screenshots/loftify_c3_tag_hottest_loaded.png`、`build/screenshots/loftify_c3_tag_filter_panel.png` 与 `build/screenshots/loftify_c3_tag_pinned.png`。独立 Android 真机当前未连接。 + +`REDESIGN-C4` 完成记录(2026-08-24):作者主页头部迁移为内容驱动的响应式布局,身份信息不再依赖 80px 固定高度,昵称、ID、性别 / IP、简介入口及横屏更多操作可随本地化文案和 2 倍字体自然增长;昵称与 ID 的长按复制、头像灯箱和简介弹窗保持不变。手机继续按身份、统计、关注和代表作纵向组织,820px 以上宽屏在正常字号下改为摘要与代表作双栏并限制 1180px 最大版心;大字体自动回退纵向。四项统计在极窄或 1.45 倍以上字号下重排为两行,计数继续使用本地化缩写;关注主操作使用清晰白色表面与主题强调色,已关注、本人和黑名单状态保持克制 / 危险语义。代表作使用真实 `BoxFit.cover`、内容驱动尺寸、渐变信息层和无水波纹卡片,并给 Tab 前保留稳定呼吸空间;正文仍以 20px 顶部圆角衔接主题背景,原等宽 `UnderlinedTabIndicator`、持久化、嵌套滚动和各子页刷新逻辑未改动。新增窄屏 2 倍字体、主操作、统计重排及 1200px 双栏几何回归;独立全量 254 项测试通过,目标静态检查无问题,Android Debug APK 与 Windows Profile 构建通过。MuMu Android 12 已在真实“梧岩”作者页验证顶部布局、代表作、正文圆角、Tab 吸顶、更多菜单、喜欢 Tab 的即时字体 / 指示器切换和自定义 Lottie 加载,并完成 Hot Reload 与 Hot Restart;运行日志未发现断言、布局溢出或未处理异常。截图保存在 `build/screenshots/loftify_c4_profile_pass2.png`、`build/screenshots/loftify_c4_profile_pinned.png`、`build/screenshots/loftify_c4_profile_menu.png`、`build/screenshots/loftify_c4_profile_tab_loading.png` 与 `build/screenshots/loftify_c4_profile_tab_loaded.png`。独立 Android 真机当前未连接。 + +`REDESIGN-C5` 完成记录(2026-08-24):设置页族继续统一经过 `BaseSettingScreen` 与公共设置 Scaffold,默认页面水平边距固定为 10px,所有 Caption 分组的顶部间距固定为 10px;普通 AppBar 保持圆形独立返回 / 更多按钮、状态栏连续背景且不绘制底部分割线。旧 Caption / SearchableCaption 公共实现改为读取主题卡片 14px 圆角,使用 16px 分组标题、48px 最小触控高度、统一的低噪声按压 / 聚焦 / 悬停反馈,并修复 `showDivider` 参数此前不生效的问题;各 Entry、Checkbox 和行内选择的水平信息轴同步对齐。行内下拉在小于 380px 或文本缩放超过 1.35 倍时自动改为上下布局,避免标题、说明和选项互相挤压;下拉菜单统一 10px 圆角、无扩散水波纹、主题强调色选中底板,并在深浅主题下保持清晰层级。新增 Caption 节奏 / 圆角 / 分割线策略、320px 两倍字体行内选择及设置 AppBar 默认状态回归;独立全量 257 项测试通过,目标静态检查无问题,Android Debug APK 与 Windows Profile 构建通过。MuMu Android 12 已完成通用设置浅色 / 深色、分组展开收起、语言行内菜单、Hot Reload 与 Hot Restart 验证;截图保存在 `build/screenshots/loftify_c5_general_pass1.png`、`build/screenshots/loftify_c5_inline_menu_pass2.png`、`build/screenshots/loftify_c5_collapsed.png`、`build/screenshots/loftify_c5_general_dark.png` 与 `build/screenshots/loftify_c5_inline_menu_dark.png`。独立 Android 16 真机 `986008e8` 已安装当前 Debug APK,以 1200×2670 / 120Hz 启动并恢复真实登录数据,独立 Flutter 调试会话完成 Hot Reload 与 Hot Restart。 + +`REDESIGN-C6-P1` 完成记录(2026-08-29):按 [`Stage_C_Self_Check.md`](docs/redesign/Stage_C_Self_Check.md) 对首页、作品详情、标签详情、作者主页与设置页执行第一轮设计连续性和内容沉浸检查。新增公共 `LoftifyContentReferenceCard` 与 `LoftifyContextPill`,将作品详情 AppBar 合集入口、正文合集 / 粮单及上下篇操作从旧 `cardColor` 和局部圆角组合迁移到语义色、细边界、14px 卡片圆角、36px 胶囊与 48px 点击目标;320px 或 1.35 倍以上字体自动重排,目录、订阅、上下篇和首末篇提示业务保持不变。通用设置“发现新版本”由硬编码红色改为独立 warning Token。真机跨页快速反向滚动还暴露 EasyRefresh 在内容范围缩短时可能返回大于当前帧位移的 overscroll;公共滚动物理现会放行向合法区间的回弹并约束向外边界结果,新增缩短范围后的弹道测试。45 项代表页与滚动回归通过,定向静态检查 0 error / 0 warning,Android Debug APK 与 Windows Profile 构建成功。Android 16 真机 `986008e8` 以真实登录数据逐页进入五类代表页,真实合集目录 Panel 与系统返回正常;连续四轮快速反向拖动和底栏显隐后运行日志 0 异常,Hot Reload 616ms、Hot Restart 2282ms。真机安全模式会遮蔽 ADB 截图,第二轮必须在不降低用户安全设置的前提下补齐当前构建可视截图,不能用旧截图完成最终验收。 + +`REDESIGN-C6-P2A` 完成记录(2026-08-29):第二轮从 Android 320dp、2 倍字体和英文长文案重新开始,不复用第一轮结论。真实 AppBar 组合回归先复现出 1321px 横向溢出;`LoftifyContextPill` 现按 360 / 420dp 阈值和动态字号将最大宽度收敛为 96 / 104 / 128 / 180dp,视觉文本安全省略但屏幕阅读器仍获得完整合集语义。公共 `ResponsiveAppBar` 的长标题改为只占剩余空间并单行省略,避免 Android 平板横屏把操作按钮推离屏幕;帖子详情自定义标题同步约束。23 项 AppBar、深浅色内容归属卡和详情组件回归通过,定向静态检查 0 error / 0 warning(46 项均为详情页既有 info)。按用户要求,本轮不继续桌面端,阶段 C 第二轮仍需完成 Android 主题、语言、状态、系统返回及当前构建截图,因此 `REDESIGN-C6-P2` 保持未完成。 + +`REDESIGN-C6-P2B1` 完成记录(2026-08-29):第二轮状态反馈检查以 320×220dp、2 倍字体和长英文错误文案复现页面状态底部溢出 788px、重试入口不可达。`LoftifyStateView` 现仅在高度受限且内容超高时提供 Android Clamping 内部滚动,内容能完整阅读并触发重试;正常高度仍垂直居中,未缩小字号。由 Chewie / EasyRefresh 提供的空态明确关闭内部滚动并直接保留原 `Center` 子树,避免 `SliverFillRemaining` 固有高度异常和双层手势竞争。29 项状态表面、通知空态、Lottie 下拉 / 上拉、提前分页、深色语义状态和帖子局部错误回归通过,定向静态检查无问题。剩余主题、三语言、真实状态切换、系统返回与截图继续留在 `REDESIGN-C6-P2B2`。 + +`REDESIGN-C6-P2B2A` 完成记录(2026-08-29):纯图标加载态此前把 `showText: false` 同时当作语义隐藏,而 Lottie 又被排除语义,最终形成无名称的 live region。状态容器现在无论标题是否视觉显示都保留本地化标题语义;可见标题与说明使用 `ExcludeSemantics`,由父状态区域组合播报一次,避免重复朗读,重试按钮仍保留独立按钮语义。纯图标加载专项由失败转为通过,状态、通知空态和 Lottie 共 19 项回归通过。 + +`REDESIGN-C6-P2B2B1` 完成记录(2026-08-29):Android 16 真机启用系统安全层时普通 ADB 截图会被遮蔽,本轮没有关闭用户防截屏或修改系统安全设置,而是新增仅用于开发验收的 Flutter 渲染截图工具,从仍在真机运行的 Debug 构建根渲染树生成当前画面。工具兼容 HTTP(S) 与已转换的 WebSocket 调试地址,不读取登录凭据、不修改应用运行逻辑;已取得 `build/screenshots/loftify_c6_p2_current_zh_light.png`,确认浅色简中真实关注流、媒体 `cover`、导航与内容布局无明显断层。地址转换专项测试和静态检查通过。深色、自定义主题、繁中、英文、大字体、真实状态切换与系统返回继续留在 `REDESIGN-C6-P2B2B`,桌面端按用户要求不处理。 + +`REDESIGN-C6-P2B2B2` 完成记录(2026-08-29):主题矩阵以默认极简白的青绿色强调色复现出白色前景仅约 2.22:1 的低对比问题,根因是基础黑白前景算法使用 0.5 相对亮度阈值,橙色、绿色等较亮自定义强调色也会被错误配成白字。基础颜色逻辑现分别计算黑 / 白前景的 WCAG 对比度并选择更高者,Material `onPrimary`、新设计系统 `onAccent`、Chewie 旧组件与主题色板选中对号共用同一结果。内置全部浅深主题和青绿 / 橙 / 绿自定义强调色均达到至少 4.5:1;主题、刷新、背景与三语言基础回归 22 项通过,定向静态检查零问题。Android 16 真机 `986008e8` 已完成完整 Debug 编译、覆盖安装、Hot Reload 685ms 与 Hot Restart 2303ms,随后 USB 断开但修复已安装;桌面端按用户要求未执行。三语言页面视觉、大字体、真实状态切换与系统返回继续留在 `REDESIGN-C6-P2B2B`。 + +`REDESIGN-C6-P2B2B3` 完成记录(2026-08-29):用户明确保存繁中或英文后,Flutter 界面会恢复持久化语言,但旧启动刷新函数只在“跟随系统”时更新 `Intl.defaultLocale`,冷启动可能出现界面文案与日期格式语言不一致。启动和系统语言变化现在始终同步到“明确选择语言或解析后的系统语言”,并只在跟随系统时触发界面通知,避免系统变化覆盖用户选择。冷启动繁中失败用例已转为通过;简中 / 繁中 / 英文解析、系统刷新、年月格式、本地化资源与窄屏大字体设置布局共 12 项回归通过,定向静态检查零问题,Android Debug APK 构建成功。真机在上一小项后 USB 断开,本提交尚待恢复连接后覆盖安装和三语言页面视觉验收,因此 `REDESIGN-C6-P2B2B` 继续未完成;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B4` 完成记录(2026-08-29):主导航外层 `PopScope` 原先忽略 `didPop`,根路由已经成功返回后仍会再次执行内部 Panel 清理,可能在页面离场过程中重置当前页、底栏和 Panel 状态。新增独立返回作用域后,只有根返回被阻止时才把 Android 系统返回委托给内部 Panel,根路由已返回时不再二次处理;内部页面关闭、根页退出和视频左滑作者页返回逻辑保持不变。2 项主导航返回分流组件测试及 1 项视频作者页返回测试通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待恢复连接后覆盖安装和真实返回手势验收,因此 `REDESIGN-C6-P2B2B` 继续未完成;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B5` 完成记录(2026-08-29):主强调按钮对比修复后继续检查 `accentContainer / onAccentContainer`,默认极简白青绿主题的柔和选中态仍只有约 4.12:1;标签筛选、合集胶囊、选中菜单、卡片和 tonal 按钮共用该颜色对。现在优先保留达到 4.5:1 的主题色前景,原候选及主题色均不足时回退到主题语义正文色,低饱和选中底板、状态含义和信息密度保持不变。全部内置浅深主题及青绿 / 橙 / 绿自定义主题均达到至少 4.5:1;主题、按钮、卡片、合集关系卡和标签详情共 34 项回归通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装和真实选中态验收,因此 `REDESIGN-C6-P2B2B` 继续未完成;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B6` 完成记录(2026-08-29):状态矩阵复现默认浅色成功绿在白底仅约 2.78:1,警告黄更低;状态页、设置提示、菜单、卡片、输入校验和下载状态都会直接使用这些颜色作为文字或图标前景。三类状态色现在保持原主题色相,只向当前明暗模式的高对比方向做达到 4.5:1 所需的最小调整,并同时约束页面、抬升面以及 10% 同色柔和底板。全部内置浅深主题的成功 / 警告 / 危险色在四类实际背景上均达到至少 4.5:1;主题、按钮、卡片、Entry、状态页、合集关系卡、设置与下载状态共 48 项回归通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装和真实状态页验收,因此 `REDESIGN-C6-P2B2B` 继续未完成;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B7` 完成记录(2026-08-29):排版矩阵此前只约束主文字和次文字,输入提示、空态辅助信息等使用的浅色 `textMuted` 在白底仅约 3.01:1。`textMuted` 现在同时按页面与柔和表面校验,并向当前明暗模式的高对比方向做满足 4.5:1 所需的最小调整;三档文字层级保持不变,不通过放大字号或高饱和颜色掩盖问题。内置浅深主题的主 / 次 / 低强调文字均达到至少 4.5:1;主题、输入、状态、Entry、设置和导航头共 38 项回归通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装和真实输入提示 / 空态辅助信息验收,因此 `REDESIGN-C6-P2B2B` 继续未完成;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B8` 完成记录(2026-08-29):新增 Android 320×568dp、1.6 倍字体下的确定性异步状态恢复回归,按真实交互顺序覆盖“初始自定义 Lottie 加载 → 网络错误与说明 → 点击重试后立即恢复加载 → 异步完成后成功”。每次切换均校验状态图形、按钮与 live-region 语义同步替换,过渡结束后旧错误图标和重试入口完全移除,全程不回退为系统默认转圈且无布局异常。状态表面专项 10 项通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并触发真实网络状态切换;三语言页面视觉、深色 / 自定义主题、系统返回与最终截图仍保留在 `REDESIGN-C6-P2B2B`。按用户要求只处理 Android,桌面端未执行。 + +`REDESIGN-C6-P2B2B9` 完成记录(2026-08-29):异步状态回归继续复现出点击重试后的淡出周期内,旧错误按钮仍留在命中树约一个状态动画时长,Android 快速连点可能重复调用同一重试回调。公共 `LoftifyStateView` 现在保持原淡入淡出视觉,但所有正在离场的旧状态立即包裹为不可命中;新状态仍按当前帧正常接收交互,不需要延迟业务切换。专项测试在首击进入 Lottie 加载后立即再次触碰旧按钮位置,确认重试计数保持一次,随后异步成功及旧控件清理均正常;状态表面 10 项通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,尚待覆盖安装后补充真实高频触控验收;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B10` 完成记录(2026-08-29):三语言状态资源复查发现繁中通知空态仍显示简体“暂无通知”,帖子详情首次加载仍显示简体“文章加载中...”;两处分别统一为“暫無通知”和“文章載入中...”,与已有“重試”及繁中页面语境保持一致。新增资源级回归直接验证生成后的 `AppLocalizationsZhTw`,并确认四套 ARB 键与占位符仍完全一致;本地化和状态表面共 14 项通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并检查真实通知空态与文章加载首帧;繁中整页视觉矩阵仍保留在 `REDESIGN-C6-P2B2B`,桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B11` 完成记录(2026-08-29):中性表面对比矩阵从页面白底扩展到页面、普通表面、浮起表面和弱化表面后,复现浅色 `textMuted` 在 `surfaceMuted` 上仅约 4.377:1、深色仅约 4.388:1;输入提示、禁用项、空态辅助信息与柔和卡片会直接使用这组颜色。低强调文字现在以四类真实底板共同作为约束,只向当前明暗模式的高对比方向做达到 4.5:1 所需的最小调整;主 / 次 / 低强调层级、字号和饱和度策略不变。主题、按钮、卡片、状态与 Entry 共 32 项回归通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并检查真实输入提示、禁用项和柔和卡片;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B12` 完成记录(2026-08-29):强调色矩阵补充“颜色本身作为页面前景”后发现默认亮青绿虽然能在实色按钮上配出可读黑字,但直接用于文字按钮、选中图标、焦点边框、下载进度和导航选中态时在白底仅约 2.2:1。设计 Token 新增独立 `accentForeground`:原始 `accent` 完整保留用户选择,用于主按钮填充、Lottie、柔和选中底板和按压染色;可读前景保持同一色相,仅向当前明暗模式的高对比方向做满足四类中性底板 4.5:1 的最小调整。Material 文字 / 描边按钮、输入焦点、底栏选中态以及已迁移的 Tag、Entry、菜单、状态卡、装扮入口、帖子合集订阅和下载进度统一接入可读前景;作者页始终为白色的强调操作面则按实际合成白底独立求可读同色相前景,避免深色主题前景反而在白底失效。全部内置浅深主题及亮青绿 / 橙 / 绿自定义主题均达标;主题与六类代表组件共 49 项回归通过,定向静态检查无新增 error / warning(详情页仅保留 46 项既有 info),Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并对比真实主题色前景;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B13` 完成记录(2026-08-29):组件边界矩阵发现浅色 `outlineStrong` 在白色 / 柔和表面、深色 `outlineStrong` 在深色表面均低于非文字交互边界 3:1;次按钮、输入悬停、可操作 Panel 把手和高对比状态使用该 Token 时边界容易与背景混合。现在保留 `outline` 作为卡片、区块与普通分割线的低噪声装饰边界,仅让 `outlineStrong` 向当前明暗模式的高对比方向做达到页面、普通、浮起和弱化四类表面 3:1 的最小调整;没有给所有内容卡片加重描边。全部内置浅深主题的可操作强边界均达标,次按钮实组件确认读取新 Token;主题、控件、表面与导航头共 36 项回归通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并比较真实输入、次按钮和 Panel 把手;桌面端按用户要求未执行。 + +`REDESIGN-C6-P2B2B14` 完成记录(2026-08-29):个人主页的拉黑状态操作此前使用 20% 透明危险色底板配白字,实际叠加浅色页面或作者头图时对比度不可预测,危险语义也可能因背景内容而变弱。现在与关注主操作共用稳定的 94% 白色操作面,危险前景在真实合成白底上按同色相计算至少 4.5:1 的可读颜色,并通过危险状态边框维持明确但克制的警示层级;普通、强调和拉黑三种业务状态的点击逻辑均未改变。浅深主题下的危险边框、文字对比和真实点击均由组件测试覆盖,个人主页相关 6 项回归通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并在真实作者头图上检查拉黑状态;按用户要求只处理 Android,桌面端未执行。 + +`REDESIGN-C6-P2B2B15` 完成记录(2026-08-29):个人主页头图原渐变最浅端只有 18% 黑色遮罩,接近白色或高亮的作者封面会让白色 AppBar、昵称和辅助信息失去稳定对比。新增可复用 `LoftifyProfileCoverScrim`,以 58%~72% 的连续暗色渐变承载整个头部,在保留封面辨识度的同时不再依赖图片本身提供对比;ID 与元数据的白色不透明度同步由约 75% 收敛到 88%,最亮纯白封面下主信息和辅助信息均达到至少 4.5:1。个人主页相关 7 项回归通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并比较真实明亮 / 深色作者封面;按用户要求只处理 Android,桌面端未执行。 + +`REDESIGN-C6-P2B2B16` 完成记录(2026-08-29):继续审查图片头部发现合集和粮单详情仅对封面做模糊,没有覆盖稳定遮罩,白色 AppBar、标题、作者信息和统计在明亮封面上无法保证可读;统计辅助标签还使用 60% 白色,进一步降低对比。作者页覆盖层现提升为公共 `LoftifyCoverScrim`,作者、合集和粮单三类头图统一使用 58%~72% 暗色渐变,合集 / 粮单统计辅助标签统一使用 88% 白色;封面仍保持 `BoxFit.cover`、模糊与原有信息布局。最亮纯白封面下主 / 辅助前景均达到至少 4.5:1,并增加源文件守卫避免三类页面重新绕过公共覆盖层;相关 9 项回归通过,定向静态检查无新增 error / warning(合集与粮单保留 16 条既有 info),Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并比较真实合集 / 粮单封面;按用户要求只处理 Android,桌面端未执行。 + +`REDESIGN-C6-P2B2B17` 完成记录(2026-08-29):公共 `buildTranslucentTag` 被 23 处图片计数、类型、热度和灯箱标识复用,但默认仅 40% 黑底,部分装扮标签还显式传入 20%,白字在纯白图片上最低只有约 2.8:1。公共媒体覆盖规范新增 58% 最低角标遮罩,所有调用方的较浅请求自动收敛到可读下限,原本更深的自定义遮罩仍保持;角标尺寸、圆角、图标、文案和图片内容不变。专项回归验证显式 20% 输入也能在纯白图片上达到至少 4.5:1,媒体覆盖相关 3 项通过,定向静态检查无新增 error / warning(`item_builder` 保留 3 条既有 info),Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并比较标签详情 Banner、装扮列表和图片灯箱角标;按用户要求只处理 Android,桌面端未执行。 + +`REDESIGN-C6-P2B2B18` 完成记录(2026-08-29):装扮壁纸卡片用白色日期与时间模拟系统预览,但文字此前直接覆盖用户图片,浅色壁纸上几乎不可见;直接压暗整张预览又会扭曲用户对壁纸原貌的判断。公共媒体覆盖组件新增 `LoftifyTopTextScrim`,只在顶部文字保护区提供 66%~58% 暗色渐变并向下完全透明,圆角内裁切且由 `IgnorePointer` 排除命中,不影响预览点击或 Hero 灯箱。纯白壁纸下保护区白字达到至少 4.5:1,底部透明与真实页面接入均有回归守卫;媒体覆盖相关 5 项通过,定向静态检查无新增 error / warning(装扮列表保留 11 条既有 info),Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并比较明亮 / 深色壁纸卡片;按用户要求只处理 Android,桌面端未执行。 + +`REDESIGN-C6-P2B2B19` 完成记录(2026-08-29):装扮详情原先使用自建 20px 圆角容器、固定 300px 预览、不可滚动正文和固定横排底栏;320×568、2 倍字体回归实际复现底部溢出 57px,下载与确认操作会离开视窗。页面现迁移到统一 `LoftifyPanel`,恢复公共顶部圆角、拖拽把手、语义表面与分割线;正文使用单一 Clamping 滚动链,短屏或 1.3 倍以上字体时底部操作随正文滚动以保证可达,正常高度继续固定底栏。单图、全部与确认操作统一为 `LoftifyButton`,手机 / 大字体纵向排列,560dp 以上正常字号横向等分;下载异常通过 `finally` 关闭加载层,避免遮罩残留。2 项真实布局与源码守卫通过,定向静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并验证真实装扮详情滑动、下载与安全区;按用户要求只处理 Android,桌面端未执行。 + +`REDESIGN-C6-P2B2B20` 完成记录(2026-08-29):装扮详情的单图与全部下载仍使用通用 `CustomLoadingDialog`,既无法表达真实传输进度,也违反“下载使用进度条而非 Lottie 加载动画”的既定规则。新增同源 `LoftifyDownloadProgressButton`:保留统一按钮尺寸、圆角和主题状态,在按钮底边显示可读的确定性线性进度,同时展示百分比并向辅助功能暴露实时 value;进度期间按钮、另一个下载入口和确认关闭均不可重复操作。`LoftifyFileUtil.saveImage` 补齐真实字节回调,新增多 URL 图片批量入口并复用 `DownloadProgressAccumulator` 等权汇总各任务,未知总字节仍保留份额,避免首个文件完成时假报 100%;成功态短暂停留到 100%,减少动态模式立即恢复。7 项真实进度、窄屏大字体、Panel 与多图聚合回归通过,6 个目标文件静态检查零问题,Android Debug APK 构建成功。真机 USB 仍离线,本提交尚待覆盖安装并执行真实单图 / 多图下载进度验收;按用户要求只处理 Android,桌面端未执行。 + +`REDESIGN-R01`~`REDESIGN-R03` 完成记录(2026-08-24):确认视觉回归来自全局 Material 排版映射与内容表面映射,而不是帖子详情业务组件本身。旧页面的 `titleLarge / titleMedium / titleSmall` 已恢复 18 / 16 / 14,`bodyLarge / bodyMedium` 恢复 16 / 14;新设计 Token 仍保留独立排版角色,只由明确迁移的新组件使用,避免旧页面已有 `fontSizeDelta` 被二次放大。浅色页面和导航继续保持白色,`canvasColor` 与 `cardColor` 则恢复用户所选主题的原始浅灰层级,帖子详情 AppBar 合集胶囊、正文底部合集 / 粮单 / 标签以及其他旧控件不再白底叠白底。28 项主题、背景、详情和组件测试通过;MuMu Android 12 已现场确认首页与“我的”字号,以及帖子详情顶部合集、底部合集导航和标签背景,并完成 Hot Reload / Hot Restart。运行日志未发现 Loftify 断言、溢出或未处理异常;独立 Android 真机当前未连接。 + +`REDESIGN-R10` 完成记录(2026-08-24):公共图标按钮的默认点击形状已由矩形圆角收口为圆形,普通页面 AppBar 的返回、更多和下载等独立操作会统一复用该规则;只有显式传入圆角的行内工具按钮继续保留矩形布局。横屏响应式 AppBar 的返回入口同步改用圆形公共按钮。11 项图标组件测试通过,目标静态检查无问题;MuMu Android 12 已在帖子详情页完成 Hot Reload 与 Hot Restart,运行日志未发现框架异常。 + +`REDESIGN-R04`~`REDESIGN-R06` 完成记录(2026-08-24):首页、搜索、关注和“我的”已移除完整横向 AppBar,统一接入 48px 全圆角悬浮胶囊、柔和边界与轻量阴影;支持毛玻璃和“降低透明效果”实色降级,并在高对比度、减少动画等辅助模式下自动停用模糊。首页头像可直达“我的”;搜索保留左侧头像,右侧搜索按钮会平滑展开为行内输入胶囊;关注四 Tab 使用主题色 `UnderlineIndicator`、无水波纹且平均铺满;“我的”左侧为通知,右侧仅保留主题和设置,装扮移入内容中心,下载管理、创作入口和退出登录均未删除。四页的安全区与首屏顶部留白现在属于各自滚动内容,首屏不被悬浮控件遮挡,上滑后内容可自然延伸进状态栏而悬浮控件保持原位。17 项悬浮层、底部导航与内容中心测试连同图标、Tab 专项共 33 项通过;目标静态检查无 error / warning。MuMu Android 12 已逐页完成浅色真实数据、搜索展开、关注 Tab、内容中心、首页沉浸滚动、Hot Reload 与 Hot Restart 验证,未发现溢出、断言或框架异常;截图保存在 `build/screenshots/loftify_search_capsules.png`、`build/screenshots/loftify_dynamic_floating_tabs.png`、`build/screenshots/loftify_mine_floating_header.png` 与 `build/screenshots/loftify_home_immersive_scroll.png`。 + +`REDESIGN-R07` 完成记录(2026-08-24):首页、搜索、动态和“我的”四个 48×48 导航 Lottie 已从单一线稿缩放重构为三层状态动画:未选中稳定停在 Lucide 语义一致的线性轮廓,选中时线稿淡出、面性图形带克制回弹淡入,并用两个短促点缀强化完成感,结束后稳定停在面性帧;四个资源仍使用统一 22px 光学视口和当前主题色。减少动画模式会直接跳到结束帧,不播放中间过程;页面选择依旧即时生效,Lottie 只播放一次且离开后复位。新增资源结构回归,验证四个资源均为 48×48、约 0.8 秒、包含 Outline / Selected Fill / Selection Spark,且首尾透明度严格从线性切换为面性;18 项 Lottie 与底栏专项测试通过。MuMu Android 12 已完成四入口连续切换、结束帧、Hot Restart 与 5 秒实机录屏验证,过程未出现空白帧、颜色跳变或持续播放;录屏与抽帧保存在 `build/screenshots/loftify_nav_animation.mp4` 和 `build/screenshots/loftify_nav_animation_contact_sheet.png`。 + +`REDESIGN-R08` 完成记录(2026-08-24):底部导航滚动显隐已改为单一连续进度驱动,透明度、向下位移、轻微缩放和布局高度使用同一帧同步变化;隐藏采用 260ms `easeInCubic`,恢复采用 `easeOutCubic`,不再由多个嵌套动画分别裁切。快速反向操作会从当前可见进度立即反向,不会先沿旧方向跳动;多滚动控制器只接受当前活动方向,列表回到顶部时导航始终恢复。减少动画或辅助导航模式下即时到达稳定状态,隐藏接近完成后同步停止点击响应。16 项专项测试覆盖真实长列表、快速反向、减少动态、60 / 90 / 120Hz 和稳定帧调度,目标静态检查无问题。MuMu Android 12 已完成首页瀑布流向上隐藏、向下恢复、Hot Reload 与 Hot Restart 验证,冷启动后未发现断言、布局异常或闪跳;截图保存在 `build/screenshots/loftify_r08_hidden.png` 与 `build/screenshots/loftify_r08_shown.png`。 + +`REDESIGN-R09` 完成记录(2026-08-24):下拉 / 上拉 Lottie 的绘制尺寸现在受当前真实留白约束,拖拽初段从接近零尺寸自然放大,任何时刻都不会先于列表让位而越过可见间隙,因此不再出现下缘被列表裁切、像是控件盖住动画的观感。默认下拉动画收紧为 30px、触发 / 最大距离为 48 / 72px;上拉动画为 28px、触发 / 最大距离为 44 / 64px;嵌套列表同步使用 28px 与 44 / 64px,不再以过短触发距离破坏拖拽节奏。指示器保持在内容上层并显式裁切于自身视口,状态切换缩短为 180ms;上拉仍在距末尾 240px 时自动预取,无需拖到底部。9 项专项测试覆盖浅深色 Lottie、可用留白与绘制尺寸关系、程序化刷新、嵌套刷新、提前分页加载和空状态;目标静态检查无 error / warning。MuMu Android 12 已完成首页瀑布流慢速下拉、松手刷新、回弹、Hot Reload 与 Hot Restart,并用实机录屏逐帧确认从小到大、完整露出及收回过程;最终录屏和截图保存在 `build/screenshots/loftify_r09_final.mp4`、`build/screenshots/loftify_r09_final_pull.png` 与 `build/screenshots/loftify_r09_final_triggered.png`。 + +`REDESIGN-R11` 完成记录(2026-09-15):按最新确认撤销 `REDESIGN-R04`~`REDESIGN-R06` 的四个主导航页悬浮 AppBar 方案,首页、搜索、动态和“我的”重新使用占据标准布局高度的 `ResponsiveAppBar`;毛玻璃胶囊、头像悬浮入口、状态栏沉浸占位和对应公共实现均已移除。首页恢复普通“首页”标题及内容区 8px 起始间距,搜索恢复 AppBar 内默认展开的原搜索框,动态恢复原可横向滚动、主题色 `UnderlinedTabIndicator` 和控制器字体动画,“我的”恢复普通标题栏,同时继续遵循后来确认的顶部仅保留主题切换与设置,装扮、下载和退出仍留在内容中心。后续分页、网格、占位高度、内容中心和底栏业务修复均未回退。四页结构守卫、内容中心及相关组件测试通过,定向静态检查没有新增 error / warning,Android Debug APK 构建成功;Android 16 真机 `986008e8` 已覆盖安装并确认真实关注流固定 Tab AppBar,Hot Reload 2.66s、Hot Restart 2.99s 均成功。按用户要求未执行桌面端构建。 + +启动门槛:除明确依赖官方接口并已登记延期的任务外,所有功能、交互、内容和稳定性任务均已完成验收;主要页面信息与入口清单已冻结;已有行为测试和基线截图可用于证明重构前后没有功能遗漏。 + +验收:形成独立、统一、现代且适合 LOFTER 内容生态的第三方客户端视觉语言;内容始终优先于界面装饰,所有主要页面、组件状态、响应式尺寸、主题和可访问性均通过文档规定的多轮检查。 + +## 7. 每轮统一验收流程(重复执行,不计入任务总数) + +`REDESIGN-C6-P2B2B99` 验证记录(2026-09-27):访问其他作者的喜欢列表时,原菜单错误展示使用当前账号 ID 的“清理失效内容”;切换账号后旧清理响应仍可能删除当前页面数据。现仅在本人喜欢页提供清理入口,清理请求核对账号和页面生命周期并抑制重复提交;清理成功时使在途分页失效但保留现有列表,再按原有归档/游标逻辑移除失效项,避免旧分页把内容加回来。两个页面错误在修复前复现,修复后含控制器并发、账号切换、窄屏与批量下载的 40 项回归通过,目标静态检查无问题,Android Debug 构建并成功安装到已连接真机;真实账户清理操作仍待手动验收。 + +`REDESIGN-C6-P2B2B98` 验证记录(2026-09-27):我的喜欢“批量保存全部”与推荐页存在相同的部分数据误判:分页失败或提前空页时直接返回已载入项,重复帖子 / 损坏项还可能令服务端游标到末尾但实际作品数量不足。现只在完整且去重后的帖子数达到接口总数时返回;加载失败抛出可重试错误,不让批量页提前标记全部完成。分页失败与提前空页已在修复前复现,修复后含重复项、280×480 / 720×320、简繁英及双倍字体入口回归共 30 项测试通过,目标静态检查无问题,Android Debug 构建并成功安装到已连接真机;真实账户批量操作仍待手动验收。 + +`REDESIGN-C6-P2B2B97` 验证记录(2026-09-27):我的推荐清理失效内容成功后原先只删除列表项,未同步扣减对应月份计数,导致后续作品归到错误月份;接口异常亦未捕获。现按删除前索引扣减月份分组和已知总数,清理请求只在本人推荐页提供,并防止旧分页响应把已删除内容重新写回;超时保留原列表且不产生未处理异常,账号变化时忽略过期操作。错误分组已在修复前复现,修复后含并发分页与超时场景的推荐页、批量下载页共 39 项测试通过,目标静态检查无问题,Android Debug 构建并成功安装到已连接真机;真实账户清理操作仍待手动验收。 + +`REDESIGN-C6-P2B2B96` 验证记录(2026-09-27):我的推荐“批量保存全部”原先在分页业务失败、请求取消或服务端提前返回空页时把当前已载入的部分帖子当作全部返回;跨页重复帖子也可能仅凭列表长度误判完成。现记录接口总数,任一不完整状态抛出可重试错误,批量页不会标记“全部加载”或以部分作品创建整批任务;成功加载后仍返回完整列表。故障场景在修复前验证问题,修复后推荐页与批量下载页共 36 项测试通过,目标静态检查无问题,Android Debug 构建并成功安装到已连接真机;真实账户批量操作仍待手动验收。 + +`REDESIGN-C6-P2B2B95` 验证记录(2026-09-27):确认“我的”页不显示“我的”标题,通知与装扮维持在 AppBar 而非内容中心;补齐横屏 / 桌面 AppBar 的同组操作入口,避免该布局隐藏通知、装扮、主题切换和设置。相关 38 项回归覆盖窄屏、大字体与横屏,目标静态检查无问题;Android Debug 构建并成功安装到已连接真机,未切走手机当前页面。真实页面视觉与触摸仍待验收。 + +`REDESIGN-C6-P2B2B94` 验证记录(2026-09-27):我的推荐独立页原先在首次请求期间先显示空态,网络超时在外层 Future 链抛出后可能一直保持加载锁;刷新解析到坏项时还会先清空已有帖子。现以稳定 EasyRefresh 容器展示加载 / 错误 / 空内容,错误按钮重试不重建刷新状态;接口、缓存读取和解析均在完整 try/finally 中,数据解析成功后才整体提交。业务失败或刷新异常保留旧内容,离页及账号切换后忽略过期响应并清理旧账号内容。独立页和个人主页内嵌页均覆盖重试;280×480 / 720×320、简繁英、2 倍字体及分组长列表共 14 项页面测试通过,目标静态检查无问题,Android Debug 构建并成功安装到真机。真实账户视觉与系统返回手势仍待验收。 + +`REDESIGN-C6-P2B2B93` 验证记录(2026-09-27):我的推荐归档统计使用 `indexOf(count)` 找月份,相同数量的两个月会生成重复标题;当接口不带 archives 或分类数不足时,返回帖子被误显示为空或遗漏。现按真实月份索引构建分组,以年份 / 月份数值排序,缺失的分类部分以无标题网格保留全部帖子;刷新缺少分类会清理旧标题,分页缺少分类仍保留已有标题。分组视口改为 Sliver 按需构建,300 条同组帖首屏只建立附近卡片。两种错误在修改前的页面测试均复现,修复后月份重复、分类缺失 / 不足、分页和长列表共 4 项页面测试通过,目标静态检查无问题,Android Debug 构建并安装到真机;真实推荐数据与视觉操作待继续验收。 + +`REDESIGN-C6-P2B2B92` 验证记录(2026-09-27):收藏夹“批量保存全部”的加载回调不再在分页失败、请求取消或服务端提前返回空页时返回部分帖子并标记为全部加载;此时抛出可重试错误,批量页保持原有选择和未完成状态。再次加载会从已载入页继续,成功后返回完整去重列表。收藏夹详情的下载入口同时补齐横向 / 桌面 AppBar 操作区。模拟分页失败与重试、服务端空页但总数未达的场景,连同详情页和批量下载页共 31 项回归通过;目标静态检查无问题,Android Debug 构建并成功安装到真机。真机页面操作仍待验证。 + +`REDESIGN-C6-P2B2B91` 验证记录(2026-09-27):收藏夹详情移除未使用的用户缓存等待与嵌套 Future 链,首次加载显示加载态,业务失败 / 网络超时显示可重试错误态;刷新失败保留已有帖子,解析完成后才整体替换数据。分页按帖子 ID 去重,月份标题按帖子当前顺序形成连续分组,避免对标题单独逆序排序后与格子内容错位。离页或切换账号后忽略过期响应,控制器按生命周期释放。模拟请求覆盖超时重试、月份顺序、分页与重复项、失败刷新、成功替换、离页和账号切换;另覆盖 280×480 / 720×320、简繁英和 2 倍字体,连同收藏夹列表共 24 项测试通过,目标静态检查无问题,Android Debug 构建并成功安装到真机。真实图片、长列表与真机视觉操作继续待验收。 + +`REDESIGN-C6-P2B2B90` 验证记录(2026-09-27):收藏夹新建、编辑、删除请求统一为可捕获异常的异步操作,并按账号与页面生命周期忽略过期响应。编辑请求改用副本,服务端确认前不修改原卡片,失败后原名称保持不变;成功只更新匹配 ID 的卡片。删除成功后立即从本地移除并刷新,失败保留卡片;新建失败可重试。同一收藏夹的重复编辑 / 删除及并发新建会被抑制,缺失 ID 的卡片不再发送 ID 0 的删除请求。模拟请求覆盖编辑失败 / 成功、删除成功及新建失败重试,连同加载和卡片回归共 17 项通过;静态检查无问题,Android Debug 构建成功。APK 已安装到 Android 真机并确认包更新时间,未切走设备当前页面;真实账户操作仍待手动验收。 + +`REDESIGN-C6-P2B2B89` 验证记录(2026-09-27):收藏夹列表分页原先每次清空已加载卡片,网络异常时请求可能不结束,首次加载失败缺少页面级错误与重试状态。现保留稳定的刷新容器,分页追加并按收藏夹 ID 去重;刷新成功后替换列表,业务失败保留旧内容,网络异常进入可重试错误态,离开页面或切换账号后忽略过期响应并释放刷新控制器。模拟接口覆盖分页、重复项、刷新失败及成功、超时重试、离页与账号切换;另在 280×480 / 720×320、简繁英和 2 倍字体下验证加载、错误、重试与卡片,共 13 项测试通过。Android Debug 构建成功;真机曾短暂连接但安装前断开,安装和真实账号数据验收待连接恢复。 + +`REDESIGN-C6-P2B2B88` 验证记录(2026-09-16):历史请求在缓存读取和接口返回后核对发起时账号,切换账号 / 退出登录后的旧结果不再写入页面;过期请求结束时清空旧列表、分类与统计并结束加载,未登录重试不访问历史接口。两项模拟账号变化用例均先复现旧帖子残留,修复后覆盖旧结果丢弃、新请求可重试和退出后不发请求,历史页共 23 项通过;静态检查无 error / warning,保留原有 6 项 info。真实账号切换流程仍待真机验证。 + +`REDESIGN-C6-P2B2B86` 验证记录(2026-09-16):历史页分组中的收缩网格会一次性创建整个分组,300 条记录用例复现创建 300 个帖子控件。改用统一 Sliver 归档网格与单一滚动视口,保持分组标题、手机三列、封面间距和底部留白,离屏单元不强制保活。280×480、2 倍字体下正常帖子首屏与滚动至第 300 条均保持少于 60 个已创建单元;历史页共 21 项通过,Android Debug 构建成功,静态检查无 error / warning(保留原有 6 项 info)。 + +- [x] `REDESIGN-C6-P2B2B87` 验证记录(2026-09-16):失效帖子占位卡根据实际可用高度与系统字体行高确定文字行数,空间不足时优先状态文案、省去装饰图标,不缩小用户字体;完整文案保留在 Tooltip 和读屏标签。80 / 120 / 160dp 方格、简繁英、浅深色及 2 倍字体共 18 种回归,修复前 8 种溢出,修复后均通过并验证字形不被竖向剪断。连同原有帖子卡和历史页共 49 项通过,目标静态检查无问题,Android Debug 构建成功;真机当前未连接,实际触摸与读屏体验待验证。 + +`REDESIGN-C6-P2B2B85` 验证记录(2026-09-16):历史页增加 280×480 / 720×320、简中 / 繁中 / 英文、浅深主题、2 倍字体共 12 种状态布局回归,覆盖初次加载、错误、滚动到重试按钮、重试成功空态及请求次数。测试补齐应用实际的 Lottie 上拉控件与统一空态构建器;初次经典 Footer 溢出属于测试配置遗漏,不计为产品缺陷。历史页共 20 项通过,本轮未修改生产布局;真实内容分组、图片和真机尺寸验收仍待完成。 + +`REDESIGN-C6-P2B2B84` 验证记录(2026-09-16):历史页刷新成功但未返回分类时,不再把上一批日期标题沿用到新帖子;分页缺少分类仍保留已有分组。三个分类场景覆盖分页、异常响应保留旧内容及刷新替换,修复前两个场景复现旧标题残留,修复后历史页 8 项测试全部通过。目标静态检查仅有原先 6 项 info,无 error / warning;真实账户及跨尺寸验收继续保留待办。 + +`REDESIGN-C6-P2B2B83` 验证记录(2026-09-16):历史本地失效项清理在无分类时索引越界,在多个分类时错误扣减末尾分类,导致剩余帖子归类错误。用原始分类范围逐段统计失效项,再统一删除并扣减总数;无分类同样安全,重复清理不重复扣减。两项模拟页面用例分别复现 RangeError 和残留错误分类标题,修复后验证保留两条有效帖及正确分类;历史页共 8 项通过,未执行真实历史删除接口。 + +`REDESIGN-C6-P2B2B82` 验证记录(2026-09-16):历史分类缺失时原先把有帖子结果显示为空,分类数量不足时遗漏剩余帖子,刚好加载完前一分类时还生成下一分类的空网格。三个真实页面用例先复现失败,现按已返回帖子显示,未覆盖的记录以无分类标题网格保留,不编造日期;无内容分类不展示空标题。三种分类状态均验证首批两个帖子、分页追加第三个及 noMore,连同加载 / 重试 / 退出共 6 项通过。真实账户数据、图片内容及跨尺寸验收仍待完成。 + +`REDESIGN-C6-P2B2B81` 验证记录(2026-09-16):历史页首次请求期间原先显示空态,业务失败不进入错误状态;错误 / 加载切换还会重建带 refreshOnStart 的刷新容器。现在保留同一个 EasyRefresh 切换主题化加载 / 错误 / 空内容,重试不再额外启动初始请求,退出后忽略响应并释放控制器;完整解析分类及帖子后再更新列表。业务失败、超时后的按钮重试均验证仅两次请求且刷新 State 不变,退出后响应安全,共 3 项页面回归通过。有内容分类分页和跨尺寸历史页仍待后续验收,真机安装待恢复连接。 + +`REDESIGN-C6-P2B2B80` 验证记录(2026-09-16):兼容旧完成任务缺少 progress 字段时,父任务和资源进度条已按完成显示满格,但两页资源百分比仍读取原始默认 0,显示相互矛盾。新增统一 displayProgress 用于进度条和文字,完成状态显示 100%,原始进度 / 字节 / 存储不变;独立任务与组详情的已知大小完成用例均从失败转为通过,已知 / 未知大小停止状态及原页面矩阵共 68 项通过。真机安装与视觉验收仍待连接。 + +`REDESIGN-C6-P2B2B79` 验证记录(2026-09-16):下载管理独立任务及详情子资源仅按 totalBytes 判断进度动画,导致未知大小的暂停 / 取消 / 失败 / 完成任务持续循环。统一使用状态感知的展示进度:仅 downloading 且未知大小时不定进度,停止 / 排队显示已有进度,完成显示 100%;不修改原始字节、进度或持久化数据。两页 8 个停止状态用例从失败转为通过,覆盖全部任务状态与已知 / 未知大小的 12 项模型测试,连同页面矩阵共 60 项通过,真机同步待恢复连接。 + +`REDESIGN-C6-P2B2B78` 验证记录(2026-09-16):下载任务详情补齐 280 / 720 宽、双倍字体、三语言及默认深浅色共 12 组操作验证,逐行字形检查“查看原内容”及整组控制文案,实际点击暂停 → 继续 → 取消 → 重试并核对 DownloadTaskManager 状态。使用内存队列与不执行网络传输的测试执行器,保留未知大小进度动画,以有限帧推进测试;不将本项当作真实文件下载或来源跳转验收。未发现新的裁剪,无需修改页面样式,本项仅新增测试。 + +`REDESIGN-C6-P2B2B77` 验证记录(2026-09-16):将字形范围检查补到批量下载完整流程,复现 280px 双倍英文字体完成后“下载管理”文字高约 65px,但按钮固定 46px。主操作 / 重试按钮改用 46px 最小高度和自适应文字高度,保留原配色与排列;12 组跨尺寸 / 三语言 / 深浅色流程新增逐行字形检查,连同异常重试与选择逻辑共 19 项通过。真机安装仍待恢复连接。 + +`REDESIGN-C6-P2B2B76` 验证记录(2026-09-16):管理项解除按钮固定 32px 时,普通布局边界断言虽通过,实际字形范围检查发现英文及双倍字体文字超出内部 30px 文本框。按钮改为按内容增高、保留 32px 最小高度与原主题色 / 圆角,并补充纵向内边距。280 / 720 宽、1× / 2× 字号、英 / 简中 / 繁中按钮共 12 组验证每行字形完整落在文本框内、按钮可点击,连同既有空态和管理项测试共 37 项通过;真机视觉仍待恢复连接。 + +`REDESIGN-C6-P2B2B75` 验证记录(2026-09-15):动态屏蔽列表刷新先 clear 再解析,响应中一个用户字段异常就会让原记录消失。新增真实页面用例复现旧作者行被清空,现改为完整解析成功后一次替换;验证部分损坏响应保留旧行、不显示半截新行,重试成功替换为新行,成功空响应才清空。连同三类管理页加载回归共 7 项通过,未调用任何真实解除屏蔽接口,真机同步仍待连接。 + +`REDESIGN-C6-P2B2B74` 验证记录(2026-09-15):三类屏蔽管理页补齐 EasyRefreshController 的 dispose,将加载异常处理扩展至整个 await 请求,并在处理返回内容或提示前检查页面是否仍挂载。黑名单 / 标签屏蔽 / 动态屏蔽分别以实际页面和模拟请求验证超时后再次刷新成功、退出后失败响应不触发页面异常,共 6 项通过。本项仅涉及读取列表,不改变添加 / 解除屏蔽业务;这些写操作及真机状态仍待独立验收。 + +`REDESIGN-C6-P2B2B73` 验证记录(2026-09-15):黑名单、屏蔽标签、不看他的动态原空态固定 140px 并禁用内层滚动,不能随文字增高。三页统一为 SettingManagementEmptyState:保留 140px 最小高度,内容自然撑高,仅由外层设置列表滚动,沿用 CaptionItem 分组与主题状态样式。280 / 720 宽度、1× / 2× 字体、默认深浅色、简中 / 繁中 / 英文内容共 24 组验证文本位于容器内、只有一个滚动区且内容可达,连同设置管理项原测试共 25 项通过;本项为组件矩阵,三页真实加载与真机视觉尚待验收。 + +`REDESIGN-C6-P2B2B72` 验证记录(2026-09-15):批量确认下载仅有 finally,资源解析意外异常或队列持久化失败会直接成为未处理异常。新增统一异常捕获与本地化下载失败提示,保留原选择并结束解析进度,允许重新提交;两类注入失败用例均从失败转为通过,验证失败不显示成功汇总、重试提交两项资源且保留 collection 来源。批量页共 19 项通过,真机安装仍待连接。 + +`REDESIGN-C6-P2B2B71` 验证记录(2026-09-15):批量下载“加载全部”失败仍会选中当前页,且当前页手动全选后会把全选点击误判为取消选择,无法继续加载其余帖子。失败现在保留原选择并允许下一次点击重试;只有全部帖子已加载才判定真正全选,加载期间禁止改选与提交,避免以未完成的选择创建部分下载。新增失败保留 / 重试、已选首屏继续加载和阻止提前提交用例,连同跨尺寸批量页共 17 项通过;真机安装继续待恢复连接。 + +`REDESIGN-C6-P2B2B70` 验证记录(2026-09-15):批量下载初始无帖子时,SliverFillRemaining 内嵌套全高滚动空态,实测触发 RenderViewport 不支持 intrinsic dimensions 的布局异常。改为同一设计系统的非独立滚动状态内容,由页面外层统一滚动;280×480 / 720×320、双倍字体、简中 / 繁中 / 英文及默认深浅色共 12 组完整验证空态 → 加载全部 → 选择 → 提交一项资源,连同已有部分选择、批量汇总和窄屏控件共 15 项通过。未改动资源解析与批次语义,真机安装和视觉验证仍待恢复连接。 + +`REDESIGN-C6-P2B2B69` 验证记录(2026-09-15):下载管理无任务 / 任务详情记录不存在时的固定 120px 顶部留白,会使 280×320 双倍英文字体空态文字底部达到 414px,初始视窗无法完整显示。两页已移除固定留白,沿用全高可滚动空状态;280×320 / 720×320、双倍字体、简中 / 繁中 / 英文、默认深浅色共 24 组首屏可见性验证及原任务控件测试共 28 项通过。真机仍无 ADB 连接,本项构建需恢复连接后同步,与 B67 一起验收。 + +`REDESIGN-C6-P2B2B68` 验证记录(2026-09-15):补齐全部 / 点赞有内容列表的请求状态回归:使用可解析通知模型渲染实际通知行,验证分页部分数据损坏不会追加半页、重试 offset 保持 1、成功分页追加第二条、刷新 offset 回到 0、刷新失败保留两条原内容且不覆盖为全屏错误、刷新成功仅显示新一条。两条路径专项通过;本项仅新增回归测试,不修改应用界面,不重新打包。真机仍未连接,B67 安装与真实数据验收继续待办。 + +设备同步状态(2026-09-15,B67):Debug APK 已构建;覆盖安装时 ADB 报告设备 `986008e8` 不存在,本项尚未安装,恢复连接后需补同步。 + +`REDESIGN-C6-P2B2B67` 验证记录(2026-09-15):通知列表为空时原先无论请求仍在进行或已经失败均显示“暂无通知”。八分类现在独立区分加载 / 错误 / 成功空内容;加载沿用主题化 Lottie,错误提供重试入口,外层保持可下拉滚动,已有列表不因刷新失败被清空。全部 / 点赞覆盖 280×320、720×360、双倍字体和默认深浅色的真实组件状态转换及点击重试,连同并发请求、缓存缺失和语言切换共 20 项通过。测试接入应用实际 Lottie 刷新头尾,不使用包内默认文字刷新头;当前构建的真机视觉与辅助朗读仍待完整验收。 + +`REDESIGN-C6-P2B2B66` 验证记录(2026-09-15):通知项紧凑模式保留 44px 头像视觉尺寸,将点击范围补足至 48px,同时缩减旁侧空隙以保持正文起点不变;头像入口增加昵称按钮语义。280×480 / 720×360、1× / 2× 字号、默认深浅色、英文 / 简中 / 繁中长昵称共 24 组验证点击边缘只打开作者、缩略图打开帖子且滚动可达;连同原占位和长文本检查共 26 项通过。Android Debug 构建成功,真实辅助朗读与页面截图仍待验收。 + +`REDESIGN-C6-P2B2B65` 验证记录(2026-09-15):通知自动分页未结束时,下拉刷新被同一加载锁忽略,新增请求数量断言已在修复前复现失败。现在刷新优先开启新一轮请求,各分类独立记录版本,旧分页响应不再修改列表 / noMore,也不能释放新刷新持有的锁。全部 / 点赞均覆盖新旧响应的两种到达顺序,以及刷新后继续分页;连同异常加载、缓存缺失和语言切换共 12 项通过。此项为模拟请求组件回归,真实通知数据与跨设备视觉验收仍待完成。 + +`REDESIGN-C6-P2B2B64` 验证记录(2026-09-15):通知两条请求路径的 try/finally 已扩展到用户缓存读取与网络请求,缺少用户缓存时返回失败并释放加载锁;退出页面后忽略迟到响应。全部 / 点赞分别验证超时后重新刷新、缺失缓存连续刷新不锁死、退出后错误响应不弹提示,连同语言切换共 8 项通过。Android Debug 构建成功;本项不替代通知真实数据、分页和跨尺寸视觉验收。 + +`REDESIGN-C6-P2B2B63` 验证记录(2026-09-15):通知页八分类运行中切换语言仍显示旧文案,已改为构建时读本地化,初始化Tab数量取稳定ID列表。默认深浅色均验证英文→简中→繁中→英文,记忆的点赞Tab索引1、State / 控制器不变且不新增请求;连同动态页共4项通过。另发现通知请求的loading释放仅覆盖成功返回后的解析阶段,网络异常 / 缺失用户缓存场景需后续回归,不标记整体通知页完成。 + +`REDESIGN-C6-P2B2B62` 验证记录(2026-09-15):动态页运行中切换语言时四个Tab文案不更新,已改为构建时读取本地化资源,控制器及索引边界取稳定ID列表。默认深浅色各验证英文→简中→繁中→英文,已记忆的粮单Tab索引3、页面State / TabController保持且请求数不增加。未覆盖真实粮单数据和全部Tab切换,不据此关闭整体验收。 + +`REDESIGN-C6-P2B2B61` 验证记录(2026-09-15):已打开的搜索结果页切换语言后Tab仍保留旧语言,改为构建时读取当前本地化文案,Tab数量使用稳定ID列表。英文→简中→繁中回归确认同一页面State / TabController保留、综合Tab索引不变且不增加请求;搜索测试共60项通过。尚不覆盖其他页面及非首Tab的完整语言切换验收。 + +`REDESIGN-C6-P2B2B60` 验证记录(2026-09-15):真实SearchResultScreen受控请求复现提交搜索后旧建议返回并覆盖结果。提交立即作废建议请求,关键词变化清空旧建议并跳过选区重复请求,捕获超时及解析异常且忽略退出后的回调。新增提交竞态、超时恢复 / 选区 / 销毁回归,连同搜索页矩阵共59项通过;结果页分页与真实手势仍需单独验收。 + +`REDESIGN-C6-P2B2B59` 验证记录(2026-09-15):搜索用户 / 标签建议与30条热榜矩阵扩展到默认深浅色,三语言、大字体与窄屏组合连同原异步回归共57项通过;用户统计小字相对实际Material卡片背景的合成颜色对比度均不低于4.5:1。此次只补测试,未调整产品界面;自定义主题、真实截图和真机导航手势仍待验收。 + +`REDESIGN-C6-P2B2B58` 验证记录(2026-09-15):搜索热榜固定945总高、30行高导致30条榜单末尾不可达,280宽英文2倍字的统计区复现471px横向溢出。改为按可见条目最大数量与字号计算总高,行高随字号增长,统计区受宽度约束;过滤不再修改原始列表。三语言 × 两字号均可触达第30条,搜索回归共33项通过;真实热榜多页切换、深色及手机手势仍待验收。 + +`REDESIGN-C6-P2B2B57` 验证记录(2026-09-15):新增长建议列表的位置回归,确认旧词滚动后换新词会回到首条;同一结果集模拟键盘250高度展开 / 收起保持滚动位置且不重复请求。27项搜索回归全部通过,此项未发现产品代码缺陷,仅增加测试保护;最终真机导航栏手势复查仍保留。 + +`REDESIGN-C6-P2B2B56` 验证记录(2026-09-15):真机搜索建议末项可完整露出,原有底部安全距离无需改动;发现建议列表未接导航栏滚动监听。补充专用控制器并只监听当前可见列表,建议显隐时刷新父级监听,避免被覆盖列表干扰;26项测试通过,包含真实ScrollToHide组件的上滑隐藏 / 下滑显示及清空后切回原列表。真机切换其他应用后停止触屏操作,最终完整真机显隐仍待复查,不据此关闭导航栏整体验收。 + +`REDESIGN-C6-P2B2B55` 真机视觉回归(2026-09-15):实际搜索art发现热榜标签在普通字号下固定把按钮放到底部,造成过高留白。改为按可用宽度 / 字号决定按钮横排或分行,保留大字体防溢出;真机热重载前后截图确认普通字号紧凑横排,实际用户结果统计可见。搜索回归共25项通过;截图保存在本地build/loftify-search-review.png与build/loftify-search-review-after.png,仍不替代深色 / 多语言全页验收。 + +`REDESIGN-C6-P2B2B54` 验证记录(2026-09-15):合集 / 粮单搜索行的80高信息区与20高标签栏在2倍文字下分别复现20 / 21px底部溢出。信息区改为最小高度80、标签栏改为自适应高度的横向滚动,保留80×80 cover封面。两类卡片 × 三语言 × 两宽度 × 两字号共24组组件回归通过,检查卡片点击和末尾标签可滚动触达。全页面验收仍保留。 + +`REDESIGN-C6-P2B2B53` 验证记录(2026-09-15):用户搜索建议原先将ID与作品 / 粉丝数拼接成单行省略文本,长ID会隐藏统计。改为独立ID和可换行统计组,保留原卡片、头像、姓名及点击逻辑。简中 / 繁中 / 英文 × 280 / 720宽 × 1 / 2倍字体的12组真实SearchScreen受控数据测试通过,连同既有场景共24项通过;未据此关闭全部页面验收。 + +`REDESIGN-C6-P2B2B52` 验证记录(2026-09-15):热榜标签长榜名与已订阅状态在280×480、2倍文字下复现英文870px、简繁中文617px溢出。名称、可换行状态及参与人数分层,“进入”移至信息下方并支持自适应高度;保留入口、背景和状态配色。三语言布局 / 入口可达及异步回归共12项通过,用户建议等其他内容组合继续检查。 + +`REDESIGN-C6-P2B2B51` 验证记录(2026-09-15):搜索标签建议在280×480、2倍文字下复现英文319px、简繁中文74px横向溢出。参与人数移至标签名称下方,使用受约束的次级文字布局,保留完整标签与人数;三语言新增回归全部通过,连同异步场景共9项通过。该验证不替代全页面尺寸 / 主题及真实内容验收。 + +`REDESIGN-C6-P2B2B50` 验证记录(2026-09-15):推荐词与热榜请求增加生命周期 / 请求序号校验及异常处理,避免页面退出后创建热榜 Tab 控制器;搜索链接解析后增加 mounted 检查。搜索页回归增至6项,通过受控网络验证初始请求在销毁后完成、初始超时后程序化重试成功。真实网络错误界面与全部尺寸 / 主题仍待验收,不据此关闭整体任务。 + +`REDESIGN-C6-P2B2B49` 验证记录(2026-09-15):搜索建议增加请求序号校验,改词立即清除旧建议,清空后重输同词也不会接收旧结果;移动光标不重复请求,页面销毁后忽略返回,当前请求异常提供失败反馈。真实 SearchScreen 的4项模拟网络回归通过,覆盖乱序、清空重输、超时恢复和销毁;不代替真实接口及全页面验收。静态检查无错误或警告,原有9条提示另行处理。 + +`REDESIGN-C6-P2B2B48` 验证记录(2026-09-15):已加载个人页矩阵扩展为简中 / 繁中 / 英文 × 深浅色 × 280×480 / 720×480 × 1倍 / 2倍文字,共24组。普通字号窄屏大数字复现62~164px溢出,横排统计改为三等列并约束每列内容宽度后全部通过;连同5项加载 / 异常回归共29项通过。此矩阵验证布局、入口可达和框架异常,不代替全页面主题色对比及真实接口验收。 + +`REDESIGN-C6-P2B2B47` 验证记录(2026-09-15):给真实“我的”页注入已加载的长昵称 / ID 和九位统计数字,280×480、两倍英文下复现统计项内部各向右溢出 284px。公共统计组件将数字与单位合并为可换行、居中的富文本,修复溢出并保留数字 / 单位字号层级。6 项个人页测试通过;此用例验证已加载数据的渲染,不替代真实资料接口成功链路验证。 + +`REDESIGN-C6-P2B2B46` 验证记录(2026-09-15):真实“我的”页测试扩展到 280×480、两倍英文,在应用实际 Lottie 刷新配置下复现统计行横向溢出约 370px。窄屏且大字体时统计项纵向排列,常规模式保留原三项横排;滚动后下载管理文字完整可命中。5 项页面测试通过,包含既有请求失败与迟到响应回归。测试宿主同步使用实际 Lottie 刷新参数,避免默认 Classic 指示器造成与应用不符的结果。 + +`REDESIGN-C6-P2B2B45` 验证记录(2026-09-15):新增真实 MineScreen 测试,以受控网络响应覆盖资料等待 / 业务失败、连接超时后刷新返回失败并再次请求、页面销毁和账号变化后迟到响应忽略,4 项通过。测试同时复现 390px 英文资料提示向右溢出 195px,已约束头像旁文字列到剩余宽度,使其自然换行。此测试补齐 B44 的首个资料请求故障与生命周期验证,后续统计请求、关系列表和真机多语言矩阵继续验收。 + +`REDESIGN-C6-P2B2B44` 实现记录(2026-09-15):已登录的“我的”页固定入口不再依赖远端资料成功返回,资料加载失败时仍展示内容中心、创作和退出入口。资料与关系列表补齐异步异常、空资料、页面释放及账号变化检查;关系列表在资料就绪后启动,修复空缓存强制解引用,并释放刷新 / 滚动控制器。静态检查零问题,7 项既有布局守卫通过,Android Debug 构建及真机正常加载、热更新通过。网络故障注入与请求中切换账号专项仍待验收,不据此标记整个状态矩阵完成。 + +最新布局确认(2026-09-15):用户明确要求“我的”页不显示标题;通知和装扮恢复到普通 AppBar,与主题切换、设置一起展示。此要求覆盖 R11 / P2B2B41 的“顶部仅保留主题和设置”以及 P2B2B42 的“通知放内容中心”决定;内容中心保留下载管理等内容入口,移除通知和装扮的重复入口。 + +`REDESIGN-C6-P2B2B42` 完成记录(2026-09-15):主导航 AppBar 回退后的入口审计发现,手机端通知入口被移除,仅宽屏侧栏仍保留。已在“我的 → 内容中心”首项恢复通知入口,使用现有本地化文案、统一 Entry 组件和原通知路由。定向静态检查零问题,7 项既有导航 / 内容中心回归通过,Android Debug 构建并覆盖安装到真机;Hot Reload 2.85s、Hot Restart 2.35s 成功,真机点击已进入通知页并显示完整空态。本轮只验证通知入口链路,阶段 C 其余主题、语言和尺寸矩阵继续待验收。 + +`REDESIGN-C6-P2B2B100` 验证记录(2026-09-27):真机核对“我的”页顶栏后,确认装扮、通知、设置原本已使用 Lucide,但 18px 上限让线条显得偏细,设置图标还被单独着成灰色。现将这三个入口的图标视觉尺寸统一为 20px、保持 44px 圆形点击区和一致前景色;深浅色按钮保留原 Lottie 资源,改为稳定挂载并在切换时正反向播放,减少动效设置下直接定位最终帧。31 项“我的”页回归及目标静态检查通过;Android 16 真机热重启后已核对浅色和深色页面与按钮触摸,调试会话继续运行。 + +`REDESIGN-C6-P2B2B101` 验证记录(2026-09-27):我的粮单页面原先网络超时、用户缓存缺失或解析异常时可能卡在加载中;首屏加载 / 错误会替换整个刷新容器,重试不稳定。现保持同一个 EasyRefresh 容器展示加载、错误、空内容和列表;失败刷新保留旧内容,离页与账号切换不写入旧响应。分页以服务端原始条数推进游标并按粮单 ID 去重,重复页不再提前停止;下拉刷新可抢占未完成分页。列表改为按需构建,80×80 封面保持 cover,标题、更新信息和横向标签在窄屏大字体下自适应高度。21 项回归覆盖超时重试、刷新 / 分页竞态、账号切换、300 项懒构建、280×480 / 720×320、简繁英、深浅色与 2 倍字体;目标静态检查零问题,Android Debug APK 构建成功。Android 16 真机热重载后已打开真实粮单并核对封面、文字、标签和拖动,无框架异常;热重启前 ADB 设备断开,待重新连接补验。 + +`REDESIGN-C6-P2B2B102` 验证记录(2026-09-27):我的作品列表原先首屏请求超时会绕过加载锁释放、错误态替换刷新容器;接口有作品但缺少月份归档时误显示空态,相同月份数量还会因 `indexOf` 归到错误月份。现固定 EasyRefresh 状态机,超时、业务失败和解析失败可重试且失败刷新保留旧作品;分页以原始响应条数推进游标,置顶帖单独展示并去除与常规列表的重复,刷新抢占旧分页,账号变化丢弃旧响应。月份索引按真实年月排序,缺失或不足的归档下仍展示剩余作品;多月方格列表改为 Sliver 按需构建,保留真实列宽和封面裁切。问题在修改前由页面测试复现,修复后作品页、推荐页、我的页面和公共归档网格共 75 项回归通过,覆盖 280×480 / 720×320、简繁英、深浅色、2 倍字体、置顶与 300 项长列表;目标静态检查零问题,Android Debug APK 构建成功。ADB 真机此轮未连接,当前构建的真机页面、Hot Reload / Hot Restart 仍待补验。 + +`REDESIGN-C6-P2B2B103` 验证记录(2026-09-27):全量回归发现 2 项守卫仍依赖已退役的组件类型:主导航测试要求旧的动态主题按钮,关系用户卡片测试要求旧的通用 Button,导致真实 Lottie 主题按钮和紧凑关注按钮实现均正常时全量测试仍失败。已将断言改为当前实际组件,同时继续检查主题 Lottie 资源、44px 以上触控区与大字体卡片高度;修改前全量 778 通过 / 2 失败,修改后全量 780 项通过,目标静态检查零问题。本项只修复测试基线,不涉及产品代码;真机页面验收继续按各页面记录单独完成。 + +`REDESIGN-C6-P2B2B104` 真机补验(2026-09-27):Android 16 真机 `986008e8` 重新连接后,以当前 `dev/redesign` Debug 构建启动并通过调试服务完成 Hot Reload 与 Hot Restart。重启后重新进入真实“我的粮单”及“我的作品”,核对粮单封面 cover、标题、篇数、标签和作品页月份标题、图文方格、空余内容区;从粮单以系统返回回到“我的”并再次打开作品页正常。1200×2670 真机前台运行日志未发现布局溢出或未处理异常。截图保存在本地 `build/screenshots/loftify-grain-qa-20260927.png` 和 `build/screenshots/loftify-post-qa-20260927.png`。此项补齐 `P2B2B101` / `P2B2B102` 记录的真机与热重启缺口,不代表两页的所有自定义主题或其他设备验收已完成。 + +`REDESIGN-C6-P2B2B105` 验证记录(2026-09-27):确认“我的”页装扮、通知、设置已使用 Lucide 图标;将三者从 20px 调整为 22px 并统一为高对比前景色,保留原有 44px 圆形点击区。深浅色切换仍使用原太阳 / 月亮 Lottie,现在点击时先启动正反向动效,再更新全局主题,外部切换主题仍同步动效状态。31 项页面回归和目标静态检查通过;Android 16 真机 Hot Reload 后实测浅色与深色图标、主题按钮及页面布局,无框架异常,最后恢复原浅色设置。未借此宣称其他主题或设备矩阵已完成。 + +`REDESIGN-C6-P2B2B106` 验证记录(2026-09-27):粉丝/关注列表改为固定 EasyRefresh 容器承载首屏加载、错误重试、空态和用户行;缓存 / 网络异常后释放加载锁,刷新失败保留旧列表,刷新可抢占未完成分页,旧账号响应不再写入新页面。分页游标按服务端原始响应条数推进,同页及跨页按用户 ID 去重,避免重复用户导致页码回退;关注状态回调不再持有可能失效的列表下标。真机巡检发现旧用户卡在手机上将关注按钮单独挤到下一行且整列呈灰色,现改为白底紧凑行、按钮靠右、长简介在中间自适应,极窄屏 / 大字体才降级纵向排列。6 项页面定向测试与用户行组件测试通过,覆盖两种接口、超时重试、重复分页、竞态、手机行布局和 280×480 繁中深色 2 倍字体;目标静态检查无 error / warning(保留既有 6 项类型推断 info)。Android 16 真机 Hot Reload 后进入真实关注列表,核对头像、长简介、按钮、滑动及返回,未见布局异常;最终截图保存在本地 `build/screenshots/loftify-following-white-20260927.png`。其他设备与自定义主题尚未据此验收。 + +`REDESIGN-C6-P2B2B107` 验证记录(2026-09-27):根据真机反馈继续收紧粉丝/关注行:移除列表左右额外留白和行间分割线,常规手机上关注按钮与资料并排,仅极窄宽度或放大字体时换行;按下改为轻微灰色状态反馈,无水波纹,列表背景保持纯白。个人主页减少封面内的顶部留白,让资料、关注按钮和代表作完整显示且列表更早进入视窗;仅在个人主页将封面遮罩强度降至原来的 60%,取消额外黑色模糊层,并给白字增加轻量阴影保持可读性。相关 18 项组件/页面测试通过,静态检查无新增 error / warning(仍有既有 6 项类型推断 info),Android Debug APK 构建并覆盖安装到真机,确认应用在前台;安装前 Hot Reload 后核对关注列表与个人主页截图,未见布局异常。个人主页手势下拉刷新的触发距离仍需单独做端到端验收;本轮只是缩短首屏头部,不能把刷新体验标记为已完全解决。其他模拟器按用户要求暂不使用。 + +`PROFILE-MORE-01` 验证记录(2026-09-27):个人主页更多菜单复用原有业务操作与回调,手机端改为横向图标网格,链接类操作与红色隐私 / 屏蔽操作分组;本人页面仅显示适用入口,桌面端原上下文菜单不变。移除冗余标题,采用 28dp 顶部圆角及细短 handle,面板按实际内容收缩、超过屏幕高度上限才内部滚动;网格行高由文字自然决定,长文字不再裁切,也没有取消按钮上方的预留空白。16 项菜单、页头和本地化相关测试通过,覆盖 280×480、2 倍字体与深色主题;目标静态检查零问题,Android Debug APK 构建并覆盖安装到真机。在非本人“月柱”主页实测两行屏蔽文案完整显示、面板底部无异常空白,复制主页链接和取消均可正常关闭菜单。危险操作为避免修改用户关系未在真机逐项执行,深色真机外观仍纳入全局视觉回归。同步发现个人主页内容区从顶部下拉约 600px 未发起文章刷新请求,原 `PROFILE-04` 完成结论已重新打开,不能以缩短头部代替刷新修复。 + +`PROFILE-SUPPORTER-01` 验证记录(2026-09-27):支持者 item 统一为无分割线的白底全宽行,48dp 头像、昵称、ID、最多两行简介与轻量贡献数胶囊并排;窄屏或大字体时贡献数移至下一行,按下仅显示淡色反馈且无水波纹,整行继续打开作者主页。列表改为按需构建,刷新控制器随页面释放;请求异常不再永久锁住加载,解析成功后才替换旧列表。3 项行组件测试覆盖常规手机、280×480 深色 2 倍字体与点击,定向静态检查零问题,Android Debug APK 构建并安装到真机。在“月柱 → 支持者列表”确认真实用户行、头像、贡献数和留白,点击支持者后成功进入其个人主页;深色真机外观纳入全局视觉回归。 + +`PROFILE-04` 触发阈值修复进展(2026-09-27):真机内容区下拉无刷新头的问题由嵌套列表错误叠加状态栏安全区导致:52dp 触发距离加约 24dp 安全区,恰等于 76dp 最大拖动距离,释放时无法进入 ready 状态。嵌套列表实际位于吸顶 Tab 下方,因此关闭它的安全区叠加;保留原动画大小、最大拖动和触发距离。新增真实 `ExtendedNestedScrollView` 手势回归及嵌套帖子列表请求回归:下拉会发起第二次请求,响应后滚动位置回到零;连同加载动画与帖子状态共 32 项通过,定向静态检查零问题。真机“光令”及本人主页已能显示或完成刷新过程,但非本人页面曾出现动画停留,尚未确认其请求完成路径;此项保持未勾选,后续须继续验证各 Tab、网络失败和折叠状态。 + +- [ ] `QA-01` 对本轮涉及文件执行格式化和静态检查,不新增编译错误或警告。 +- [ ] `QA-02` 为修复项补充最小回归测试,并运行相关测试集。 +- [ ] `QA-03` 完成 Android Debug APK 构建。 +- [ ] `QA-04` 在已连接的 Android 模拟器和真机启动验证;设备缺席时必须记录未覆盖项。 +- [ ] `QA-05` 在调试会话中分别执行 Hot Reload 和 Hot Restart。 +- [ ] `QA-06` 按需求覆盖浅色 / 深色、中文 / 英文、竖屏 / 横屏和关键边界状态。 +- [ ] `QA-07` UI 专项保留验收截图;性能专项记录设备、刷新率、操作路径和复现结果。 +- [ ] `QA-08` 对复杂布局和性能问题使用 Flutter Inspector / Performance Overlay / DevTools 定位并记录结论。 + +每轮交付记录至少包含:完成的任务编号、修改范围、测试结果、模拟器结果、真机结果、遗留风险和下一步任务。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 05e4da09..02b58ab1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,6 +1,3 @@ -buildscript { - ext.kotlin_version = '1.8.0' -} plugins { id "com.android.application" id "kotlin-android" @@ -32,17 +29,23 @@ if (flutterVersionName == null) { } android { + namespace "com.cloudchewie.loftify" compileSdkVersion flutter.compileSdkVersion ndkVersion flutter.ndkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' } defaultConfig { applicationId "com.cloudchewie.loftify" - minSdkVersion 23 + minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -76,6 +79,7 @@ flutter { } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' implementation "androidx.core:core-splashscreen:1.0.1" implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.appcompat:appcompat:1.6.1' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a8aa8a5f..573b7c0e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,10 @@ + + + + + + diff --git a/android/app/src/main/java/com/cloudchewie/loftify/MainActivity.kt b/android/app/src/main/java/com/cloudchewie/loftify/MainActivity.kt index dd21c16f..cd4172dd 100644 --- a/android/app/src/main/java/com/cloudchewie/loftify/MainActivity.kt +++ b/android/app/src/main/java/com/cloudchewie/loftify/MainActivity.kt @@ -1,23 +1,120 @@ package com.cloudchewie.loftify; +import android.os.Build +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager import io.flutter.embedding.android.FlutterFragmentActivity import androidx.annotation.NonNull import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import io.flutter.plugins.GeneratedPluginRegistrant.* + class MainActivity : FlutterFragmentActivity() { - private val CHANNEL = "android/back/desktop" + private val backDesktopChannel = "android/back/desktop" + private val displayModeChannel = "loftify/display_mode" + private var preferredRefreshRate = 0f + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { registerWith(flutterEngine); MethodChannel( flutterEngine.dartExecutor.binaryMessenger, - CHANNEL + backDesktopChannel ).setMethodCallHandler { methodCall, result -> if (methodCall.method == "backDesktop") { result.success(true) moveTaskToBack(false) + } else { + result.notImplemented() } } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + displayModeChannel + ).setMethodCallHandler { methodCall, result -> + if (methodCall.method != "setPreferredRefreshRate") { + result.notImplemented() + return@setMethodCallHandler + } + val refreshRate = + (methodCall.argument("refreshRate"))?.toFloat() + if (refreshRate == null || refreshRate < 0f) { + result.error( + "invalid_refresh_rate", + "refreshRate must be zero or a positive number", + null + ) + return@setMethodCallHandler + } + preferredRefreshRate = refreshRate + applyPreferredRefreshRate() + result.success(null) + } + } + + override fun onPostResume() { + super.onPostResume() + applyPreferredRefreshRate() + } + + private fun applyPreferredRefreshRate() { + val attributes = window.attributes + attributes.preferredRefreshRate = preferredRefreshRate + window.attributes = attributes + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + window.decorView.post { + val surfaceView = findFlutterSurfaceView(window.decorView) ?: return@post + applySurfaceFrameRate(surfaceView) + } + } + + private fun applySurfaceFrameRate(surfaceView: SurfaceView) { + val surface = surfaceView.holder.surface + if (surface.isValid) { + surface.setFrameRate( + preferredRefreshRate, + Surface.FRAME_RATE_COMPATIBILITY_DEFAULT + ) + return + } + surfaceView.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + holder.surface.setFrameRate( + preferredRefreshRate, + Surface.FRAME_RATE_COMPATIBILITY_DEFAULT + ) + holder.removeCallback(this) + } + + override fun surfaceChanged( + holder: SurfaceHolder, + format: Int, + width: Int, + height: Int + ) = Unit + + override fun surfaceDestroyed(holder: SurfaceHolder) = Unit + }) + } + + private fun findFlutterSurfaceView(view: View): SurfaceView? { + if ( + view is SurfaceView && + view.javaClass.name == "io.flutter.embedding.android.FlutterSurfaceView" + ) { + return view + } + if (view !is ViewGroup) return null + for (index in 0 until view.childCount) { + val match = findFlutterSurfaceView(view.getChildAt(index)) + if (match != null) return match + } + return null } -} \ No newline at end of file +} diff --git a/android/build.gradle b/android/build.gradle index 9d8d542c..31a76b20 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,24 +1,53 @@ allprojects { repositories { -// maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/google' } // maven { url 'https://maven.aliyun.com/repository/jcenter' } + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://maven.aliyun.com/repository/central' } google() mavenCentral() // maven { url 'https://jitpack.io' } // maven { url "https://maven.google.com" } -// maven { url 'https://maven.aliyun.com/repository/public' } -// maven { url 'https://maven.aliyun.com/repository/central' } // maven { url 'https://maven.aliyun.com/nexus/content/groups/public' } // maven { url 'https://maven.aliyun.com/repository/flutter-plugin' } // maven { url "https://download.flutter.io" } } } +allprojects { + configurations.configureEach { + resolutionStrategy.force 'androidx.core:core:1.15.0' + resolutionStrategy.force 'androidx.core:core-ktx:1.15.0' + resolutionStrategy.force 'androidx.browser:browser:1.8.0' + } +} + rootProject.buildDir = '../build' subprojects { - afterEvaluate { - android { - compileSdkVersion 34 + afterEvaluate { project -> + project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions.jvmTarget = '17' + } + + if (project.extensions.findByName('android') != null) { + project.android { + compileSdkVersion 36 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + if (namespace == null) { + def manifestFile = project.file('src/main/AndroidManifest.xml') + if (manifestFile.exists()) { + def manifestPackage = new XmlSlurper().parse(manifestFile).@package.text() + if (manifestPackage) { + namespace manifestPackage + } + } + } + } } } } diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a3..ca27bbbe 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1024M android.useAndroidX=true android.enableJetifier=true +kotlin.incremental=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c472b99..afa1e8eb 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 535f11e9..fc46ef3d 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -10,6 +10,9 @@ pluginManagement { includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { + maven { url "https://maven.aliyun.com/repository/google" } + maven { url "https://maven.aliyun.com/repository/central" } + maven { url "https://maven.aliyun.com/repository/gradle-plugin" } google() mavenCentral() gradlePluginPortal() @@ -18,8 +21,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.2.2" apply false - id "org.jetbrains.kotlin.android" version "1.8.0" apply false + id "com.android.application" version "8.7.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.20" apply false } -include ":app" \ No newline at end of file +include ":app" diff --git a/assets/icon/code.txt b/assets/icon/code.txt deleted file mode 100644 index 12ceab78..00000000 --- a/assets/icon/code.txt +++ /dev/null @@ -1,49 +0,0 @@ -static const String collectionDarkIcon = "assets/icon/collection_dark.png"; -static const String collectionLightIcon = "assets/icon/collection_light.png"; -static const String collectionPrimaryIcon = "assets/icon/collection_primary.png"; -static const String collectionWhiteIcon = "assets/icon/collection_white.png"; -static const String confirmIcon = "assets/icon/confirm.png"; -static const String downloadWhiteIcon = "assets/icon/download_white.png"; -static const String dressDarkIcon = "assets/icon/dress_dark.png"; -static const String dressLightIcon = "assets/icon/dress_light.png"; -static const String dynamicDarkIcon = "assets/icon/dynamic_dark.png"; -static const String dynamicDarkSelectedIcon = "assets/icon/dynamic_dark_selected.png"; -static const String dynamicLightIcon = "assets/icon/dynamic_light.png"; -static const String dynamicLightSelectedIcon = "assets/icon/dynamic_light_selected.png"; -static const String favoriteDarkIcon = "assets/icon/favorite_dark.png"; -static const String favoriteLightIcon = "assets/icon/favorite_light.png"; -static const String grainWhiteIcon = "assets/icon/grain_white.png"; -static const String homeDarkIcon = "assets/icon/home_dark.png"; -static const String homeDarkSelectedIcon = "assets/icon/home_dark_selected.png"; -static const String homeLightIcon = "assets/icon/home_light.png"; -static const String homeLightSelectedIcon = "assets/icon/home_light_selected.png"; -static const String hotIcon = "assets/icon/hot.png"; -static const String hotlessIcon = "assets/icon/hotless.png"; -static const String hottestIcon = "assets/icon/hottest.png"; -static const String hotWhiteIcon = "assets/icon/hot_white.png"; -static const String infoIcon = "assets/icon/info.png"; -static const String likeDarkIcon = "assets/icon/like_dark.png"; -static const String likeFilledIcon = "assets/icon/like_filled.png"; -static const String likeLightIcon = "assets/icon/like_light.png"; -static const String linkDarkIcon = "assets/icon/link_dark.png"; -static const String linkGreyIcon = "assets/icon/link_grey.png"; -static const String linkLightIcon = "assets/icon/link_light.png"; -static const String linkPrimaryIcon = "assets/icon/link_primary.png"; -static const String linkWhiteIcon = "assets/icon/link_white.png"; -static const String mineDarkIcon = "assets/icon/mine_dark.png"; -static const String mineDarkSelectedIcon = "assets/icon/mine_dark_selected.png"; -static const String mineLightIcon = "assets/icon/mine_light.png"; -static const String mineLightSelectedIcon = "assets/icon/mine_light_selected.png"; -static const String orderDownDarkIcon = "assets/icon/order_down_dark.png"; -static const String orderDownLightIcon = "assets/icon/order_down_light.png"; -static const String orderUpDarkIcon = "assets/icon/order_up_dark.png"; -static const String orderUpLightIcon = "assets/icon/order_up_light.png"; -static const String searchDarkIcon = "assets/icon/search_dark.png"; -static const String searchGreyIcon = "assets/icon/search_grey.png"; -static const String searchLightIcon = "assets/icon/search_light.png"; -static const String settingDarkIcon = "assets/icon/setting_dark.png"; -static const String settingLightIcon = "assets/icon/setting_light.png"; -static const String tagDarkIcon = "assets/icon/tag_dark.png"; -static const String tagGreyIcon = "assets/icon/tag_grey.png"; -static const String tagLightIcon = "assets/icon/tag_light.png"; -static const String tagWhiteIcon = "assets/icon/tag_white.png"; diff --git a/assets/icon/collection_dark.png b/assets/icon/collection_dark.png deleted file mode 100644 index 1746f472..00000000 Binary files a/assets/icon/collection_dark.png and /dev/null differ diff --git a/assets/icon/collection_light.png b/assets/icon/collection_light.png deleted file mode 100644 index 459d84bc..00000000 Binary files a/assets/icon/collection_light.png and /dev/null differ diff --git a/assets/icon/collection_primary.png b/assets/icon/collection_primary.png deleted file mode 100644 index d82f06f1..00000000 Binary files a/assets/icon/collection_primary.png and /dev/null differ diff --git a/assets/icon/collection_white.png b/assets/icon/collection_white.png deleted file mode 100644 index 2e457c70..00000000 Binary files a/assets/icon/collection_white.png and /dev/null differ diff --git a/assets/icon/confirm.png b/assets/icon/confirm.png deleted file mode 100644 index 5d645947..00000000 Binary files a/assets/icon/confirm.png and /dev/null differ diff --git a/assets/icon/download_white.png b/assets/icon/download_white.png deleted file mode 100644 index 79dd61b7..00000000 Binary files a/assets/icon/download_white.png and /dev/null differ diff --git a/assets/icon/dress_dark.png b/assets/icon/dress_dark.png deleted file mode 100644 index 101e6a10..00000000 Binary files a/assets/icon/dress_dark.png and /dev/null differ diff --git a/assets/icon/dress_light.png b/assets/icon/dress_light.png deleted file mode 100644 index 0b66d9b8..00000000 Binary files a/assets/icon/dress_light.png and /dev/null differ diff --git a/assets/icon/dynamic_dark.png b/assets/icon/dynamic_dark.png deleted file mode 100644 index 0c1ec1f7..00000000 Binary files a/assets/icon/dynamic_dark.png and /dev/null differ diff --git a/assets/icon/dynamic_dark_selected.png b/assets/icon/dynamic_dark_selected.png deleted file mode 100644 index 4b85ded6..00000000 Binary files a/assets/icon/dynamic_dark_selected.png and /dev/null differ diff --git a/assets/icon/dynamic_light.png b/assets/icon/dynamic_light.png deleted file mode 100644 index 890a8411..00000000 Binary files a/assets/icon/dynamic_light.png and /dev/null differ diff --git a/assets/icon/dynamic_light_selected.png b/assets/icon/dynamic_light_selected.png deleted file mode 100644 index 8b897689..00000000 Binary files a/assets/icon/dynamic_light_selected.png and /dev/null differ diff --git a/assets/icon/favorite_dark.png b/assets/icon/favorite_dark.png deleted file mode 100644 index f1c404c9..00000000 Binary files a/assets/icon/favorite_dark.png and /dev/null differ diff --git a/assets/icon/favorite_light.png b/assets/icon/favorite_light.png deleted file mode 100644 index c0270d92..00000000 Binary files a/assets/icon/favorite_light.png and /dev/null differ diff --git a/assets/icon/grain_white.png b/assets/icon/grain_white.png deleted file mode 100644 index b0f6ca81..00000000 Binary files a/assets/icon/grain_white.png and /dev/null differ diff --git a/assets/icon/home_dark.png b/assets/icon/home_dark.png deleted file mode 100644 index 6a68689f..00000000 Binary files a/assets/icon/home_dark.png and /dev/null differ diff --git a/assets/icon/home_dark_selected.png b/assets/icon/home_dark_selected.png deleted file mode 100644 index 92bee3a5..00000000 Binary files a/assets/icon/home_dark_selected.png and /dev/null differ diff --git a/assets/icon/home_light.png b/assets/icon/home_light.png deleted file mode 100644 index 43d32838..00000000 Binary files a/assets/icon/home_light.png and /dev/null differ diff --git a/assets/icon/home_light_selected.png b/assets/icon/home_light_selected.png deleted file mode 100644 index cca4b665..00000000 Binary files a/assets/icon/home_light_selected.png and /dev/null differ diff --git a/assets/icon/hot.png b/assets/icon/hot.png deleted file mode 100644 index fccd1646..00000000 Binary files a/assets/icon/hot.png and /dev/null differ diff --git a/assets/icon/hot_white.png b/assets/icon/hot_white.png deleted file mode 100644 index 76d337cf..00000000 Binary files a/assets/icon/hot_white.png and /dev/null differ diff --git a/assets/icon/hotless.png b/assets/icon/hotless.png deleted file mode 100644 index 2502b9b3..00000000 Binary files a/assets/icon/hotless.png and /dev/null differ diff --git a/assets/icon/hottest.png b/assets/icon/hottest.png deleted file mode 100644 index 0f4fbde7..00000000 Binary files a/assets/icon/hottest.png and /dev/null differ diff --git a/assets/icon/info.png b/assets/icon/info.png deleted file mode 100644 index 6b7f16ed..00000000 Binary files a/assets/icon/info.png and /dev/null differ diff --git a/assets/icon/like_dark.png b/assets/icon/like_dark.png deleted file mode 100644 index 8dd42329..00000000 Binary files a/assets/icon/like_dark.png and /dev/null differ diff --git a/assets/icon/like_filled.png b/assets/icon/like_filled.png deleted file mode 100644 index ab4c36b4..00000000 Binary files a/assets/icon/like_filled.png and /dev/null differ diff --git a/assets/icon/like_light.png b/assets/icon/like_light.png deleted file mode 100644 index f8e506b1..00000000 Binary files a/assets/icon/like_light.png and /dev/null differ diff --git a/assets/icon/link_dark.png b/assets/icon/link_dark.png deleted file mode 100644 index 242e3e19..00000000 Binary files a/assets/icon/link_dark.png and /dev/null differ diff --git a/assets/icon/link_grey.png b/assets/icon/link_grey.png deleted file mode 100644 index 68e2cfae..00000000 Binary files a/assets/icon/link_grey.png and /dev/null differ diff --git a/assets/icon/link_light.png b/assets/icon/link_light.png deleted file mode 100644 index 9cbb4be2..00000000 Binary files a/assets/icon/link_light.png and /dev/null differ diff --git a/assets/icon/link_primary.png b/assets/icon/link_primary.png deleted file mode 100644 index df6ead66..00000000 Binary files a/assets/icon/link_primary.png and /dev/null differ diff --git a/assets/icon/link_white.png b/assets/icon/link_white.png deleted file mode 100644 index e078a25d..00000000 Binary files a/assets/icon/link_white.png and /dev/null differ diff --git a/assets/icon/mine_dark.png b/assets/icon/mine_dark.png deleted file mode 100644 index de33dd5b..00000000 Binary files a/assets/icon/mine_dark.png and /dev/null differ diff --git a/assets/icon/mine_dark_selected.png b/assets/icon/mine_dark_selected.png deleted file mode 100644 index 73879d4e..00000000 Binary files a/assets/icon/mine_dark_selected.png and /dev/null differ diff --git a/assets/icon/mine_light.png b/assets/icon/mine_light.png deleted file mode 100644 index 2de1cc0c..00000000 Binary files a/assets/icon/mine_light.png and /dev/null differ diff --git a/assets/icon/mine_light_selected.png b/assets/icon/mine_light_selected.png deleted file mode 100644 index ebd819a1..00000000 Binary files a/assets/icon/mine_light_selected.png and /dev/null differ diff --git a/assets/icon/order_down_dark.png b/assets/icon/order_down_dark.png deleted file mode 100644 index cb8b1b38..00000000 Binary files a/assets/icon/order_down_dark.png and /dev/null differ diff --git a/assets/icon/order_down_light.png b/assets/icon/order_down_light.png deleted file mode 100644 index 0bb9a797..00000000 Binary files a/assets/icon/order_down_light.png and /dev/null differ diff --git a/assets/icon/order_up_dark.png b/assets/icon/order_up_dark.png deleted file mode 100644 index 49400a40..00000000 Binary files a/assets/icon/order_up_dark.png and /dev/null differ diff --git a/assets/icon/order_up_light.png b/assets/icon/order_up_light.png deleted file mode 100644 index 6d41d62c..00000000 Binary files a/assets/icon/order_up_light.png and /dev/null differ diff --git a/assets/icon/pin_dark.png b/assets/icon/pin_dark.png deleted file mode 100644 index 1ccf9a21..00000000 Binary files a/assets/icon/pin_dark.png and /dev/null differ diff --git a/assets/icon/pin_light.png b/assets/icon/pin_light.png deleted file mode 100644 index b9ece311..00000000 Binary files a/assets/icon/pin_light.png and /dev/null differ diff --git a/assets/icon/run.py b/assets/icon/run.py deleted file mode 100644 index 38b3ed02..00000000 --- a/assets/icon/run.py +++ /dev/null @@ -1,41 +0,0 @@ -# 遍历文件夹下的json文件,对于每个json文件生成对应的代码 -# 如对于gift_dark.json,对应代码行为static const String giftDark = "assets/lottie/gift_dark.json" -# 生成所有json文件对应的代码,并写入到文件中 -import os - - -def to_camel_case(snake_str): - """将蛇形命名(下划线分隔)转换为驼峰命名(首字母小写)""" - components = snake_str.split("_") - return components[0].lower() + "".join(x.title() for x in components[1:]) - - -def generate_code_for_json_files(folder_path, output_file): - # 初始化一个列表来存储所有的代码行 - code_lines = [] - - # 遍历文件夹下的所有文件 - for root, _, files in os.walk(folder_path): - for file in files: - if file.endswith(".png"): - # 提取文件名(不包括扩展名) - base_name = os.path.splitext(file)[0] - # 将文件名转换为驼峰命名 - variable_name = to_camel_case(base_name) + "Icon" - # 生成代码行 - code_line = ( - f'static const String {variable_name} = "assets/icon/{file}";' - ) - # 将代码行添加到列表中 - code_lines.append(code_line) - - # 将所有代码行写入到输出文件中 - with open(output_file, "w") as f: - for line in code_lines: - f.write(line + "\n") - - -# 使用示例 -folder_path = "./" # 替换为你的文件夹路径 -output_file = "./code.txt" # 替换为你的输出文件路径 -generate_code_for_json_files(folder_path, output_file) diff --git a/assets/icon/search_dark.png b/assets/icon/search_dark.png deleted file mode 100644 index 1b1b210d..00000000 Binary files a/assets/icon/search_dark.png and /dev/null differ diff --git a/assets/icon/search_grey.png b/assets/icon/search_grey.png deleted file mode 100644 index e42f5f8f..00000000 Binary files a/assets/icon/search_grey.png and /dev/null differ diff --git a/assets/icon/search_light.png b/assets/icon/search_light.png deleted file mode 100644 index b2a33e05..00000000 Binary files a/assets/icon/search_light.png and /dev/null differ diff --git a/assets/icon/setting_dark.png b/assets/icon/setting_dark.png deleted file mode 100644 index 36ded9f4..00000000 Binary files a/assets/icon/setting_dark.png and /dev/null differ diff --git a/assets/icon/setting_light.png b/assets/icon/setting_light.png deleted file mode 100644 index 7610c10b..00000000 Binary files a/assets/icon/setting_light.png and /dev/null differ diff --git a/assets/icon/tag_dark.png b/assets/icon/tag_dark.png deleted file mode 100644 index c904d9bc..00000000 Binary files a/assets/icon/tag_dark.png and /dev/null differ diff --git a/assets/icon/tag_grey.png b/assets/icon/tag_grey.png deleted file mode 100644 index 0d3b5970..00000000 Binary files a/assets/icon/tag_grey.png and /dev/null differ diff --git a/assets/icon/tag_light.png b/assets/icon/tag_light.png deleted file mode 100644 index 28a553f5..00000000 Binary files a/assets/icon/tag_light.png and /dev/null differ diff --git a/assets/icon/tag_white.png b/assets/icon/tag_white.png deleted file mode 100644 index 385f278e..00000000 Binary files a/assets/icon/tag_white.png and /dev/null differ diff --git a/assets/lottie/code.txt b/assets/lottie/code.txt deleted file mode 100644 index 7b117949..00000000 --- a/assets/lottie/code.txt +++ /dev/null @@ -1,40 +0,0 @@ -static const String brightness = "assets/lottie/brightness.json"; -static const String celebrate = "assets/lottie/celebrate.json"; -static const String collectionBigNormalDark = "assets/lottie/collection_big_normal_dark.json"; -static const String collectionBigNormalLight = "assets/lottie/collection_big_normal_light.json"; -static const String collectionMediumNormalDark = "assets/lottie/collection_medium_normal_dark.json"; -static const String collectionMediumNormalLight = "assets/lottie/collection_medium_normal_light.json"; -static const String followDark = "assets/lottie/follow_dark.json"; -static const String followLight = "assets/lottie/follow_light.json"; -static const String followVideo = "assets/lottie/follow_video.json"; -static const String giftDark = "assets/lottie/gift_dark.json"; -static const String letter = "assets/lottie/letter.json"; -static const String likeBigNormalDark = "assets/lottie/like_big_normal_dark.json"; -static const String likeBigNormalLight = "assets/lottie/like_big_normal_light.json"; -static const String likeDoubleClickDark = "assets/lottie/like_double_click_dark.json"; -static const String likeDoubleClickLight = "assets/lottie/like_double_click_light.json"; -static const String likeDoubleTap = "assets/lottie/like_double_tap.json"; -static const String likeMediumDark = "assets/lottie/like_medium_dark.json"; -static const String likeMediumLight = "assets/lottie/like_medium_light.json"; -static const String likeVibrateLight = "assets/lottie/like_vibrate_light.json"; -static const String likeVideoNormal = "assets/lottie/like_video_normal.json"; -static const String likeVideoVibrate = "assets/lottie/like_video_vibrate.json"; -static const String loading01 = "assets/lottie/loading_01.json"; -static const String loading02 = "assets/lottie/loading_02.json"; -static const String loadingDark = "assets/lottie/loading_dark.json"; -static const String loadingDarkTransparent = "assets/lottie/loading_dark_transparent.json"; -static const String loadingGradient = "assets/lottie/loading_gradient.json"; -static const String loadingLight = "assets/lottie/loading_light.json"; -static const String moonLight = "assets/lottie/moon_light.json"; -static const String recommendBigNormalDark = "assets/lottie/recommend_big_normal_dark.json"; -static const String recommendBigNormalLight = "assets/lottie/recommend_big_normal_light.json"; -static const String recommendBigVibrateDark = "assets/lottie/recommend_big_vibrate_dark.json"; -static const String recommendBigVibrateLight = "assets/lottie/recommend_big_vibrate_light.json"; -static const String recommendMediumFocusDark = "assets/lottie/recommend_medium_focus_dark.json"; -static const String recommendMediumFocusLight = "assets/lottie/recommend_medium_focus_light.json"; -static const String recommendVideoNormal = "assets/lottie/recommend_video_normal.json"; -static const String shareVideoVibrate = "assets/lottie/share_video_vibrate.json"; -static const String shine = "assets/lottie/shine.json"; -static const String sunLight = "assets/lottie/sun_light.json"; -static const String videoPlayingDark = "assets/lottie/video_playing_dark.json"; -static const String videoPlayingLight = "assets/lottie/video_playing_light.json"; diff --git a/assets/lottie/nav_heart.json b/assets/lottie/nav_heart.json new file mode 100644 index 00000000..22d852e7 --- /dev/null +++ b/assets/lottie/nav_heart.json @@ -0,0 +1,36 @@ +{ + "v": "5.7.4", "fr": 30, "ip": 0, "op": 24, "w": 48, "h": 48, + "nm": "loftify_nav_follow_heart", "ddd": 0, "assets": [], + "layers": [ + { + "ddd": 0, "ind": 1, "ty": 4, "nm": "Filled Heart", "sr": 1, + "ks": { + "o": {"a": 1, "k": [{"t": 0, "s": [0]}, {"t": 5, "s": [0]}, {"t": 12, "s": [100]}, {"t": 24, "s": [100]}]}, + "r": {"a": 1, "k": [{"t": 0, "s": [-8]}, {"t": 14, "s": [3]}, {"t": 24, "s": [0]}]}, + "p": {"a": 0, "k": [24, 24, 0]}, "a": {"a": 0, "k": [24, 24, 0]}, + "s": {"a": 1, "k": [{"t": 0, "s": [65, 65, 100]}, {"t": 12, "s": [109, 109, 100]}, {"t": 18, "s": [96, 96, 100]}, {"t": 24, "s": [100, 100, 100]}]} + }, + "ao": 0, + "shapes": [ + {"ty": "sh", "ks": {"a": 0, "k": {"i": [[0, -1], [0, 8], [-5, 0], [-1, -3], [-4, 0], [0, -6]], "o": [[0, -1], [0, -8], [4, 0], [2, -3], [7, 0], [0, 8]], "v": [[24, 40], [7, 19], [16, 9], [24, 14], [32, 9], [41, 19]], "c": true}}, "nm": "Heart silhouette"}, + {"ty": "fl", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "r": 1, "nm": "Heart ink"} + ], + "ip": 0, "op": 25, "st": 0, "bm": 0 + }, + { + "ddd": 0, "ind": 2, "ty": 4, "nm": "Outline Heart", "sr": 1, + "ks": { + "o": {"a": 1, "k": [{"t": 0, "s": [100]}, {"t": 7, "s": [100]}, {"t": 15, "s": [0]}, {"t": 24, "s": [0]}]}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [24, 24, 0]}, "a": {"a": 0, "k": [24, 24, 0]}, + "s": {"a": 1, "k": [{"t": 0, "s": [100, 100, 100]}, {"t": 9, "s": [94, 94, 100]}, {"t": 24, "s": [100, 100, 100]}]} + }, + "ao": 0, + "shapes": [ + {"ty": "sh", "ks": {"a": 0, "k": {"i": [[0, -1], [0, 8], [-5, 0], [-1, -3], [-4, 0], [0, -6]], "o": [[0, -1], [0, -8], [4, 0], [2, -3], [7, 0], [0, 8]], "v": [[24, 40], [7, 19], [16, 9], [24, 14], [32, 9], [41, 19]], "c": true}}, "nm": "Heart outline"}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 3.2}, "lc": 2, "lj": 2, "nm": "Rounded heart stroke"} + ], + "ip": 0, "op": 25, "st": 0, "bm": 0 + } + ] +} diff --git a/assets/lottie/nav_home.json b/assets/lottie/nav_home.json new file mode 100644 index 00000000..bdcfa448 --- /dev/null +++ b/assets/lottie/nav_home.json @@ -0,0 +1,716 @@ +{ + "v": "5.7.8", + "fr": 30, + "ip": 0, + "op": 50, + "w": 128, + "h": 128, + "nm": "loftify_nav_home_loading_mark", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "draw 3", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 70, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 64, + 64, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 8.25 + ], + [ + 5.942, + 0 + ], + [ + 0, + -2.75 + ], + [ + 0, + -5.75 + ], + [ + -11.25, + 0 + ], + [ + -6.5, + 0 + ], + [ + 0, + -5 + ], + [ + 3.125, + 0 + ], + [ + 2.75, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + -8.25 + ], + [ + -5.875, + 0 + ], + [ + 0, + 2.75 + ], + [ + 0, + 5.75 + ], + [ + 11.25, + 0 + ], + [ + 6.5, + 0 + ], + [ + 0, + 5 + ], + [ + -3.125, + 0 + ], + [ + -5, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -6.75, + 5.25 + ], + [ + -6.75, + -22.125 + ], + [ + -15.875, + -36.25 + ], + [ + -21.125, + -28.875 + ], + [ + -21.125, + 3.75 + ], + [ + -7, + 17.875 + ], + [ + 10.125, + 17.875 + ], + [ + 20.75, + 27.5 + ], + [ + 13.875, + 31.625 + ], + [ + 2.375, + 31.625 + ], + [ + -5, + 27.375 + ] + ], + "c": false + }, + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 6, + "ix": 5 + }, + "lc": 2, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "描边 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "形状 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 0, + "k": 0, + "ix": 1 + }, + "e": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.801 + ], + "y": [ + 0.518 + ] + }, + "o": { + "x": [ + 0.333 + ], + "y": [ + 0 + ] + }, + "t": 0, + "s": [ + 100 + ] + }, + { + "t": 21, + "s": [ + 0 + ] + } + ], + "ix": 2 + }, + "o": { + "a": 0, + "k": 0, + "ix": 3 + }, + "m": 1, + "ix": 2, + "nm": "修剪路径 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + } + ], + "ip": 0, + "op": 50, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "draw", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 70, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 64, + 64, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 8.25 + ], + [ + 5.942, + 0 + ], + [ + 0, + -2.75 + ], + [ + 0, + -5.75 + ], + [ + -11.25, + 0 + ], + [ + -6.5, + 0 + ], + [ + 0, + -5 + ], + [ + 3.125, + 0 + ], + [ + 2.75, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + -8.25 + ], + [ + -5.875, + 0 + ], + [ + 0, + 2.75 + ], + [ + 0, + 5.75 + ], + [ + 11.25, + 0 + ], + [ + 6.5, + 0 + ], + [ + 0, + 5 + ], + [ + -3.125, + 0 + ], + [ + -5, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -6.75, + 5.25 + ], + [ + -6.75, + -22.125 + ], + [ + -15.875, + -36.25 + ], + [ + -21.125, + -28.875 + ], + [ + -21.125, + 3.75 + ], + [ + -7, + 17.875 + ], + [ + 10.125, + 17.875 + ], + [ + 20.75, + 27.5 + ], + [ + 13.875, + 31.625 + ], + [ + 2.375, + 31.625 + ], + [ + -5, + 27.375 + ] + ], + "c": false + }, + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 6, + "ix": 5 + }, + "lc": 2, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "描边 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "形状 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 0, + "k": 100, + "ix": 1 + }, + "e": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.667 + ], + "y": [ + 1 + ] + }, + "o": { + "x": [ + 0.333 + ], + "y": [ + 0 + ] + }, + "t": 19, + "s": [ + 100 + ] + }, + { + "t": 38.3994140625, + "s": [ + 0 + ] + } + ], + "ix": 2 + }, + "o": { + "a": 0, + "k": 0, + "ix": 3 + }, + "m": 1, + "ix": 2, + "nm": "修剪路径 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + } + ], + "ip": 0, + "op": 50, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/assets/lottie/nav_search.json b/assets/lottie/nav_search.json new file mode 100644 index 00000000..050f5b0a --- /dev/null +++ b/assets/lottie/nav_search.json @@ -0,0 +1,40 @@ +{ + "v": "5.7.4", "fr": 30, "ip": 0, "op": 24, "w": 48, "h": 48, + "nm": "loftify_nav_search_lens", "ddd": 0, "assets": [], + "layers": [ + { + "ddd": 0, "ind": 1, "ty": 4, "nm": "Bold Lens", "sr": 1, + "ks": { + "o": {"a": 1, "k": [{"t": 0, "s": [0]}, {"t": 5, "s": [0]}, {"t": 14, "s": [100]}, {"t": 24, "s": [100]}]}, + "r": {"a": 1, "k": [{"t": 0, "s": [-14]}, {"t": 15, "s": [3]}, {"t": 24, "s": [0]}]}, + "p": {"a": 0, "k": [24, 24, 0]}, "a": {"a": 0, "k": [24, 24, 0]}, + "s": {"a": 1, "k": [{"t": 0, "s": [84, 84, 100]}, {"t": 14, "s": [105, 105, 100]}, {"t": 24, "s": [100, 100, 100]}]} + }, + "ao": 0, + "shapes": [ + {"ty": "el", "d": 1, "p": {"a": 0, "k": [21, 21]}, "s": {"a": 0, "k": [25, 25]}, "nm": "Lens"}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 5.2}, "lc": 2, "lj": 2, "nm": "Bold lens stroke"}, + {"ty": "sh", "ks": {"a": 0, "k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[30, 30], [40, 40]], "c": false}}, "nm": "Handle"}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 5.2}, "lc": 2, "lj": 2, "nm": "Bold handle stroke"} + ], + "ip": 0, "op": 25, "st": 0, "bm": 0 + }, + { + "ddd": 0, "ind": 2, "ty": 4, "nm": "Outline Lens", "sr": 1, + "ks": { + "o": {"a": 1, "k": [{"t": 0, "s": [100]}, {"t": 8, "s": [100]}, {"t": 16, "s": [0]}, {"t": 24, "s": [0]}]}, + "r": {"a": 1, "k": [{"t": 0, "s": [0]}, {"t": 12, "s": [9]}, {"t": 24, "s": [0]}]}, + "p": {"a": 0, "k": [24, 24, 0]}, "a": {"a": 0, "k": [24, 24, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + {"ty": "el", "d": 1, "p": {"a": 0, "k": [21, 21]}, "s": {"a": 0, "k": [25, 25]}, "nm": "Lens"}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 3.2}, "lc": 2, "lj": 2, "nm": "Fine lens stroke"}, + {"ty": "sh", "ks": {"a": 0, "k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[30, 30], [40, 40]], "c": false}}, "nm": "Handle"}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 3.2}, "lc": 2, "lj": 2, "nm": "Fine handle stroke"} + ], + "ip": 0, "op": 25, "st": 0, "bm": 0 + } + ] +} diff --git a/assets/lottie/nav_user.json b/assets/lottie/nav_user.json new file mode 100644 index 00000000..eae66063 --- /dev/null +++ b/assets/lottie/nav_user.json @@ -0,0 +1,55 @@ +{ + "v": "5.7.4", "fr": 30, "ip": 0, "op": 24, "w": 48, "h": 48, + "nm": "loftify_nav_my_portrait", "ddd": 0, "assets": [], + "layers": [ + { + "ddd": 0, "ind": 1, "ty": 4, "nm": "Portrait Head", "sr": 1, + "ks": { + "o": {"a": 1, "k": [{"t": 0, "s": [0]}, {"t": 5, "s": [0]}, {"t": 12, "s": [100]}, {"t": 24, "s": [100]}]}, + "r": {"a": 0, "k": 0}, + "p": {"a": 1, "k": [{"t": 0, "s": [24, 10, 0]}, {"t": 14, "s": [24, 19, 0]}, {"t": 19, "s": [24, 16, 0]}, {"t": 24, "s": [24, 17, 0]}]}, + "a": {"a": 0, "k": [24, 17, 0]}, + "s": {"a": 1, "k": [{"t": 0, "s": [86, 86, 100]}, {"t": 14, "s": [105, 105, 100]}, {"t": 24, "s": [100, 100, 100]}]} + }, + "ao": 0, + "shapes": [ + {"ty": "el", "d": 1, "p": {"a": 0, "k": [24, 17]}, "s": {"a": 0, "k": [18, 18]}, "nm": "Head"}, + {"ty": "fl", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "r": 1, "nm": "Head ink"} + ], + "ip": 0, "op": 25, "st": 0, "bm": 0 + }, + { + "ddd": 0, "ind": 2, "ty": 4, "nm": "Portrait Shoulders", "sr": 1, + "ks": { + "o": {"a": 1, "k": [{"t": 0, "s": [0]}, {"t": 7, "s": [0]}, {"t": 16, "s": [100]}, {"t": 24, "s": [100]}]}, + "r": {"a": 0, "k": 0}, + "p": {"a": 1, "k": [{"t": 0, "s": [24, 45, 0]}, {"t": 16, "s": [24, 38, 0]}, {"t": 21, "s": [24, 41, 0]}, {"t": 24, "s": [24, 40, 0]}]}, + "a": {"a": 0, "k": [24, 40, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + {"ty": "sh", "ks": {"a": 0, "k": {"i": [[0, 0], [-9, 0], [0, -8]], "o": [[0, -8], [9, 0], [0, 0]], "v": [[8, 40], [24, 27], [40, 40]], "c": true}}, "nm": "Rounded shoulder silhouette"}, + {"ty": "fl", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "r": 1, "nm": "Shoulder ink"} + ], + "ip": 0, "op": 25, "st": 0, "bm": 0 + }, + { + "ddd": 0, "ind": 3, "ty": 4, "nm": "Outline Portrait", "sr": 1, + "ks": { + "o": {"a": 1, "k": [{"t": 0, "s": [100]}, {"t": 7, "s": [100]}, {"t": 17, "s": [0]}, {"t": 24, "s": [0]}]}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [24, 24, 0]}, "a": {"a": 0, "k": [24, 24, 0]}, + "s": {"a": 0, "k": [100, 100, 100]} + }, + "ao": 0, + "shapes": [ + {"ty": "el", "d": 1, "p": {"a": 0, "k": [24, 17]}, "s": {"a": 0, "k": [18, 18]}, "nm": "Head outline"}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 3.2}, "lc": 2, "lj": 2, "nm": "Head stroke"}, + {"ty": "sh", "ks": {"a": 0, "k": {"i": [[0, 0], [-9, 0], [0, -8]], "o": [[0, -8], [9, 0], [0, 0]], "v": [[8, 40], [24, 27], [40, 40]], "c": false}}, "nm": "Rounded shoulder outline"}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 3.2}, "lc": 2, "lj": 2, "nm": "Shoulder stroke"} + ], + "ip": 0, "op": 25, "st": 0, "bm": 0 + } + ] +} diff --git a/assets/lottie/run.py b/assets/lottie/run.py deleted file mode 100644 index 25742d25..00000000 --- a/assets/lottie/run.py +++ /dev/null @@ -1,36 +0,0 @@ -#遍历文件夹下的json文件,对于每个json文件生成对应的代码 -#如对于gift_dark.json,对应代码行为static const String giftDark = "assets/lottie/gift_dark.json" -#生成所有json文件对应的代码,并写入到文件中 -import os - -def to_camel_case(snake_str): - """将蛇形命名(下划线分隔)转换为驼峰命名(首字母小写)""" - components = snake_str.split('_') - return components[0].lower() + ''.join(x.title() for x in components[1:]) - -def generate_code_for_json_files(folder_path, output_file): - # 初始化一个列表来存储所有的代码行 - code_lines = [] - - # 遍历文件夹下的所有文件 - for root, _, files in os.walk(folder_path): - for file in files: - if file.endswith(".json"): - # 提取文件名(不包括扩展名) - base_name = os.path.splitext(file)[0] - # 将文件名转换为驼峰命名 - variable_name = to_camel_case(base_name) - # 生成代码行 - code_line = f'static const String {variable_name} = "assets/lottie/{file}";' - # 将代码行添加到列表中 - code_lines.append(code_line) - - # 将所有代码行写入到输出文件中 - with open(output_file, 'w') as f: - for line in code_lines: - f.write(line + '\n') - -# 使用示例 -folder_path = './' # 替换为你的文件夹路径 -output_file = './code.txt' # 替换为你的输出文件路径 -generate_code_for_json_files(folder_path, output_file) \ No newline at end of file diff --git a/docs/redesign/Stage_C_Self_Check.md b/docs/redesign/Stage_C_Self_Check.md new file mode 100644 index 00000000..5cd0d3e9 --- /dev/null +++ b/docs/redesign/Stage_C_Self_Check.md @@ -0,0 +1,516 @@ +# Loftify 视觉重构阶段 C:跨页面自检 + +> 上位规范:[`Final_Visual_Redesign.md`](../../Final_Visual_Redesign.md) +> 代表页面:首页、作品详情、标签详情、作者主页、设置页 +> 完成条件:至少两轮真实数据、真实交互和跨尺寸检查;每轮发现的问题必须修复并重新验证。 + +## 第一轮:设计系统连续性与内容沉浸(2026-08-29) + +本轮从“页面框架是否退后、作品是否仍是主角、相邻页面是否共享同一语义组件”出发,交叉检查五类代表页。检查同时覆盖代码实现、320px / 2 倍字体组件边界、浅深色 Token、既有真实页面行为和代表页回归测试。 + +| 页面 | 检查结论 | 本轮处理 | +| --- | --- | --- | +| 首页 | 页面使用纯白语义底色、响应式最大版心和真实列宽瀑布流;封面继续 `BoxFit.cover`,文字帖灰底只包正文。未发现新的内容竞争或栅格断层。 | 保留当前高密度内容流配方;用真实列宽和媒体裁切回归继续守卫。 | +| 作品详情 | 阅读版心和右侧悬浮操作条已进入新设计系统,但正文底部合集 / 粮单和 AppBar 合集入口仍使用旧 `cardColor`、局部圆角和 3px 间距,形成明显半新半旧断层;旧操作行在窄屏大字体下也缺少增长策略。 | 新增公共 `LoftifyContentReferenceCard` 与 `LoftifyContextPill`;统一语义色、细边界、14px 卡片圆角、48px 操作目标和无扩散水波纹反馈。320px 或大字体时订阅区和上下篇操作自动重排,正常宽度保持紧凑行内布局;首篇 / 末篇原提示及目录、订阅、切篇业务完整保留。 | +| 标签详情 | 标签头部、发现入口、筛选吸顶条和内容栅格均由 Token 驱动;原 Underline Tab、自动刷新和筛选记忆未被视觉层延迟。未发现新的视觉断层。 | 保留当前中密度发现配方;继续以长文案、自动刷新和真实单元格宽度测试守卫。 | +| 作者主页 | 身份、统计、主操作、代表作和正文圆角已形成稳定信息层级;主题头图上的白色与半透明黑仅用于保证媒体覆盖层对比,登记为受控内容叠层,而非第二套页面色彩。 | 保留当前主题媒体叠层;继续以 320px / 2 倍字体、宽屏双栏和 Tab 刷新回归守卫。 | +| 设置页 | Caption / Entry / 行内选择和 AppBar 已统一,但“发现新版本”说明仍直接使用 `Colors.redAccent`,会绕过自定义主题与独立状态色。 | 改为 `design.colors.warning`,继续使用同一危险 / 警告 / 成功语义体系;页面 10px 外边距、Caption 10px 顶距和无 AppBar 分割线规则保持不变。 | + +### 第一轮交叉结论 + +- 浅色主页面继续以纯白承载内容;灰色只用于明确的低强调表面,不新增整块灰色评论或卡片墙。 +- 普通 AppBar 独立操作继续使用圆形点击面;只有带文字的内容关系入口使用紧凑胶囊,未把返回 / 更多按钮改成矩形圆角。 +- 首页高密度、详情与主页中密度、设置低密度的差异继续由同一 Token 体系表达,没有逐页复制常量。 +- 真机快速反向拖动“我的”列表时额外发现 EasyRefresh 的旧惯性仍在变化后的内容边界外运行,返回的 overscroll 大于当前帧位移。公共滚动物理现在会完整放行向合法区间的回弹,并把向外限制约束在当前帧位移内;底栏显隐改变视窗高度时不再抛出未处理异常。 +- 本轮发现的三个真实断层均已修复并重新验证,第一轮通过。 + +### 第一轮完成证据 + +- 45 项代表页与滚动专项测试通过,覆盖设计 Token、首页真实列宽、作品详情、标签头部、作者头部、设置节奏、320px / 2 倍字体、浅深色和内容范围缩短时的弹道回弹。 +- 涉及文件定向静态检查为 0 error / 0 warning;46 项 info 均为作品详情既有类型推断与弃用提示,本轮没有新增。 +- Android Debug APK 与 Windows Profile 构建通过。 +- Android 16 真机 `986008e8`(1200×2670 / 120Hz)以真实登录数据逐页进入首页、作品详情、标签详情、作者主页、设置和通用设置;作品详情 AppBar 合集胶囊实测 36dp 高,下载 / 更多保持圆形,正文合集操作实测 48dp 高,真实合集目录 Panel 可打开并通过系统返回关闭。 +- 修复后在真机连续执行四轮快速反向拖动与底栏显隐,运行日志为 0 个滚动物理、断言、RenderFlex 溢出或未处理异常;Hot Reload 616ms、Hot Restart 2282ms。 +- 真机当前开启安全模式,ADB 截图会被系统安全层遮蔽;本轮保留可访问性节点、物理边界和运行日志作为实机证据,第二轮仍须补充当前构建的可视截图,不能用旧截图代替最终验收。 + +## 第二轮:可读性、状态和极端尺寸 + +进行中。本轮在第一轮最终构建基础上重新检查,不复用第一轮结论;按用户最新要求停止桌面端验收,当前只继续 Android 手机与平板、深色与自定义主题、简中 / 繁中 / 英文、大字体、空态 / 错误 / 加载和系统返回手势。完成剩余矩阵并补齐当前构建截图前,不勾选 `REDESIGN-C6`。 + +### P2A:紧凑屏与大字体 AppBar(2026-08-29) + +- 以 320dp、2 倍字体和英文长文案组合真实还原作品详情 AppBar 的返回、标题、合集胶囊、下载与更多入口,首次运行复现 `RenderFlex overflowed by 1321 pixels`。 +- `LoftifyContextPill` 不再在所有设备固定使用 180dp 最大宽度:小于等于 360dp 收敛到 96dp,360~420dp 根据文字缩放使用 104 / 128dp,更宽设备保持 180dp。视觉标签安全省略,36dp 最小高度、图标、点击行为及完整语义标签保持不变。 +- 公共 `ResponsiveAppBar` 在横向布局中把标题限制到剩余空间,并为内建标题统一单行省略;帖子详情自定义标题同步处理。Android 平板横屏或长本地化标题不再把尾部操作推离屏幕。 +- 23 项 AppBar、浅深色内容归属卡及帖子详情组件回归通过;定向静态检查 0 error / 0 warning,46 项 info 均为帖子详情既有问题。 +- 本小项已完成并可独立提交;第二轮的主题、三语言、状态、系统返回与当前构建截图仍待继续。 + +### P2B1:短屏状态反馈与重试可达性(2026-08-29) + +- 以 320×220dp、2 倍字体和长英文标题 / 说明 / 重试文案检查错误状态,首次运行复现底部 `RenderFlex overflowed by 788 pixels`,重试操作无法到达。 +- 页面级加载、错误、警告和成功状态在高度受限时改用 Android Clamping 滚动;内容未超高时仍保持原居中布局,不缩放文字或删除说明。 +- EasyRefresh 与 `SliverFillRemaining` 空态继续由原外层滚动承载,内部不创建 `LayoutBuilder` 或第二个滚动区;通知页占位、下拉刷新与触底预加载手势保持不变。 +- 29 项状态表面、通知空态、Lottie 刷新、提前分页、深色语义状态与帖子局部解析错误回归通过;涉及文件静态检查无问题。 +- 本小项已完成并可独立提交;第二轮仍需继续主题、三语言、真实状态切换、系统返回与当前构建截图。 + +### P2B2A:纯图标加载态语义(2026-08-29) + +- 纯图标加载场景会传入已本地化标题但设置 `showText: false`;此前该标题也从语义标签移除,而 Lottie 动画本身又被排除语义,屏幕阅读器只能获得无名称的 live region。 +- 状态容器现在始终保留非空标题和说明的组合语义;视觉标题是否显示不再影响辅助技术。可见标题 / 说明不再各自重复生成语义,重试按钮仍作为独立按钮节点。 +- 纯图标加载专项由失败转为通过,状态表面、通知 Sliver 空态和 Lottie 共 19 项回归通过。 +- 本小项已完成并可独立提交;主题、三语言、真实状态切换、系统返回与当前构建截图仍待继续。 + +### P2B2B1:当前真机构建的安全截图通道(2026-08-29) + +- Android 16 真机启用系统安全层后,普通 ADB 截图会被遮蔽;验收不关闭用户防截屏、不修改系统安全设置,也不使用旧版本图片代替当前构建。 +- 新增仅用于开发验收的 Flutter 渲染截图工具,通过调试服务请求当前根渲染树截图;画面直接来自真机仍在运行的 Debug 构建,不包含或读取登录凭据,也不改变应用运行逻辑。 +- 已在真机当前进程成功生成 `build/screenshots/loftify_c6_p2_current_zh_light.png`,确认浅色简中真实关注流、封面裁切、顶部导航和底部导航无明显裁切、变形或布局断层。 +- 工具地址转换专项测试与静态检查通过;该通道只补足应用渲染层证据,系统状态栏、安全遮罩和系统返回仍须通过真机交互单独验收。 +- 本小项已完成并可独立提交;深色、自定义主题、繁中、英文、大字体、真实状态切换和系统返回继续留在 `REDESIGN-C6-P2B2B`。 + +### P2B2B2:主题强调色前景对比(2026-08-29) + +- 第二轮主题矩阵首先以默认极简白的青绿色强调色复现问题:旧黑白前景算法在相对亮度不超过 0.5 时一律选择白色,实际白字与默认青绿色仅有约 2.22:1 的对比度;橙色、绿色等较亮自定义主题同样不可读。 +- 基础颜色逻辑改为分别计算黑色和白色前景的 WCAG 对比度并选择更高者;Material `onPrimary`、新设计系统 `onAccent`、Chewie 旧组件及主题色板选中对号共用同一结果,避免新旧组件继续出现两套前景色。 +- 新增内置全部浅色 / 深色主题以及青绿、橙色、绿色自定义强调色回归,所有强调色前景均达到至少 4.5:1;主题刷新、背景和三语言基础回归共 22 项通过,定向静态检查无问题。 +- Android 16 真机 `986008e8` 已完成完整 Debug 编译、覆盖安装、Hot Reload 685ms 与 Hot Restart 2303ms;之后 USB 断开,但当前修复已安装到真机。桌面端未执行。 +- 本小项已完成并可独立提交;三语言页面视觉、大字体、真实状态切换和系统返回继续留在 `REDESIGN-C6-P2B2B`。 + +### P2B2B3:持久化语言的冷启动同步(2026-08-29) + +- 明确选择繁中或英文后,Flutter 本地化会从持久化值恢复,但旧启动刷新逻辑只在“跟随系统”时设置 `Intl.defaultLocale`;冷启动可能出现界面文案已切换、日期等格式化仍沿用进程默认语言的混合状态。 +- 启动和系统语言变更时现在始终把 `intl` 同步到“用户明确选择的语言,或解析后的系统语言”;只有跟随系统时才因系统语言变更通知整棵界面刷新,明确语言不会被系统变化覆盖。 +- 冷启动繁中失败用例已转为通过;简中 / 繁中 / 英文解析、系统语言刷新、年月格式、本地化资源和窄屏大字体设置布局共 12 项回归通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机在上一小项后 USB 断开,本小项 APK 尚待设备恢复连接后覆盖安装;因此三语言真机页面视觉仍保留在 `REDESIGN-C6-P2B2B`,不能仅凭自动化勾选第二轮完成。桌面端未执行。 + +### P2B2B4:主导航系统返回分流(2026-08-29) + +- 主导航外层 `PopScope` 旧回调忽略 `didPop`:即使根路由已经成功返回,仍会再次执行内部 Panel 的返回清理,离场过程中可能重置当前页、底栏与 Panel 显示状态。 +- 新增独立返回作用域,只在根路由被阻止时把 Android 系统返回委托给内部 Panel;根路由已成功返回时不再执行第二次清理。原内部页面关闭、根页面退出和视频左滑作者页返回语义保持不变。 +- 组件测试分别验证“根返回关闭时只处理内部页”和“根路由已返回时不调用内部清理”,并复测视频作者页系统返回后视频仍保持在原位置;3 项通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待恢复连接后覆盖安装并执行真实返回手势;因此系统返回真机验收仍保留在 `REDESIGN-C6-P2B2B`。桌面端未执行。 + +### P2B2B5:主题柔和选中态对比(2026-08-29) + +- 主强调按钮对比修复后继续检查 `accentContainer / onAccentContainer`,默认极简白青绿主题仍复现约 4.12:1 的不足;标签筛选、合集胶囊、选中菜单、卡片和 tonal 按钮共用该颜色对,不能只修单个页面。 +- 柔和选中态现在先保留满足 4.5:1 的主题色前景;主题色及其原候选都不足时,回退到同一主题的语义正文色。选中底板仍保留低饱和强调色,业务状态、圆角和信息密度不变。 +- 全部内置浅色 / 深色主题及青绿、橙色、绿色自定义主题的柔和选中态均达到至少 4.5:1;主题、按钮、卡片、合集关系卡和标签详情共 34 项回归通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待恢复连接后覆盖安装并检查真实选中态;阶段 C 第二轮继续未完成。桌面端未执行。 + +### P2B2B6:成功 / 警告 / 危险状态可读性(2026-08-29) + +- 状态矩阵继续复现默认浅色成功绿在白底仅约 2.78:1,警告黄更低;这些颜色被状态页、设置提示、菜单、卡片、输入校验和下载状态直接作为文字或图标前景使用。 +- 三类状态色现在保持原主题色相,并只向当前明暗模式的高对比方向做满足 4.5:1 所需的最小调整;同时检查纯页面、抬升表面以及 10% 同色柔和底板,避免只在纯白背景达标。 +- 全部内置浅色 / 深色主题的成功、警告、危险色在四类实际背景上均达到至少 4.5:1;主题、按钮、卡片、Entry、状态页、合集关系卡、设置与下载状态共 48 项回归通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实状态页与设置提示;阶段 C 第二轮继续未完成。桌面端未执行。 + +### P2B2B7:低强调文字可读性(2026-08-29) + +- 文字层级矩阵此前只约束主文字和次文字;输入提示、空态辅助信息等使用的浅色 `textMuted` 在白底仅约 3.01:1,低于普通文字 4.5:1。 +- `textMuted` 现在按页面与柔和表面分别校验,并向当前明暗模式的高对比方向做最小调整;主文字、次文字和低强调文字的相对层级保持不变,没有放大字号或改成高饱和颜色。 +- 内置浅色 / 深色主题的三档文字在页面与柔和表面均达到至少 4.5:1;主题、输入、状态、Entry、设置和导航头共 38 项回归通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实输入提示与空态辅助信息;阶段 C 第二轮继续未完成。桌面端未执行。 + +### P2B2B8:Android 异步状态恢复链路(2026-08-29) + +- 新增 Android 320×568dp、1.6 倍字体下的确定性异步状态回归,按实际交互顺序覆盖初始 Lottie 加载、网络错误、点击重试、重新加载和成功反馈。 +- 每个阶段均校验状态图形、按钮与 live-region 语义同步更新;统一状态过渡结束后旧错误图标和重试入口彻底移除,不会与新加载或成功反馈叠加。 +- 回归确认加载阶段始终使用应用自定义 Lottie,不会回退到系统 `CircularProgressIndicator`;状态表面专项 10 项通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并触发真实网络状态切换;三语言、深色 / 自定义主题、系统返回与当前构建最终截图继续保留在 `REDESIGN-C6-P2B2B`。按用户要求只处理 Android,桌面端未执行。 + +### P2B2B9:状态离场期间的重复操作防护(2026-08-29) + +- 异步恢复回归进一步发现:点击重试后旧错误状态会按设计淡出,但淡出组件仍短暂留在命中树,Android 快速连点可能重复调用重试回调。 +- `LoftifyStateView` 保留原状态淡入淡出和布局关系,只将正在离场的旧状态立即设为不可命中;当前新状态继续正常响应,不延迟加载状态或业务请求。 +- 专项测试在首次点击已经进入 Lottie 加载后立即再次触碰旧按钮位置,重试回调始终只执行一次,随后完成态和旧控件清理均正常;状态表面 10 项通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装后补充真实高频触控验收;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B10:繁中页面状态文案一致性(2026-08-29) + +- 三语言资源复查发现通知空态的“暂无通知”和帖子详情首次加载的“文章加载中...”仍为简体字,会直接破坏繁中页面状态的一致性。 +- 两处分别修正为“暫無通知”和“文章載入中...”,保留原业务含义、标点和加载流程,并与已有“重試”文案保持同一语言环境。 +- 新增资源级测试直接验证生成后的繁中本地化类,同时继续校验全部语言键和占位符一致;本地化与状态表面共 14 项通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实通知空态与文章加载首帧;繁中整页视觉矩阵仍留在 `REDESIGN-C6-P2B2B`。按用户要求只处理 Android,桌面端未执行。 + +### P2B2B11:弱化表面的低强调文字对比(2026-08-29) + +- 文字对比矩阵此前只覆盖页面与普通表面;扩展到浮起表面和弱化表面后,浅色 `textMuted / surfaceMuted` 为约 4.377:1,深色为约 4.388:1,未达到普通文字 4.5:1。 +- 低强调文字现在以页面、普通、浮起和弱化四类真实底板共同计算最小可读颜色,只向当前明暗模式的高对比方向做必要调整;主 / 次 / 低强调层级、字号和低饱和视觉关系保持不变。 +- 测试一次收集并报告全部失败组合,避免只修首个断言;主题、按钮、卡片、状态与 Entry 共 32 项回归通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实输入提示、禁用项和柔和卡片;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B12:品牌强调底色与可读强调前景分层(2026-08-29) + +- 既有矩阵只验证“强调色底板上的文字”;默认亮青绿直接作为白底文字、图标、焦点环和进度条时仅约 2.2:1,选中状态能识别但不够清晰。 +- 新增 `accentForeground` 语义角色:原始 `accent` 继续完整承载用户主题色,用于主按钮填充、Lottie、选中底板和低透明交互染色;前景角色保持同一色相,只做满足四类中性底板 4.5:1 的最小明度调整。 +- Material 文字 / 描边按钮、输入焦点和底栏选中态,以及 Tag、Entry、菜单、状态卡、装扮入口、帖子合集订阅和下载进度已统一接入可读前景;作者页固定白色强调操作面按实际合成白底独立求同色相可读前景,浅深主题均达到 4.5:1。全部内置浅深主题及亮青绿 / 橙 / 绿自定义主题均达标;主题与六类代表组件共 49 项回归通过,定向静态检查无新增 error / warning(详情页仅保留既有 info),Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并对比真实主题色前景;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B13:装饰边界与可操作强边界分层(2026-08-29) + +- 边界矩阵发现原 `outlineStrong` 在浅色白底、柔和底板和深色表面均不足 3:1;次按钮、输入悬停、可操作把手与高对比状态会因此显得边界缺失。 +- `outline` 继续保留为内容卡片、区块和普通分割线的低噪声装饰边界;只有 `outlineStrong` 按页面、普通、浮起和弱化四类表面计算达到 3:1 所需的最小明度调整,避免全局边框变脏变重。 +- 全部内置浅深主题的可操作强边界均达标,次按钮实组件确认读取新 Token;主题、控件、表面与导航头共 36 项回归通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并比较真实输入、次按钮和 Panel 把手;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B14:作者页危险操作的稳定表面(2026-08-29) + +- 作者页拉黑状态操作原先使用 20% 透明危险色底板与白字,叠加浅色页面或不同明暗的作者头图时无法保证文字对比,危险状态边界也不稳定。 +- 拉黑状态现在使用与关注主操作一致的 94% 白色稳定表面;危险前景基于实际合成白底计算同色相的最小可读调整,并增加危险状态边框,不依赖背景图片提供对比。 +- 浅色与深色主题均验证危险边框、至少 4.5:1 的文字对比和点击回调;个人主页组件相关 6 项回归通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并在真实作者头图上检查拉黑状态;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B15:作者头图上的固定可读遮罩(2026-08-29) + +- 作者页头图原遮罩从 18% 黑色开始,接近白色的封面会让白色 AppBar、昵称、ID 与元数据直接失去对比;实际可读性依赖用户上传内容,不受主题系统控制。 +- 新增同源 `LoftifyProfileCoverScrim`,以 58%~72% 连续暗色渐变覆盖头图;ID 与元数据的白色不透明度统一为 88%,仍保留主次层级但不再过淡。 +- 以纯白封面作为最坏输入,最浅渐变位置的主信息和辅助信息均达到至少 4.5:1;个人主页组件相关 7 项回归通过,定向静态检查无问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并比较真实明亮 / 深色作者封面;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B16:作品集合头图共用可读遮罩(2026-08-29) + +- 合集和粮单详情此前只模糊用户封面,没有暗色遮罩;白色 AppBar、标题、作者信息和统计依赖图片自身明暗,统计辅助标签还只有 60% 白色。 +- 作者页遮罩提升为公共 `LoftifyCoverScrim`,作者、合集和粮单三类头图统一使用 58%~72% 渐变;合集 / 粮单统计辅助前景统一为 88% 白色,原封面 `BoxFit.cover`、模糊与内容结构保持不变。 +- 纯白封面下主 / 辅助前景均达到至少 4.5:1,源文件守卫确保三类页面持续接入公共组件;相关 9 项回归通过,定向静态检查无新增 error / warning(合集与粮单保留 16 条既有 info),Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并比较真实合集 / 粮单封面;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B17:图片角标的最低可读遮罩(2026-08-29) + +- 23 处图片计数、类型、热度和灯箱角标共用 `buildTranslucentTag`,默认 40% 黑底,部分页面显式传入 20%;白字叠加纯白图片时最低只有约 2.8:1。 +- 公共媒体覆盖规范增加 58% 最低角标遮罩;较浅的调用参数自动收敛到可读下限,更深的自定义遮罩不变,尺寸、图标、圆角和文案均保持原逻辑。 +- 专项回归以 20% 请求和纯白图片验证实际合成结果达到至少 4.5:1;媒体覆盖相关 3 项通过,定向静态检查无新增 error / warning(`item_builder` 保留 3 条既有 info),Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并比较标签详情 Banner、装扮列表和图片灯箱角标;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B18:壁纸预览顶部文字保护区(2026-08-29) + +- 装扮壁纸卡片的白色日期与时间原本直接叠在用户图片上,浅色壁纸无法阅读;整图压暗则会破坏壁纸预览的真实性。 +- 新增公共 `LoftifyTopTextScrim`,顶部以 66%~58% 暗色保护文字,向下完全透明;遮罩在圆角内裁切并由 `IgnorePointer` 排除命中,不影响预览点击和 Hero 灯箱。 +- 纯白壁纸下保护区白字达到至少 4.5:1,底部透明与真实页面接入均有回归守卫;媒体覆盖相关 5 项通过,定向静态检查无新增 error / warning(装扮列表保留 11 条既有 info),Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并比较明亮 / 深色壁纸卡片;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B19:装扮详情 Panel 的短屏响应式(2026-08-29) + +- 装扮详情原自建容器包含固定 300px 预览、不可滚动正文和固定横排操作;320×568、2 倍字体回归复现底部溢出 57px,下载与确认入口会离开视窗。 +- 详情迁移到统一 `LoftifyPanel`,复用公共顶部圆角、把手、表面和分割线;正文维持单滚动链,短屏 / 大字体将操作区纳入滚动,正常高度仍固定底栏。 +- 单图、全部和确认统一使用 `LoftifyButton`,手机或大字体纵排、560dp 以上正常字号横排;下载异常会在 `finally` 中关闭加载层。2 项真实布局与源码守卫通过,定向静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并验证真实装扮详情滑动、下载与安全区;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B20:装扮下载的真实线性进度(2026-08-29) + +- 装扮详情单图 / 全部下载仍弹出通用 Lottie 加载对话框,无法表达实际传输进度,也会遮蔽整个 Panel。 +- 新增 `LoftifyDownloadProgressButton`,在统一按钮底边显示真实确定性线性进度与百分比,并向辅助功能暴露实时 value;下载中阻止当前 / 另一下载入口重复入队,同时禁用确认关闭。 +- 图片保存底座补齐单图字节回调和多 URL 批量入口;批量下载复用等权聚合器,未知总字节保留份额,成功短暂停留到 100%,减少动态模式立即恢复。7 项真实进度、窄屏大字体、Panel 与多图聚合回归通过,6 个目标文件静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并执行真实单图 / 多图下载进度验收;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B21:表情包与装扮部件下载进度统一(2026-08-29) + +- 表情包详情与装扮部件详情仍使用阻断页面的通用 Lottie 加载对话框,既看不到真实传输进度,也无法区分多个独立资源的下载状态。 +- 表情包卡片改用带百分比的线性进度按钮;空间紧凑的装扮部件卡片改用圆形确定性进度环。两者都按资源 URL 独立记录状态,仅阻止同一资源重复点击,不影响浏览或下载其他资源。 +- 两类入口统一复用图片保存底座的真实字节回调,成功后短暂停留在 100%,异常与组件销毁时安全清理状态;同时修正表情包首次刷新未返回 `IndicatorResult` 的既有问题,避免刷新控件无法正确结束。 +- 真实进度、窄屏大字体、圆形按钮语义、重复点击防护和源码守卫共 6 项通过,4 个目标文件定向静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并执行真实表情包 / 装扮部件下载验收;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B22:登录页面族的短屏与键盘可达性(2026-08-29) + +- 短屏与大字体复查发现,验证码、手机号密码、LOFTER ID 和邮箱四种登录页都将“其他登录方式”用 `Stack + Positioned(bottom: 50)` 覆盖在表单上;Android 横屏、分屏或键盘弹出后会遮住输入框和登录按钮,而且底部入口无法随页面滚动。 +- 新增公共 `LoftifyLoginLayout`,四种登录方式统一进入同一条 Clamping 滚动链。正常高屏仍用弹性留白保持底部视觉重心,短屏或 1.35 倍以上字体自动收紧节奏;表单限制为 520px、主操作限制为 360px,避免宽屏输入区失控拉长。 +- “其他登录方式”长文案可换行,两侧分割线只占剩余空间;入口改为统一圆形描边按钮,并补齐 Tooltip、按钮语义、48px 点击目标和 Lucide 图标。图片验证码恢复 `BoxFit.cover`,原焦点顺序、自动填充、密码显隐、倒计时和登录跳转保持不变。 +- 320×360、2 倍字体下输入、主操作和登录方式均可滚动到达且无溢出,1000px 宽度下表单不会超过 520px;布局、输入附件和源码守卫共 5 项通过,8 个目标文件定向静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并验证真实软键盘、验证码刷新与系统返回;阶段 D 仍等待阶段 C 当前构建截图门槛,不因本次公共布局收口提前标记启动。按用户要求只处理 Android,桌面端未执行。 + +### P2B2B23:评论区统一 Panel 与可变视窗(2026-08-29) + +- 评论正文与二级回复已经保持无灰色卡片背景、昵称垂直居中和回复目标下置,但外层评论区仍自建 16px 顶部圆角容器,并把“最新评论”作为列表内固定 50px 的吸顶项;它与其他 Panel 的圆角、把手、表面和标题层级不一致。 +- 评论区迁移到公共 `LoftifyPanel`,统一 20px 顶部圆角、拖拽把手、柔和边界、抬升表面与标题排版;“最新评论”移到稳定 Panel 头部,列表只保留加载、错误、空态、一级评论和二级回复这一条滚动链,不再复制列表吸顶标题。 +- 新增 `LoftifyCommentPanel` 作为可验证外框:高度取当前可见视窗的 82% 并限制为 760px,键盘占高时按 `viewInsets` 同步收缩;原 EasyRefresh 下拉、提前分页、Lottie 状态、评论点赞、作者跳转和二级回复加载逻辑保持不变,未新增依赖官方接口的评论能力。 +- 320×300、2 倍字体下长标题无溢出且末条评论可滚动到达,360×640 加 280px 键盘占高时 Panel 高度正确收缩;评论 Panel、长评论、嵌套回复与详情动作共 14 项通过,本次目标文件静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实评论刷新、分页、二级回复和系统返回;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B24:帖子收藏夹选择 Panel 的紧凑布局(2026-08-29) + +- 帖子收藏夹选择仍使用自建 20px 圆角容器、手工分割线和旧圆角按钮;固定 80% 高度下,标题右侧“新建”与底部取消 / 确认在 320px、2 倍字体或键盘占高时会共同挤压文件夹列表。 +- 选择器迁移到公共 `LoftifyPanel`,统一顶部把手、圆角、表面、边界、标题与 footer;创建、取消和确认均改用 `LoftifyButton`,选择列表继续保留 EasyRefresh、提前分页、创建收藏夹和原选择结果回调。 +- 正常设备保持 80% 可见高度并将“新建”放在标题行;小于 380px、1.35 倍以上字体或短视窗改用 92% 可见高度,把“新建”移到标题下方且底部操作竖排、收紧 padding。文件夹封面维持 `BoxFit.cover`,名称允许两行并安全省略,ID 与篇数使用统一元数据层级。 +- 320×480、2 倍字体下新建和确认均可操作且无溢出,360×700 加 300px 键盘占高时 Panel 高度正确收缩;3 项布局与源码守卫通过,本次目标文件静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并验证真实文件夹创建、刷新、选择与确认;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B25:合集目录统一 Panel 与真实栅格宽度(2026-08-29) + +- 合集目录仍使用自建 20px 圆角容器和固定 80% 高度;目录网格通过整机屏幕宽度猜测 `wh`,而 `GridView.extent(maxCrossAxisExtent: 160)` 在 320px Panel 内实际只分两列,传给帖子封面的尺寸又按三列计算,造成封面留白、缩放与实际单元格不一致。 +- 外框迁移到公共 `LoftifyPanel`,正常设备使用 80% 可见高度,短屏、大字体和窄屏使用 92%,并限制最大 760px;合集封面继续 `BoxFit.cover`,标题和说明在紧凑环境允许两行,订阅操作改用统一 tonal / secondary 按钮并在小屏移到摘要下方。 +- 新增 `LoftifyCollectionGridGeometry`:手机稳定使用三列,600px 以上按约 160px 密度扩展到最多八列;网格使用 `LayoutBuilder` 的真实可用宽度计算方格边长,帖子卡片收到的 `wh` 与渲染单元格完全一致。原下拉刷新、上下分页、正倒序、订阅与合集详情跳转保持不变。 +- 320×420、2 倍字体下 Panel 保持单滚动视窗且末项可达,296px 内容宽度得到三个 94.67px 方格,1000px 得到六列;3 项布局、几何与源码守卫通过,本次目标文件静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实目录刷新、正倒序、订阅、封面裁切与详情跳转;滑块验证码明确保留独立居中验证器形态,不机械迁移为底部内容 Panel。按用户要求只处理 Android,桌面端未执行。 + +### P2B2B26:作品归档页族的真实单元格宽度(2026-08-29) + +- 从合集目录继续扫描发现,我的作品、我的推荐、合集详情和粮单详情仍使用 `GridView.extent(maxCrossAxisExtent: 160)`,同时固定向封面组件传入 `wh: 160`;手机实际格宽只有约 95~145px,图片解码、裁切、角标和点击区域均与渲染单元格不一致。 +- 新增公共 `LoftifyPostArchiveGrid` 与 `LoftifyArchiveGridGeometry`,将内边距从约束中扣除后再计算真实方格边长,并把该值直接传给帖子封面;合集目录、我的作品、我的推荐、合集详情和粮单详情五个入口全部迁移,移除各页重复的固定 160px 实现。 +- 手机继续保持用户熟悉的三列归档,600px 以上按约 160px 目标密度增加到最多八列,封面不会因窗口变宽而无限拉伸;外层页面仍持有唯一滚动链,月份分组、位置恢复、刷新、分页、批量下载和帖子跳转逻辑不变。 +- 320px 宽并含 12px 两侧 padding 时,组件实测生成三个 94.67px 正方形且回调尺寸一致;296px / 1000px 几何、五入口源码守卫与合集 Panel 共 5 项通过,公共组件与测试静态检查零问题,旧页面无新增 error / warning,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并比较五个真实页面的封面裁切、角标、月份分组和滚动位置;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B27:收藏与历史归档网格收口(2026-08-29) + +- 继续扫描后发现,收藏夹详情、我的喜欢和浏览历史仍以 `maxCrossAxisExtent: 160` 布局,却固定向封面传入 `wh: 160`;手机三列实际格宽远小于 160px,同样会造成解码尺寸、裁切和角标与渲染区域不一致。 +- 公共归档组件新增 `LoftifyPostArchiveSliverGrid`,直接使用 Sliver 的真实横轴约束并扣除左右 padding,月份分组页不需要改成嵌套滚动;收藏夹详情和喜欢使用 Sliver 版本,浏览历史使用普通版本,三页均把真实 `tileExtent` 传给封面。 +- 原月份归档、下拉刷新、提前分页、收藏失效提示、取消喜欢、浏览历史清理、批量下载与详情跳转逻辑保持不变;手机保持三列,宽屏只增加合理列数,不无限拉伸图片。 +- 320px 宽并含 12px 两侧 padding 时,普通网格和 Sliver 网格均生成三个 94.67px 正方形且回调尺寸一致;公共几何、八个页面入口源码守卫与合集 Panel 共 6 项通过,目标文件无新增 error / warning,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查收藏夹、喜欢和历史三页的封面裁切、月份分组、取消操作与滚动位置;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B28:收藏夹管理卡片的窄屏适配(2026-08-29) + +- “我的收藏夹”仍使用透明旧 Row:80px 封面、标题 / ID / 篇数和编辑 / 删除按钮全部挤在同一横轴,320px 或大字体下正文列被操作按钮持续压缩;同时缺少当前组件体系的白色内容表面和柔和边界。 +- 新增公共 `LoftifyFavoriteFolderCard`,使用统一 `LoftifyCard` 白色表面、14px 卡片圆角、低噪声边界和无扩散水波纹按压;封面继续 `BoxFit.cover`,标题允许两行,ID 与篇数进入元数据层级,长按复制、详情跳转、编辑、删除和默认收藏夹不可删除的业务语义保持不变。 +- 小于 420px 或文字缩放超过 1.35 倍时,封面缩至 72px,编辑 / 删除移到摘要下一行且继续使用圆形 48px 点击目标;正常宽度保持单行卡片,所有文本和卡片高度由内容驱动,不再以固定 80px 行高裁切。 +- 320×480、2 倍字体与超长英文标题 / ID 下卡片无溢出、管理按钮可点击且不会误触卡片跳转;默认收藏夹危险操作隐藏和真实页面接入守卫共 3 项通过,公共组件零 error / warning(页面仅保留 6 项既有 info),Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实收藏夹封面、长按复制、编辑 / 删除 Panel 和列表分页;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B29:粉丝 / 关注用户卡与关系按钮(2026-08-29) + +- 粉丝 / 关注列表虽然已有圆角卡片,但头像、昵称、ID、简介和关注按钮始终强制同排;旧关系按钮只有 4px 纵向 padding、没有 48px 最小点击目标,仍使用扩散水波纹和局部主题色,窄屏大字体下会持续压缩身份信息。 +- 用户关系卡迁移到公共 `LoftifyCard`,统一白色内容表面、14px 圆角、低噪声边界、无水波纹反馈和 Token 排版;昵称允许两行,ID 与简介使用次级元数据层级,头像、作者跳转、关注 / 取消关注请求和双方关注文案保持不变。 +- 小于 420px 或文字缩放超过 1.35 倍时,关系按钮移到身份摘要下方右对齐;正常宽度继续单行。公共 `buildFramedDoubleButton` 改为 `LoftifyButton` compact 规格,未关注使用克制 tonal 状态、已关注使用 secondary 状态,所有复用位置同步获得至少 48px 点击区和无水波纹反馈。 +- 320×568、2 倍字体的真实长昵称 / 长 ID / 长简介用户卡无溢出且高度自然增长;280px、2 倍长英文关系按钮仍保持至少 48px 并可点击,真实卡片、控件和源码守卫共 3 项通过,目标文件无新增 error / warning(仅保留既有 info),Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查粉丝 / 关注列表、帖子作者区和信息流作者关注入口的真实状态切换;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B30:下载管理任务卡的极窄屏重排(2026-08-29) + +- 下载管理页的父任务标题与状态胶囊、独立任务标题与状态胶囊始终强制同排,独立任务的进度文字还与两枚控制按钮共用一行;280dp、英文、2 倍字体真实回归分别复现 161px 和 3.5px 横向溢出,状态与操作会离开视窗。 +- 小于 380px 或文字缩放超过 1.35 倍时,父任务和独立任务的状态胶囊移到标题下方左对齐,胶囊文本在真实内容宽度内安全省略;独立任务控制按钮移动到进度文字下一行右对齐,进度文字允许两行。正常宽度继续保持原单行信息密度。 +- 任务缩略图继续使用 48px `BoxFit.cover`,确定 / 不确定线性进度、父任务跳转、暂停、继续、重试、取消、删除和批量任务模型均未改变;任务控制按钮保持圆形视觉和原 Tooltip 语义。 +- 280×480、英文、2 倍字体下滚动覆盖父任务与独立任务,父状态、子状态和控制组右边界均未超过 280dp且无框架异常;下载管理与任务详情共 3 项回归通过,目标文件静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实下载状态切换、进度更新、暂停 / 继续和任务详情跳转;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B31:下载任务详情的极窄屏重排(2026-08-29) + +- 下载管理父页修复后继续验证详情链路,280dp、英文、2 倍字体下整组状态与总进度同排会溢出 111px;资源标题与状态胶囊同排分别溢出 165px 和 7.5px,长文案批量按钮虽然使用 Wrap,也不能保证单个按钮受内容宽度约束。 +- 极窄屏或大字体下,整组状态胶囊与总进度改为上下排列,批量暂停 / 恢复 / 重试 / 取消改为纵向满宽按钮;正常宽度仍保持原横向状态行与紧凑 Wrap。资源任务的状态胶囊移到标题下方,进度文字与圆形操作按钮分行,标题、文件名与错误说明继续安全省略。 +- 来源摘要、58px 来源封面和 48px 资源缩略图继续使用 `BoxFit.cover`,查看原内容、线性字节进度、单项暂停 / 恢复 / 重试 / 取消以及整组任务控制逻辑均未改变;所有按钮继续保留至少 48px 点击目标和完整 Tooltip / 文案语义。 +- 280×480、英文、2 倍字体下沿真实详情列表滚动覆盖来源摘要、整组状态、满宽取消按钮与第二个资源任务,所有状态和操作右边界均不超过 280dp且无框架异常;下载页完整 4 项回归通过,目标文件静态检查零问题,Android Debug APK 构建成功。 +- 真机 USB 仍离线,本小项尚待覆盖安装并检查真实整组进度、暂停 / 恢复 / 取消、单项操作和原内容跳转;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B32:批量下载确认页的极窄屏重排(2026-08-29) + +- 继续验证批量下载入口时,280dp、英文、2 倍字体真实复现选择说明与列表所在纵向布局溢出 106px;完成入队后,“重试”和“下载管理”仍被强制放在同一水平行并向右溢出 24px,短屏上选择入口也会被底部操作区遮挡。 +- 将选择摘要、全选规则与帖子列表合并为同一条可滚动 Sliver 内容链,短屏或键盘压缩时说明区不再挤占列表的全部可用高度;窄屏底部结果操作改为纵向满宽按钮,常规宽度继续保持紧凑水平布局。 +- 帖子选择、按需加载全部内容、不可下载内容过滤、逐帖资源解析、批量入队、来源类型、去重结果、失败汇总、重试和下载管理跳转逻辑均未改变;帖子缩略图继续使用 56px `BoxFit.cover`,底部真实解析进度保持线性反馈。 +- 新增 280×480、英文、2 倍字体真实回归,覆盖全选、两项解析入队、结果摘要和底部双操作,所有控件均留在 280dp 视窗内且无框架异常;批量下载页完整 3 项回归通过,目标文件静态检查零问题,Android Debug APK 构建成功。 +- ADB 当前仍未检测到真机,本小项尚待覆盖安装并检查真实批量选择、解析进度、重试和下载管理跳转;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B33:通知消息卡的极窄屏阅读重排(2026-08-29) + +- 通知空态已保持满视窗可滚动,但消息卡在 280dp 下同时横排头像、正文和 50px 帖子预览,正文实际只剩约 130dp;长昵称、英文通知与大字体会被迫形成过窄的碎片化文本列,虽然不一定触发框架溢出,阅读性明显不足。 +- 将消息卡抽取为独立可回归组件;小于 360px 或文字缩放超过 1.35 倍时,头像收紧到 44px,正文获得完整剩余宽度,帖子预览移动到时间下方并保持右对齐。常规宽度继续保留头像、正文和预览同排的信息密度。 +- 昵称强调、通知说明、时间、头像跳转、整卡帖子跳转、底部分隔线及 50px `BoxFit.cover` 预览均保持原业务与视觉语义;同时补齐通知页原有异步加载和各 Tab 构建方法的明确返回类型,目标文件静态检查恢复零问题。 +- 新增 280×480、英文长文案、2 倍字体组件回归,消息正文和预览右边界均不超过 280dp且无框架异常;通知空态与消息卡共 2 项回归通过,Android Debug APK 构建成功。 +- ADB 当前仍未检测到真机,本小项尚待覆盖安装并检查八类通知 Tab 的真实长昵称、预览图、帖子跳转与作者跳转;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B34:手势锁九宫格的短屏几何修复(2026-08-29) + +- 280×480、英文、2 倍字体回归发现手势锁创建页虽然没有抛出 RenderFlex 溢出,却因固定 50 / 30 / 50px 上下留白和 `Flexible` 收缩把声明为 280×280 的九宫格实际压成 280×80;圆点间距、连线轨迹和触摸命中几何因此严重失真,验证页使用相同结构也存在同源风险。 +- 创建与验证页面统一改为明确的正方形画布约束;极窄屏或大字体下使用 240px 画布、44px 内边距和 32px 点间距,常规宽度继续使用最大 400px 画布及原 60 / 40px 几何。标题改为居中换行,空间不足时整页滚动,不再压缩九宫格本体。 +- 手势创建、旧密码验证、二次确认、错误 / 成功颜色、轨迹计算、至少四点规则、生物识别入口、成功回调和安全模式逻辑均未改变;短屏下按钮与画布仍处于同一可滚动内容链,安全区保持可达。 +- 新增 280×480、英文、2 倍字体组件回归,画布由修复前 280×80 恢复为不小于 200px 的正方形(当前为 240×240),且整页具备滚动容错;创建 / 验证目标文件静态检查零问题,Android Debug APK 构建成功。 +- ADB 当前仍未检测到真机,本小项尚待覆盖安装并实际绘制短 / 长轨迹,检查错误重试、二次确认和生物识别入口;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B35:文件命名字段说明的窄屏阅读重排(2026-08-29) + +- 文件命名设置页的输入与字段按钮在 280dp、英文、2 倍字体下没有框架溢出,但字段、说明、示例固定三列后,每列实际只剩约 76dp;长英文说明被拆成大量一两词短行,页面极长且难以建立字段与示例的对应关系。 +- 小于 360px 或文字缩放超过 1.35 倍时,字段参考改为逐项纵向信息卡,依次展示字段名、完整说明和主题色示例;常规宽度继续保留紧凑三列表格。卡片沿用原设置分组背景与轻分割线,没有额外厚重描边或阴影。 +- 文件名格式输入、光标位置插入、全部字段按钮、保存、重置、持久化键和回调逻辑均未改变;同时补齐页面退出时输入控制器与焦点节点的释放,并为保存 / 重置方法增加明确返回类型。 +- 新增 280×480、英文、2 倍字体真实页面回归,确认输入、两枚圆形操作按钮、纵向字段参考均存在且无框架异常;目标文件静态检查零问题,Android Debug APK 构建成功。 +- ADB 当前仍未检测到真机,本小项尚待覆盖安装并检查软键盘弹出、光标插入字段、保存 / 重置和实际下载文件名;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B36:主题预览与强调色的窄屏可访问性修复(2026-08-29) + +- 280×480、英文、2 倍字体真实页面回归复现 3 张主题预览卡各向下溢出 15px:固定 166.4px 卡高无法同时容纳预览内容和放大后的主题名称。强调色色点的 Ink 命中区域也只有 36×36,低于本轮统一的 48px 触摸目标。 +- 公共 `ThemeItem` / `EmptyThemeItem` 按系统文字缩放增加卡片高度,常规字体仍保持原尺寸;窄屏或大字体下,“主题色”标签移到色板上方释放横向空间,全部重置 / 预设色点改为 48×48 命中区,内部可见色块仍保持 36px。 +- 主题预览、横向滚动、明暗主题选择、预设色索引、默认色重置、自定义主题列表和即时持久化逻辑均未改变;旧版已弃用的独立 `Radio` 改为具有 `selected` / `button` 语义的选中状态图标,整卡点击行为保持一致。 +- 新增 280×480、英文、2 倍字体真实页面回归,检查主题卡随字体增高、明亮主题重置色点精确为 48×48 且页面无框架异常;目标文件静态检查零问题,Android Debug APK 构建成功。 +- ADB 当前仍未检测到真机,本小项尚待覆盖安装并检查浅 / 深主题切换、默认极简黑白、自定义主题和所有强调色的即时视觉更新;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B37:移动设置总页的公共结构迁移(2026-08-29) + +- 全局复核普通 AppBar 后确认,移动设置总页仍显式传入 `showBorder: true`,与“所有页面 AppBar 不显示底部分割线”的既定规则冲突;页面还自行拼装 Scaffold、EasyRefresh、10px 边距,并通过 `BlankIconButton` 补齐右侧占位,没有复用已冻结的 `BaseSettingScreen` 结构。 +- `MobileSettingNavigationScreen` 现继承 `BaseSettingScreen` 并统一通过公共设置 Scaffold 构建,默认页面水平边距继续为 10px,Caption 顶部间距继续由组件保持 10px;AppBar 使用公共默认无底线样式,移除不可操作的空按钮,返回按钮继续使用圆形点击面。 +- 通用设置、外观设置、图片设置、LOFTER 基础设置和实验设置五个入口、图标、顺序与原路由跳转均未改变;竖屏保留返回入口,横屏继续遵循公共设置页面的返回规则。 +- 新增 280×480、英文、2 倍字体真实页面回归,确认 AppBar `showBorder=false`、无 `BlankIconButton`、五个 `EntryItem` 完整存在且无框架异常;目标文件静态检查零问题,Android Debug APK 构建成功。 +- ADB 当前仍未检测到真机,本小项尚待覆盖安装并检查设置总页滚动、五个入口跳转和状态栏背景连续性;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B38:字体选择卡的窄屏可访问性修复(2026-08-29) + +- 公共字体卡仍使用新版 Flutter 已弃用的独立 `Radio`,选择入口与整卡预览割裂;自定义字体删除按钮实际约 41×41,低于统一 48px 触摸目标。若直接并排放置两个 48px 控件,又会超过 110px 卡片扣除内边距后的可用宽度。 +- 选择操作改由整张字体卡承载,并补齐字体名称、按钮与选中状态语义;底部只保留 32px 非交互选中标记,删除按钮扩展为精确 48×48,卡片密度、圆角、边框和字体名称层级保持不变。 +- 四行字体预览是固定比例的字样标本,现与系统正文缩放解耦,避免 2 倍字号下向下溢出 54px;字体名称和整卡辅助语义仍遵循系统可访问性设置。字体存在性检查、内置 / 自定义字体加载、应用、持久化和删除回调均未改变。 +- 新增 280×480、2 倍字体组件回归,确认页面无框架异常、整卡可选择、删除入口精确为 48×48 且可点击;目标文件静态检查零问题,Android Debug APK 完整构建成功。 +- ADB 当前仍未检测到真机,本小项尚待覆盖安装并检查内置 / 自定义字体切换、下载、删除和重启恢复;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B39:行内下拉选项的统一交互与选中状态(2026-08-29) + +- 公共行内下拉菜单的选项行没有最小高度约束,短文案默认可能低于 48px;多选和已选单选项右侧还嵌套了收缩版 Material `Checkbox`,与整行点击形成两个重复事件目标,并混入与当前 Lucide / 无水波纹规范不一致的控件反馈。 +- 所有默认选项行现统一为不低于 48px 的完整按钮语义,并直接暴露选中状态;右侧改为 22px 轻量圆角勾选标记,使用统一 `ChewieIcons.check`,标记本身忽略指针事件,点击、键盘与辅助功能入口均由整行唯一承载。 +- 单选关闭、多选保持展开、搜索、已选排除、自定义列表构建器、主题色选中底板、透明水波纹和回调模型均未改变;自定义 `listItemPadding` 仍可调整视觉密度,但不会再把默认交互目标压缩到 48px 以下。 +- 320×600、2 倍字体回归打开真实 `InlineSelectionItem.multiSelect` 浮层,确认两行标记存在、旧 `Checkbox` 不再构建、选项行高度不低于 48px、点击第二项后回调包含两项且无框架异常;菜单组件 12 项测试全部通过,目标文件静态检查零问题,Android Debug APK 完整构建成功。 +- ADB 当前仍未检测到真机,本小项尚待覆盖安装并检查真实语言 / 主题等行内单选、多选、搜索、浮层朝向和系统返回关闭;按用户要求只处理 Android,桌面端未执行。 + +### P2B2B40:字体 / 主题选择卡的统一状态与整卡命中(2026-09-15) + +- 字体与主题预览卡仍分别使用 Material `radio_button_checked` / `radio_button_unchecked`,与 Lucide 图标规范断层;主题卡的选中语义只挂在底部 48px 区域,字体 / 主题新增卡的名称与透明留白也不属于可点击区域。 +- 新增公共 `ChewieSelectionIndicator`,以同一 Lucide 勾选、主题色圆面和低噪声未选边界表达状态,160ms 动画在系统“减少动画”开启时自动关闭;指示器始终忽略指针,选择手势与语义由整张卡唯一承载。 +- 公共 `ClickableGestureDetector` 默认使用不透明命中行为,使组件边界内的透明留白仍可操作,但不扩大布局或触控范围;字体删除按钮继续作为独立子操作,点击删除不会误触字体选择。主题长按编辑、字体加载 / 删除 / 应用及新增文件业务逻辑均未改变。 +- 4 项专项回归覆盖 280×480、2 倍字体、字体卡选择与 48px 删除、字体 / 主题新增卡底部文字区域、主题整卡选中与长按辅助语义,全部通过;目标文件静态检查零问题,Android Debug APK 完整构建成功。 +- Android 16 真机 `986008e8` 已完成 Debug 覆盖安装并在 1200×2670 / 120Hz 下启动,真实登录态恢复正常;Hot Reload 572ms、Hot Restart 2.57s 均成功。按用户要求未执行桌面端构建。 + +### P2B2B63:通知页八分类语言切换(2026-09-15) + +- 已复现通知分类缓存旧语言;改用页面本地化getter,TabController初始化保持稳定ID列表长度。 +- 390×844深浅色,从记忆的点赞Tab开始,三语言循环后八项文案更新、索引1及State / 控制器保持、请求数不增加;两项通知回归及两项动态回归均通过。 +- 测试使用受控用户缓存与空通知响应,不代替真实通知内容验收。源码审查另发现网络失败和缺失用户缓存可能绕过loading集合释放,留待后续实际回归。 + +### P2B2B62:动态页语言切换与非首Tab保留(2026-09-15) + +- 同样复现Tab文案缓存旧语言;显示文案改为读取页面context本地化资源,初始化和索引校验使用稳定ID列表。 +- 390×844默认深浅色各一项,持久化初始Tab设为粮单,英文→简中→繁中→英文,四项文案即时更新、索引3及State / TabController不变、请求数不增加。 +- 使用受控空响应,不代表真实粮单内容或所有Tab数据加载验收完成;真机仍只后台安装,避免干扰当前应用。 + +### P2B2B61:搜索结果页运行中切换语言(2026-09-15) + +- 原Tab文案在State创建时缓存,英文切换简中后仍保留英文,真实页面回归先复现。 +- 文案改为构建时从页面context取得当前本地化资源;控制器长度取稳定ID列表,不在initState订阅本地化依赖。 +- 英文→简中→繁中测试确认页面State与TabController不变、综合Tab索引保持0、语言切换不新增请求;全组60项通过。 +- 未验证非首Tab及其他页面的完整语言切换,相关整体任务保留。 + +### P2B2B60:搜索结果页建议竞态(2026-09-15) + +- 真实SearchResultScreen在建议未返回时提交同一关键词,原实现会接受该旧回调并重新覆盖结果页;新增回归先复现后通过。 +- 提交搜索时递增建议请求序号;改词清空旧建议,移动选区不重复请求;当前异常提供失败反馈,销毁及过期异常不影响页面。 +- 两项结果页场景连同原57项搜索回归,共59项通过,覆盖提交竞态、超时后重新输入成功、选区变化、退出后异常返回。 +- 本轮不代表搜索结果分页、全部Tab真实内容及真机手势通过;相关总体任务保持未完成。 + +### P2B2B59:搜索默认深浅色组合(2026-09-15) + +- 用户建议24组:默认深浅色 × 三语言 × 280 / 720宽 × 1 / 2倍字号,统计可触达且小字与实际Material卡片背景的合成对比度均达到4.5:1。 +- 标签建议12组:默认深浅色 × 三语言 × 普通 / 热榜建议,280宽2倍字号检查;热榜12组:默认深浅色 × 三语言 × 1 / 2倍字号,第30条可达。 +- 连同其余9项交互 / 异步回归,共57项通过。此次未修改产品逻辑,不重新安装同一构建;未覆盖自定义主题、真实页面截图和待复查的真机导航栏手势。 + +### P2B2B58:搜索热榜条目完整性(2026-09-15) + +- 受控30条热榜复现末条不可达;280宽英文2倍字号下统计文本横向溢出471px。 +- Swiper高度按最大可见条目数计算,行高 / 序号宽度随字号适配,统计文本分配受约束的剩余宽度;内部列表移除多余默认padding,过滤不再就地修改模型。 +- 简中 / 繁中 / 英文 × 1 / 2倍字号,第30条均可通过外层滚动触达;连同既有搜索回归33项通过。 +- 尚未覆盖真实榜单页切换、深色与完整真机手势,不将本项测试视为全部搜索页验收。 + +### P2B2B57:关键词与键盘变化的滚动位置(2026-09-15) + +- 真实 SearchScreen 受控网络返回30条建议,滚动后切换关键词,确认新词偏移归零且首条可触达。 +- 新词结果再次滚动,模拟250高度键盘展开 / 收起,确认偏移不变且没有新增搜索请求。 +- 共27项回归通过。本项验证现有行为正确,只新增测试、不调整产品逻辑;未操作手机界面,不代替真机手势复查。 + +### P2B2B56:搜索建议导航栏滚动联动(2026-09-15) + +- 真机确认最后一条建议完整位于导航栏上方,不增加重复底部间距;建议滚动原先不在父级监听控制器中。 +- 增加独立控制器、生命周期释放及可见列表切换通知,仅把当前可见列表交给父级显隐逻辑,避免覆盖下的原列表收到视窗变化时反向影响状态。 +- 26项搜索回归通过:新增受控建议列表接真实ScrollToHide组件,上滑隐藏、反向滑动显示、清空后监听切回原列表。 +- 真机调试中设备切换到其他应用,已停止触屏;热重载完成,但最终完整真机显隐验证仍待复查,自动化结果不代替该项。 + +### P2B2B55:真机搜索建议视觉复查(2026-09-15) + +- 真机普通字号实际搜索art,确认B52统一分行导致热榜条目过高,按钮与内容间留白松散;组件测试通过不等于视觉验收完成。 +- 调整为可用宽度不足280或14字号缩放后超过19时分行,其余横排按钮,保留状态换行保护。 +- 真机热重载前后已查看截图:`build/loftify-search-review.png` / `build/loftify-search-review-after.png`(本地验收,不纳入版本库);普通字号紧凑横排恢复,真实用户结果统计可见。 +- 新增普通尺寸操作区横排测试,连同既有回归共25项通过。深色、其他语言及实际跳转验收仍未完成。 + +### P2B2B54:合集 / 粮单搜索行自适应高度(2026-09-15) + +- 固定80高信息区在2倍字体下复现合集20px、粮单21px底部溢出;固定20高标签栏也无法容纳放大后的文字。 +- 信息区改最小高度,标签使用自然高度的横向滚动,保留原封面尺寸、cover裁切及卡片 / 标签入口。 +- 两类真实组件 × 简中 / 繁中 / 英文 × 280 / 720宽 × 1 / 2倍字号共24组通过,覆盖卡片点击与最后一个标签可达。使用受控模型和空封面,不冒充真实图片或完整页面验收。 + +### P2B2B53:用户搜索建议统计可读性(2026-09-15) + +- 原先ID、作品数、粉丝数全部在同一段单行省略文本,长ID压缩后续统计;拆为独立ID与自动换行统计组,未改卡片风格及用户跳转入口。 +- 三语言 × 两种宽度(280 / 720)× 两档字体(1 / 2倍)共12组,检查独立统计存在、粉丝信息可滚动触达及无布局异常;连同既有搜索异步 / 标签回归共24项通过。 +- 尚未完成真实用户跳转、深浅色截图及所有页面组合验收。 + +### P2B2B52:热榜标签状态与操作布局(2026-09-15) + +- 280×480、2倍文字下,长榜单名与已订阅状态同时出现,英文溢出870px,简繁中文617px。 +- 标签名与状态分行,状态用可换行的文本标记;保留原背景 / 字色,人数完整换行,“进入”在信息下方自适应高度,整行点击逻辑保留。 +- 搜索页受控网络回归共12项通过,包括三语言热榜标签布局和滚动后操作入口可达。 +- 尚未覆盖用户建议及全页面主题 / 内容组合,不据此结束整体验收。 + +### P2B2B51:搜索标签建议大字布局(2026-09-15) + +- 真实 SearchScreen 注入长标签和123456789参与人数:280×480、2倍文字下,英文横向溢出319px,简繁中文各74px。 +- 共享标签建议行把人数调整到标签名下方作为次级信息;内容自然换行,保留完整数据和整行点击区域。 +- 三语言布局回归及既有异步回归共9项通过。仅验证此入口与内容组合,全页面主题 / 尺寸 / 真实接口验收继续保留。 + +### P2B2B50:搜索首页异步生命周期(2026-09-15) + +- 推荐词 / 热榜原先没有捕获网络异常,热榜成功返回时在 mounted 检查前调用 initTab,存在退出后创建控制器的风险。 +- 两条请求均加入生命周期 / 请求序号检查及失败反馈;搜索链接解析完成后也确认页面仍存在再跳转。 +- 真实 SearchScreen 受控网络回归共6项通过:新增初始请求在销毁后完成,以及初始超时后调用原有加载方法重试成功。 +- 重试测试验证加载方法,不表示新增了用户可见的重试入口;全页面真实网络状态、主题与尺寸验收仍未完成。 +- 静态检查无错误 / 警告,剩余6条原有返回类型提示;Android debug 编译、真机覆盖安装及启动成功。 + +### P2B2B49:搜索建议异步顺序(2026-09-15) + +- 复现依据:原逻辑仅判断输入非空,旧请求晚返回可以覆盖新词结果;选区变化也重复请求。 +- 修复:输入变化递增请求序号并清空旧建议;返回时检查页面生命周期及序号,异常同样检查有效性。 +- 验证:真实 SearchScreen 配合受控网络的4项测试通过,覆盖乱序返回、清空后重输同词、过期超时后继续输入、仅移动光标及销毁后返回。 +- 边界:尚不代表搜索整页真实接口、全部主题与尺寸验收完成;静态检查仍有原有9条提示,无错误或警告。 + +### P2B2B48:个人页语言 / 主题 / 尺寸组合验证(2026-09-15) + +- 已加载长资料矩阵涵盖3种语言、2种明暗主题、280×480及720×480、1倍及2倍文字,共24组;连同原有5项等待 / 请求异常 / 页面生命周期测试共29项通过。 +- 矩阵复现普通字号下大数字撑宽整排统计,英文溢出62px,简繁中文溢出164px;横排统计使用三等列并约束内部文字,保持居中和各项点击回调,大字体窄屏继续纵向排列。 +- 各组合均验证长昵称存在、滚动后下载管理可命中且无框架异常;此为渲染与可达性证据,不代表全部主题对比度、服务端行为及其他页面矩阵完成。 +- 定向静态检查零问题,Android Debug 构建并覆盖安装到真机,Hot Reload 成功。 + +### P2B2B47:已加载长资料和大数字统计(2026-09-15) + +- 测试向真实 MineScreen 状态注入长昵称、长 ID、九位热度 / 粉丝 / 关注 / 作品 / 合集数字;280×480、两倍英文复现公共统计数字 Row 向右溢出 284px。 +- 数字和单位改为同一 Text.rich,保持分级字号并居中、按可用宽度换行;标签同步居中。六项页面测试通过,长资料和数字显示后可滚动到下载入口,未检测到布局异常。 +- 此用例是已加载状态的渲染验证,没有伪造通过完整资料接口链路;异步网络场景继续由前四项故障测试单独覆盖。 +- 用户信息组件另 5 项测试通过,定向静态检查无 error / warning(3 项既有 info);Android Debug 构建并安装到真机,截图 `build/loftify-stats.png` 确认普通字号统计与资料布局正常。 + +### P2B2B46:个人页窄屏大字体统计重排(2026-09-15) + +- 真实页面在 280×480、两倍英文下统计行向右溢出 370px。宽度小于 420 且文字缩放超过约 1.36 倍时,统计项改为纵向排列;正常字体保留原横排。 +- 测试采用应用实际 Lottie 刷新参数,滚动并等待回弹后验证下载管理入口命中;5 项页面与请求生命周期回归通过。此项覆盖加载中的窄屏页面,已加载长昵称与超大统计数字仍需后续矩阵检查。 +- 定向静态检查零问题,Android Debug 构建并覆盖安装至真机,Hot Reload 成功;280px 两倍英文几何由页面测试验证。 + +### P2B2B45:个人页异步故障回归与英文资料排版(2026-09-15) + +- 新增真实页面网络拦截测试,所有请求在测试内响应,不连接真实账号或服务器。4 项覆盖资料等待 / 业务失败时下载入口仍存在、连接超时后的刷新返回 fail 且允许重试、页面释放及账号改变后忽略迟到响应。 +- 测试复现 390px 英文资料提示横向溢出 195px,头像旁文字列改为 Expanded 约束后自然换行,四项测试全部通过。保留头像尺寸、字号和复制操作。 +- Android Debug 构建并覆盖安装到真机,Hot Reload 3.02s、Hot Restart 2.80s 成功。 +- 本轮证据覆盖首个资料请求的故障,不延伸为统计和关系列表全部验证;阶段 C 其余尺寸、语言和主题矩阵继续未完成。 + +### P2B2B44:个人页加载异常与固定入口可用性(2026-09-15) + +- 内容中心、创作和退出入口取消对 `blogInfo` 的渲染依赖,继续由外层登录态控制;远端资料未返回时固定入口也能显示。 +- 资料请求整体捕获异步失败,空博客列表和空博客资料返回失败结果;每个异步阶段在更新界面前检查 mounted 与发起请求时的账号。关系请求从资料就绪后启动,移除缓存空值强制解引用,并修正粉丝分页使用关注列表长度的问题。 +- 页面释放刷新及滚动控制器,首帧回调检查挂载状态。静态检查零问题,7 项既有布局守卫通过,Android Debug 构建及真机覆盖安装成功,真实资料显示正常,Hot Reload 成功。 +- 现有布局守卫不覆盖异步故障;断网注入、请求中销毁和切换账号仍需专项验证,阶段 C 状态矩阵保持未完成。 + +### P2B2B43:按用户确认恢复“我的”原顶栏操作(2026-09-15) + +- “我的”普通 AppBar 移除标题,恢复主题切换、装扮、通知、设置四项操作。装扮继续遵循已有功能开关,通知与装扮使用原路由。 +- 内容中心移除通知、装扮的重复入口;下载管理继续保留。此确认覆盖 B41 / B42 的相关入口安排。 +- 7 项导航 / 内容中心检查通过,Android Debug 构建、真机覆盖安装完成;真机截图 `build/loftify-mine-bar.png` 已确认无标题、四个操作及内容中心布局。 + +### P2B2B42:恢复手机端通知入口(2026-09-15) + +- 上一轮 AppBar 回退后,通知路由只剩宽屏侧栏调用,手机端缺少入口。内容中心首项现恢复“通知”,沿用现有本地化、Entry 样式和通知页面路由。 +- 定向静态检查零问题,7 项既有导航 / 内容中心回归通过,Android Debug 构建与真机覆盖安装成功;Hot Reload 2.85s、Hot Restart 2.35s 成功。 +- 真机实际点击已打开通知页,空态完整展示。当前截图位于 `build/loftify-notice-entry.png`、`build/loftify-notice-open.png`。其他主题、语言、尺寸及通知业务状态尚不由本小项证明完成。 + +### P2B2B41:主导航页 AppBar 悬浮方案撤回(2026-09-15) + +- 最新体验复核确认,首页、搜索、动态和“我的”不再采用毛玻璃胶囊、头像按钮和额外状态栏占位组成的悬浮顶栏;四页全部恢复为占据正常 Scaffold 高度的 `ResponsiveAppBar`,页面滚动范围重新从 AppBar 下方开始,已废弃的悬浮导航头实现与专项测试一并删除,避免后续误用。 +- 首页恢复普通“首页”标题和原有 8px 内容起始间距;搜索恢复 AppBar 内默认展开的搜索框,不再增加头像或关闭按钮;动态恢复原可横向滚动的四 Tab、主题色 `UnderlinedTabIndicator`、原标签间距及控制器驱动字体动画;“我的”恢复普通标题栏,并继续遵循已确认的业务入口分配,只在顶部保留主题切换和设置,装扮、下载管理与退出登录仍处于内容中心。 +- 本轮只撤回顶栏结构与视觉,不回退后续已修复的首页响应式瀑布流、动态 Tab 即时加载与底栏显隐稳定、动态合集间距、我的内容中心、下载入口以及各页分页 / 占位逻辑;四个主页面均增加源码结构守卫,防止毛玻璃悬浮头或状态栏沉浸占位再次混入。 +- 新增 5 项主导航 AppBar 专项回归,连同内容中心、导航 Lottie、底栏、关注 / 订阅和帖子归属控件共 41 项相关测试全部通过;四个页面定向静态检查没有新增 error / warning(仅保留 32 项既有 info),Android Debug APK 构建成功。 +- Android 16 真机 `986008e8` 已覆盖安装当前 Debug APK,并在 1200×2670 / 120Hz 真实登录关注流确认固定白色 Tab AppBar、主题色下划线及内容起点正常;Hot Reload 2.66s、Hot Restart 2.99s 均成功。按用户要求未运行 MuMu 或桌面端。 diff --git a/docs/redesign/Visual_Redesign_Audit.md b/docs/redesign/Visual_Redesign_Audit.md new file mode 100644 index 00000000..b9a2d808 --- /dev/null +++ b/docs/redesign/Visual_Redesign_Audit.md @@ -0,0 +1,278 @@ +# Loftify 最终视觉重构:阶段 A 审计与方向冻结 + +> 审计日期:2026-08-24 +> 重构前代码基线:`dev/redesign@c9d72e3` +> 上位总纲:[`Final_Visual_Redesign.md`](../../Final_Visual_Redesign.md) +> 结论:选择“静谧内容工作室(Quiet Content Atelier)”作为唯一实现方向;阶段 B 起不得再逐页临时发明颜色、字号、间距、圆角或动效。 + +## 1. 启动门槛核对 + +| 门槛 | 证据 | 结论 | +| --- | --- | --- | +| 不依赖官方接口的功能均已完成 | `Todo.md` 中仅 `ANNOTATION-01`~`ANNOTATION-04` 与 `REDESIGN-00` 未勾选 | 通过 | +| 官方接口能力已登记延期 | 用户明确允许划线评论及评论扩展暂不实现,Todo 保留原需求且不伪造入口 | 通过 | +| P0 / P1 无未关闭回归 | 基线巡检发现的动态合集卡片溢出已由 `DYNAMIC-01` 修复;205 项测试、Android / Windows Debug 构建通过 | 通过,后续每批继续复核 | +| 信息与入口可用于遗漏对照 | 本文第 5 节冻结页面族与入口;不允许以视觉简化为由删除 | 通过 | +| 有重构前截图与行为测试 | `docs/redesign/baseline/` 归档 19 张跨主题、尺寸与页面族截图 | 通过 | +| 图标与主导航不再边设计边补功能 | Lucide 与毛玻璃导航任务均已独立完成并有守卫测试 | 通过 | +| 工作区可构建、可回退 | `c9d72e3` 已推送到 `origin/dev/redesign` | 通过 | + +阶段 A 可以继续,阶段 B 只能先实现设计 Token 与组件样板,不能越过公共组件直接批量改页面。 + +## 2. 审计方法与代码规模 + +本次使用语法级扫描、现有组件测试、真实 MuMu 数据巡检和历史双端验收截图交叉核对。统计数字用于定位分散程度,不等于每一处都是缺陷;例如媒体遮罩中的纯黑、纯白以及主题预设色属于可能保留的受控例外。 + +| 指标 | Loftify 业务层 | `awesome_chewie` 公共运行层(排除 Module 内置代码高亮等第三方实现) | 观察 | +| --- | ---: | ---: | --- | +| Dart 文件 | 92 个 Screen / Widget 文件(62 Screen、30 Widget) | 168 | 页面和公共层边界存在,但视觉语义仍大量透传原始数值 | +| 直接颜色表达 | 281 | 518 | 现有主题字段很多,但页面层仍会直接使用黑白、透明度和状态色 | +| 显式圆角表达 | 140,分布在 15 个数值 | 54 | 8、10、12、14、16、20、50、99 等并存,缺少角色语义 | +| `EdgeInsets` 表达 | 329 | 174 | 页面边距、卡片节奏、控件内边距没有统一阶梯 | +| 毫秒级动画时长 | 63 | 55 | 业务层存在 14~1600ms 多档时长,状态反馈节奏不稳定 | +| `GestureDetector` / `InkWell` | 87 / 14 | 51 / 52 | 点击反馈、水波纹、键盘焦点与语义行为分散 | +| `Card` / `IconButton` | 35 / 66 | 0 / 107 | 业务层仍直接组合原生组件,状态样式不一定经过公共语义层 | +| 系统圆形 / 线性进度 | 3 / 2 | 3 / 1 | 需要逐项区分“加载”与“可测下载进度”;加载继续使用 Lottie,下载继续使用进度条 | + +现有积极基础: + +- ThemeData、ColorScheme、自定义主题、字体、语言、系统明暗和持久化链路已经存在。 +- Lucide 语义图标、统一 AppBar、Tab、EntryItem、CaptionItem、Panel、Lottie 加载和主导航已有公共实现与回归测试。 +- 图片裁切、列表懒构建、详情局部错误、刷新状态、视频手势、下载队列等业务行为已有明确测试,最终重构无需重新发明逻辑。 + +## 3. 现状问题清单 + +### 3.1 色彩与层级 + +1. `ChewieThemeColorData` 同时承担预设色、组件色和语义色,但 `ColorScheme` 只覆盖部分角色;页面底色、内容底色、输入层、悬浮层、选中层和禁用层缺少稳定的一一映射。 +2. 浅色页面已经按产品要求保持白色,但灰色卡片、输入层和 Caption 区域的灰度、边框与分隔线强度不一致,容易形成“很多浅灰盒子叠在白底上”的脏乱感。 +3. `ChewieColors` 中点赞、收藏、分享等功能色较多。高频互动如果同时使用各自彩色,会和作品图片争夺注意力;未来只允许当前选中态或危险状态使用强调色。 +4. 深色主题存在多个不同黑灰起点,阴影直接复用黑色会失去层级;需要以边界、亮度差和极弱环境阴影共同表达。 +5. 自定义主题必须改变强调色而不是破坏内容底色、正文对比度或危险色含义。 + +### 3.2 字体与阅读 + +1. TextTheme 已经集中定义,但仍偏 Material 通用模板:显示级 57 / 45 / 36 基本不服务当前内容产品,而卡片标题、作者、标签、元信息和按钮又依赖局部 `apply` 叠加。 +2. 页面标题、区块标题和卡片标题在不同页面经常只靠字重区分,字号和行高关系不稳定;中英文混排时基线与视觉重量变化明显。 +3. 长文正文目前沿用普通正文思路,缺少独立的阅读宽度、17px 左右字号、1.75~1.85 行高、段落节奏、引用和图片说明语义。 +4. 次要文字存在过浅风险;低对比元信息在浅色图片密集页面和深色模式中都容易消失。 +5. 大字体时仍有固定高度页面残留。`DYNAMIC-01` 已证明仅以当前字号调通并不可靠,后续组件默认必须内容驱动高度。 + +### 3.3 间距、栅格与响应式 + +1. 业务层 329 处直接 EdgeInsets 表达,4、5、6、8、10、12、15、16、18、20 等近邻数值并存,页面节奏无法形成清晰重复规律。 +2. 当前断点同时存在 380、480、500、600×900、700 等页面级判断;“平板/桌面”更多由平台与方向决定,而不是统一的可用宽度和内容最大宽度。 +3. 手机瀑布流内容很满,标题、标签、作者、热度和菜单同时出现;平板横屏则容易把过多列铺满整屏,缺少内容最大宽度和密度上限。 +4. 设置、管理页适合低密度分组;首页、搜索适合高密度内容流;个人主页与详情适合中密度。当前没有显式密度等级。 +5. 键盘、安全区、系统手势区已有局部处理,但需要由页面框架统一提供而不是每页重复计算。 + +### 3.4 圆角、边框与阴影 + +1. 15 种圆角数值会让卡片、图片、标签、输入框、Panel 和 Dialog 的角色关系模糊。 +2. 某些区域同时使用灰底、描边、阴影和圆角,层级信号重复;另一些可点击区域只有透明 GestureDetector,没有焦点或按下反馈。 +3. 圆角 50 / 99 被当作“胶囊”的数值技巧,需要替换为明确的 full/pill 语义。 +4. 图片圆角必须服务裁切边界,Panel 圆角必须服务空间层级;不能因为统一而让所有内容都变成同一个圆角盒子。 + +### 3.5 图标、交互与动效 + +1. Lucide 迁移已经完成,后续重点从“换图标”转为统一视觉尺寸、文字基线、选中容器和语义状态。 +2. 87 个业务 GestureDetector 说明点击反馈仍分散。瀑布流卡片无水波纹是已确认规则,但设置项、菜单、按钮等需要统一 hover / press / focus;不能全局机械添加水波纹。 +3. 业务层 63 处动画时长覆盖太多档位。90ms 按下、180ms 状态、220ms 页面、260ms Panel、280ms 内容进入足以覆盖绝大多数场景。 +4. 减少动画、降低透明度和高对比度已有部分链路,阶段 B 必须成为每个公共动效/玻璃组件的默认分支。 +5. Lottie 只承担不可测的加载状态;下载图片、视频和文件必须继续展示确定进度,不能再次替换成 Lottie。 + +### 3.6 内容呈现 + +1. 首页作品卡片中封面、标题、标签、作者、热度和更多菜单常处于相近视觉权重,内容层级不足。 +2. 搜索热榜、推荐词和历史记录使用不同分组方式;热榜大灰卡容易比搜索本身更重。 +3. 动态的关注、标签、合集和粮单具有不同信息结构,但需要共享作者/封面/摘要/状态/操作的节奏。`DYNAMIC-01` 前的固定高度是典型反例。 +4. 个人主页头图、身份信息、统计、主操作和 Tab 目前信息完整,但头图区很重,容易压缩首屏作品空间。 +5. 帖子详情的正文已可读,但 AppBar 信息、作者区、正文、合集/粮单提示、浮动操作和评论边界仍需以“阅读模式”重新组织视觉权重。 +6. 评论区不应重新添加整块灰底;优化重点是头像、昵称、回复关系、正文、时间和操作的垂直顺序。 + +## 4. 两套完整视觉方向 + +### 方向 A:静谧内容工作室(Quiet Content Atelier)— 选定 + +核心比喻是“白色工作台上摆放作品集”。页面框架安静、低噪声,作品本身提供绝大多数色彩;界面使用现有青绿色强调色建立 Loftify 自身识别,不模拟 LOFTER 官方视觉。 + +| 维度 | 方案 | +| --- | --- | +| 浅色 | 页面与主导航保持纯白;内容层使用接近白色的中性暖灰,边界比阴影更常用 | +| 深色 | 非纯黑的中性墨色页面,内容层只提高少量亮度,避免大块发灰或黑洞感 | +| 强调 | 默认延续 Loftify 青绿;只用于选中、主要动作、链接和进度,不制造彩色按钮墙 | +| 字体 | 系统字体 / 用户字体保持可切换;以清晰无衬线为主,长文通过字号、行高和宽度建立文艺感,不强制装饰字体 | +| 形态 | 图片 12~14px、卡片 14px、Panel 24px;标签使用 6~10px 或胶囊语义,不把所有容器都做成大圆角 | +| 层级 | 白底、内容面、悬浮面三层;普通内容优先留白与分隔,只有真实悬浮物使用阴影 | +| 动效 | 轻、短、跟手;图片和阅读过渡强调连续性,状态切换强调结果,不做循环装饰 | +| 密度 | 首页/搜索高密度,详情/主页中密度,设置/管理低密度,共享 Token 但使用不同布局配方 | +| 可访问性 | 44×44 最小点击区、文本对比度目标 WCAG AA、选中态同时使用颜色与形态 | + +优势:最大限度保持用户已经确认的白色浅色背景、青绿主题、毛玻璃导航和内容优先;对摄影、绘画、同人、文字等内容类型都中性;自定义主题与深色模式容易稳定派生。 +风险:如果留白和层级控制不精确,会显得普通或空。必须依靠字体节奏、栅格、细边界和内容裁切质量体现精致度。 + +### 方向 B:编辑画廊(Editorial Gallery)— 未选 + +核心比喻是“独立杂志与展览目录”。使用更强的标题尺度、细分隔线、较小圆角和更严格的版心,让文学、摄影与专题合集具有编辑感。 + +| 维度 | 方案 | +| --- | --- | +| 浅色 | 纸张暖白作为主底,纯白作为图片和高价值内容的展板 | +| 深色 | 深蓝墨色页面与偏暖白正文,形成印刷品式对比 | +| 强调 | 墨绿或暗朱红作为单一编辑标记,标签和序号更接近目录符号 | +| 字体 | 标题可使用用户选择字体的较强字重,长文可引入受控衬线回退;元信息使用紧凑无衬线 | +| 形态 | 6~10px 小圆角、更多细线和编号,Panel/导航仍保留较大空间圆角 | +| 层级 | 更少灰卡,更多版心、留白、分隔线和跨栏图像 | +| 动效 | 类似翻页、目录定位和展品聚焦的空间连续性,时长略长但仍支持减少动画 | +| 密度 | 首页更像杂志拼版,专题/合集表现突出,设置页则保持编辑目录式分组 | + +优势:辨识度强,长文、专题、摄影和合集展示有明显气质。 +风险:暖色页面不符合用户已多次确认的浅色纯白要求;衬线和强编辑网格会增加多语言、用户字体、瀑布流与自定义主题的适配成本,也更容易让框架压过作品。 + +### 选型结论 + +选择方向 A。吸收方向 B 的“长文版心、标题节奏、细分隔线和专题秩序”,但不引入暖纸主底、装饰性衬线或杂志式强拼版。这个决定同时满足: + +- 浅色所有页面与主导航继续是白色。 +- 现有青绿色成为独立第三方产品的克制识别,不冒充 LOFTER 官方。 +- 内容图片仍提供主要色彩,主题系统不会与作品竞争。 +- 既有自定义主题、字体、语言、深浅色和响应式逻辑可以平滑迁移。 + +## 5. 信息架构与入口冻结 + +最终重构只允许改变视觉层级和布局密度,下列信息与入口不得删除、隐藏或改写业务含义。 + +```text +Loftify +├─ 主导航 +│ ├─ 首页:信息流、布局切换、刷新、帖子更多、作品详情 +│ ├─ 搜索:搜索框、历史、猜你喜欢、热榜、搜索结果六类 Tab +│ ├─ 动态:关注、标签、合集、粮单四类 Tab +│ └─ 我的 +│ ├─ 身份:头像、昵称、ID、文章/合集、热度、粉丝、关注 +│ ├─ 内容中心:喜欢、推荐、收藏、足迹、下载管理 +│ ├─ 我的创作:作品、合集、粮单 +│ └─ 快捷操作:退出、主题、装扮、通知、设置 +├─ 内容发现 +│ ├─ 标签详情:推荐、最新、最热、筛选、合集/粮单、相关标签、装扮 +│ ├─ 搜索结果:全部、标签、合集、粮单、文章、用户 +│ └─ 收藏夹、喜欢、推荐、历史、合集与粮单列表 +├─ 内容消费 +│ ├─ 图片/图文/长文详情:作者、正文、标签、互动、合集/粮单、评论、切篇 +│ ├─ 图片灯箱:原图、翻页、缩放、下拉退出、下载/链接 +│ ├─ 视频:播放、进度、全屏、作者页、弹幕、倍速、长按菜单、下载 +│ └─ 评论:热评、全部评论、多级回复、输入与分页 +├─ 作者与关系 +│ ├─ 个人主页:资料头、统计、关注/浏览器、作品/喜欢/推荐/合集/粮单 +│ ├─ 粉丝、关注、支持者 +│ └─ 黑名单、屏蔽标签、不看 TA 动态 +├─ 资产与批量操作 +│ ├─ 下载管理:活动任务、历史、暂停/继续/取消/重试/清理 +│ ├─ 批量下载:选择、全选、解析、进度、失败重试、去重 +│ └─ 装扮:主题背景、头像框、装扮、表情包及详情 +└─ 系统与身份 + ├─ 设置:通用、外观、图片、LOFTER 基础、实验室、管理、关于 + ├─ 主题、语言、字体、刷新率、安全与锁屏 + └─ 登录:验证码、密码、邮箱、LOFTER ID、滑动验证 +``` + +官方接口暂缓项仍保留在需求层:正文选区评论、区间绑定、划线高亮与定位、原文变更和重叠选区处理。最终视觉重构不得伪造可提交的划线评论入口。 + +## 6. 重构前截图矩阵 + +截图均归档在 [`baseline/`](baseline/)。“现场”表示本轮在 MuMu 当前构建重新采集;“既有验收”表示来自重构前已通过对应功能验收的截图并重新归档。 + +| 页面 / 状态 | 手机浅色 | 手机深色 | 平板 / 横屏 | Windows | +| --- | --- | --- | --- | --- | +| 首页 | [现场](baseline/android-phone-light-home.png) | [现场](baseline/android-phone-dark-home.png) | [竖屏浅色](baseline/android-tablet-portrait-light-home.png)、[横屏深色](baseline/android-tablet-landscape-dark-home.png) | [最小窗口浅色](baseline/windows-minimum-light-home.png) | +| 搜索 | [现场](baseline/android-phone-light-search.png) | [既有验收](baseline/android-phone-dark-search.png) | — | 由首页最小窗口先冻结导航与网格,阶段 C 补搜索实景 | +| 动态 | [修复后现场](baseline/android-phone-light-dynamic.png) | — | — | 阶段 C 补齐 | +| 我的 | [现场](baseline/android-phone-light-mine.png) | [现场](baseline/android-phone-dark-mine.png) | — | 阶段 C 补齐 | +| 帖子详情 / 长文 | [既有验收](baseline/android-phone-light-post-detail.png) | — | — | 阶段 C 补齐阅读版心 | +| 标签详情 | [既有验收](baseline/android-phone-light-tag-detail.png) | — | — | 阶段 C 补齐 | +| 个人主页 | [既有验收](baseline/android-phone-light-profile.png) | — | — | 阶段 C 补齐 | +| 设置 | [既有验收](baseline/android-phone-light-settings.png) | — | [横屏深色](baseline/android-tablet-landscape-dark-settings.png) | 阶段 C 补齐 | +| 登录 / 键盘 | [既有验收](baseline/android-phone-light-login.png) | — | — | 阶段 D 补齐 | +| 评论 Panel | [既有验收](baseline/android-phone-light-comment-panel.png) | — | — | 阶段 D 补齐 | +| 视频横屏 | — | — | [既有验收](baseline/android-landscape-video.png) | 不适用播放器原生横屏布局 | + +补充回归证据:[动态合集溢出修复前](baseline/android-phone-light-dynamic-overflow-before.png)。 + +当前矩阵已经足够证明阶段 B/C 的前后变化;缺口不是完成豁免,必须在相应页面族迁移时补齐深色、英文、大字体、桌面与异常状态截图。 + +## 7. 已冻结的设计语言骨架 + +以下数值是阶段 B Token 的实现合同;页面不得直接复制这些数字,应通过语义 Token 使用。 + +### 7.1 语义色目标 + +| 角色 | 浅色基准 | 深色基准 | 规则 | +| --- | --- | --- | --- | +| page | `#FFFFFF` | `#121412` | Scaffold、主滚动区、主导航的连续底色 | +| surface | `#F7F8F7` | `#191C1A` | 普通分组、输入层、低强调内容面 | +| surfaceRaised | `#FFFFFF` | `#202421` | Panel、Dialog、浮动操作和真实悬浮层 | +| textPrimary | `#202522` | `#F2F5F3` | 正文与标题,必须满足 AA | +| textSecondary | `#66706B` | `#AEB8B2` | 作者补充、时间、统计 | +| textMuted | `#8D9791` | `#839089` | 占位与低优先信息,不用于关键正文 | +| outline | `#E5E9E6` | `#303630` | 结构边界,默认 0.6~1px | +| accent | 默认延续 `#14C2BB` | 默认提高亮度 | 自定义主题可替换;选中态还需形态信号 | +| danger / warning / success | 独立语义 | 独立语义 | 不随自定义强调色漂移 | + +最终颜色将由当前 `ChewieThemeColorData` 派生,不直接把默认青绿写死到页面。高对比度模式会提升文本和边界对比,降低透明度模式会把玻璃层替换为 `surfaceRaised`。 + +### 7.2 字体层级目标 + +| 角色 | 手机基准 | 说明 | +| --- | --- | --- | +| display | 28 / 1.25 / 700 | 极少量品牌或内容数字,不作为普通页面标题 | +| pageTitle | 20 / 1.30 / 700 | AppBar 与页面主标题 | +| sectionTitle | 16 / 1.35 / 650 | 区块和 Caption 标题 | +| cardTitle | 15 / 1.40 / 600 | 作品标题,通常 1~2 行 | +| body | 15 / 1.60 / 400 | 普通正文和说明 | +| readingBody | 17 / 1.80 / 400 | 长文正文,宽屏版心最大 720px | +| metadata | 12 / 1.45 / 400 | 时间、地点、热度、次要关系 | +| label | 13 / 1.30 / 600 | 按钮、标签与筛选 | + +系统字体缩放不通过固定高度抵消;控件可增长,操作行必要时换布局而不是截断业务文案。自定义字体只替换家族,不破坏角色字号、行高和最大宽度。 + +### 7.3 间距、圆角与栅格目标 + +- 间距阶梯:2、4、6、8、12、16、20、24、32、40。 +- 页面水平边距:小屏 12,普通手机 16,平板 24;正文与图片可按内容类型使用独立版心。 +- 圆角:小控件 6,输入/标签 10,图片与普通卡片 14,Panel / Dialog 20~24,胶囊使用 `full` 语义。 +- 内容沟槽:手机双列 8~10,宽手机/平板 12,桌面 16;列数由最小卡片宽度和最大内容宽度共同决定,不铺满无限宽屏。 +- 宽度等级:compact `<600`、medium `600–839`、expanded `840–1199`、large `>=1200`。设备方向只影响布局选择,不替代可用宽度判断。 +- 密度等级:`contentDense`(首页/搜索)、`contentComfortable`(详情/主页)、`controlComfortable`(设置/管理)。 + +### 7.4 边界、阴影与动效目标 + +- 普通内容默认不同时使用灰底、描边和阴影;优先留白,其次细边界。 +- 悬浮导航/操作条使用一级环境阴影;Panel/Dialog 使用二级阴影;深色模式主要依靠亮度差和边界。 +- 时长阶梯:press 90ms、state 180ms、page 220ms、panel 260ms、content 280ms。 +- 曲线:进入 `easeOutCubic`,退出 `easeInCubic`,手势回弹使用受控弹性曲线;减少动画时全部收口到即时或极短淡化。 +- Lottie、模糊、大图变换必须有重绘边界和性能降级;60/90/120Hz 使用真实时间而非逐帧增量。 + +## 8. 阶段 B/C 执行顺序与守卫 + +1. 建立颜色、字体、间距、圆角、阴影、栅格、密度和动效 ThemeExtension;现有主题与用户自定义强调色必须可派生。 +2. 建立真实内容组件样板,覆盖中文/繁体/英文、长标题、大字体、无图、失败、禁用、加载和选中。 +3. 先迁移按钮、输入、标签、卡片框架、设置项、菜单、Panel 和空/错/加载状态,再迁移页面。 +4. 代表页面依次为:首页、帖子详情、标签详情、个人主页、设置。每个试点都保留真实数据、滚动、刷新、失败、空态与交互。 +5. 页面族迁移顺序遵循总纲;每完成一族就运行全量测试、Android/Windows 构建、MuMu Hot Reload/Restart,并单独提交推送。 + +新增守卫: + +- 新页面不得直接新增未登记的颜色、圆角、间距或时长数值。 +- 媒体黑白遮罩、品牌标识、用户内容、代码高亮主题等例外必须登记角色和退出条件。 +- 不以统一风格为由给瀑布流、Tab、评论区重新添加已明确取消的水波纹或灰色整块背景。 +- 任何视觉迁移都要对照第 5 节入口清单,证明信息、数据、操作和返回路径没有遗漏。 + +## 9. 阶段 A 完成判定 + +- [x] 已形成现状问题清单和可量化组件/样式分散数据。 +- [x] 已冻结主要页面、功能和入口,不删除业务含义。 +- [x] 已归档手机、平板、横屏、桌面、浅色、深色与代表页面截图。 +- [x] 已比较两套完整方向并选择“静谧内容工作室”。 +- [x] 已明确设计语言骨架、阶段顺序、例外和测试责任。 + +下一步进入阶段 B:先实现全局设计 Token 与组件样板,不直接开始全量页面换肤。 diff --git a/docs/redesign/baseline/android-landscape-video.png b/docs/redesign/baseline/android-landscape-video.png new file mode 100644 index 00000000..d39b2912 Binary files /dev/null and b/docs/redesign/baseline/android-landscape-video.png differ diff --git a/docs/redesign/baseline/android-phone-dark-home.png b/docs/redesign/baseline/android-phone-dark-home.png new file mode 100644 index 00000000..4b07130a Binary files /dev/null and b/docs/redesign/baseline/android-phone-dark-home.png differ diff --git a/docs/redesign/baseline/android-phone-dark-mine.png b/docs/redesign/baseline/android-phone-dark-mine.png new file mode 100644 index 00000000..27aeb719 Binary files /dev/null and b/docs/redesign/baseline/android-phone-dark-mine.png differ diff --git a/docs/redesign/baseline/android-phone-dark-search.png b/docs/redesign/baseline/android-phone-dark-search.png new file mode 100644 index 00000000..03c44066 Binary files /dev/null and b/docs/redesign/baseline/android-phone-dark-search.png differ diff --git a/docs/redesign/baseline/android-phone-light-comment-panel.png b/docs/redesign/baseline/android-phone-light-comment-panel.png new file mode 100644 index 00000000..60e803bf Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-comment-panel.png differ diff --git a/docs/redesign/baseline/android-phone-light-dynamic-overflow-before.png b/docs/redesign/baseline/android-phone-light-dynamic-overflow-before.png new file mode 100644 index 00000000..b028dd9d Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-dynamic-overflow-before.png differ diff --git a/docs/redesign/baseline/android-phone-light-dynamic.png b/docs/redesign/baseline/android-phone-light-dynamic.png new file mode 100644 index 00000000..4e08cb94 Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-dynamic.png differ diff --git a/docs/redesign/baseline/android-phone-light-home.png b/docs/redesign/baseline/android-phone-light-home.png new file mode 100644 index 00000000..68bfbf43 Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-home.png differ diff --git a/docs/redesign/baseline/android-phone-light-login.png b/docs/redesign/baseline/android-phone-light-login.png new file mode 100644 index 00000000..8e0c353c Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-login.png differ diff --git a/docs/redesign/baseline/android-phone-light-mine.png b/docs/redesign/baseline/android-phone-light-mine.png new file mode 100644 index 00000000..950bd9b5 Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-mine.png differ diff --git a/docs/redesign/baseline/android-phone-light-post-detail.png b/docs/redesign/baseline/android-phone-light-post-detail.png new file mode 100644 index 00000000..d460bd50 Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-post-detail.png differ diff --git a/docs/redesign/baseline/android-phone-light-profile.png b/docs/redesign/baseline/android-phone-light-profile.png new file mode 100644 index 00000000..19300cee Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-profile.png differ diff --git a/docs/redesign/baseline/android-phone-light-search.png b/docs/redesign/baseline/android-phone-light-search.png new file mode 100644 index 00000000..d5f9f3c9 Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-search.png differ diff --git a/docs/redesign/baseline/android-phone-light-settings.png b/docs/redesign/baseline/android-phone-light-settings.png new file mode 100644 index 00000000..d53e264d Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-settings.png differ diff --git a/docs/redesign/baseline/android-phone-light-tag-detail.png b/docs/redesign/baseline/android-phone-light-tag-detail.png new file mode 100644 index 00000000..d2c84e84 Binary files /dev/null and b/docs/redesign/baseline/android-phone-light-tag-detail.png differ diff --git a/docs/redesign/baseline/android-tablet-landscape-dark-home.png b/docs/redesign/baseline/android-tablet-landscape-dark-home.png new file mode 100644 index 00000000..e00b9121 Binary files /dev/null and b/docs/redesign/baseline/android-tablet-landscape-dark-home.png differ diff --git a/docs/redesign/baseline/android-tablet-landscape-dark-settings.png b/docs/redesign/baseline/android-tablet-landscape-dark-settings.png new file mode 100644 index 00000000..cb4b21c4 Binary files /dev/null and b/docs/redesign/baseline/android-tablet-landscape-dark-settings.png differ diff --git a/docs/redesign/baseline/android-tablet-portrait-light-home.png b/docs/redesign/baseline/android-tablet-portrait-light-home.png new file mode 100644 index 00000000..658b509c Binary files /dev/null and b/docs/redesign/baseline/android-tablet-portrait-light-home.png differ diff --git a/docs/redesign/baseline/windows-minimum-light-home.png b/docs/redesign/baseline/windows-minimum-light-home.png new file mode 100644 index 00000000..014c3465 Binary files /dev/null and b/docs/redesign/baseline/windows-minimum-light-home.png differ diff --git a/l10n.yaml b/l10n.yaml index bfb3f244..c036dc55 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -3,4 +3,3 @@ template-arb-file: intl_en.arb output-localization-file: app_localizations.dart output-class: AppLocalizations output-dir: lib/generated -synthetic-package: false \ No newline at end of file diff --git a/lib/Api/tag_api.dart b/lib/Api/tag_api.dart index 896d75b0..c91bb01b 100644 --- a/lib/Api/tag_api.dart +++ b/lib/Api/tag_api.dart @@ -226,6 +226,60 @@ class GetTagPostListParams { }; } + Map toPreferenceJson() { + return { + "resultType": tagPostResultType.index, + "recentDayType": recentDayType.index, + "rangeType": tagRangeType.index, + "protected": protectedFlag, + "postType": postTypes.index, + "postYm": postYm, + }; + } + + factory GetTagPostListParams.fromPreference({ + required String tag, + required dynamic value, + required TagPostResultType fallbackResultType, + }) { + if (value is! Map) { + return GetTagPostListParams( + tag: tag, + tagPostResultType: fallbackResultType, + ); + } + return GetTagPostListParams( + tag: tag, + tagPostResultType: _enumAt( + TagPostResultType.values, + value["resultType"], + fallbackResultType, + ), + recentDayType: _enumAt( + TagRecentDayType.values, + value["recentDayType"], + TagRecentDayType.noLimit, + ), + tagRangeType: _enumAt( + TagRangeType.values, + value["rangeType"], + TagRangeType.noLimit, + ), + protectedFlag: value["protected"] == true, + postTypes: _enumAt( + TagPostType.values, + value["postType"], + TagPostType.noLimit, + ), + postYm: value["postYm"] is String ? value["postYm"] as String : "", + ); + } + + static T _enumAt(List values, dynamic value, T fallback) { + final index = value is num ? value.toInt() : -1; + return index >= 0 && index < values.length ? values[index] : fallback; + } + GetTagPostListParams clone() { return GetTagPostListParams( tag: tag, diff --git a/lib/Models/download_task.dart b/lib/Models/download_task.dart new file mode 100644 index 00000000..5baa83b8 --- /dev/null +++ b/lib/Models/download_task.dart @@ -0,0 +1,426 @@ +enum DownloadMediaType { + image, + video, + file; + + static DownloadMediaType fromName(String? value) { + return DownloadMediaType.values.firstWhere( + (type) => type.name == value, + orElse: () => DownloadMediaType.file, + ); + } +} + +enum DownloadTaskStatus { + queued, + downloading, + paused, + completed, + failed, + cancelled; + + static DownloadTaskStatus fromName(String? value) { + return DownloadTaskStatus.values.firstWhere( + (status) => status.name == value, + orElse: () => DownloadTaskStatus.failed, + ); + } +} + +enum DownloadFailureKind { + network, + storage, + server, + unknown; + + static DownloadFailureKind? fromName(String? value) { + if (value == null || value.isEmpty) return null; + for (final kind in DownloadFailureKind.values) { + if (kind.name == value) return kind; + } + return DownloadFailureKind.unknown; + } +} + +/// Stable business source for a parent download task. +enum DownloadSourceType { + postAll, + collection, + grain, + likes, + recommendations, + favoriteFolder, + other; + + static DownloadSourceType fromName(String? value) { + return DownloadSourceType.values.firstWhere( + (type) => type.name == value, + orElse: () => DownloadSourceType.other, + ); + } +} + +class DownloadSourceDescriptor { + const DownloadSourceDescriptor({ + required this.type, + required this.sourceId, + required this.title, + this.thumbnailUrl, + this.metadata = const {}, + }); + + final DownloadSourceType type; + final String sourceId; + final String title; + final String? thumbnailUrl; + final Map metadata; + + String get stableKey => '${type.name}:${sourceId.trim()}'; + + Map toJson() => { + 'type': type.name, + 'sourceId': sourceId, + 'title': title, + 'thumbnailUrl': thumbnailUrl, + 'metadata': metadata, + }; + + factory DownloadSourceDescriptor.fromJson(Map json) { + final rawMetadata = json['metadata']; + return DownloadSourceDescriptor( + type: DownloadSourceType.fromName(json['type']?.toString()), + sourceId: json['sourceId']?.toString() ?? '', + title: json['title']?.toString() ?? '', + thumbnailUrl: json['thumbnailUrl']?.toString(), + metadata: rawMetadata is Map + ? rawMetadata.map( + (key, value) => MapEntry(key.toString(), value.toString()), + ) + : const {}, + ); + } +} + +enum DownloadGroupStatus { + queued, + downloading, + paused, + completed, + partiallyFailed, + failed, + cancelled, +} + +/// A normalized request that can be submitted alone or as part of a batch. +class DownloadRequest { + const DownloadRequest({ + required this.url, + required this.fileName, + required this.mediaType, + this.title, + this.thumbnailUrl, + }); + + final String url; + final String fileName; + final String? title; + final String? thumbnailUrl; + final DownloadMediaType mediaType; +} + +/// Result of one atomic batch enqueue operation. +/// +/// Existing active or completed resources are deliberately skipped. Failed, +/// cancelled and paused tasks are requeued so a partial batch can be retried +/// without creating duplicate records or files. +class DownloadBatchResult { + const DownloadBatchResult({ + required this.requestedCount, + required this.queuedCount, + required this.skippedCount, + required this.invalidCount, + required this.requeuedCount, + required this.tasks, + this.group, + }); + + final int requestedCount; + final int queuedCount; + final int skippedCount; + final int invalidCount; + final int requeuedCount; + final List tasks; + final DownloadGroup? group; + + int get newTaskCount => queuedCount - requeuedCount; +} + +class DownloadGroup { + const DownloadGroup({ + required this.id, + required this.source, + required this.taskIds, + required this.requestedCount, + required this.createdAt, + required this.updatedAt, + this.unavailableCount = 0, + this.skippedCount = 0, + }); + + final String id; + final DownloadSourceDescriptor source; + final List taskIds; + final int requestedCount; + final int unavailableCount; + final int skippedCount; + final DateTime createdAt; + final DateTime updatedAt; + + DownloadGroupSnapshot snapshot(Iterable allTasks) { + final taskIdsSet = taskIds.toSet(); + final tasks = allTasks + .where((task) => taskIdsSet.contains(task.id)) + .toList(growable: false); + return DownloadGroupSnapshot(group: this, tasks: tasks); + } + + Map toJson() => { + 'id': id, + 'source': source.toJson(), + 'taskIds': taskIds, + 'requestedCount': requestedCount, + 'unavailableCount': unavailableCount, + 'skippedCount': skippedCount, + 'createdAt': createdAt.millisecondsSinceEpoch, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + }; + + factory DownloadGroup.fromJson(Map json) { + final createdAt = DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ); + final rawSource = json['source']; + return DownloadGroup( + id: json['id']?.toString() ?? '', + source: DownloadSourceDescriptor.fromJson( + rawSource is Map + ? Map.from(rawSource) + : const {}, + ), + taskIds: (json['taskIds'] as List?) + ?.map((id) => id.toString()) + .where((id) => id.isNotEmpty) + .toList(growable: false) ?? + const [], + requestedCount: (json['requestedCount'] as num?)?.toInt() ?? 0, + unavailableCount: (json['unavailableCount'] as num?)?.toInt() ?? 0, + skippedCount: (json['skippedCount'] as num?)?.toInt() ?? 0, + createdAt: createdAt, + updatedAt: DateTime.fromMillisecondsSinceEpoch( + (json['updatedAt'] as num?)?.toInt() ?? + createdAt.millisecondsSinceEpoch, + ), + ); + } +} + +class DownloadGroupSnapshot { + const DownloadGroupSnapshot({required this.group, required this.tasks}); + + final DownloadGroup group; + final List tasks; + + int get completedCount => + tasks.where((task) => task.status == DownloadTaskStatus.completed).length; + + int get failedCount => tasks + .where((task) => + task.status == DownloadTaskStatus.failed || + task.status == DownloadTaskStatus.cancelled) + .length; + + bool get isActive => tasks.any((task) => task.isActive); + bool get isTerminal => + tasks.isNotEmpty && tasks.every((task) => task.isTerminal); + + double get progress { + if (tasks.isEmpty) return 0; + final total = tasks.fold(0, (sum, task) { + if (task.status == DownloadTaskStatus.completed) return sum + 1; + return sum + task.progress.clamp(0.0, 1.0); + }); + return (total / tasks.length).clamp(0.0, 1.0); + } + + int get receivedBytes => + tasks.fold(0, (sum, task) => sum + task.receivedBytes); + int get totalBytes => + tasks.fold(0, (sum, task) => sum + task.totalBytes); + + DownloadGroupStatus get status { + if (tasks.isEmpty) return DownloadGroupStatus.failed; + if (tasks.any((task) => task.status == DownloadTaskStatus.downloading)) { + return DownloadGroupStatus.downloading; + } + if (tasks.any((task) => task.status == DownloadTaskStatus.queued)) { + return DownloadGroupStatus.queued; + } + if (tasks.any((task) => task.status == DownloadTaskStatus.paused)) { + return DownloadGroupStatus.paused; + } + if (completedCount == tasks.length) return DownloadGroupStatus.completed; + if (completedCount > 0 && failedCount > 0) { + return DownloadGroupStatus.partiallyFailed; + } + if (tasks.every((task) => task.status == DownloadTaskStatus.cancelled)) { + return DownloadGroupStatus.cancelled; + } + return DownloadGroupStatus.failed; + } +} + +class DownloadTask { + const DownloadTask({ + required this.id, + required this.url, + required this.fileName, + required this.mediaType, + required this.status, + required this.createdAt, + required this.updatedAt, + this.title, + this.thumbnailUrl, + this.progress = 0, + this.receivedBytes = 0, + this.totalBytes = 0, + this.savedPath, + this.errorMessage, + this.failureKind, + }); + + final String id; + final String url; + final String fileName; + final String? title; + final String? thumbnailUrl; + final DownloadMediaType mediaType; + final DownloadTaskStatus status; + final double progress; + final int receivedBytes; + final int totalBytes; + final String? savedPath; + final String? errorMessage; + final DownloadFailureKind? failureKind; + final DateTime createdAt; + final DateTime updatedAt; + + bool get isActive => + status == DownloadTaskStatus.queued || + status == DownloadTaskStatus.downloading || + status == DownloadTaskStatus.paused; + + /// Unknown totals animate only while bytes are actively being transferred. + double? get indicatorProgress { + if (status == DownloadTaskStatus.downloading && totalBytes <= 0) { + return null; + } + return displayProgress; + } + + /// Old completed records may omit progress; keep labels and bars consistent. + double get displayProgress => status == DownloadTaskStatus.completed + ? 1 + : progress.clamp(0.0, 1.0).toDouble(); + + bool get isTerminal => + status == DownloadTaskStatus.completed || + status == DownloadTaskStatus.failed || + status == DownloadTaskStatus.cancelled; + + DownloadTask copyWith({ + DownloadTaskStatus? status, + double? progress, + int? receivedBytes, + int? totalBytes, + Object? savedPath = _notSpecified, + Object? errorMessage = _notSpecified, + Object? failureKind = _notSpecified, + DateTime? updatedAt, + }) { + return DownloadTask( + id: id, + url: url, + fileName: fileName, + title: title, + thumbnailUrl: thumbnailUrl, + mediaType: mediaType, + status: status ?? this.status, + progress: (progress ?? this.progress).clamp(0.0, 1.0).toDouble(), + receivedBytes: receivedBytes ?? this.receivedBytes, + totalBytes: totalBytes ?? this.totalBytes, + savedPath: identical(savedPath, _notSpecified) + ? this.savedPath + : savedPath as String?, + errorMessage: identical(errorMessage, _notSpecified) + ? this.errorMessage + : errorMessage as String?, + failureKind: identical(failureKind, _notSpecified) + ? this.failureKind + : failureKind as DownloadFailureKind?, + createdAt: createdAt, + updatedAt: updatedAt ?? DateTime.now(), + ); + } + + Map toJson() { + return { + 'id': id, + 'url': url, + 'fileName': fileName, + 'title': title, + 'thumbnailUrl': thumbnailUrl, + 'mediaType': mediaType.name, + 'status': status.name, + 'progress': progress, + 'receivedBytes': receivedBytes, + 'totalBytes': totalBytes, + 'savedPath': savedPath, + 'errorMessage': errorMessage, + 'failureKind': failureKind?.name, + 'createdAt': createdAt.millisecondsSinceEpoch, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + }; + } + + factory DownloadTask.fromJson(Map json) { + final createdAt = DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ); + return DownloadTask( + id: json['id']?.toString() ?? '', + url: json['url']?.toString() ?? '', + fileName: json['fileName']?.toString() ?? 'download', + title: json['title']?.toString(), + thumbnailUrl: json['thumbnailUrl']?.toString(), + mediaType: DownloadMediaType.fromName(json['mediaType']?.toString()), + status: DownloadTaskStatus.fromName(json['status']?.toString()), + progress: ((json['progress'] as num?)?.toDouble() ?? 0) + .clamp(0.0, 1.0) + .toDouble(), + receivedBytes: (json['receivedBytes'] as num?)?.toInt() ?? 0, + totalBytes: (json['totalBytes'] as num?)?.toInt() ?? 0, + savedPath: json['savedPath']?.toString(), + errorMessage: json['errorMessage']?.toString(), + failureKind: DownloadFailureKind.fromName( + json['failureKind']?.toString(), + ), + createdAt: createdAt, + updatedAt: DateTime.fromMillisecondsSinceEpoch( + (json['updatedAt'] as num?)?.toInt() ?? + createdAt.millisecondsSinceEpoch, + ), + ); + } +} + +const Object _notSpecified = Object(); diff --git a/lib/Models/grain_response.dart b/lib/Models/grain_response.dart index 379100b4..46e6d3d8 100644 --- a/lib/Models/grain_response.dart +++ b/lib/Models/grain_response.dart @@ -101,15 +101,22 @@ class GrainPostItem { liked: json['liked'] ?? false, opTime: json['opTime'] ?? 0, postData: GrainPostData.fromJson(json['postData']), - shared: json['shared'], - showFullText: json['showFullText'], - subscribed: json['subscribed'], + shared: json['shared'] ?? false, + showFullText: json['showFullText'] ?? false, + subscribed: json['subscribed'] ?? false, shareInfo: json['shareInfo'] == null ? null : ShareInfo.fromJson(json['shareInfo']), ); } + /// Timeline responses can contain unavailable/deleted entries without a post. + /// They have nothing for the post tile to render, so omit them from the feed. + static GrainPostItem? fromTimelineJson(Map json) { + if (json['postData'] == null) return null; + return GrainPostItem.fromJson(json); + } + Map toJson() { final Map data = {}; data['followed'] = followed; diff --git a/lib/Models/post_detail_response.dart b/lib/Models/post_detail_response.dart index 3824c856..0306c2e1 100644 --- a/lib/Models/post_detail_response.dart +++ b/lib/Models/post_detail_response.dart @@ -1,11 +1,45 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:loftify/Models/recommend_response.dart'; -import '../Utils/utils.dart'; import 'account_response.dart'; enum PostLayoutType { waterfallflow, grid, detail } +int _detailInt(dynamic value) { + if (value is num) return value.toInt(); + return NumberUtil.parseToInt(value); +} + +bool _detailBool(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + if (value is String) { + return value == '1' || value.toLowerCase() == 'true'; + } + return false; +} + +String _detailString(dynamic value) => value?.toString() ?? ''; + +SimpleBlogInfo _detailBlogInfo(dynamic value) { + final map = + value is Map ? Map.from(value) : {}; + try { + return SimpleBlogInfo.fromJson(map); + } catch (_) { + final blogName = _detailString(map['blogName']); + return SimpleBlogInfo( + bigAvaImg: _detailString(map['bigAvaImg']), + blogId: _detailInt(map['blogId']), + blogName: blogName, + blogNickName: _detailString(map['blogNickName']).isNotEmpty + ? _detailString(map['blogNickName']) + : blogName, + extraBits: _detailInt(map['extraBits']), + ); + } +} + ///PostDetailData class PostDetailData { int? followed; @@ -39,20 +73,27 @@ class PostDetailData { } PostDetailData.fromJson(Map json) { - followed = (json['followed'] is bool) - ? (json['followed'] == true ? 1 : 0) - : json['followed']; - liked = json['liked']; - misc = json['misc']; - post = json['post'] != null ? PostDetail.fromJson(json['post']) : null; - postData = - json['postData'] != null ? PostDetail.fromJson(json['postData']) : null; - shared = json['shared']; - showLuckyBoy = json['showLuckyBoy']; - opTime = json['opTime']; - subscribed = json['subscribed']; - grainInfo = json['grainInfo'] != null - ? GrainInfo.fromJson(json['grainInfo']) + followed = _detailInt(json['followed']); + liked = json['liked'] == null ? null : _detailBool(json['liked']); + misc = json['misc']?.toString(); + post = json['post'] is Map + ? PostDetail.fromJson(Map.from(json['post'] as Map)) + : null; + postData = json['postData'] is Map + ? PostDetail.fromJson( + Map.from(json['postData'] as Map), + ) + : null; + shared = json['shared'] == null ? null : _detailBool(json['shared']); + showLuckyBoy = + json['showLuckyBoy'] == null ? null : _detailBool(json['showLuckyBoy']); + opTime = json['opTime'] == null ? null : _detailInt(json['opTime']); + subscribed = + json['subscribed'] == null ? null : _detailBool(json['subscribed']); + grainInfo = json['grainInfo'] is Map + ? GrainInfo.fromJson( + Map.from(json['grainInfo'] as Map), + ) : null; } @@ -308,98 +349,119 @@ class PostDetail { factory PostDetail.fromJson(Map json) { return PostDetail( - imageAiMark: json['imageAiMark'] ?? 0, - imageMarkInfo: json['imageMarkInfo'] ?? "", - imageReblogMark: json['imageReblogMark'] ?? 0, - reblogAuthorFromEmbed: json['reblogAuthorFromEmbed'] ?? "", - allowReward: json['allowReward'] ?? 0, - allowView: json['allowView'] ?? 0, - applyStatus: json['applyStatus'] ?? 0, - blogId: json['blogId'] ?? 0, - blogInfo: json['blogInfo'] != null - ? FullBlogInfo.fromJson(json['blogInfo']) + imageAiMark: _detailInt(json['imageAiMark']), + imageMarkInfo: _detailString(json['imageMarkInfo']), + imageReblogMark: _detailInt(json['imageReblogMark']), + reblogAuthorFromEmbed: _detailString(json['reblogAuthorFromEmbed']), + allowReward: _detailInt(json['allowReward']), + allowView: _detailInt(json['allowView']), + applyStatus: _detailInt(json['applyStatus']), + blogId: _detailInt(json['blogId']), + blogInfo: json['blogInfo'] is Map + ? FullBlogInfo.fromJson( + Map.from(json['blogInfo'] as Map), + ) : null, - returnContent: json['returnContent'] != null + returnContent: json['returnContent'] is List ? (json['returnContent'] as List) - .map((v) => ReturnContent.fromJson(v)) + .whereType() + .map( + (value) => ReturnContent.fromJson( + Map.from(value), + ), + ) .toList() : [], - blogPageUrl: json['blogPageUrl'] ?? "", - caption: json['caption'] ?? "", - embed: json['embed'] ?? "", - cctype: (json['cctype'] ?? json['ccType']) ?? 0, - cited: json['cited'] ?? false, - citeParentBlogId: json['citeParentBlogId'] ?? 0, - citeParentPermalink: json['citeParentPermalink'] ?? "", - citeParentPostId: json['citeParentPostId'] ?? 0, - citeRootBlogId: json['citeRootBlogId'] ?? 0, - citeRootPostId: json['citeRootPostId'] ?? 0, - collectionId: json['collectionId'] ?? 0, - content: json['content'] ?? "", - digest: json['digest'] ?? "", - dirPostType: json['dirPostType'] ?? 0, - fansVipPost: json['fansVipPost'] ?? 0, - firstImage: json['firstImage'] != null - ? FirstImage.fromJson(json['firstImage']) + blogPageUrl: _detailString(json['blogPageUrl']), + caption: _detailString(json['caption']), + embed: _detailString(json['embed']), + cctype: _detailInt(json['cctype'] ?? json['ccType']), + cited: _detailBool(json['cited']), + citeParentBlogId: _detailInt(json['citeParentBlogId']), + citeParentPermalink: _detailString(json['citeParentPermalink']), + citeParentPostId: _detailInt(json['citeParentPostId']), + citeRootBlogId: _detailInt(json['citeRootBlogId']), + citeRootPostId: _detailInt(json['citeRootPostId']), + collectionId: _detailInt(json['collectionId']), + content: _detailString(json['content']), + digest: _detailString(json['digest']), + dirPostType: _detailInt(json['dirPostType']), + fansVipPost: _detailInt(json['fansVipPost']), + firstImage: json['firstImage'] is Map + ? FirstImage.fromJson( + Map.from(json['firstImage'] as Map), + ) : null, - firstImageUrl: json['firstImageUrl'] ?? "", - firstImageWh: json['firstImageWh'] != null - ? (json['firstImageWh'] as List).map((v) => v as int).toList() + firstImageUrl: _detailString(json['firstImageUrl']), + firstImageWh: json['firstImageWh'] is List + ? (json['firstImageWh'] as List).map(_detailInt).toList() : [], - firstSmallImageUrl: json['firstSmallImageUrl'] ?? "", - forbidPcomment: json['forbidPcomment'] ?? 0, - forbidShare: json['forbidShare'] ?? 0, - hot: json['hot'] ?? 0, - id: json['id'] ?? 0, - ipLocation: json['ipLocation'] ?? "", - isContribute: json['isContribute'] == 1 ? true : false, - isPublished: json['isPublished'] == 1 ? true : false, - locationId: json['locationId'] ?? 0, - needPay: json['needPay'] ?? false, - newVersionAuditing: json['newVersionAuditing'] ?? false, - payingView: json['payingView'] ?? false, - payView: json['payView'] ?? 0, - payViewExpire: json['payViewExpire'] ?? false, - payViewPost: json['payViewPost'] ?? false, - permalink: json['permalink'] ?? "", - photoCaptions: json['photoCaptions'] ?? "", - photoLinks: json['photoLinks'] ?? "", - photoType: json['photoType'] ?? 0, - pos: json['pos'] ?? 0, - postCollection: json['postCollection'] != null - ? FullPostCollection.fromJson(json['postCollection']) + firstSmallImageUrl: _detailString(json['firstSmallImageUrl']), + forbidPcomment: _detailInt(json['forbidPcomment']), + forbidShare: _detailInt(json['forbidShare']), + hot: _detailInt(json['hot']), + id: _detailInt(json['id']), + ipLocation: _detailString(json['ipLocation']), + isContribute: _detailBool(json['isContribute']), + isPublished: _detailBool(json['isPublished']), + locationId: _detailInt(json['locationId']), + needPay: _detailBool(json['needPay']), + newVersionAuditing: _detailBool(json['newVersionAuditing']), + payingView: _detailBool(json['payingView']), + payView: _detailInt(json['payView']), + payViewExpire: _detailBool(json['payViewExpire']), + payViewPost: _detailBool(json['payViewPost']), + permalink: _detailString(json['permalink']), + photoCaptions: _detailString(json['photoCaptions']), + photoLinks: _detailString(json['photoLinks']), + photoType: _detailInt(json['photoType']), + pos: _detailInt(json['pos']), + postCollection: json['postCollection'] is Map + ? FullPostCollection.fromJson( + Map.from(json['postCollection'] as Map), + ) : null, - postCount: json['postCount'] != null - ? PostCount.fromJson(json['postCount']) - : json['postCountView'] != null - ? PostCount.fromJson(json['postCountView']) + postCount: json['postCount'] is Map + ? PostCount.fromJson( + Map.from(json['postCount'] as Map), + ) + : json['postCountView'] is Map + ? PostCount.fromJson( + Map.from(json['postCountView'] as Map), + ) : null, - postSource: json['postSource'] ?? 0, - postStyle: json['postStyle'] ?? 0, - publisherMainBlogInfo: json['publisherMainBlogInfo'] != null - ? FullBlogInfo.fromJson(json['publisherMainBlogInfo']) + postSource: _detailInt(json['postSource']), + postStyle: _detailInt(json['postStyle']), + publisherMainBlogInfo: json['publisherMainBlogInfo'] is Map + ? FullBlogInfo.fromJson( + Map.from(json['publisherMainBlogInfo'] as Map), + ) : null, - publisherUserId: json['publisherUserId'], - publishTime: json['publishTime'], - rank: json['rank'] ?? 0, - showGift: json['showGift'] ?? 0, - tag: json['tag'] ?? "", - tagList: json['tagList'] != null - ? (json['tagList'] as List).map((v) => v as String).toList() + publisherUserId: _detailInt(json['publisherUserId']), + publishTime: _detailInt(json['publishTime']), + rank: _detailInt(json['rank']), + showGift: _detailInt(json['showGift']), + tag: _detailString(json['tag']), + tagList: json['tagList'] is List + ? (json['tagList'] as List).map(_detailString).toList() : [], - tagRankList: json['tagRankList'] != null - ? (json['tagRankList'] as List).map((v) => v as String).toList() + tagRankList: json['tagRankList'] is List + ? (json['tagRankList'] as List).map(_detailString).toList() : [], - title: json['title'] ?? "", - top: json['top'], - type: json['type'], - valid: json['valid'], - viewRank: json['viewRank'], - videoPostView: json['videoPostView'] != null - ? VideoPostView.fromJson(json['videoPostView']) + title: _detailString(json['title']), + top: _detailInt(json['top']), + type: _detailInt(json['type']), + valid: _detailInt(json['valid']), + viewRank: _detailInt(json['viewRank']), + videoPostView: json['videoPostView'] is Map + ? VideoPostView.fromJson( + Map.from(json['videoPostView'] as Map), + ) : null, - photoPostView: json['photoPostView'] != null - ? PhotoPostView.fromJson(json['photoPostView']) + photoPostView: json['photoPostView'] is Map + ? PhotoPostView.fromJson( + Map.from(json['photoPostView'] as Map), + ) : null, ); } @@ -886,31 +948,41 @@ class Comment { factory Comment.fromJson(Map json) { return Comment( l2CommentOffset: -1, - blogId: json['blogId'], - content: json['content'], - emotes: json['emotes'] != null + blogId: _detailInt(json['blogId']), + content: _detailString(json['content']), + emotes: json['emotes'] is List ? (json['emotes'] as List) - .map((v) => CommentEmote.fromJson(v)) + .whereType() + .map( + (value) => CommentEmote.fromJson( + Map.from(value), + ), + ) .toList() : [], - id: json['id'], - ipLocation: json['ipLocation'] ?? "", - l2Comments: json['l2Comments'] != null + id: _detailInt(json['id']), + ipLocation: _detailString(json['ipLocation']), + l2Comments: json['l2Comments'] is List ? (json['l2Comments'] as List) - .map((v) => Comment.fromJson(v)) + .whereType() + .map( + (value) => Comment.fromJson( + Map.from(value), + ), + ) .toList() : [], - l2Count: json['l2Count'] ?? 0, - likeCount: json['likeCount'], - liked: json['liked'], - postId: json['postId'], - publisherBlogInfo: SimpleBlogInfo.fromJson(json['publisherBlogInfo']), - publishTime: json['publishTime'], - replyL1CommentId: json['replyL1CommentId'], - replyL2CommentId: json['replyL2CommentId'], - top: json['top'], - replyBlogInfo: json['replyBlogInfo'] != null - ? SimpleBlogInfo.fromJson(json['replyBlogInfo']) + l2Count: _detailInt(json['l2Count']), + likeCount: _detailInt(json['likeCount']), + liked: _detailBool(json['liked']), + postId: _detailInt(json['postId']), + publisherBlogInfo: _detailBlogInfo(json['publisherBlogInfo']), + publishTime: _detailInt(json['publishTime']), + replyL1CommentId: _detailInt(json['replyL1CommentId']), + replyL2CommentId: _detailInt(json['replyL2CommentId']), + top: _detailInt(json['top']), + replyBlogInfo: json['replyBlogInfo'] is Map + ? _detailBlogInfo(json['replyBlogInfo']) : null, ); } diff --git a/lib/Models/recommend_response.dart b/lib/Models/recommend_response.dart index dbcdb1a2..89e3a89a 100644 --- a/lib/Models/recommend_response.dart +++ b/lib/Models/recommend_response.dart @@ -1,8 +1,6 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:loftify/Models/collection_response.dart'; -import '../Utils/utils.dart'; - ///PostListItem class PostListItem { ///作者信息 @@ -663,15 +661,20 @@ class PostCount { factory PostCount.fromJson(Map json) { return PostCount( - blogId: json['blogId'], - favoriteCount: json['favoriteCount'], - hotCount: json['hotCount'], - postHot: json['postHot'], - reblogCount: json['reblogCount'], - responseCount: json['responseCount'], - shareCount: json['shareCount'], - subscribeCount: json['subscribeCount'], - viewCount: json['viewCount'], + blogId: + json['blogId'] == null ? null : NumberUtil.parseToInt(json['blogId']), + favoriteCount: NumberUtil.parseToInt(json['favoriteCount']), + hotCount: json['hotCount'] == null + ? null + : NumberUtil.parseToInt(json['hotCount']), + postHot: json['postHot'] == null + ? null + : NumberUtil.parseToInt(json['postHot']), + reblogCount: NumberUtil.parseToInt(json['reblogCount']), + responseCount: NumberUtil.parseToInt(json['responseCount']), + shareCount: NumberUtil.parseToInt(json['shareCount']), + subscribeCount: NumberUtil.parseToInt(json['subscribeCount']), + viewCount: NumberUtil.parseToInt(json['viewCount']), ); } diff --git a/lib/Models/search_response.dart b/lib/Models/search_response.dart index 79513811..6b83be23 100644 --- a/lib/Models/search_response.dart +++ b/lib/Models/search_response.dart @@ -163,7 +163,7 @@ class RankItem { factory RankItem.fromJson(Map json) { return RankItem( blogId: json['blogId'], - icon: json['icon'], + icon: json['icon']?.toString() ?? "", img: json['img'], interactionCount: json['interactionCount'], isAuth: json['isAuth'], diff --git a/lib/Screens/Download/batch_download_screen.dart b/lib/Screens/Download/batch_download_screen.dart new file mode 100644 index 00000000..0286761b --- /dev/null +++ b/lib/Screens/Download/batch_download_screen.dart @@ -0,0 +1,519 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Models/download_task.dart'; +import '../../Utils/download_task_manager.dart'; +import '../../Utils/enums.dart'; +import '../../Utils/post_batch_download_resolver.dart'; +import '../../Widgets/PostItem/general_post_item.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; +import '../../Widgets/loftify_icons.dart'; +import '../../l10n/l10n.dart'; +import 'download_management_screen.dart'; + +typedef LoadAllBatchPosts = Future> Function(); + +class BatchDownloadScreen extends StatefulWidget { + const BatchDownloadScreen({ + super.key, + required this.sourceTitle, + required this.source, + required this.initialItems, + this.loadAllItems, + this.resolver, + this.manager, + }); + + final String sourceTitle; + final DownloadSourceDescriptor source; + final List initialItems; + final LoadAllBatchPosts? loadAllItems; + final PostBatchDownloadResolver? resolver; + final DownloadTaskManager? manager; + + @override + State createState() => _BatchDownloadScreenState(); +} + +class _BatchDownloadScreenState extends State { + late final PostBatchDownloadResolver _resolver; + late final DownloadTaskManager _manager; + final List _items = []; + final Set _selectedPostIds = {}; + bool _loadingAll = false; + bool _loadedAll = false; + bool _resolving = false; + int _resolvedCount = 0; + int _resolveTotal = 0; + DownloadBatchResult? _lastResult; + int _lastUnavailablePosts = 0; + + @override + void initState() { + super.initState(); + _resolver = widget.resolver ?? PostBatchDownloadResolver(); + _manager = widget.manager ?? DownloadTaskManager.instance; + _replaceItems(widget.initialItems); + _loadedAll = widget.loadAllItems == null; + } + + void _replaceItems(Iterable items) { + final seen = {}; + _items + ..clear() + ..addAll(items.where((item) => item.postId > 0 && seen.add(item.postId))); + _selectedPostIds.removeWhere((id) => !seen.contains(id)); + } + + List get _selectableItems => _items + .where((item) => item.type != PostType.invalid) + .toList(growable: false); + + bool get _allSelected { + final selectable = _selectableItems; + return _loadedAll && + selectable.isNotEmpty && + selectable.every((item) => _selectedPostIds.contains(item.postId)); + } + + Future _toggleAll() async { + if (_resolving || _loadingAll) return; + if (_allSelected) { + setState(_selectedPostIds.clear); + return; + } + if (!_loadedAll && widget.loadAllItems != null) { + setState(() => _loadingAll = true); + try { + final items = await widget.loadAllItems!(); + if (!mounted) return; + setState(() { + _replaceItems(items); + _loadedAll = true; + }); + } catch (_) { + if (mounted) IToast.showTop(appLocalizations.loadFailed); + return; + } finally { + if (mounted) setState(() => _loadingAll = false); + } + } + if (!mounted) return; + setState(() { + _selectedPostIds.addAll( + _selectableItems.map((item) => item.postId), + ); + _lastResult = null; + }); + } + + void _toggleItem(GeneralPostItem item) { + if (_resolving || _loadingAll || item.type == PostType.invalid) return; + setState(() { + if (!_selectedPostIds.add(item.postId)) { + _selectedPostIds.remove(item.postId); + } + _lastResult = null; + }); + } + + Future _submit() async { + if (_resolving || _loadingAll) return; + final selected = _items + .where((item) => _selectedPostIds.contains(item.postId)) + .toList(growable: false); + if (selected.isEmpty) { + IToast.showTop(appLocalizations.batchDownloadNoSelection); + return; + } + setState(() { + _resolving = true; + _resolvedCount = 0; + _resolveTotal = selected.length; + _lastResult = null; + _lastUnavailablePosts = 0; + }); + + final requests = []; + var unavailablePosts = 0; + try { + for (final item in selected) { + final resolution = await _resolver.resolve(item); + requests.addAll(resolution.requests); + if (resolution.failed || resolution.requests.isEmpty) { + unavailablePosts++; + } + if (mounted) setState(() => _resolvedCount++); + } + if (requests.isEmpty) { + if (mounted) { + IToast.showTop(appLocalizations.noDownloadableResources); + setState(() => _lastUnavailablePosts = unavailablePosts); + } + return; + } + final result = await _manager.enqueueBatch( + requests, + source: widget.source, + unavailableCount: unavailablePosts, + ); + if (!mounted) return; + setState(() { + _lastResult = result; + _lastUnavailablePosts = unavailablePosts + result.invalidCount; + }); + IToast.showTop(appLocalizations.batchDownloadSummary( + _lastUnavailablePosts, + result.queuedCount, + result.skippedCount, + )); + } catch (error, stackTrace) { + ILogger.error('Failed to prepare batch download', error, stackTrace); + if (mounted) IToast.showTop(appLocalizations.downloadFailed); + } finally { + if (mounted) setState(() => _resolving = false); + } + } + + void _openDownloadManager() { + RouteUtil.pushPanelCupertinoRoute( + context, + const DownloadManagementScreen(), + ); + } + + @override + Widget build(BuildContext context) { + final body = CustomScrollView( + key: const Key('batch-download-scroll'), + slivers: [ + SliverToBoxAdapter(child: _buildSelectionHeader()), + if (_items.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: LoftifyStateVisual.empty, + title: appLocalizations.noArticle, + icon: LoftifyIcons.download, + scrollWhenConstrained: false, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 18), + sliver: SliverList.builder( + itemCount: _items.length * 2 - 1, + itemBuilder: (context, index) { + if (index.isOdd) return const SizedBox(height: 6); + final item = _items[index ~/ 2]; + return _BatchPostTile( + item: item, + selected: _selectedPostIds.contains(item.postId), + onTap: () => _toggleItem(item), + ); + }, + ), + ), + ], + ); + return Scaffold( + backgroundColor: ChewieTheme.getBackground(context), + appBar: ResponsiveAppBar( + showBack: true, + title: '${widget.sourceTitle} · ${appLocalizations.batchDownload}', + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: body, + ), + ), + bottomNavigationBar: _buildBottomBar(), + ); + } + + Widget _buildSelectionHeader() { + final selectedText = appLocalizations.batchDownloadSelected( + _selectedPostIds.length, + _selectableItems.length, + ); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: CaptionItem( + context: context, + title: selectedText, + children: [ + CheckboxItem( + value: _allSelected, + disabled: _resolving || _loadingAll, + roundTop: true, + roundBottom: true, + title: _loadingAll + ? appLocalizations.loading + : appLocalizations.selectAll, + description: _loadedAll + ? appLocalizations.batchDownloadRule + : '${appLocalizations.loadAllPostsForBatch}\n' + '${appLocalizations.batchDownloadRule}', + onTap: _toggleAll, + ), + ], + ), + ); + } + + Widget _buildBottomBar() { + final scheme = Theme.of(context).colorScheme; + final result = _lastResult; + final statusText = _resolving + ? appLocalizations.batchDownloadResolving( + _resolvedCount, + _resolveTotal, + ) + : result == null + ? appLocalizations.batchDownloadSelected( + _selectedPostIds.length, + _selectableItems.length, + ) + : appLocalizations.batchDownloadSummary( + _lastUnavailablePosts, + result.queuedCount, + result.skippedCount, + ); + return Material( + color: scheme.surface, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_resolving) + LinearProgressIndicator( + minHeight: 3, + value: _resolveTotal > 0 ? _resolvedCount / _resolveTotal : 0, + backgroundColor: scheme.surfaceContainerHighest, + ), + Container( + constraints: const BoxConstraints(minHeight: 68), + padding: const EdgeInsets.fromLTRB(14, 8, 12, 8), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: scheme.outlineVariant.withValues(alpha: 0.55), + width: 0.6, + ), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 500; + final status = Text( + statusText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelMedium.copyWith( + color: scheme.onSurfaceVariant, + height: 1.35, + ), + ); + final retryButton = RoundIconTextButton( + key: const Key('batch-download-retry'), + text: appLocalizations.retry, + onPressed: _submit, + height: null, + minHeight: 46, + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + ); + final primaryButton = RoundIconTextButton( + key: const Key('batch-download-primary'), + text: result == null + ? appLocalizations.download + : appLocalizations.downloadManagement, + icon: ChewieIcon( + LoftifyIcons.download, + size: 17, + color: Colors.white, + ), + background: scheme.primary, + height: null, + minHeight: 46, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + disabled: _resolving || + _loadingAll || + (result == null && _selectedPostIds.isEmpty), + onPressed: result == null ? _submit : _openDownloadManager, + ); + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + status, + const SizedBox(height: 8), + if (result != null) ...[ + retryButton, + const SizedBox(height: 8), + ], + primaryButton, + ], + ); + } + final actions = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (result != null) ...[ + retryButton, + const SizedBox(width: 8), + ], + primaryButton, + ], + ); + return Row( + children: [ + Expanded(child: status), + const SizedBox(width: 10), + actions, + ], + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _BatchPostTile extends StatelessWidget { + const _BatchPostTile({ + required this.item, + required this.selected, + required this.onTap, + }); + + final GeneralPostItem item; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final disabled = item.type == PostType.invalid; + final imageUrl = item.photoLinks.isNotEmpty + ? item.photoLinks.first.middle + : item.firstImageUrl.trim(); + final title = item.processedTitle.isNotEmpty + ? item.processedTitle + : disabled + ? appLocalizations.invalidContent + : '#${item.postId}'; + return Opacity( + opacity: disabled ? 0.48 : 1, + child: InkAnimation( + borderRadius: BorderRadius.circular(12), + color: ChewieTheme.cardColor, + ink: !disabled, + onTap: disabled ? null : onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 72), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + color: selected + ? scheme.primary.withValues(alpha: 0.38) + : scheme.outlineVariant.withValues(alpha: 0.42), + width: selected ? 1 : 0.6, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.35), + ), + child: + imageUrl.startsWith('http') || imageUrl.startsWith('//') + ? ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: imageUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + showLoading: false, + simpleError: true, + ) + : ChewieIcon( + switch (item.type) { + PostType.video => LoftifyIcons.video, + PostType.article => LoftifyIcons.article, + _ => LoftifyIcons.image, + }, + size: 21, + color: scheme.primary, + ), + ), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.titleSmall.copyWith( + height: 1.3, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + item.blogNickName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? scheme.primary : Colors.transparent, + border: Border.all( + color: selected ? scheme.primary : scheme.outline, + width: 1.4, + ), + ), + child: selected + ? const ChewieIcon( + LoftifyIcons.check, + size: 15, + color: Colors.white, + ) + : null, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/Screens/Download/download_group_detail_screen.dart b/lib/Screens/Download/download_group_detail_screen.dart new file mode 100644 index 00000000..2e6f5db4 --- /dev/null +++ b/lib/Screens/Download/download_group_detail_screen.dart @@ -0,0 +1,800 @@ +import 'dart:async'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Models/download_task.dart'; +import '../../Utils/download_source_router.dart'; +import '../../Utils/download_task_manager.dart'; +import '../../Widgets/Design/loftify_controls.dart'; +import '../../Widgets/loftify_icons.dart'; +import '../../l10n/l10n.dart'; +import '../Setting/base_setting_screen.dart'; + +class DownloadGroupDetailScreen extends BaseSettingScreen { + const DownloadGroupDetailScreen({ + super.key, + required this.groupId, + this.manager, + super.padding, + super.showTitleBar, + }); + + final String groupId; + final DownloadTaskManager? manager; + + static const String routeName = '/download/group-detail'; + + @override + State createState() => + _DownloadGroupDetailScreenState(); +} + +class _DownloadGroupDetailScreenState extends State { + late final DownloadTaskManager _manager; + + @override + void initState() { + super.initState(); + _manager = widget.manager ?? DownloadTaskManager.instance; + unawaited(_manager.initialize()); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _manager, + builder: (context, _) { + final snapshot = _manager.snapshotForGroup(widget.groupId); + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.downloadTaskDetails, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + showBorder: false, + overrideBody: snapshot == null + ? EmptyPlaceholder( + text: appLocalizations.noDownloadTasks, + icon: LoftifyIcons.download, + shrinkWrap: false, + topPadding: 0, + ) + : _buildBody(snapshot), + ); + }, + ); + } + + Widget _buildBody(DownloadGroupSnapshot snapshot) { + return ListView( + key: const Key('download-group-detail-list'), + padding: widget.padding, + children: [ + CaptionItem( + context: context, + title: appLocalizations.downloadSourceSummary, + children: [ + _DownloadSourceSummary( + snapshot: snapshot, + onOpenOriginal: () => _openOriginal(snapshot.group.source), + ), + ], + ), + CaptionItem( + context: context, + title: appLocalizations.downloadOverallProgress, + children: [ + _DownloadGroupOverview( + snapshot: snapshot, + onPause: () => _manager.pauseGroup(widget.groupId), + onResume: () => _manager.resumeGroup(widget.groupId), + onCancel: () => _manager.cancelGroup(widget.groupId), + onRetryFailed: () => _manager.retryFailedGroup(widget.groupId), + ), + ], + ), + CaptionItem( + context: context, + title: appLocalizations.downloadResourceList, + children: snapshot.tasks + .map( + (task) => _DownloadResourceTile( + key: ValueKey('download-resource-${task.id}'), + task: task, + onPause: () => _manager.pause(task.id), + onResume: () => _manager.resume(task.id), + onCancel: () => _manager.cancel(task.id), + onRetry: () => _manager.retry(task.id), + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 16), + ], + ); + } + + void _openOriginal(DownloadSourceDescriptor source) { + final destination = DownloadSourceRouter.destinationFor(source); + if (destination == null) { + IToast.showTop(appLocalizations.downloadSourceUnavailable); + return; + } + RouteUtil.pushPanelCupertinoRoute(context, destination); + } +} + +class _DownloadSourceSummary extends StatelessWidget { + const _DownloadSourceSummary({ + required this.snapshot, + required this.onOpenOriginal, + }); + + final DownloadGroupSnapshot snapshot; + final VoidCallback onOpenOriginal; + + @override + Widget build(BuildContext context) { + final source = snapshot.group.source; + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SourcePreview(source: source), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + source.title.trim().isEmpty + ? _sourceTypeText(source.type) + : source.title.trim(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.titleSmall.copyWith( + height: 1.35, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 5), + Text( + _sourceTypeText(source.type), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 5), + Text( + appLocalizations.downloadBatchCounts( + snapshot.group.requestedCount, + snapshot.group.skippedCount, + snapshot.group.unavailableCount, + ), + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + height: 1.35, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + LoftifyButton( + key: const Key('download-view-original'), + label: appLocalizations.viewOriginalContent, + icon: LoftifyIcons.openExternal, + variant: LoftifyButtonVariant.secondary, + size: LoftifyButtonSize.compact, + expand: true, + onPressed: onOpenOriginal, + ), + ], + ), + ); + } +} + +class _SourcePreview extends StatelessWidget { + const _SourcePreview({required this.source}); + + final DownloadSourceDescriptor source; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final thumbnail = source.thumbnailUrl?.trim(); + return Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: thumbnail != null && thumbnail.isNotEmpty + ? ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: thumbnail, + width: 58, + height: 58, + fit: BoxFit.cover, + showLoading: false, + simpleError: true, + ) + : ChewieIcon( + _sourceIcon(source.type), + size: 23, + color: scheme.primary, + ), + ); + } +} + +class _DownloadGroupOverview extends StatelessWidget { + const _DownloadGroupOverview({ + required this.snapshot, + required this.onPause, + required this.onResume, + required this.onCancel, + required this.onRetryFailed, + }); + + final DownloadGroupSnapshot snapshot; + final VoidCallback onPause; + final VoidCallback onResume; + final VoidCallback onCancel; + final VoidCallback onRetryFailed; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final compact = _useCompactDownloadDetailLayout(context); + final statusColor = _groupStatusColor(context, snapshot.status); + final percent = (snapshot.progress * 100).clamp(0, 100).round(); + final hasRunning = snapshot.tasks.any( + (task) => + task.status == DownloadTaskStatus.queued || + task.status == DownloadTaskStatus.downloading, + ); + final hasPaused = snapshot.tasks.any( + (task) => task.status == DownloadTaskStatus.paused, + ); + final hasFailed = snapshot.tasks.any( + (task) => + task.status == DownloadTaskStatus.failed || + task.status == DownloadTaskStatus.cancelled, + ); + final hasActive = snapshot.tasks.any((task) => task.isActive); + final actions = [ + if (hasRunning) + LoftifyButton( + key: const Key('download-group-pause'), + label: appLocalizations.pauseAllDownloads, + icon: LoftifyIcons.pause, + size: LoftifyButtonSize.compact, + variant: LoftifyButtonVariant.secondary, + expand: compact, + onPressed: onPause, + ), + if (hasPaused) + LoftifyButton( + key: const Key('download-group-resume'), + label: appLocalizations.resumeAllDownloads, + icon: LoftifyIcons.play, + size: LoftifyButtonSize.compact, + variant: LoftifyButtonVariant.tonal, + expand: compact, + onPressed: onResume, + ), + if (hasFailed) + LoftifyButton( + key: const Key('download-group-retry'), + label: appLocalizations.retryFailedDownloads, + icon: LoftifyIcons.retry, + size: LoftifyButtonSize.compact, + variant: LoftifyButtonVariant.tonal, + expand: compact, + onPressed: onRetryFailed, + ), + if (hasActive) + LoftifyButton( + key: const Key('download-group-cancel'), + label: appLocalizations.cancelAllDownloads, + icon: LoftifyIcons.close, + size: LoftifyButtonSize.compact, + variant: LoftifyButtonVariant.danger, + expand: compact, + onPressed: onCancel, + ), + ]; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatusAndProgress( + context, + statusColor, + percent, + compact: compact, + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + minHeight: 5, + value: snapshot.progress, + color: statusColor, + backgroundColor: + scheme.surfaceContainerHighest.withValues(alpha: 0.72), + ), + ), + if (snapshot.totalBytes > 0) ...[ + const SizedBox(height: 8), + Text( + '${_formatBytes(snapshot.receivedBytes)} / ' + '${_formatBytes(snapshot.totalBytes)}', + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + if (actions.isNotEmpty) ...[ + const SizedBox(height: 14), + compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < actions.length; index++) ...[ + if (index > 0) const SizedBox(height: 8), + actions[index], + ], + ], + ) + : Wrap(spacing: 8, runSpacing: 8, children: actions), + ], + ], + ), + ); + } + + Widget _buildStatusAndProgress( + BuildContext context, + Color statusColor, + int percent, { + required bool compact, + }) { + final scheme = Theme.of(context).colorScheme; + final badge = _StatusBadge( + key: const Key('download-group-overview-status'), + text: _groupStatusText(snapshot.status), + color: statusColor, + ); + final progress = Text( + appLocalizations.downloadGroupProgress( + snapshot.completedCount, + percent, + snapshot.tasks.length, + ), + textAlign: compact ? TextAlign.start : TextAlign.end, + maxLines: compact ? 2 : 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + ); + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align(alignment: Alignment.centerLeft, child: badge), + const SizedBox(height: 8), + progress, + ], + ); + } + return Row( + children: [ + badge, + const SizedBox(width: 10), + Expanded(child: progress), + ], + ); + } +} + +class _DownloadResourceTile extends StatelessWidget { + const _DownloadResourceTile({ + super.key, + required this.task, + required this.onPause, + required this.onResume, + required this.onCancel, + required this.onRetry, + }); + + final DownloadTask task; + final VoidCallback onPause; + final VoidCallback onResume; + final VoidCallback onCancel; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final compact = _useCompactDownloadDetailLayout(context); + final statusColor = _taskStatusColor(context, task.status); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ResourcePreview(task: task), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitleAndStatus( + statusColor, + compact: compact, + ), + const SizedBox(height: 5), + Text( + task.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 9), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + minHeight: 3, + value: task.indicatorProgress, + color: statusColor, + backgroundColor: + scheme.surfaceContainerHighest.withValues(alpha: 0.72), + ), + ), + const SizedBox(height: 7), + _buildProgressAndActions( + context, + compact: compact, + ), + if (task.errorMessage?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 5), + Text( + task.errorMessage!.trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: ChewieTheme.errorColor, + height: 1.3, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildTitleAndStatus( + Color statusColor, { + required bool compact, + }) { + final title = Text( + task.title?.trim().isNotEmpty == true + ? task.title!.trim() + : task.fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.titleSmall.copyWith( + height: 1.35, + fontWeight: FontWeight.w600, + ), + ); + final badge = _StatusBadge( + key: ValueKey('download-resource-status-${task.id}'), + text: _taskStatusText(task.status), + color: statusColor, + ); + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + title, + const SizedBox(height: 6), + Align(alignment: Alignment.centerLeft, child: badge), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: title), + const SizedBox(width: 8), + badge, + ], + ); + } + + Widget _buildProgressAndActions( + BuildContext context, { + required bool compact, + }) { + final scheme = Theme.of(context).colorScheme; + final progress = Text( + _taskProgressText(task), + maxLines: compact ? 2 : 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + ); + final actionItems = _actions(); + final actions = Row( + key: ValueKey('download-resource-actions-${task.id}'), + mainAxisSize: MainAxisSize.min, + children: actionItems, + ); + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + progress, + if (actionItems.isNotEmpty) ...[ + const SizedBox(height: 6), + Align(alignment: Alignment.centerRight, child: actions), + ], + ], + ); + } + return Row( + children: [ + Expanded(child: progress), + if (actionItems.isNotEmpty) ...[ + const SizedBox(width: 6), + actions, + ], + ], + ); + } + + List _actions() { + return switch (task.status) { + DownloadTaskStatus.queued || DownloadTaskStatus.downloading => [ + _ResourceActionButton( + tooltip: appLocalizations.downloadPaused, + icon: LoftifyIcons.pause, + onTap: onPause, + ), + _ResourceActionButton( + tooltip: appLocalizations.cancel, + icon: LoftifyIcons.close, + onTap: onCancel, + ), + ], + DownloadTaskStatus.paused => [ + _ResourceActionButton( + tooltip: appLocalizations.resumeAllDownloads, + icon: LoftifyIcons.play, + onTap: onResume, + ), + _ResourceActionButton( + tooltip: appLocalizations.cancel, + icon: LoftifyIcons.close, + onTap: onCancel, + ), + ], + DownloadTaskStatus.failed || DownloadTaskStatus.cancelled => [ + _ResourceActionButton( + tooltip: appLocalizations.retry, + icon: LoftifyIcons.retry, + onTap: onRetry, + ), + ], + DownloadTaskStatus.completed => const [], + }; + } +} + +class _ResourcePreview extends StatelessWidget { + const _ResourcePreview({required this.task}); + + final DownloadTask task; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final canPreview = task.mediaType == DownloadMediaType.image && + task.thumbnailUrl?.trim().isNotEmpty == true; + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.7), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: canPreview + ? ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: task.thumbnailUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + showLoading: false, + simpleError: true, + ) + : ChewieIcon( + switch (task.mediaType) { + DownloadMediaType.image => LoftifyIcons.image, + DownloadMediaType.video => LoftifyIcons.video, + DownloadMediaType.file => LoftifyIcons.file, + }, + size: 20, + color: scheme.primary, + ), + ); + } +} + +class _ResourceActionButton extends StatelessWidget { + const _ResourceActionButton({ + required this.tooltip, + required this.icon, + required this.onTap, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 4), + child: ChewieIconButton( + tooltip: tooltip, + icon: icon, + onPressed: onTap, + style: ChewieIconButtonStyle.soft, + iconSize: 16, + cornerRadius: 99, + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({super.key, required this.text, required this.color}); + + final String text; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +bool _useCompactDownloadDetailLayout(BuildContext context) { + return MediaQuery.sizeOf(context).width < 380 || + MediaQuery.textScalerOf(context).scale(1) > 1.35; +} + +IconData _sourceIcon(DownloadSourceType type) => switch (type) { + DownloadSourceType.postAll => LoftifyIcons.article, + DownloadSourceType.collection => LoftifyIcons.collection, + DownloadSourceType.grain => LoftifyIcons.grain, + DownloadSourceType.likes => LoftifyIcons.favorite, + DownloadSourceType.recommendations => LoftifyIcons.recommend, + DownloadSourceType.favoriteFolder => LoftifyIcons.favorite, + DownloadSourceType.other => LoftifyIcons.batchDownload, + }; + +String _sourceTypeText(DownloadSourceType type) => switch (type) { + DownloadSourceType.postAll => appLocalizations.downloadSourcePostAll, + DownloadSourceType.collection => appLocalizations.collection, + DownloadSourceType.grain => appLocalizations.grain, + DownloadSourceType.likes => appLocalizations.myLikes, + DownloadSourceType.recommendations => appLocalizations.myRecommends, + DownloadSourceType.favoriteFolder => + appLocalizations.downloadSourceFavoriteFolder, + DownloadSourceType.other => appLocalizations.downloadSourceOther, + }; + +String _groupStatusText(DownloadGroupStatus status) => switch (status) { + DownloadGroupStatus.queued => appLocalizations.waitingForDownload, + DownloadGroupStatus.downloading => appLocalizations.downloading, + DownloadGroupStatus.paused => appLocalizations.downloadPaused, + DownloadGroupStatus.completed => appLocalizations.downloadComplete, + DownloadGroupStatus.partiallyFailed => + appLocalizations.downloadPartiallyFailed, + DownloadGroupStatus.failed => appLocalizations.downloadFailed, + DownloadGroupStatus.cancelled => appLocalizations.downloadCancelled, + }; + +String _taskStatusText(DownloadTaskStatus status) => switch (status) { + DownloadTaskStatus.queued => appLocalizations.waitingForDownload, + DownloadTaskStatus.downloading => appLocalizations.downloading, + DownloadTaskStatus.paused => appLocalizations.downloadPaused, + DownloadTaskStatus.completed => appLocalizations.downloadComplete, + DownloadTaskStatus.failed => appLocalizations.downloadFailed, + DownloadTaskStatus.cancelled => appLocalizations.downloadCancelled, + }; + +Color _groupStatusColor(BuildContext context, DownloadGroupStatus status) { + final scheme = Theme.of(context).colorScheme; + return switch (status) { + DownloadGroupStatus.completed => ChewieTheme.successColor, + DownloadGroupStatus.failed || + DownloadGroupStatus.partiallyFailed => + ChewieTheme.errorColor, + DownloadGroupStatus.cancelled => scheme.outline, + _ => scheme.primary, + }; +} + +Color _taskStatusColor(BuildContext context, DownloadTaskStatus status) { + final scheme = Theme.of(context).colorScheme; + return switch (status) { + DownloadTaskStatus.completed => ChewieTheme.successColor, + DownloadTaskStatus.failed => ChewieTheme.errorColor, + DownloadTaskStatus.cancelled => scheme.outline, + _ => scheme.primary, + }; +} + +String _taskProgressText(DownloadTask task) { + if (task.totalBytes <= 0) return appLocalizations.downloadUnknownSize; + final percent = (task.displayProgress * 100).round(); + return '${_formatBytes(task.receivedBytes)} / ' + '${_formatBytes(task.totalBytes)} · $percent%'; +} + +String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + final kilobytes = bytes / 1024; + if (kilobytes < 1024) return '${kilobytes.toStringAsFixed(1)} KB'; + final megabytes = kilobytes / 1024; + if (megabytes < 1024) return '${megabytes.toStringAsFixed(1)} MB'; + return '${(megabytes / 1024).toStringAsFixed(1)} GB'; +} diff --git a/lib/Screens/Download/download_management_screen.dart b/lib/Screens/Download/download_management_screen.dart new file mode 100644 index 00000000..81dfcf20 --- /dev/null +++ b/lib/Screens/Download/download_management_screen.dart @@ -0,0 +1,675 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Models/download_task.dart'; +import '../../Utils/download_task_manager.dart'; +import '../../Widgets/loftify_icons.dart'; +import '../../l10n/l10n.dart'; +import '../Setting/base_setting_screen.dart'; +import 'download_group_detail_screen.dart'; + +class DownloadManagementScreen extends BaseSettingScreen { + const DownloadManagementScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + this.manager, + }); + + final DownloadTaskManager? manager; + + static const String routeName = '/download/management'; + + @override + State createState() => + _DownloadManagementScreenState(); +} + +class _DownloadManagementScreenState + extends BaseDynamicState + with TickerProviderStateMixin { + late final DownloadTaskManager _manager; + + @override + void initState() { + super.initState(); + _manager = widget.manager ?? DownloadTaskManager.instance; + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _manager, + builder: (context, _) { + final groupSnapshots = _manager.groups + .map((group) => group.snapshot(_manager.tasks)) + .toList(growable: false); + final groupedTaskIds = + _manager.groups.expand((group) => group.taskIds).toSet(); + final standaloneTasks = _manager.tasks + .where((task) => !groupedTaskIds.contains(task.id)) + .toList(growable: false); + final activeGroups = groupSnapshots + .where((snapshot) => snapshot.isActive) + .toList(growable: false); + final historyGroups = groupSnapshots + .where((snapshot) => !snapshot.isActive) + .toList(growable: false); + final activeTasks = standaloneTasks + .where((task) => task.isActive) + .toList(growable: false); + final historyTasks = standaloneTasks + .where((task) => task.isTerminal) + .toList(growable: false); + final hasHistory = historyGroups.isNotEmpty || historyTasks.isNotEmpty; + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.downloadManagement, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + showBorder: false, + actions: !hasHistory + ? const [] + : [ + ChewieIconButton( + tooltip: appLocalizations.clearFinishedDownloads, + onPressed: _manager.clearFinished, + icon: LoftifyIcons.delete, + ), + ], + overrideBody: _buildBody( + activeGroups, + historyGroups, + activeTasks, + historyTasks, + ), + ); + }, + ); + } + + Widget _buildBody( + List activeGroups, + List historyGroups, + List activeTasks, + List historyTasks, + ) { + if (activeGroups.isEmpty && + historyGroups.isEmpty && + activeTasks.isEmpty && + historyTasks.isEmpty) { + return EmptyPlaceholder( + text: appLocalizations.noDownloadTasks, + icon: LoftifyIcons.download, + shrinkWrap: false, + topPadding: 0, + ); + } + return ListView( + padding: widget.padding, + children: [ + if (activeGroups.isNotEmpty || activeTasks.isNotEmpty) + CaptionItem( + context: context, + title: appLocalizations.activeDownloads, + children: [ + ...activeGroups.map(_buildGroup), + ...activeTasks.map(_buildTask), + ], + ), + if (historyGroups.isNotEmpty || historyTasks.isNotEmpty) + CaptionItem( + context: context, + title: appLocalizations.downloadHistory, + children: [ + ...historyGroups.map(_buildGroup), + ...historyTasks.map(_buildTask), + ], + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _buildTask(DownloadTask task) { + return _DownloadTaskTile( + task: task, + onPause: () => _manager.pause(task.id), + onResume: () => _manager.resume(task.id), + onCancel: () => _manager.cancel(task.id), + onRetry: () => _manager.retry(task.id), + onRemove: () => _manager.remove(task.id), + ); + } + + Widget _buildGroup(DownloadGroupSnapshot snapshot) { + return _DownloadGroupTile( + snapshot: snapshot, + onTap: () => RouteUtil.pushPanelCupertinoRoute( + context, + DownloadGroupDetailScreen( + groupId: snapshot.group.id, + manager: _manager, + ), + ), + ); + } +} + +class _DownloadGroupTile extends StatelessWidget { + const _DownloadGroupTile({required this.snapshot, required this.onTap}); + + final DownloadGroupSnapshot snapshot; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final compact = _useCompactDownloadTileLayout(context); + final statusColor = switch (snapshot.status) { + DownloadGroupStatus.failed || + DownloadGroupStatus.partiallyFailed => + ChewieTheme.errorColor, + DownloadGroupStatus.cancelled => scheme.outline, + _ => scheme.primary, + }; + final percent = (snapshot.progress * 100).clamp(0, 100).round(); + return InkWell( + key: ValueKey('download-group-${snapshot.group.id}'), + onTap: onTap, + splashFactory: NoSplash.splashFactory, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPreview(context), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitleAndStatus( + context, + statusColor, + compact: compact, + ), + const SizedBox(height: 5), + Text( + _sourceTypeText(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 9), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + minHeight: 3, + value: snapshot.progress, + color: statusColor, + backgroundColor: + scheme.surfaceContainerHighest.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 7), + Text( + appLocalizations.downloadGroupProgress( + snapshot.completedCount, + percent, + snapshot.tasks.length, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildTitleAndStatus( + BuildContext context, + Color statusColor, { + required bool compact, + }) { + final title = Text( + snapshot.group.source.title.trim().isEmpty + ? _sourceTypeText() + : snapshot.group.source.title.trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.titleSmall.copyWith( + height: 1.35, + fontWeight: FontWeight.w600, + ), + ); + final badge = _buildStatusBadge(context, statusColor); + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + title, + const SizedBox(height: 6), + Align(alignment: Alignment.centerLeft, child: badge), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: title), + const SizedBox(width: 8), + badge, + ], + ); + } + + Widget _buildPreview(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final thumbnailUrl = snapshot.group.source.thumbnailUrl?.trim(); + final canPreview = thumbnailUrl != null && thumbnailUrl.isNotEmpty; + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + ), + child: canPreview + ? ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: thumbnailUrl, + width: 48, + height: 48, + fit: BoxFit.cover, + showLoading: false, + simpleError: true, + ) + : ChewieIcon( + _sourceIcon, + size: 21, + color: scheme.primary, + ), + ), + ); + } + + Widget _buildStatusBadge(BuildContext context, Color color) { + return Container( + key: const ValueKey('download-group-status'), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + _statusText(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + IconData get _sourceIcon => switch (snapshot.group.source.type) { + DownloadSourceType.postAll => LoftifyIcons.article, + DownloadSourceType.collection => LoftifyIcons.collection, + DownloadSourceType.grain => LoftifyIcons.grain, + DownloadSourceType.likes => LoftifyIcons.favorite, + DownloadSourceType.recommendations => LoftifyIcons.recommend, + DownloadSourceType.favoriteFolder => LoftifyIcons.favorite, + DownloadSourceType.other => LoftifyIcons.batchDownload, + }; + + String _sourceTypeText() => switch (snapshot.group.source.type) { + DownloadSourceType.postAll => appLocalizations.downloadSourcePostAll, + DownloadSourceType.collection => appLocalizations.collection, + DownloadSourceType.grain => appLocalizations.grain, + DownloadSourceType.likes => appLocalizations.myLikes, + DownloadSourceType.recommendations => appLocalizations.myRecommends, + DownloadSourceType.favoriteFolder => + appLocalizations.downloadSourceFavoriteFolder, + DownloadSourceType.other => appLocalizations.downloadSourceOther, + }; + + String _statusText() => switch (snapshot.status) { + DownloadGroupStatus.queued => appLocalizations.waitingForDownload, + DownloadGroupStatus.downloading => appLocalizations.downloading, + DownloadGroupStatus.paused => appLocalizations.downloadPaused, + DownloadGroupStatus.completed => appLocalizations.downloadComplete, + DownloadGroupStatus.partiallyFailed => + appLocalizations.downloadPartiallyFailed, + DownloadGroupStatus.failed => appLocalizations.downloadFailed, + DownloadGroupStatus.cancelled => appLocalizations.downloadCancelled, + }; +} + +class _DownloadTaskTile extends StatelessWidget { + const _DownloadTaskTile({ + required this.task, + required this.onPause, + required this.onResume, + required this.onCancel, + required this.onRetry, + required this.onRemove, + }); + + final DownloadTask task; + final VoidCallback onPause; + final VoidCallback onResume; + final VoidCallback onCancel; + final VoidCallback onRetry; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final compact = _useCompactDownloadTileLayout(context); + final progressColor = task.status == DownloadTaskStatus.failed || + task.status == DownloadTaskStatus.cancelled + ? colorScheme.outlineVariant + : colorScheme.primary; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMediaPreview(context), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitleAndStatus(context, compact: compact), + const SizedBox(height: 5), + Text( + task.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 9), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + minHeight: 3, + value: task.indicatorProgress, + color: progressColor, + backgroundColor: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 7), + _buildProgressAndActions( + context, + compact: compact, + ), + if (task.errorMessage?.isNotEmpty == true) ...[ + const SizedBox(height: 5), + Text( + task.errorMessage!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: ChewieTheme.errorColor, + height: 1.3, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildTitleAndStatus( + BuildContext context, { + required bool compact, + }) { + final title = Text( + task.title?.trim().isNotEmpty == true + ? task.title!.trim() + : task.fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.titleSmall.copyWith( + height: 1.35, + fontWeight: FontWeight.w600, + ), + ); + final badge = _buildStatusBadge(context); + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + title, + const SizedBox(height: 6), + Align(alignment: Alignment.centerLeft, child: badge), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: title), + const SizedBox(width: 8), + badge, + ], + ); + } + + Widget _buildProgressAndActions( + BuildContext context, { + required bool compact, + }) { + final colorScheme = Theme.of(context).colorScheme; + final progress = Text( + _progressText(), + maxLines: compact ? 2 : 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ); + final actions = Row( + key: const ValueKey('download-task-actions'), + mainAxisSize: MainAxisSize.min, + children: _buildActions(context), + ); + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + progress, + const SizedBox(height: 6), + Align(alignment: Alignment.centerRight, child: actions), + ], + ); + } + return Row( + children: [ + Expanded(child: progress), + const SizedBox(width: 8), + actions, + ], + ); + } + + Widget _buildMediaPreview(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final canPreview = task.mediaType == DownloadMediaType.image && + task.thumbnailUrl?.isNotEmpty == true; + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + ), + child: canPreview + ? ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: task.thumbnailUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + showLoading: false, + simpleError: true, + ) + : ChewieIcon( + switch (task.mediaType) { + DownloadMediaType.image => LoftifyIcons.image, + DownloadMediaType.video => LoftifyIcons.video, + DownloadMediaType.file => LoftifyIcons.file, + }, + size: 21, + color: colorScheme.primary, + ), + ), + ); + } + + Widget _buildStatusBadge(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isError = task.status == DownloadTaskStatus.failed; + final color = isError ? ChewieTheme.errorColor : colorScheme.primary; + return Container( + key: const ValueKey('download-task-status'), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + _statusText(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.labelSmall.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + List _buildActions(BuildContext context) { + final actions = []; + if (task.status == DownloadTaskStatus.downloading || + task.status == DownloadTaskStatus.queued) { + actions.add(_TaskActionButton( + tooltip: appLocalizations.downloadPaused, + icon: LoftifyIcons.pause, + onTap: onPause, + )); + } else if (task.status == DownloadTaskStatus.paused) { + actions.add(_TaskActionButton( + tooltip: appLocalizations.downloading, + icon: LoftifyIcons.play, + onTap: onResume, + )); + } else if (task.status == DownloadTaskStatus.failed || + task.status == DownloadTaskStatus.cancelled) { + actions.add(_TaskActionButton( + tooltip: appLocalizations.retry, + icon: LoftifyIcons.retry, + onTap: onRetry, + )); + } + if (task.isActive) { + actions.add(_TaskActionButton( + tooltip: appLocalizations.cancel, + icon: LoftifyIcons.close, + onTap: onCancel, + )); + } else { + actions.add(_TaskActionButton( + tooltip: appLocalizations.delete, + icon: LoftifyIcons.delete, + onTap: onRemove, + )); + } + return actions; + } + + String _statusText() { + return switch (task.status) { + DownloadTaskStatus.queued => appLocalizations.waitingForDownload, + DownloadTaskStatus.downloading => appLocalizations.downloading, + DownloadTaskStatus.paused => appLocalizations.downloadPaused, + DownloadTaskStatus.completed => appLocalizations.downloadComplete, + DownloadTaskStatus.failed => appLocalizations.downloadFailed, + DownloadTaskStatus.cancelled => appLocalizations.downloadCancelled, + }; + } + + String _progressText() { + if (task.totalBytes <= 0) return appLocalizations.downloadUnknownSize; + final percent = (task.displayProgress * 100).round(); + return '${_formatBytes(task.receivedBytes)} / ' + '${_formatBytes(task.totalBytes)} · $percent%'; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + final kilobytes = bytes / 1024; + if (kilobytes < 1024) return '${kilobytes.toStringAsFixed(1)} KB'; + final megabytes = kilobytes / 1024; + if (megabytes < 1024) return '${megabytes.toStringAsFixed(1)} MB'; + return '${(megabytes / 1024).toStringAsFixed(1)} GB'; + } +} + +class _TaskActionButton extends StatelessWidget { + const _TaskActionButton({ + required this.tooltip, + required this.icon, + required this.onTap, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 4), + child: ChewieIconButton( + icon: icon, + onPressed: onTap, + tooltip: tooltip, + style: ChewieIconButtonStyle.soft, + iconSize: 16, + cornerRadius: 999, + ), + ); + } +} + +bool _useCompactDownloadTileLayout(BuildContext context) { + return MediaQuery.sizeOf(context).width < 380 || + MediaQuery.textScalerOf(context).scale(1) > 1.35; +} diff --git a/lib/Screens/Info/collection_screen.dart b/lib/Screens/Info/collection_screen.dart index 5cafee80..d7a3c12b 100644 --- a/lib/Screens/Info/collection_screen.dart +++ b/lib/Screens/Info/collection_screen.dart @@ -1,15 +1,14 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/user_api.dart'; +import 'package:loftify/Screens/Info/nested_mixin.dart'; import 'package:loftify/Utils/hive_util.dart'; import '../../Models/post_detail_response.dart'; import '../../Utils/enums.dart'; -import '../../Utils/utils.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; import '../Post/collection_detail_screen.dart'; -import 'nested_mixin.dart'; class CollectionScreen extends StatefulWidgetForNested { CollectionScreen({ @@ -20,6 +19,8 @@ class CollectionScreen extends StatefulWidgetForNested { this.blogName, this.collectionCount, super.nested = false, + super.refreshListenable, + super.refreshId = 'collection', }) { if (infoMode == InfoMode.other) { assert(blogName != null); @@ -39,7 +40,10 @@ class CollectionScreen extends StatefulWidgetForNested { } class _CollectionScreenState extends BaseDynamicState - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with + TickerProviderStateMixin, + AutomaticKeepAliveClientMixin, + NestedRefreshSignalMixin { @override bool get wantKeepAlive => true; final List _collectionList = []; @@ -51,9 +55,15 @@ class _CollectionScreenState extends BaseDynamicState @override void initState() { super.initState(); + bindNestedRefreshSignal(() { + _refreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 140), + ); + }); if (widget.nested) { WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 300), () => _onRefresh()); + if (mounted) _onRefresh(); }); } else { _initPhase = InitPhase.successful; @@ -61,6 +71,13 @@ class _CollectionScreenState extends BaseDynamicState } } + @override + void dispose() { + unbindNestedRefreshSignal(); + _refreshController.dispose(); + super.dispose(); + } + _fetchGrain({bool refresh = false}) async { if (_loading) return; if (refresh) _noMore = false; @@ -94,11 +111,10 @@ class _CollectionScreenState extends BaseDynamicState _collectionList.addAll(tmp); if (mounted) setState(() {}); _initPhase = InitPhase.successful; - if (((widget.collectionCount != null && - _collectionList.length >= widget.collectionCount!) || - tmp.isEmpty) && - !refresh) { - _noMore = true; + _noMore = (widget.collectionCount != null && + _collectionList.length >= widget.collectionCount!) || + tmp.isEmpty; + if (_noMore && !refresh) { return IndicatorResult.noMore; } else { return IndicatorResult.success; @@ -140,25 +156,27 @@ class _CollectionScreenState extends BaseDynamicState _buildBody() { switch (_initPhase) { case InitPhase.connecting: - return const LoadingWidget( - background: Colors.transparent); + return const LoadingWidget(background: Colors.transparent); case InitPhase.failed: return CustomErrorWidget( onTap: _onRefresh, ); case InitPhase.successful: return EasyRefresh.builder( - refreshOnStart: true, + header: widget.nested ? buildNestedRefreshHeader() : null, + refreshOnStart: !widget.nested, controller: _refreshController, onRefresh: _onRefresh, - onLoad: _onLoad, + onLoad: _noMore ? null : _onLoad, triggerAxis: Axis.vertical, childBuilder: (context, physics) { return _collectionList.isNotEmpty ? _buildMainBody(physics) : EmptyPlaceholder( text: appLocalizations.noCollection, - physics: physics); + physics: physics, + shrinkWrap: false, + ); }, ); default: @@ -167,30 +185,27 @@ class _CollectionScreenState extends BaseDynamicState } Widget _buildMainBody(ScrollPhysics physics) { - return LoadMoreNotification( - noMore: _noMore, - onLoad: _onLoad, - child: WaterfallFlow.extent( - maxCrossAxisExtent: 560, - physics: physics, - padding: const EdgeInsets.only(bottom: 20), - children: List.generate( - _collectionList.length, - (index) => _buildCollectionRow( - _collectionList[index], - verticalPadding: 8, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - CollectionDetailScreen( - collectionId: _collectionList[index].id, - blogId: _collectionList[index].blogId, - blogName: "", - postId: 0, - ), - ); - }, - ), + return WaterfallFlow.extent( + controller: widget.scrollController, + maxCrossAxisExtent: 560, + physics: physics, + padding: const EdgeInsets.only(bottom: 20), + children: List.generate( + _collectionList.length, + (index) => _buildCollectionRow( + _collectionList[index], + verticalPadding: 8, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + CollectionDetailScreen( + collectionId: _collectionList[index].id, + blogId: _collectionList[index].blogId, + blogName: "", + postId: 0, + ), + ); + }, ), ), ); @@ -202,77 +217,75 @@ class _CollectionScreenState extends BaseDynamicState double verticalPadding = 12, }) { List tags = collection.tags.split(","); - return - ClickableGestureDetector( - onTap: onTap, - child: Container( - color: Colors.transparent, - padding: - EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16), - child: Column( - children: [ - Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: MyCachedNetworkImage( - imageUrl: collection.coverUrl, - width: 80, - height: 80, - fit: BoxFit.cover, - showLoading: false, - ), + return ClickableGestureDetector( + onTap: onTap, + child: Container( + color: Colors.transparent, + padding: + EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16), + child: Column( + children: [ + Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: MyCachedNetworkImage( + imageUrl: collection.coverUrl, + width: 80, + height: 80, + fit: BoxFit.cover, + showLoading: false, ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 80, - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - collection.name, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - "${collection.postCount}${appLocalizations.chapter} · ${appLocalizations.updateAt}${TimeUtil.formatTimestamp(collection.lastPublishTime)}", - style: Theme.of(context).textTheme.labelMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - SizedBox( - height: 20, - child: ListView( - scrollDirection: Axis.horizontal, - cacheExtent: 9999, - children: [ - ...List.generate( - tags.length, - (index) => Container( - margin: const EdgeInsets.only(right: 5), - child: ItemBuilder.buildSmallTagItem( - context, - tags[index], - showIcon: false, - ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 80, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + collection.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + "${collection.postCount}${appLocalizations.chapter} · ${appLocalizations.updateAt}${TimeUtil.formatTimestamp(collection.lastPublishTime)}", + style: Theme.of(context).textTheme.labelMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox( + height: 20, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + ...List.generate( + tags.length, + (index) => Container( + margin: const EdgeInsets.only(right: 5), + child: ItemBuilder.buildSmallTagItem( + context, + tags[index], + showIcon: false, ), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ), - ], - ) - ], - ), + ), + ], + ) + ], ), + ), ); } diff --git a/lib/Screens/Info/favorite_folder_detail_screen.dart b/lib/Screens/Info/favorite_folder_detail_screen.dart index 5eda22c8..ca7f0524 100644 --- a/lib/Screens/Info/favorite_folder_detail_screen.dart +++ b/lib/Screens/Info/favorite_folder_detail_screen.dart @@ -1,15 +1,20 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Models/favorites_response.dart'; -import 'package:loftify/Models/recommend_response.dart'; import '../../Api/user_api.dart'; +import '../../Models/download_task.dart'; import '../../Models/history_response.dart'; import '../../Models/post_detail_response.dart'; +import '../../Screens/Download/batch_download_screen.dart'; +import '../../Utils/app_provider.dart'; import '../../Utils/enums.dart'; -import '../../Utils/hive_util.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/PostItem/favorite_folder_post_item_builder.dart'; +import '../../Widgets/PostItem/general_post_item.dart'; +import '../../Widgets/PostItem/loftify_post_archive_grid.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; import '../Post/post_detail_screen.dart'; @@ -25,19 +30,19 @@ class FavoriteFolderDetailScreen extends StatefulWidget { _FavoriteFolderDetailScreenState(); } -class _FavoriteFolderDetailScreenState extends BaseDynamicState +class _FavoriteFolderDetailScreenState + extends BaseDynamicState with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; late int favoriteFolderId; FavoriteFolder? _favoriteFolder; - bool _follow = false; - SimpleBlogInfo? _creatorInfo; final List _posts = []; final List _archiveDataList = []; bool _loading = false; final EasyRefreshController _refreshController = EasyRefreshController(); bool _noMore = false; + InitPhase _initPhase = InitPhase.connecting; @override void initState() { @@ -45,75 +50,106 @@ class _FavoriteFolderDetailScreenState extends BaseDynamicState _fetchDetail({bool refresh = false}) async { + if (_loading || !mounted) return IndicatorResult.none; + final token = appProvider.token; + bool isCurrentAccount() => mounted && appProvider.token == token; + if (token.isEmpty) { + setState(() { + _posts.clear(); + _archiveDataList.clear(); + _favoriteFolder = null; + _initPhase = InitPhase.failed; + }); + return IndicatorResult.fail; + } if (refresh) _noMore = false; _loading = true; - int offset = refresh ? 0 : _posts.length; - return await HiveUtil.getUserInfo().then((blogInfo) async { - return await UserApi.getFavoriteFolderDetail( + final offset = refresh ? 0 : _posts.length; + if (_posts.isEmpty) { + _initPhase = InitPhase.connecting; + setState(() {}); + } + try { + final value = await UserApi.getFavoriteFolderDetail( folderId: favoriteFolderId, offset: offset, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - return IndicatorResult.fail; - } else { - _follow = value['data']['followStatus']; - _creatorInfo = SimpleBlogInfo.fromJson(value['data']['blogInfo']); - _favoriteFolder = FavoriteFolder.fromJson(value['data']['folder']); - List t = value['data']['posts']; - if (refresh) _posts.clear(); - for (var e in t) { - if (e != null) { - _posts.add(FavoritePostDetailData.fromJson(e)); - } - } - Map monthCount = {}; - for (var e in _posts) { - String yearMonth = TimeUtil.formatYearMonth(e.opTime ?? 0); - monthCount.putIfAbsent(yearMonth, () => 0); - monthCount[yearMonth] = monthCount[yearMonth]! + 1; - } + ); + if (!isCurrentAccount()) return IndicatorResult.none; + if (value['code'] != 0) { + if (_posts.isEmpty) _initPhase = InitPhase.failed; + IToast.showTop(value['msg']); + return IndicatorResult.fail; + } + final folder = FavoriteFolder.fromJson(value['data']['folder']); + final page = (value['data']['posts'] as List) + .where((item) => item != null) + .map((item) => FavoritePostDetailData.fromJson(item)) + .toList(); + final posts = refresh ? [] : [..._posts]; + final existingIds = posts.map((item) => item.post?.id).toSet(); + for (final item in page) { + if (existingIds.add(item.post?.id)) posts.add(item); + } + final archives = []; + for (final item in posts) { + final month = formatLocalizedYearMonth(item.opTime ?? 0); + if (archives.isNotEmpty && archives.last.desc == month) { + archives.last.count += 1; + } else { + archives.add(ArchiveData( + desc: month, + count: 1, + endTime: 0, + startTime: 0, + )); + } + } + _favoriteFolder = folder; + _posts + ..clear() + ..addAll(posts); + _archiveDataList + ..clear() + ..addAll(archives); + _noMore = page.isEmpty || + (!refresh && posts.length == offset) || + posts.length >= (folder.postCount ?? 0); + _initPhase = InitPhase.successful; + return !refresh && _noMore + ? IndicatorResult.noMore + : IndicatorResult.success; + } catch (error, stackTrace) { + if (!isCurrentAccount()) return IndicatorResult.none; + if (_posts.isEmpty) _initPhase = InitPhase.failed; + ILogger.error('Failed to load folder detail', error, stackTrace); + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + if (mounted) { + setState(() { + if (!isCurrentAccount()) { + _posts.clear(); _archiveDataList.clear(); - for (var e in monthCount.keys) { - _archiveDataList.add(ArchiveData( - desc: e, - count: monthCount[e] ?? 0, - endTime: 0, - startTime: 0, - )); - } - _archiveDataList.sort((a, b) => b.desc.compareTo(a.desc)); - if (mounted) setState(() {}); - if (_posts.length >= (_favoriteFolder?.postCount ?? 0) && - !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } + _favoriteFolder = null; + _noMore = false; + _initPhase = InitPhase.failed; } - } catch (e, t) { - ILogger.error("Failed to load folder detail", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; - } - }); - }); + }); + } + _loading = false; + } } - _onRefresh() async { - return await _fetchDetail(refresh: true); - } + Future _onRefresh() => _fetchDetail(refresh: true); - _onLoad() async { - return await _fetchDetail(); - } + Future _onLoad() => _fetchDetail(); @override Widget build(BuildContext context) { @@ -125,22 +161,46 @@ class _FavoriteFolderDetailScreenState extends BaseDynamicState - _archiveDataList.isNotEmpty && _posts.isNotEmpty - ? _buildNineGridGroup(physics) - : EmptyPlaceholder( - text: appLocalizations.noFavorite, - physics: physics, - shrinkWrap: false, + childBuilder: (context, physics) { + if (_initPhase != InitPhase.successful) { + final loading = _initPhase == InitPhase.connecting; + return CustomScrollView( + physics: physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: loading + ? LoftifyStateVisual.loading + : LoftifyStateVisual.error, + title: loading + ? appLocalizations.loading + : appLocalizations.loadFailed, + scrollWhenConstrained: false, + actionLabel: loading ? null : chewieLocalizations.retry, + onAction: + loading ? null : () => _refreshController.callRefresh(), ), + ), + ], + ); + } + return _posts.isNotEmpty + ? _buildNineGridGroup(physics) + : EmptyPlaceholder( + text: appLocalizations.noFavorite, + physics: physics, + shrinkWrap: false, + ); + }, ), ); } Widget _buildNineGridGroup(ScrollPhysics physics) { - List widgets = []; + final slivers = []; int startIndex = 0; for (var e in _archiveDataList) { if (_posts.length < startIndex) { @@ -151,57 +211,58 @@ class _FavoriteFolderDetailScreenState extends BaseDynamicState ChewieIconButton( + icon: LoftifyIcons.download, + tooltip: appLocalizations.batchDownload, + onPressed: _openBatchDownload, + ); + + void _openBatchDownload() { + RouteUtil.pushPanelCupertinoRoute( + context, + BatchDownloadScreen( + sourceTitle: + _favoriteFolder?.name ?? appLocalizations.favoriteFolderDetail, + source: DownloadSourceDescriptor( + type: DownloadSourceType.favoriteFolder, + sourceId: favoriteFolderId.toString(), + title: _favoriteFolder?.name ?? appLocalizations.favoriteFolderDetail, + metadata: { + 'favoriteFolderId': favoriteFolderId.toString(), + }, + ), + initialItems: _posts + .map(FavoriteFolderPostItemBuilder.getGeneralPostItem) + .toList(), + loadAllItems: _loadAllBatchItems, + ), ); } + + Future> _loadAllBatchItems() async { + if (_posts.isEmpty) { + final result = await _onRefresh(); + if (result != IndicatorResult.success && + result != IndicatorResult.noMore) { + throw StateError('Could not load favorite folder posts'); + } + } + while (!_noMore) { + final previousLength = _posts.length; + final result = await _onLoad(); + if (result == IndicatorResult.fail || + result == IndicatorResult.none || + _posts.length == previousLength) { + throw StateError('Could not load all favorite folder posts'); + } + } + if (_posts.length < (_favoriteFolder?.postCount ?? 0)) { + throw StateError('Favorite folder posts are incomplete'); + } + return _posts + .map(FavoriteFolderPostItemBuilder.getGeneralPostItem) + .toList(growable: false); + } } diff --git a/lib/Screens/Info/favorite_folder_list_screen.dart b/lib/Screens/Info/favorite_folder_list_screen.dart index 9e07793a..74f8a85a 100644 --- a/lib/Screens/Info/favorite_folder_list_screen.dart +++ b/lib/Screens/Info/favorite_folder_list_screen.dart @@ -4,8 +4,12 @@ import 'package:loftify/Api/user_api.dart'; import 'package:loftify/Models/favorites_response.dart'; import 'package:loftify/Screens/Info/favorite_folder_detail_screen.dart'; +import '../../Utils/app_provider.dart'; +import '../../Utils/enums.dart'; import '../../Utils/utils.dart'; -import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; +import '../../Widgets/Favorite/favorite_folder_card.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class FavoriteFolderListScreen extends StatefulWidget { @@ -25,55 +29,87 @@ class _FavoriteFolderListScreenState bool get wantKeepAlive => true; final List _favoriteFolderList = []; int _createCount = 0; - int _subscribeCount = 0; bool _loading = false; + final Set _pendingEditIds = {}; + final Set _pendingDeleteIds = {}; + bool _creatingFolder = false; + InitPhase _initPhase = InitPhase.connecting; final EasyRefreshController _refreshController = EasyRefreshController(); @override - void initState() { - super.initState(); + void dispose() { + _refreshController.dispose(); + super.dispose(); } - _fetchFavoriteFolderList({bool refresh = false}) async { - if (_loading) return; + Future _fetchFavoriteFolderList( + {bool refresh = false}) async { + if (_loading || !mounted) return IndicatorResult.none; + final token = appProvider.token; + bool isCurrentAccount() => mounted && appProvider.token == token; + if (token.isEmpty) { + setState(() { + _favoriteFolderList.clear(); + _createCount = 0; + _initPhase = InitPhase.failed; + }); + return IndicatorResult.fail; + } _loading = true; - int offset = refresh ? 0 : _favoriteFolderList.length; - return await UserApi.getFavoriteFolderList(offset: offset).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - return IndicatorResult.fail; - } else { - _createCount = value['data']['createCount']; - _subscribeCount = value['data']['subscribeCount']; - _favoriteFolderList.clear(); - for (var e in value['data']['folders']) { - _favoriteFolderList.add(FavoriteFolder.fromJson(e)); - } - if (_favoriteFolderList.length == _createCount && !refresh) { - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - ILogger.error("Failed to load folder list", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); + final offset = refresh ? 0 : _favoriteFolderList.length; + if (_favoriteFolderList.isEmpty) { + _initPhase = InitPhase.connecting; + setState(() {}); + } + try { + final value = await UserApi.getFavoriteFolderList(offset: offset); + if (!isCurrentAccount()) return IndicatorResult.none; + if (value['code'] != 0) { + if (_favoriteFolderList.isEmpty) _initPhase = InitPhase.failed; + IToast.showTop(value['msg']); return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; } - }); + final count = value['data']['createCount'] as int; + final folders = (value['data']['folders'] as List) + .map((item) => FavoriteFolder.fromJson(item)) + .toList(); + _createCount = count; + if (refresh) _favoriteFolderList.clear(); + final existingIds = + _favoriteFolderList.map((folder) => folder.id).toSet(); + for (final folder in folders) { + if (existingIds.add(folder.id)) _favoriteFolderList.add(folder); + } + _initPhase = InitPhase.successful; + if (!refresh && + (folders.isEmpty || _favoriteFolderList.length >= _createCount)) { + return IndicatorResult.noMore; + } + return IndicatorResult.success; + } catch (error, stackTrace) { + if (!isCurrentAccount()) return IndicatorResult.none; + if (_favoriteFolderList.isEmpty) _initPhase = InitPhase.failed; + ILogger.error('Failed to load folder list', error, stackTrace); + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + if (mounted) { + setState(() { + if (!isCurrentAccount()) { + _favoriteFolderList.clear(); + _createCount = 0; + _initPhase = InitPhase.failed; + } + }); + } + _loading = false; + } } - _onRefresh() async { - return await _fetchFavoriteFolderList(refresh: true); - } + Future _onRefresh() => + _fetchFavoriteFolderList(refresh: true); - _onLoad() async { - return await _fetchFavoriteFolderList(); - } + Future _onLoad() => _fetchFavoriteFolderList(); @override Widget build(BuildContext context) { @@ -83,26 +119,61 @@ class _FavoriteFolderListScreenState appBar: _buildAppBar(), body: Stack( children: [ - EasyRefresh( + EasyRefresh.builder( refreshOnStart: true, controller: _refreshController, onRefresh: _onRefresh, onLoad: _onLoad, triggerAxis: Axis.vertical, - child: _buildBody(), - ), - Positioned( - right: ResponsiveUtil.isLandscapeLayout() ? 16 : 12, - bottom: ResponsiveUtil.isLandscapeLayout() ? 16 : 76, - child: _buildFloatingButtons(), + childBuilder: (context, physics) { + if (_initPhase != InitPhase.successful) { + final loading = _initPhase == InitPhase.connecting; + return CustomScrollView( + physics: physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: loading + ? LoftifyStateVisual.loading + : LoftifyStateVisual.error, + title: loading + ? appLocalizations.loading + : appLocalizations.loadFailed, + scrollWhenConstrained: false, + actionLabel: loading ? null : chewieLocalizations.retry, + onAction: loading + ? null + : () => _refreshController.callRefresh(), + ), + ), + ], + ); + } + if (_favoriteFolderList.isEmpty) { + return EmptyPlaceholder( + text: appLocalizations.noFavoriteFolder, + physics: physics, + shrinkWrap: false, + ); + } + return _buildBody(physics); + }, ), + if (_initPhase == InitPhase.successful) + Positioned( + right: ResponsiveUtil.isLandscapeLayout() ? 16 : 12, + bottom: ResponsiveUtil.isLandscapeLayout() ? 16 : 76, + child: _buildFloatingButtons(), + ), ], ), ); } - Widget _buildBody() { + Widget _buildBody(ScrollPhysics physics) { return WaterfallFlow.extent( + physics: physics, maxCrossAxisExtent: 600, mainAxisSpacing: 12, crossAxisSpacing: 12, @@ -117,151 +188,152 @@ class _FavoriteFolderListScreenState } Widget _buildFolderItem(BuildContext context, FavoriteFolder item) { - return ClickableGestureDetector( + return LoftifyFavoriteFolderCard( + title: item.name ?? "", + folderIdLabel: appLocalizations.folderId(item.id.toString()), + postCountLabel: "${item.postCount}${appLocalizations.chapter}", + editLabel: appLocalizations.edit, + deleteLabel: appLocalizations.delete, onTap: () { RouteUtil.pushPanelCupertinoRoute( context, FavoriteFolderDetailScreen(favoriteFolderId: item.id ?? 0), ); }, - child: Container( - color: Colors.transparent, - child: Row( - children: [ - Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, width: 0.5), - borderRadius: BorderRadius.circular(10), - color: Colors.transparent, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - height: 80, - width: 80, - child: ChewieItemBuilder.buildCachedImage( - context: context, - fit: BoxFit.cover, - showLoading: false, - imageUrl: Utils.removeWatermark(item.coverUrl ?? ""), - ), - ), - ), - ), - Expanded( - child: Container( - padding: const EdgeInsets.only(left: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - children: [ - ItemBuilder.buildCopyable( - context, - child: Text( - item.name ?? "", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - text: item.name ?? "", - toastText: appLocalizations.haveCopiedFolderName, - ), - const SizedBox(height: 10), - ItemBuilder.buildCopyable(context, - child: Text( - appLocalizations.folderId(item.id.toString()), - style: Theme.of(context).textTheme.titleSmall, - ), - text: item.id.toString(), - toastText: appLocalizations.haveCopiedFolderID), - const SizedBox(height: 10), - Text( - "${item.postCount}${appLocalizations.chapter}", - style: Theme.of(context).textTheme.titleSmall, - ), - ], - ), - ), - ), - CircleIconButton( - icon: const Icon(Icons.edit_note_rounded), - onTap: () { - BottomSheetBuilder.showBottomSheet( - context, - (sheetContext) => InputBottomSheet( - title: appLocalizations.editFolderTitle, - hint: appLocalizations.inputFolderTitle, - text: item.name ?? "", - onConfirm: (text) { - var tmp = item; - tmp.name = text; - UserApi.editFolder(folder: tmp).then((value) { - if (value['code'] == 0) { - IToast.showTop(appLocalizations.editSuccess); - item.name = text; - setState(() {}); - } else { - IToast.showTop(value['msg']); - } - }); - }, - ), - preferMinWidth: 400, - responsive: true, - ); - }, - ), - if (item.isDefault != 1) - CircleIconButton( - icon: - const Icon(Icons.delete_outline_rounded, color: Colors.red), - onTap: () { - DialogBuilder.showConfirmDialog( - context, - title: appLocalizations.deleteFolder, - message: - appLocalizations.deleteFolderMessage(item.name.toString()), - messageTextAlign: TextAlign.center, - onTapConfirm: () async { - UserApi.deleteFolder(folderId: item.id ?? 0) - .then((value) { - if (value['code'] == 0) { - IToast.showTop(appLocalizations.deleteSuccess); - _refreshController.callRefresh(); - } else { - IToast.showTop(value['msg']); - } - }); - }, - ); - }, - ), - ], - ), + onCopyTitle: () => ChewieUtils.copy( + context, + item.name ?? "", + toastText: appLocalizations.haveCopiedFolderName, + ), + onCopyFolderId: () => ChewieUtils.copy( + context, + item.id.toString(), + toastText: appLocalizations.haveCopiedFolderID, + ), + cover: ChewieItemBuilder.buildCachedImage( + context: context, + fit: BoxFit.cover, + showLoading: false, + imageUrl: Utils.removeWatermark(item.coverUrl ?? ""), ), + onEdit: () { + BottomSheetBuilder.showBottomSheet( + context, + (sheetContext) => InputBottomSheet( + title: appLocalizations.editFolderTitle, + hint: appLocalizations.inputFolderTitle, + text: item.name ?? "", + onConfirm: (text) => _editFolder(item, text), + ), + preferMinWidth: 400, + responsive: true, + ); + }, + onDelete: item.isDefault == 1 || item.id == null + ? null + : () { + DialogBuilder.showConfirmDialog( + context, + title: appLocalizations.deleteFolder, + message: + appLocalizations.deleteFolderMessage(item.name.toString()), + messageTextAlign: TextAlign.center, + onTapConfirm: () => _deleteFolder(item.id!), + ); + }, ); } - handleAdd() { + Future _editFolder(FavoriteFolder item, String name) async { + final id = item.id; + if (id == null || !_pendingEditIds.add(id)) return; + final token = appProvider.token; + final edited = FavoriteFolder.fromJson({ + ...item.toJson(), + 'name': name, + 'tags': item.tags ?? [], + 'themes': item.themes ?? [], + }); + try { + final value = await UserApi.editFolder(folder: edited); + if (!mounted || appProvider.token != token) return; + if (value['code'] != 0) { + IToast.showTop(value['msg']); + return; + } + setState(() { + for (final folder in _favoriteFolderList) { + if (folder.id == id) folder.name = name; + } + }); + IToast.showTop(appLocalizations.editSuccess); + } catch (error, stackTrace) { + ILogger.error('Failed to edit favorite folder', error, stackTrace); + if (mounted && appProvider.token == token) { + IToast.showTop(appLocalizations.loadFailed); + } + } finally { + _pendingEditIds.remove(id); + } + } + + Future _deleteFolder(int id) async { + if (!_pendingDeleteIds.add(id)) return; + final token = appProvider.token; + try { + final value = await UserApi.deleteFolder(folderId: id); + if (!mounted || appProvider.token != token) return; + if (value['code'] != 0) { + IToast.showTop(value['msg']); + return; + } + setState(() { + _favoriteFolderList.removeWhere((folder) => folder.id == id); + _createCount = (_createCount - 1).clamp(0, _createCount); + }); + IToast.showTop(appLocalizations.deleteSuccess); + _refreshController.callRefresh(); + } catch (error, stackTrace) { + ILogger.error('Failed to delete favorite folder', error, stackTrace); + if (mounted && appProvider.token == token) { + IToast.showTop(appLocalizations.loadFailed); + } + } finally { + _pendingDeleteIds.remove(id); + } + } + + Future _createFolder(String name) async { + if (_creatingFolder) return; + _creatingFolder = true; + final token = appProvider.token; + try { + final value = await UserApi.createFolder(name: name); + if (!mounted || appProvider.token != token) return; + if (value['code'] != 0) { + IToast.showTop(value['msg']); + return; + } + IToast.showTop(appLocalizations.createSuccess); + _refreshController.callRefresh(); + } catch (error, stackTrace) { + ILogger.error('Failed to create favorite folder', error, stackTrace); + if (mounted && appProvider.token == token) { + IToast.showTop(appLocalizations.loadFailed); + } + } finally { + _creatingFolder = false; + } + } + + void handleAdd() { BottomSheetBuilder.showBottomSheet( context, (sheetContext) => InputBottomSheet( title: appLocalizations.newFolder, hint: appLocalizations.inputFolderTitle, text: "", - onConfirm: (text) { - UserApi.createFolder(name: text).then((value) { - if (value['code'] == 0) { - IToast.showTop(appLocalizations.createSuccess); - _refreshController.callRefresh(); - } else { - IToast.showTop(value['msg']); - } - }); - }, + onConfirm: _createFolder, ), preferMinWidth: 400, responsive: true, @@ -273,27 +345,21 @@ class _FavoriteFolderListScreenState showBack: true, title: appLocalizations.myFavorites, actions: [ - // CircleIconButton( - // context: context, - // icon: Icon(Icons.search_rounded, - // color: Theme.of(context).iconTheme.color), - // onTap: () {}), - // const SizedBox(width: 5), - CircleIconButton( - icon: - Icon(Icons.add_rounded, color: ChewieTheme.iconColor), - onTap: handleAdd, + ChewieIconButton( + icon: LoftifyIcons.add, + tooltip: appLocalizations.newFolder, + onPressed: handleAdd, ), ], ); } - _buildFloatingButtons() { + Widget _buildFloatingButtons() { return ResponsiveUtil.isLandscapeLayout() ? Column( children: [ ShadowIconButton( - icon: const Icon(Icons.add_rounded), + icon: const ChewieIcon(LoftifyIcons.add), onTap: handleAdd, ), ], diff --git a/lib/Screens/Info/following_follower_screen.dart b/lib/Screens/Info/following_follower_screen.dart index 38e6cd71..3f50eb9f 100644 --- a/lib/Screens/Info/following_follower_screen.dart +++ b/lib/Screens/Info/following_follower_screen.dart @@ -4,7 +4,9 @@ import 'package:loftify/Api/user_api.dart'; import 'package:loftify/Utils/hive_util.dart'; import '../../Models/user_response.dart'; +import '../../Utils/app_provider.dart'; import '../../Utils/enums.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; import '../../l10n/l10n.dart'; @@ -42,9 +44,15 @@ class _FollowingFollowerScreenState bool get wantKeepAlive => true; final List _followingList = []; bool _loading = false; + bool _loadingRefresh = false; + String? _loadingToken; + String? _dataToken; + int _requestEpoch = 0; + int _nextOffset = 0; int total = 0; final EasyRefreshController _refreshController = EasyRefreshController(); bool _noMore = false; + InitPhase _initPhase = InitPhase.connecting; @override void initState() { @@ -52,78 +60,113 @@ class _FollowingFollowerScreenState super.initState(); } - _processResult(value, {bool refresh = false}) { + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchList({bool refresh = false}) async { + if (!mounted) return IndicatorResult.none; + final token = appProvider.token; + if (_loading && _loadingToken == token && (!refresh || _loadingRefresh)) { + return IndicatorResult.none; + } + if (_dataToken != null && _dataToken != token) { + _followingList.clear(); + _nextOffset = 0; + _noMore = false; + _initPhase = InitPhase.connecting; + } + final epoch = ++_requestEpoch; + bool isCurrentRequest() => + mounted && appProvider.token == token && epoch == _requestEpoch; + _loading = true; + _loadingRefresh = refresh; + _loadingToken = token; + final offset = refresh ? 0 : _nextOffset; + if (_followingList.isEmpty) { + _initPhase = InitPhase.connecting; + setState(() {}); + } try { + final blogInfo = widget.infoMode == InfoMode.me && widget.blogName == null + ? await HiveUtil.getUserInfo() + : null; + if (!isCurrentRequest()) return IndicatorResult.none; + final blogName = widget.blogName ?? blogInfo?.blogName; + if (blogName == null || blogName.isEmpty) { + if (_followingList.isEmpty) _initPhase = InitPhase.failed; + return IndicatorResult.fail; + } + final value = widget.followingMode == FollowingMode.timeline + ? await UserApi.getFollowingTimeline( + blogName: blogName, + offset: offset, + ) + : await UserApi.getFollowingList( + blogName: blogName, + offset: offset, + followingMode: widget.followingMode, + ); + if (!isCurrentRequest()) return IndicatorResult.none; if (value['meta']['status'] != 200) { + if (_followingList.isEmpty) _initPhase = InitPhase.failed; IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); return IndicatorResult.fail; - } else { - List t = value['response']; - if (refresh) _followingList.clear(); - List notExist = []; - for (var e in t) { - if (e != null) { - if (_followingList.indexWhere((element) => - element.blogInfo.blogId == e['blogInfo']['blogId']) == - -1) { - notExist.add(FollowingUserItem.fromJson(e)); - } - } - } - _followingList.addAll(notExist); - if (mounted) setState(() {}); - if ((_followingList.length >= widget.total || notExist.isEmpty) && - !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } } - } catch (e, t) { - ILogger.error("Failed to load following or follower", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); + final rawItems = value['response'] as List; + final page = rawItems + .whereType() + .map((item) => + FollowingUserItem.fromJson(Map.from(item))) + .toList(); + final users = refresh ? [] : [..._followingList]; + final seen = users.map((user) => user.blogInfo.blogId).toSet(); + for (final user in page) { + if (seen.add(user.blogInfo.blogId)) users.add(user); + } + _followingList + ..clear() + ..addAll(users); + _nextOffset = offset + rawItems.length; + _noMore = rawItems.isEmpty || (total > 0 && _nextOffset >= total); + _initPhase = InitPhase.successful; + _dataToken = token; + return !refresh && _noMore + ? IndicatorResult.noMore + : IndicatorResult.success; + } catch (error, stackTrace) { + if (!isCurrentRequest()) return IndicatorResult.none; + if (_followingList.isEmpty) _initPhase = InitPhase.failed; + ILogger.error('Failed to load following or follower', error, stackTrace); + IToast.showTop(appLocalizations.loadFailed); return IndicatorResult.fail; } finally { - if (mounted) setState(() {}); - _loading = false; - } - } - - _fetchList({bool refresh = false}) async { - if (_loading) return; - if (refresh) _noMore = false; - _loading = true; - int offset = refresh ? 0 : _followingList.length; - return await HiveUtil.getUserInfo().then((blogInfo) async { - if (widget.followingMode == FollowingMode.timeline) { - String blogName = widget.blogName!; - return await UserApi.getFollowingTimeline( - blogName: blogName, - offset: offset, - ).then((value) { - return _processResult(value, refresh: refresh); - }); - } else { - String blogName = widget.infoMode == InfoMode.me - ? blogInfo!.blogName - : widget.blogName!; - return await UserApi.getFollowingList( - blogName: blogName, - offset: offset, - followingMode: widget.followingMode, - ).then((value) { - return _processResult(value, refresh: refresh); + if (epoch == _requestEpoch && mounted) { + setState(() { + if (appProvider.token != token) { + _followingList.clear(); + _nextOffset = 0; + _noMore = false; + _initPhase = InitPhase.failed; + _dataToken = null; + } }); } - }); + if (epoch == _requestEpoch) { + _loading = false; + _loadingRefresh = false; + _loadingToken = null; + } + } } - _onRefresh() async { + Future _onRefresh() async { return await _fetchList(refresh: true); } - _onLoad() async { + Future _onLoad() async { return await _fetchList(); } @@ -137,7 +180,7 @@ class _FollowingFollowerScreenState refreshOnStart: true, controller: _refreshController, onRefresh: _onRefresh, - onLoad: _onLoad, + onLoad: _noMore ? null : _onLoad, triggerAxis: Axis.vertical, childBuilder: (context, physics) { return _buildBody(physics); @@ -147,20 +190,53 @@ class _FollowingFollowerScreenState } Widget _buildBody(ScrollPhysics physics) { - return LoadMoreNotification( - noMore: _noMore, - onLoad: _onLoad, - child: WaterfallFlow.extent( - maxCrossAxisExtent: 600, + if (_initPhase != InitPhase.successful) { + final loading = _initPhase == InitPhase.connecting; + return CustomScrollView( + physics: physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: loading + ? LoftifyStateVisual.loading + : LoftifyStateVisual.error, + title: loading + ? appLocalizations.loading + : appLocalizations.loadFailed, + scrollWhenConstrained: false, + actionLabel: loading ? null : chewieLocalizations.retry, + onAction: loading ? null : () => _refreshController.callRefresh(), + ), + ), + ], + ); + } + if (_followingList.isEmpty) { + return EmptyPlaceholder( + text: appLocalizations.noUser, physics: physics, - children: List.generate(_followingList.length, (index) { - return LoftifyItemBuilder.buildFollowerOrFollowingItem( - context, index, _followingList[index], onFollowOrUnFollow: () { - total += _followingList[index].following ? 1 : -1; - setState(() {}); - }); - }), + shrinkWrap: false, + ); + } + return WaterfallFlow.builder( + physics: physics, + padding: const EdgeInsets.only(bottom: 20), + gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 560, + mainAxisSpacing: 0, + crossAxisSpacing: 8, ), + itemCount: _followingList.length, + itemBuilder: (context, index) { + final user = _followingList[index]; + return LoftifyItemBuilder.buildFollowerOrFollowingItem( + context, index, user, onFollowOrUnFollow: () { + if (!mounted || !_followingList.contains(user)) return; + total = (total + (user.following ? 1 : -1)).clamp(0, 0x7fffffff); + setState(() {}); + }); + }, ); } diff --git a/lib/Screens/Info/grain_screen.dart b/lib/Screens/Info/grain_screen.dart index 01ded847..de3c2be7 100644 --- a/lib/Screens/Info/grain_screen.dart +++ b/lib/Screens/Info/grain_screen.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; import 'package:loftify/Api/user_api.dart'; import 'package:loftify/Models/recommend_response.dart'; +import 'package:loftify/Screens/Info/nested_mixin.dart'; import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:loftify/Screens/Post/grain_detail_screen.dart'; import 'package:loftify/Utils/hive_util.dart'; +import '../../Utils/app_provider.dart'; import '../../Utils/enums.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; -import 'nested_mixin.dart'; class GrainScreen extends StatefulWidgetForNested { GrainScreen({ @@ -18,6 +20,8 @@ class GrainScreen extends StatefulWidgetForNested { this.blogId, this.blogName, super.nested = false, + super.refreshListenable, + super.refreshId = 'grain', }) { if (infoMode == InfoMode.other) { assert(blogName != null); @@ -36,85 +40,143 @@ class GrainScreen extends StatefulWidgetForNested { } class _GrainScreenState extends BaseDynamicState - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with + TickerProviderStateMixin, + AutomaticKeepAliveClientMixin, + NestedRefreshSignalMixin { @override bool get wantKeepAlive => true; final List _grainList = []; bool _loading = false; + bool _loadingRefresh = false; + String? _loadingToken; + String? _dataToken; + int _requestEpoch = 0; int _total = 0; - int _offset = 0; + int _nextOffset = 0; final EasyRefreshController _refreshController = EasyRefreshController(); bool _noMore = false; - InitPhase _initPhase = InitPhase.haveNotConnected; + InitPhase _initPhase = InitPhase.connecting; @override void initState() { super.initState(); + bindNestedRefreshSignal(() { + _refreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 140), + ); + }); if (widget.nested) { WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 300), () => _onRefresh()); + if (mounted) _onRefresh(); }); - } else { - _initPhase = InitPhase.successful; - setState(() {}); } } - _fetchGrain({bool refresh = false}) async { - if (_loading) return; - if (refresh) _noMore = false; + @override + void dispose() { + unbindNestedRefreshSignal(); + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchGrain({bool refresh = false}) async { + if (!mounted) return IndicatorResult.none; + final token = appProvider.token; + if (_loading && _loadingToken == token && (!refresh || _loadingRefresh)) { + return IndicatorResult.none; + } + if (_dataToken != null && _dataToken != token) { + _grainList.clear(); + _total = 0; + _nextOffset = 0; + _noMore = false; + _initPhase = InitPhase.connecting; + } + final epoch = ++_requestEpoch; + bool isCurrentRequest() => + mounted && appProvider.token == token && epoch == _requestEpoch; _loading = true; - int offset = refresh ? 0 : _grainList.length; - if (_initPhase != InitPhase.successful) { + _loadingRefresh = refresh; + _loadingToken = token; + final offset = refresh ? 0 : _nextOffset; + if (_grainList.isEmpty) { _initPhase = InitPhase.connecting; setState(() {}); } - return await HiveUtil.getUserInfo().then((blogInfo) async { - int blogId = - widget.infoMode == InfoMode.me ? blogInfo!.blogId : widget.blogId!; - return await UserApi.getGrainList(blogId: blogId, offset: offset) - .then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['desc'] ?? value['msg']); - return IndicatorResult.fail; - } else { - _total = value['data']['total']; - _offset = value['data']['offset']; - List t = value['data']['grains']; - if (refresh) _grainList.clear(); - for (var e in t) { - if (e != null) { - _grainList.add(GrainInfo.fromJson(e)); - } - } - if (mounted) setState(() {}); - _initPhase = InitPhase.successful; - if ((t.isEmpty || _grainList.length > _total) && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } + try { + final blogInfo = + widget.infoMode == InfoMode.me ? await HiveUtil.getUserInfo() : null; + if (!isCurrentRequest()) return IndicatorResult.none; + final blogId = + widget.infoMode == InfoMode.me ? blogInfo?.blogId : widget.blogId; + if (blogId == null || blogId <= 0) { + if (_grainList.isEmpty) _initPhase = InitPhase.failed; + return IndicatorResult.fail; + } + final value = await UserApi.getGrainList(blogId: blogId, offset: offset); + if (!isCurrentRequest()) return IndicatorResult.none; + if (value['code'] != 0) { + if (_grainList.isEmpty) _initPhase = InitPhase.failed; + IToast.showTop(value['desc'] ?? value['msg']); + return IndicatorResult.fail; + } + final data = value['data'] as Map; + final total = (data['total'] as num).toInt(); + final rawItems = data['grains'] as List; + final page = rawItems + .whereType() + .map((item) => GrainInfo.fromJson(Map.from(item))) + .toList(); + final grains = refresh ? [] : [..._grainList]; + final seen = grains.map((grain) => grain.id).toSet(); + for (final grain in page) { + if (seen.add(grain.id)) grains.add(grain); + } + _grainList + ..clear() + ..addAll(grains); + _total = total; + _nextOffset = offset + rawItems.length; + _noMore = rawItems.isEmpty || _nextOffset >= _total; + _initPhase = InitPhase.successful; + _dataToken = token; + return !refresh && _noMore + ? IndicatorResult.noMore + : IndicatorResult.success; + } catch (error, stackTrace) { + if (!isCurrentRequest()) return IndicatorResult.none; + if (_grainList.isEmpty) _initPhase = InitPhase.failed; + ILogger.error('Failed to load grain list', error, stackTrace); + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + if (epoch == _requestEpoch && mounted) { + setState(() { + if (appProvider.token != token) { + _grainList.clear(); + _total = 0; + _nextOffset = 0; + _noMore = false; + _initPhase = InitPhase.failed; + _dataToken = null; } - } catch (e, t) { - _initPhase = InitPhase.failed; - ILogger.error("Failed to load grain list", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; - } - }); - }); + }); + } + if (epoch == _requestEpoch) { + _loading = false; + _loadingRefresh = false; + _loadingToken = null; + } + } } - _onRefresh() async { + Future _onRefresh() async { return await _fetchGrain(refresh: true); } - _onLoad() async { + Future _onLoad() async { return await _fetchGrain(); } @@ -130,61 +192,80 @@ class _GrainScreenState extends BaseDynamicState ); } - _buildBody() { - switch (_initPhase) { - case InitPhase.connecting: - return const LoadingWidget(background: Colors.transparent); - case InitPhase.failed: - return CustomErrorWidget( - onTap: _onRefresh, - ); - case InitPhase.successful: - return EasyRefresh.builder( - refreshOnStart: true, - controller: _refreshController, - onRefresh: _onRefresh, - onLoad: _onLoad, - triggerAxis: Axis.vertical, - childBuilder: (context, physics) { - return _grainList.isNotEmpty - ? _buildMainBody(physics) - : EmptyPlaceholder(text: appLocalizations.noGrain, physics: physics); - }, - ); - default: - return Container(); - } + Widget _buildBody() { + return EasyRefresh.builder( + header: widget.nested ? buildNestedRefreshHeader() : null, + refreshOnStart: !widget.nested, + controller: _refreshController, + onRefresh: _onRefresh, + onLoad: _noMore ? null : _onLoad, + triggerAxis: Axis.vertical, + childBuilder: (context, physics) { + if (_initPhase != InitPhase.successful) { + final loading = _initPhase == InitPhase.connecting; + return CustomScrollView( + controller: widget.scrollController, + physics: physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: loading + ? LoftifyStateVisual.loading + : LoftifyStateVisual.error, + title: loading + ? appLocalizations.loading + : appLocalizations.loadFailed, + scrollWhenConstrained: false, + actionLabel: loading ? null : chewieLocalizations.retry, + onAction: + loading ? null : () => _refreshController.callRefresh(), + ), + ), + ], + ); + } + return _grainList.isNotEmpty + ? _buildMainBody(physics) + : EmptyPlaceholder( + text: appLocalizations.noGrain, + physics: physics, + shrinkWrap: false, + ); + }, + ); } Widget _buildMainBody(ScrollPhysics physics) { - return LoadMoreNotification( - noMore: _noMore, - onLoad: _onLoad, - child: WaterfallFlow.extent( + return WaterfallFlow.builder( + controller: widget.scrollController, + physics: physics, + cacheExtent: MediaQuery.sizeOf(context).height, + padding: const EdgeInsets.only(bottom: 20), + itemCount: _grainList.length, + gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 560, - physics: physics, - padding: EdgeInsets.zero, - children: List.generate( - _grainList.length, - (index) => _buildGrainRow( - _grainList[index], - verticalPadding: 8, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - GrainDetailScreen( - grainId: _grainList[index].id, - blogId: _grainList[index].userId, - ), - ); - }, - ), - ), ), + itemBuilder: (context, index) { + final grain = _grainList[index]; + return _buildGrainRow( + grain, + verticalPadding: 8, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + GrainDetailScreen( + grainId: grain.id, + blogId: grain.userId, + ), + ); + }, + ); + }, ); } - _buildGrainRow( + Widget _buildGrainRow( GrainInfo grain, { Function()? onTap, double verticalPadding = 12, @@ -198,6 +279,7 @@ class _GrainScreenState extends BaseDynamicState child: Column( children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(8), @@ -212,44 +294,44 @@ class _GrainScreenState extends BaseDynamicState ), const SizedBox(width: 8), Expanded( - child: SizedBox( - height: 80, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 80), child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( grain.name, style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, ), + const SizedBox(height: 6), Text( "${grain.postCount}${appLocalizations.chapter} · ${appLocalizations.updateAt}${TimeUtil.formatTimestamp(grain.updateTime)}", style: Theme.of(context).textTheme.labelMedium, - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, ), - SizedBox( - height: 20, - child: ListView( + if (grain.tags.isNotEmpty) ...[ + const SizedBox(height: 6), + SingleChildScrollView( scrollDirection: Axis.horizontal, - children: [ - ...List.generate( - grain.tags.length, - (index) => Container( - margin: const EdgeInsets.only(right: 5), - child: ItemBuilder.buildSmallTagItem( - context, - grain.tags[index], - showIcon: false, + child: Row( + children: [ + for (final tag in grain.tags) + Padding( + padding: const EdgeInsets.only(right: 5), + child: ItemBuilder.buildSmallTagItem( + context, + tag, + showIcon: false, + ), ), - ), - ), - ], + ], + ), ), - ), + ], ], ), ), diff --git a/lib/Screens/Info/history_screen.dart b/lib/Screens/Info/history_screen.dart index f0734df7..852fa742 100644 --- a/lib/Screens/Info/history_screen.dart +++ b/lib/Screens/Info/history_screen.dart @@ -5,10 +5,14 @@ import 'package:loftify/Models/history_response.dart'; import 'package:loftify/Utils/hive_util.dart'; import '../../Models/post_detail_response.dart'; +import '../../Utils/app_provider.dart'; import '../../Utils/enums.dart'; import '../../Utils/utils.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/PostItem/common_info_post_item_builder.dart'; +import '../../Widgets/PostItem/loftify_post_archive_grid.dart'; +import '../../Widgets/loftify_icons.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; import '../../l10n/l10n.dart'; class HistoryScreen extends StatefulWidget { @@ -31,61 +35,98 @@ class _HistoryScreenState extends BaseDynamicState bool _loading = false; final EasyRefreshController _refreshController = EasyRefreshController(); bool _noMore = false; - InitPhase _initPhase = InitPhase.successful; + InitPhase _initPhase = InitPhase.connecting; + + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } _fetchHistory({bool refresh = false}) async { - if (_loading) return; + if (_loading || !mounted) return IndicatorResult.none; + final token = appProvider.token; + bool isCurrentAccount() => mounted && appProvider.token == token; + if (token.isEmpty) { + setState(() { + _histories.clear(); + _archiveDataList.clear(); + _total = 0; + _recordHistory = 0; + _initPhase = InitPhase.failed; + }); + return IndicatorResult.fail; + } if (refresh) _noMore = false; _loading = true; int offset = refresh ? 0 : _histories.length; - if (_initPhase != InitPhase.successful) { + if (_histories.isEmpty) { _initPhase = InitPhase.connecting; setState(() {}); } - return await HiveUtil.getUserInfo().then((blogInfo) async { - String domain = Utils.getBlogDomain(blogInfo?.blogName); - return await UserApi.getHistoryList(blogDomain: domain, offset: offset) - .then((value) { - try { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - _total = value['response']['count']; - _recordHistory = value['response']['recordHistory']; - if (value['response']['archiveData'] != null) { - _archiveDataList.clear(); - List t = value['response']['archiveData']; - for (var e in t) { - _archiveDataList.add(ArchiveData.fromJson(e)); - } - } - List t = value['response']['items']; - if (refresh) _histories.clear(); - for (var e in t) { - if (e != null) { - _histories.add(PostDetailData.fromJson(e)); - } - } - _initPhase = InitPhase.successful; - if (_histories.length >= _total && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - _initPhase = InitPhase.failed; - ILogger.error("Failed to load history", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; + try { + final blogInfo = await HiveUtil.getUserInfo(); + if (!isCurrentAccount()) return IndicatorResult.none; + if (blogInfo == null) { + if (_histories.isEmpty) _initPhase = InitPhase.failed; + return IndicatorResult.fail; + } + String domain = Utils.getBlogDomain(blogInfo.blogName); + final value = + await UserApi.getHistoryList(blogDomain: domain, offset: offset); + if (!isCurrentAccount()) return IndicatorResult.none; + if (value['meta']['status'] != 200) { + if (_histories.isEmpty) _initPhase = InitPhase.failed; + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return IndicatorResult.fail; + } else { + final int total = value['response']['count']; + final int recordHistory = value['response']['recordHistory']; + final archives = (value['response']['archiveData'] as List?) + ?.map((e) => ArchiveData.fromJson(e)) + .toList(); + final posts = (value['response']['items'] as List) + .where((e) => e != null) + .map((e) => PostDetailData.fromJson(e)) + .toList(); + _total = total; + _recordHistory = recordHistory; + if (refresh || archives != null) { + _archiveDataList + ..clear() + ..addAll(archives ?? []); } - }); - }); + if (refresh) _histories.clear(); + _histories.addAll(posts); + _initPhase = InitPhase.successful; + if (_histories.length >= _total && !refresh) { + _noMore = true; + return IndicatorResult.noMore; + } else { + return IndicatorResult.success; + } + } + } catch (e, t) { + if (!isCurrentAccount()) return IndicatorResult.none; + if (_histories.isEmpty) _initPhase = InitPhase.failed; + ILogger.error("Failed to load history", e, t); + if (mounted) IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + if (mounted) { + setState(() { + if (!isCurrentAccount()) { + _histories.clear(); + _archiveDataList.clear(); + _total = 0; + _recordHistory = 0; + _noMore = false; + _initPhase = InitPhase.failed; + } + }); + } + _loading = false; + } } _onRefresh() async { @@ -106,93 +147,112 @@ class _HistoryScreenState extends BaseDynamicState ); } - _buildBody() { - switch (_initPhase) { - case InitPhase.connecting: - return const LoadingWidget(background: Colors.transparent); - case InitPhase.failed: - return CustomErrorWidget( - onTap: _onRefresh, - ); - case InitPhase.successful: - return Stack( - children: [ - EasyRefresh.builder( - refreshOnStart: true, - controller: _refreshController, - onRefresh: _onRefresh, - onLoad: _onLoad, - triggerAxis: Axis.vertical, - childBuilder: (context, physics) { - return _archiveDataList.isNotEmpty && _histories.isNotEmpty - ? _buildNineGridGroup(physics) - : EmptyPlaceholder( - text: appLocalizations.noHistory, - physics: physics, - shrinkWrap: false, - ); - }, - ), - Positioned( - right: ResponsiveUtil.isLandscapeLayout() ? 16 : 12, - bottom: ResponsiveUtil.isLandscapeLayout() ? 16 : 76, - child: _buildFloatingButtons(), - ), - ], - ); - default: - return Container(); - } + Widget _buildBody() { + return Stack( + children: [ + EasyRefresh.builder( + refreshOnStart: true, + controller: _refreshController, + onRefresh: _onRefresh, + onLoad: _onLoad, + triggerAxis: Axis.vertical, + childBuilder: (context, physics) { + if (_initPhase != InitPhase.successful) { + final loading = _initPhase == InitPhase.connecting; + return CustomScrollView( + physics: physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: loading + ? LoftifyStateVisual.loading + : LoftifyStateVisual.error, + title: loading + ? appLocalizations.loading + : appLocalizations.loadFailed, + scrollWhenConstrained: false, + actionLabel: loading ? null : chewieLocalizations.retry, + onAction: loading + ? null + : () => _refreshController.callRefresh(), + ), + ) + ], + ); + } + return _histories.isNotEmpty + ? _buildNineGridGroup(physics) + : EmptyPlaceholder( + text: appLocalizations.noHistory, + physics: physics, + shrinkWrap: false, + ); + }, + ), + if (_initPhase == InitPhase.successful) + Positioned( + right: ResponsiveUtil.isLandscapeLayout() ? 16 : 12, + bottom: ResponsiveUtil.isLandscapeLayout() ? 16 : 76, + child: _buildFloatingButtons(), + ), + ], + ); } Widget _buildNineGridGroup(ScrollPhysics physics) { List widgets = []; int startIndex = 0; for (var e in _archiveDataList) { - if (_histories.length < startIndex) { + if (_histories.length <= startIndex) { break; } - if (e.count == 0) continue; + if (e.count <= 0) continue; int count = e.count; if (_histories.length < startIndex + count) { count = _histories.length - startIndex; } - widgets.add(ItemBuilder.buildTitle( + widgets.add(SliverToBoxAdapter( + child: ItemBuilder.buildTitle( context, - title: appLocalizations.descriptionWithPostCount(e.desc, e.count.toString()), + title: appLocalizations.descriptionWithPostCount( + e.desc, e.count.toString()), topMargin: 16, bottomMargin: 0, - )); + ))); widgets.add(_buildNineGrid(startIndex, count)); startIndex += e.count; } + // Category metadata may be absent or lag behind the returned posts. Keep + // those posts visible without inventing a date/category for them. + if (startIndex < _histories.length) { + widgets.add(_buildNineGrid(startIndex, _histories.length - startIndex)); + } return LoadMoreNotification( noMore: _noMore, onLoad: _onLoad, - child: ListView( + child: CustomScrollView( physics: physics, - padding: const EdgeInsets.only(bottom: 20), - children: widgets, + slivers: [ + ...widgets, + const SliverToBoxAdapter(child: SizedBox(height: 20)), + ], ), ); } Widget _buildNineGrid(int startIndex, int count) { - return GridView.builder( + return LoftifyPostArchiveSliverGrid( padding: const EdgeInsets.only(top: 12, left: 12, right: 12), - shrinkWrap: true, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 6, - crossAxisSpacing: 6, - maxCrossAxisExtent: 160, - ), - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (BuildContext context, int index) { + itemCount: count, + addAutomaticKeepAlives: false, + itemBuilder: (context, index, tileExtent) { return CommonInfoItemBuilder.buildNineGridPostItem( - context, _histories[startIndex + index], - wh: 160); + context, + _histories[startIndex + index], + wh: tileExtent, + ); }, - itemCount: count, ); } @@ -201,33 +261,35 @@ class _HistoryScreenState extends BaseDynamicState showBack: true, title: appLocalizations.myHistory, actions: [ - CircleIconButton( - icon: Icon(Icons.more_vert_rounded, - color: Theme.of(context).iconTheme.color), - onTap: () { - BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()); - }), + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + onPressed: () => + BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()), + ), ], ); } void clearInvalidHistory() { - for (var e in _histories) { - if (CommonInfoItemBuilder.isInvalid(e)) { - int index = _histories.indexOf(e); - int archiveIndex = 0; - int count = 0; - for (var element in _archiveDataList) { - if (count + element.count < index) { - count++; - } else { - archiveIndex = _archiveDataList.indexOf(element); - } - } - _archiveDataList[archiveIndex].count--; + // Use original category ranges; decrementing counts during lookup would + // shift later boundaries and attribute removals to the wrong category. + var start = 0; + for (final archive in _archiveDataList) { + if (start >= _histories.length) break; + final originalCount = archive.count; + if (originalCount <= 0) continue; + final end = (start + originalCount).clamp(0, _histories.length); + var removed = 0; + for (var index = start; index < end; index++) { + if (CommonInfoItemBuilder.isInvalid(_histories[index])) removed++; } + archive.count -= removed; + start += originalCount; } - _histories.removeWhere((e) => CommonInfoItemBuilder.isInvalid(e)); + final previousLength = _histories.length; + _histories.removeWhere(CommonInfoItemBuilder.isInvalid); + _total = (_total - (previousLength - _histories.length)).clamp(0, _total); setState(() {}); } @@ -236,7 +298,8 @@ class _HistoryScreenState extends BaseDynamicState entries: [ FlutterContextMenuItem( appLocalizations.clearMyHistory, - iconData: Icons.clear_rounded, + iconData: LoftifyIcons.clear, + status: MenuItemStatus.error, onPressed: () { DialogBuilder.showConfirmDialog( context, @@ -261,7 +324,8 @@ class _HistoryScreenState extends BaseDynamicState ), FlutterContextMenuItem( appLocalizations.clearInvalidContent, - iconData: Icons.delete_outline_rounded, + iconData: LoftifyIcons.delete, + status: MenuItemStatus.error, onPressed: () async { UserApi.deleteInvalidHistory(blogId: await HiveUtil.getUserId()) .then((value) { @@ -279,9 +343,7 @@ class _HistoryScreenState extends BaseDynamicState _recordHistory == 1 ? appLocalizations.closeMyHistory : appLocalizations.openMyHistory, - iconData: _recordHistory == 1 - ? Icons.history_toggle_off_rounded - : Icons.history_toggle_off_rounded, + iconData: LoftifyIcons.history, onPressed: () { HiveUtil.getUserInfo().then((blogInfo) async { close() { @@ -329,7 +391,7 @@ class _HistoryScreenState extends BaseDynamicState ? Column( children: [ ShadowIconButton( - icon: const Icon(Icons.more_vert_rounded), + icon: const ChewieIcon(LoftifyIcons.moreVertical), onTap: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); diff --git a/lib/Screens/Info/like_screen.dart b/lib/Screens/Info/like_screen.dart index 6ef229fa..eb3aebd2 100644 --- a/lib/Screens/Info/like_screen.dart +++ b/lib/Screens/Info/like_screen.dart @@ -1,15 +1,25 @@ +import 'dart:math' as math; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/user_api.dart'; import 'package:loftify/Models/history_response.dart'; +import 'package:loftify/Screens/Info/nested_mixin.dart'; import 'package:loftify/Utils/hive_util.dart'; import '../../Models/post_detail_response.dart'; +import '../../Models/download_task.dart'; +import '../../Screens/Download/batch_download_screen.dart'; +import '../../Utils/app_provider.dart'; import '../../Utils/enums.dart'; +import '../../Utils/like_archive_util.dart'; +import '../../Utils/paged_data_controller.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/PostItem/common_info_post_item_builder.dart'; +import '../../Widgets/PostItem/general_post_item.dart'; +import '../../Widgets/PostItem/loftify_post_archive_grid.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; -import 'nested_mixin.dart'; class LikeScreen extends StatefulWidgetForNested { LikeScreen({ @@ -19,6 +29,8 @@ class LikeScreen extends StatefulWidgetForNested { this.blogId, this.blogName, super.nested = false, + super.refreshListenable, + super.refreshId = 'like', }) { if (infoMode == InfoMode.other) { assert(blogName != null); @@ -37,23 +49,49 @@ class LikeScreen extends StatefulWidgetForNested { } class _LikeScreenState extends BaseDynamicState - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with + TickerProviderStateMixin, + AutomaticKeepAliveClientMixin, + NestedRefreshSignalMixin { @override bool get wantKeepAlive => true; - final List _likeList = []; - List _archiveDataList = []; - int _total = 0; - bool _loading = false; final EasyRefreshController _refreshController = EasyRefreshController(); - bool _noMore = false; + bool _clearingInvalid = false; + late final PagedDataController> + _pagingController; InitPhase _initPhase = InitPhase.haveNotConnected; + List get _likeList => _pagingController.items; + List get _archiveDataList => + _pagingController.metadata ?? const []; + @override void initState() { super.initState(); + bindNestedRefreshSignal(() { + _refreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 140), + ); + }); + _pagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => + item.post?.id ?? item.postData?.id ?? identityHashCode(item), + loader: _loadLikePage, + onError: (error, stackTrace) { + ILogger.error('Failed to load like list', error, stackTrace); + if (!mounted) return; + IToast.showTop( + error is PagedDataException && StringUtil.isNotEmpty(error.message) + ? error.message + : appLocalizations.loadFailed, + ); + }, + )..addListener(_handlePagingChanged); if (widget.nested) { WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 300), () => _onRefresh()); + if (mounted) _onRefresh(); }); } else { _initPhase = InitPhase.successful; @@ -61,89 +99,97 @@ class _LikeScreenState extends BaseDynamicState } } - _fetchLike({bool refresh = false}) async { - if (_loading) return; - if (refresh) _noMore = false; - _loading = true; - int offset = refresh ? 0 : _likeList.length; - if (_initPhase != InitPhase.successful) { - _initPhase = InitPhase.connecting; - setState(() {}); + @override + void dispose() { + unbindNestedRefreshSignal(); + _pagingController + ..removeListener(_handlePagingChanged) + ..dispose(); + _refreshController.dispose(); + super.dispose(); + } + + Future>> _loadLikePage( + int cursor, bool refresh) async { + final blogInfo = await HiveUtil.getUserInfo(); + final blogName = + widget.infoMode == InfoMode.me ? blogInfo?.blogName : widget.blogName; + if (StringUtil.isEmpty(blogName)) { + throw const PagedDataException(''); } - return await HiveUtil.getUserInfo().then((blogInfo) async { - String blogName = widget.infoMode == InfoMode.me - ? blogInfo!.blogName - : widget.blogName!; - return await UserApi.getLikeList(blogName: blogName, offset: offset) - .then((value) { - try { - if (value['meta']['status'] != 200) { - if (StringUtil.isNotEmpty( - value['meta']['desc'] ?? value['meta']['msg'])) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } - return IndicatorResult.fail; - } else { - _total = value['response']['count']; - if (value['response']['archives'] != null) { - _archiveDataList = []; - List archiveItems = []; - List t = value['response']['archives']; - for (var e in t) { - archiveItems.add(ArchiveItem.fromJson(e)); - } - for (var e in archiveItems) { - for (var item in e.monthCount) { - if (item > 0) { - int month = e.monthCount.indexOf(item); - _archiveDataList.add(ArchiveData( - desc: appLocalizations.yearAndMonth(e.year, month + 1), - count: item, - endTime: 0, - startTime: 0, - )); - } - } - } - _archiveDataList.sort((a, b) => b.desc.compareTo(a.desc)); - } - List t = value['response']['items']; - if (refresh) _likeList.clear(); - for (var e in t) { - if (e != null) { - _likeList.add(PostDetailData.fromJson(e)); - } - } - if (mounted) setState(() {}); - _initPhase = InitPhase.successful; - if (_likeList.length >= _total && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - _initPhase = InitPhase.failed; - ILogger.error("Failed to load like list", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; - } - }); - }); + + final offset = refresh ? 0 : cursor; + final value = await UserApi.getLikeList( + blogName: blogName!, + offset: offset, + ); + final meta = value['meta']; + final status = meta is Map ? (meta['status'] as num?)?.toInt() : null; + if (status != 200) { + final message = meta is Map ? meta['desc'] ?? meta['msg'] : null; + throw PagedDataException(message?.toString() ?? ''); + } + + final response = value['response']; + if (response is! Map) throw const PagedDataException(''); + final total = (response['count'] as num?)?.toInt() ?? 0; + final archives = _parseArchives(response['archives']); + final rawItems = response['items'] is List + ? List.from(response['items'] as List) + : const []; + final items = []; + for (final rawItem in rawItems) { + if (rawItem is! Map) continue; + try { + items.add(PostDetailData.fromJson( + Map.from(rawItem), + )); + } catch (error, stackTrace) { + ILogger.error('Skipped malformed liked card', error, stackTrace); + } + } + final nextOffset = offset + rawItems.length; + return PagedDataPage( + items: items, + nextCursor: nextOffset, + hasMore: rawItems.isNotEmpty && nextOffset < total, + total: total, + metadata: archives, + ); + } + + List _parseArchives(dynamic rawArchives) { + return buildLikeArchives( + rawArchives, + descriptionBuilder: (month, year) => + appLocalizations.yearAndMonth(month, year), + onMalformed: (error, stackTrace) { + ILogger.error('Skipped malformed like archive', error, stackTrace); + }, + ); } - _onRefresh() async { - return await _fetchLike(refresh: true); + void _handlePagingChanged() { + if (mounted) setState(() {}); } - _onLoad() async { - return await _fetchLike(); + Future _onRefresh() async { + final wasEmpty = _likeList.isEmpty; + if (wasEmpty && _initPhase != InitPhase.successful) { + _initPhase = InitPhase.connecting; + if (mounted) setState(() {}); + } + final result = await _pagingController.refresh(); + if (!mounted) return result; + _initPhase = result == IndicatorResult.fail && _likeList.isEmpty + ? InitPhase.failed + : InitPhase.successful; + setState(() {}); + return result; } + Future _onLoad() => _pagingController.load(); + @override Widget build(BuildContext context) { super.build(context); @@ -156,7 +202,7 @@ class _LikeScreenState extends BaseDynamicState ); } - _buildBody() { + Widget _buildBody() { switch (_initPhase) { case InitPhase.connecting: return const LoadingWidget(background: Colors.transparent); @@ -168,13 +214,14 @@ class _LikeScreenState extends BaseDynamicState return Stack( children: [ EasyRefresh.builder( - refreshOnStart: true, + header: widget.nested ? buildNestedRefreshHeader() : null, + refreshOnStart: !widget.nested, controller: _refreshController, onRefresh: _onRefresh, - onLoad: _onLoad, + onLoad: _pagingController.noMore ? null : _onLoad, triggerAxis: Axis.vertical, childBuilder: (context, physics) { - return _archiveDataList.isNotEmpty && _likeList.isNotEmpty + return _likeList.isNotEmpty ? _buildNineGridGroup(physics) : EmptyPlaceholder( text: appLocalizations.noLike, @@ -196,51 +243,89 @@ class _LikeScreenState extends BaseDynamicState } Widget _buildNineGridGroup(ScrollPhysics physics) { - List widgets = []; - int startIndex = 0; - for (var e in _archiveDataList) { - if (_likeList.length < startIndex) { - break; - } - if (e.count == 0) continue; - int count = e.count; - if (_likeList.length < startIndex + count) { - count = _likeList.length - startIndex; - } - widgets.add(ItemBuilder.buildTitle( - context, - title: appLocalizations.descriptionWithPostCount(e.desc, e.count.toString()), - topMargin: 16, - bottomMargin: 0, - )); - widgets.add(_buildNineGrid(startIndex, count)); - startIndex += e.count; + final slivers = []; + var startIndex = 0; + + void addGroup({required String description, required int groupCount}) { + if (groupCount <= 0 || startIndex >= _likeList.length) return; + final visibleCount = math.min(groupCount, _likeList.length - startIndex); + final groupStartIndex = startIndex; + slivers.add( + SliverToBoxAdapter( + child: ItemBuilder.buildTitle( + context, + title: appLocalizations.descriptionWithPostCount( + description, + groupCount.toString(), + ), + topMargin: 16, + bottomMargin: 0, + ), + ), + ); + slivers.add( + LoftifyPostArchiveSliverGrid( + padding: const EdgeInsets.only(top: 12, left: 12, right: 12), + itemCount: visibleCount, + addAutomaticKeepAlives: false, + itemBuilder: (context, index, tileExtent) { + final trueIndex = groupStartIndex + index; + final item = _likeList[trueIndex]; + final postId = item.post?.id ?? item.postData?.id; + return CommonInfoItemBuilder.buildNineGridPostItem( + context, + item, + key: postId == null || postId == 0 + ? ObjectKey(item) + : ValueKey('liked-$postId'), + wh: tileExtent, + onLikeChanged: (liked) { + if (!liked) _removeLikedPost(item); + }, + ); + }, + ), + ); + startIndex += groupCount; } - return LoadMoreNotification( - noMore: _noMore, - onLoad: _onLoad, - child: ListView( - padding: const EdgeInsets.only(bottom: 20), - physics: physics, - children: widgets, - ), + + for (final archive in _archiveDataList) { + addGroup(description: archive.desc, groupCount: archive.count); + } + if (startIndex < _likeList.length) { + addGroup( + description: appLocalizations.myLikes, + groupCount: _likeList.length - startIndex, + ); + } + slivers.add( + const SliverToBoxAdapter(child: SizedBox(height: 20)), + ); + + return CustomScrollView( + key: PageStorageKey('likes-${widget.blogName ?? 'me'}'), + controller: widget.scrollController, + physics: physics, + slivers: slivers, ); } - Widget _buildNineGrid(int startIndex, int count) { - return GridView.extent( - padding: const EdgeInsets.only(top: 12, left: 12, right: 12), - shrinkWrap: true, - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(count, (index) { - int trueIndex = startIndex + index; - return CommonInfoItemBuilder.buildNineGridPostItem( - context, _likeList[trueIndex], - wh: 160); - }), + void _removeLikedPost(PostDetailData target) { + _removeLocalItems(Set.identity()..add(target)); + } + + void _removeLocalItems(Set targets) { + if (targets.isEmpty) return; + final removedIndices = []; + for (var index = 0; index < _likeList.length; index++) { + if (!targets.contains(_likeList[index])) continue; + removedIndices.add(index); + } + decrementLikeArchives(_archiveDataList, removedIndices); + _pagingController.removeWhere( + targets.contains, + updateCursor: (cursor, removedCount) => + math.max(0, cursor - removedCount), ); } @@ -249,46 +334,123 @@ class _LikeScreenState extends BaseDynamicState showBack: true, title: appLocalizations.myLikes, actions: [ - CircleIconButton( - icon: Icon(Icons.more_vert_rounded, - color: Theme.of(context).iconTheme.color), - onTap: () { - BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()); - }), + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + onPressed: () => + BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()), + ), ], ); } - _buildMoreButtons() { + FlutterContextMenu _buildMoreButtons() { return FlutterContextMenu( entries: [ FlutterContextMenuItem( - appLocalizations.clearInvalidContent, - iconData: Icons.delete_outline_rounded, - onPressed: () async { - UserApi.deleteInvalidLike(blogId: await HiveUtil.getUserId()) - .then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } else { - _likeList - .removeWhere((e) => CommonInfoItemBuilder.isInvalid(e)); - setState(() {}); - IToast.showTop(appLocalizations.clearSuccess); - } - }); - }, + appLocalizations.batchDownload, + iconData: LoftifyIcons.download, + onPressed: _openBatchDownload, ), + if (widget.infoMode == InfoMode.me) + FlutterContextMenuItem( + appLocalizations.clearInvalidContent, + iconData: LoftifyIcons.delete, + status: MenuItemStatus.error, + onPressed: _clearInvalidLikes, + ), ], ); } - _buildFloatingButtons() { + Future _clearInvalidLikes() async { + if (_clearingInvalid || !mounted) return; + _clearingInvalid = true; + final token = appProvider.token; + bool isCurrentAccount() => mounted && appProvider.token == token; + try { + final blogId = await HiveUtil.getUserId(); + if (!isCurrentAccount()) return; + final value = await UserApi.deleteInvalidLike(blogId: blogId); + if (!isCurrentAccount()) return; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return; + } + _pagingController.cancelPending(); + final invalidItems = Set.identity() + ..addAll(_likeList.where(CommonInfoItemBuilder.isInvalid)); + _removeLocalItems(invalidItems); + IToast.showTop(appLocalizations.clearSuccess); + } catch (error, stackTrace) { + ILogger.error('Failed to clear invalid likes', error, stackTrace); + if (isCurrentAccount()) IToast.showTop(appLocalizations.loadFailed); + } finally { + _clearingInvalid = false; + } + } + + void _openBatchDownload() { + RouteUtil.pushPanelCupertinoRoute( + context, + BatchDownloadScreen( + sourceTitle: appLocalizations.myLikes, + source: DownloadSourceDescriptor( + type: DownloadSourceType.likes, + sourceId: widget.blogId?.toString() ?? 'self', + title: appLocalizations.myLikes, + metadata: widget.blogId == null + ? const {} + : { + 'blogId': widget.blogId.toString(), + if (widget.blogName?.trim().isNotEmpty == true) + 'blogName': widget.blogName!.trim(), + }, + ), + initialItems: + _likeList.map(CommonInfoItemBuilder.getGeneralPostItem).toList(), + loadAllItems: _loadAllBatchItems, + ), + ); + } + + Future> _loadAllBatchItems() async { + if (_likeList.isEmpty) { + final result = await _onRefresh(); + if (result != IndicatorResult.success && + result != IndicatorResult.noMore) { + throw StateError('Could not load liked posts'); + } + } + while (!_pagingController.noMore) { + final previousLength = _likeList.length; + final result = await _pagingController.load(); + if (result == IndicatorResult.fail || + result == IndicatorResult.none || + _likeList.length == previousLength) { + throw StateError('Could not load all liked posts'); + } + } + final items = _likeList + .map(CommonInfoItemBuilder.getGeneralPostItem) + .toList(growable: false); + if (items + .where((item) => item.postId > 0) + .map((item) => item.postId) + .toSet() + .length < + (_pagingController.total ?? 0)) { + throw StateError('Liked posts are incomplete'); + } + return items; + } + + Widget _buildFloatingButtons() { return ResponsiveUtil.isLandscapeLayout() ? Column( children: [ ShadowIconButton( - icon: const Icon(Icons.more_vert_rounded), + icon: const ChewieIcon(LoftifyIcons.moreVertical), onTap: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); diff --git a/lib/Screens/Info/nested_mixin.dart b/lib/Screens/Info/nested_mixin.dart index 9f4e71aa..26c35bf4 100644 --- a/lib/Screens/Info/nested_mixin.dart +++ b/lib/Screens/Info/nested_mixin.dart @@ -1,7 +1,60 @@ import 'package:flutter/cupertino.dart'; +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:loftify/Utils/lottie_files.dart'; + +Header buildNestedRefreshHeader() => LottieCupertinoHeader( + backgroundColor: const Color(0x00000000), + indicator: LottieFiles.buildLoadingAnimation(36, false), + // The nested list starts below the profile tabs, not behind the status + // bar. Including the outer safe area made the trigger equal to the + // maximum overscroll (52 + 24 = 76), so it could never become ready. + safeArea: false, + hapticFeedback: true, + triggerOffset: 52, + maxOverOffset: 76, + radius: 18, + ); + +class NestedRefreshRequest { + const NestedRefreshRequest(this.targetId); + + final String targetId; +} abstract class StatefulWidgetForNested extends StatefulWidget { final bool nested; + final ValueNotifier? refreshListenable; + final String? refreshId; + + const StatefulWidgetForNested({ + super.key, + required this.nested, + this.refreshListenable, + this.refreshId, + }); +} + +mixin NestedRefreshSignalMixin on State { + VoidCallback? _nestedRefreshCallback; + VoidCallback? _nestedRefreshListener; + + void bindNestedRefreshSignal(VoidCallback callback) { + _nestedRefreshCallback = callback; + _nestedRefreshListener = () { + final request = widget.refreshListenable?.value; + if (request != null && request.targetId == widget.refreshId) { + callback(); + } + }; + widget.refreshListenable?.addListener(_nestedRefreshListener!); + } - const StatefulWidgetForNested({super.key, required this.nested}); + void unbindNestedRefreshSignal() { + final callback = _nestedRefreshCallback; + if (callback != null) { + widget.refreshListenable?.removeListener(_nestedRefreshListener!); + } + _nestedRefreshCallback = null; + _nestedRefreshListener = null; + } } diff --git a/lib/Screens/Info/post_screen.dart b/lib/Screens/Info/post_screen.dart index da427561..d0b4c3ad 100644 --- a/lib/Screens/Info/post_screen.dart +++ b/lib/Screens/Info/post_screen.dart @@ -6,9 +6,13 @@ import 'package:loftify/Screens/Info/nested_mixin.dart'; import 'package:loftify/Utils/hive_util.dart'; import '../../Models/post_detail_response.dart'; +import '../../Utils/app_provider.dart'; import '../../Utils/enums.dart'; +import '../../Utils/like_archive_util.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/PostItem/common_info_post_item_builder.dart'; +import '../../Widgets/PostItem/loftify_post_archive_grid.dart'; import '../../l10n/l10n.dart'; class PostScreen extends StatefulWidgetForNested { @@ -19,6 +23,8 @@ class PostScreen extends StatefulWidgetForNested { this.blogId, this.blogName, super.nested = false, + super.refreshListenable, + super.refreshId = 'article', }) { if (infoMode == InfoMode.other) { assert(blogName != null); @@ -37,141 +43,179 @@ class PostScreen extends StatefulWidgetForNested { } class _PostScreenState extends BaseDynamicState - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with + TickerProviderStateMixin, + AutomaticKeepAliveClientMixin, + NestedRefreshSignalMixin { @override bool get wantKeepAlive => true; PostDetailData? _topPost; final List _postList = []; List _archiveDataList = []; bool _loading = false; + bool _loadingRefresh = false; + String? _loadingToken; + String? _dataToken; + int _requestEpoch = 0; + int _nextOffset = 0; final EasyRefreshController _refreshController = EasyRefreshController(); bool _noMore = false; - InitPhase _initPhase = InitPhase.haveNotConnected; + InitPhase _initPhase = InitPhase.connecting; @override void initState() { super.initState(); + bindNestedRefreshSignal(() { + _refreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 140), + ); + }); if (widget.nested) { WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 300), () => _onRefresh()); + if (mounted) _onRefresh(); }); - } else { - _initPhase = InitPhase.successful; - setState(() {}); } } - _fetchLike({bool refresh = false}) async { - if (_loading) return; - if (refresh) _noMore = false; - _loading = true; - int offset = 0; - if (refresh) { - offset = 0; - } else { - if (_archiveDataList.isNotEmpty && _archiveDataList[0].isTop) { - offset = _postList.length - _archiveDataList[0].count; - } + @override + void dispose() { + unbindNestedRefreshSignal(); + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchPosts({bool refresh = false}) async { + if (!mounted) return IndicatorResult.none; + final token = appProvider.token; + if (_loading && _loadingToken == token && (!refresh || _loadingRefresh)) { + return IndicatorResult.none; } - if (_initPhase != InitPhase.successful) { + if (_dataToken != null && _dataToken != token) { + _postList.clear(); + _topPost = null; + _archiveDataList = []; + _nextOffset = 0; + _noMore = false; + _initPhase = InitPhase.connecting; + } + final epoch = ++_requestEpoch; + bool isCurrentRequest() => + mounted && appProvider.token == token && epoch == _requestEpoch; + _loading = true; + _loadingRefresh = refresh; + _loadingToken = token; + final offset = refresh ? 0 : _nextOffset; + if (_postList.isEmpty && _topPost == null) { _initPhase = InitPhase.connecting; setState(() {}); } - return await HiveUtil.getUserInfo().then((blogInfo) async { - String blogName = widget.infoMode == InfoMode.me - ? blogInfo!.blogName - : widget.blogName!; - int blogId = - widget.infoMode == InfoMode.me ? blogInfo!.blogId : widget.blogId!; - return await UserApi.getPostList( + try { + final blogInfo = + widget.infoMode == InfoMode.me ? await HiveUtil.getUserInfo() : null; + if (!isCurrentRequest()) return IndicatorResult.none; + final blogName = + widget.infoMode == InfoMode.me ? blogInfo?.blogName : widget.blogName; + final blogId = + widget.infoMode == InfoMode.me ? blogInfo?.blogId : widget.blogId; + if (blogName == null || + blogName.isEmpty || + blogId == null || + blogId <= 0) { + if (_postList.isEmpty && _topPost == null) { + _initPhase = InitPhase.failed; + } + return IndicatorResult.fail; + } + final value = await UserApi.getPostList( blogName: blogName, blogId: blogId, offset: offset, - ).then((value) { - try { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - if (value['response']['archives'] != null) { - _archiveDataList = []; - List archiveItems = []; - List t = value['response']['archives']; - for (var e in t) { - archiveItems.add(ArchiveItem.fromJson(e)); - } - for (var e in archiveItems) { - for (var item in e.monthCount) { - if (item > 0) { - int month = e.monthCount.indexOf(item); - _archiveDataList.add(ArchiveData( - desc: appLocalizations.yearAndMonth(e.year, month + 1), - count: item, - endTime: 0, - startTime: 0, - )); - } - } - } - _archiveDataList.sort((a, b) => b.desc.compareTo(a.desc)); - } - List tmp = []; - if (refresh) _postList.clear(); - for (var e in (value['response']['posts'] as List)) { - if (e != null && - _postList.indexWhere( - (element) => element.post!.id == e['post']['id']) == - -1) { - tmp.add(PostDetailData.fromJson(e)); - } - } - _postList.addAll(tmp); - if (value['response']['topPost'] != null) { - _topPost = PostDetailData.fromJson(value['response']['topPost']); - _archiveDataList.insert( - 0, - ArchiveData( - desc: appLocalizations.pin, - count: 1, - endTime: 0, - startTime: 0, - isTop: true, - ), - ); - if ((_postList.isNotEmpty && - _postList[0].post!.id != _topPost!.post!.id) || - _postList.isEmpty) { - _postList.insert(0, _topPost!); - } - } - if (mounted) setState(() {}); - _initPhase = InitPhase.successful; - if (tmp.isEmpty && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { + ); + if (!isCurrentRequest()) return IndicatorResult.none; + if (value['meta']['status'] != 200) { + if (_postList.isEmpty && _topPost == null) { _initPhase = InitPhase.failed; - ILogger.error("Failed to load post list", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; } - }); - }); + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return IndicatorResult.fail; + } + final response = value['response'] as Map; + final rawPosts = response['posts'] as List; + final page = rawPosts + .whereType() + .map((item) => + PostDetailData.fromJson(Map.from(item))) + .where((item) => item.post != null) + .toList(); + final posts = refresh ? [] : [..._postList]; + final seen = posts.map((item) => item.post!.id).toSet(); + for (final item in page) { + if (seen.add(item.post!.id)) posts.add(item); + } + final rawTopPost = response['topPost']; + final parsedTopPost = rawTopPost is Map + ? PostDetailData.fromJson(Map.from(rawTopPost)) + : null; + final topPost = parsedTopPost?.post != null + ? parsedTopPost + : refresh + ? null + : _topPost; + final archives = response['archives'] == null + ? refresh + ? [] + : _archiveDataList + : buildLikeArchives( + response['archives'], + descriptionBuilder: appLocalizations.yearAndMonth, + ); + _postList + ..clear() + ..addAll(posts); + _topPost = topPost; + _archiveDataList = archives; + _nextOffset = offset + rawPosts.length; + _noMore = rawPosts.isEmpty; + _initPhase = InitPhase.successful; + _dataToken = token; + return !refresh && _noMore + ? IndicatorResult.noMore + : IndicatorResult.success; + } catch (error, stackTrace) { + if (!isCurrentRequest()) return IndicatorResult.none; + if (_postList.isEmpty && _topPost == null) _initPhase = InitPhase.failed; + ILogger.error('Failed to load post list', error, stackTrace); + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + if (epoch == _requestEpoch && mounted) { + setState(() { + if (appProvider.token != token) { + _postList.clear(); + _topPost = null; + _archiveDataList = []; + _nextOffset = 0; + _noMore = false; + _initPhase = InitPhase.failed; + _dataToken = null; + } + }); + } + if (epoch == _requestEpoch) { + _loading = false; + _loadingRefresh = false; + _loadingToken = null; + } + } } - _onRefresh() async { - return await _fetchLike(refresh: true); + Future _onRefresh() async { + return await _fetchPosts(refresh: true); } - _onLoad() async { - return await _fetchLike(); + Future _onLoad() async { + return await _fetchPosts(); } @override @@ -186,78 +230,127 @@ class _PostScreenState extends BaseDynamicState ); } - _buildBody() { - switch (_initPhase) { - case InitPhase.connecting: - return LoadingWidget(background: Colors.transparent); - case InitPhase.failed: - return CustomErrorWidget( - onTap: _onRefresh, - ); - case InitPhase.successful: - return EasyRefresh.builder( - refreshOnStart: true, - controller: _refreshController, - onRefresh: _onRefresh, - onLoad: _onLoad, - triggerAxis: Axis.vertical, - childBuilder: (context, physics) { - return _archiveDataList.isNotEmpty - ? _buildNineGridGroup(physics) - : EmptyPlaceholder(text: appLocalizations.noArticle, physics: physics); - }, - ); - default: - return Container(); - } + Widget _buildBody() { + return EasyRefresh.builder( + header: widget.nested ? buildNestedRefreshHeader() : null, + refreshOnStart: !widget.nested, + controller: _refreshController, + onRefresh: _onRefresh, + onLoad: _noMore ? null : _onLoad, + triggerAxis: Axis.vertical, + childBuilder: (context, physics) { + if (_initPhase != InitPhase.successful) { + final loading = _initPhase == InitPhase.connecting; + return CustomScrollView( + controller: widget.scrollController, + physics: physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: loading + ? LoftifyStateVisual.loading + : LoftifyStateVisual.error, + title: loading + ? appLocalizations.loading + : appLocalizations.loadFailed, + scrollWhenConstrained: false, + actionLabel: loading ? null : chewieLocalizations.retry, + onAction: + loading ? null : () => _refreshController.callRefresh(), + ), + ), + ], + ); + } + return _postList.isNotEmpty || _topPost != null + ? _buildNineGridGroup(physics) + : EmptyPlaceholder( + text: appLocalizations.noArticle, + physics: physics, + shrinkWrap: false, + ); + }, + ); } Widget _buildNineGridGroup(ScrollPhysics physics) { - List widgets = []; - int startIndex = 0; - for (var e in _archiveDataList) { - if (_postList.length < startIndex) { - break; - } - if (e.count == 0) continue; - int count = e.count; - if (_postList.length < startIndex + count) { - count = _postList.length - startIndex; + final posts = [..._postList]; + final archives = [ + for (final archive in _archiveDataList) + ArchiveData( + desc: archive.desc, + count: archive.count, + endTime: archive.endTime, + startTime: archive.startTime, + ), + ]; + final slivers = []; + final top = _topPost; + if (top?.post != null) { + final repeatedIndex = + posts.indexWhere((item) => item.post?.id == top!.post!.id); + if (repeatedIndex >= 0) { + final archive = likeArchiveForItemIndex(archives, repeatedIndex); + if (archive != null) archive.count--; + posts.removeAt(repeatedIndex); } - widgets.add(ItemBuilder.buildTitle( - context, - title: appLocalizations.descriptionWithPostCount(e.desc, e.count.toString()), - topMargin: 16, - bottomMargin: 0, + slivers.add(SliverToBoxAdapter( + child: ItemBuilder.buildTitle( + context, + title: appLocalizations.descriptionWithPostCount( + appLocalizations.pin, + '1', + ), + topMargin: 16, + bottomMargin: 0, + ), )); - widgets.add(_buildNineGrid(startIndex, count)); - startIndex += e.count; + slivers.add(_buildNineGrid([top!], 0, 1)); } - return LoadMoreNotification( - noMore: _noMore, - onLoad: _onLoad, - child: ListView( - padding: const EdgeInsets.only(bottom: 20), - physics: physics, - children: widgets, - ), + var startIndex = 0; + for (final archive in archives) { + if (posts.length <= startIndex) break; + if (archive.count <= 0) continue; + final count = (posts.length - startIndex).clamp(0, archive.count); + slivers.add(SliverToBoxAdapter( + child: ItemBuilder.buildTitle( + context, + title: appLocalizations.descriptionWithPostCount( + archive.desc, + archive.count.toString(), + ), + topMargin: 16, + bottomMargin: 0, + ), + )); + slivers.add(_buildNineGrid(posts, startIndex, count)); + startIndex += archive.count; + } + if (startIndex < posts.length) { + slivers.add(_buildNineGrid(posts, startIndex, posts.length - startIndex)); + } + slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 20))); + return CustomScrollView( + controller: widget.scrollController, + physics: physics, + slivers: slivers, ); } - Widget _buildNineGrid(int startIndex, int count) { - return GridView.extent( + Widget _buildNineGrid(List posts, int startIndex, int count) { + return LoftifyPostArchiveSliverGrid( padding: const EdgeInsets.only(top: 12, left: 12, right: 12), - shrinkWrap: true, - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(count, (index) { - int trueIndex = startIndex + index; + itemCount: count, + addAutomaticKeepAlives: false, + itemBuilder: (context, index, tileExtent) { + final trueIndex = startIndex + index; return CommonInfoItemBuilder.buildNineGridPostItem( - context, _postList[trueIndex], - wh: 160); - }), + context, + posts[trueIndex], + wh: tileExtent, + ); + }, ); } diff --git a/lib/Screens/Info/share_screen.dart b/lib/Screens/Info/share_screen.dart index a3afadcd..c789c69e 100644 --- a/lib/Screens/Info/share_screen.dart +++ b/lib/Screens/Info/share_screen.dart @@ -1,4 +1,3 @@ - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/user_api.dart'; @@ -7,9 +6,17 @@ import 'package:loftify/Screens/Info/nested_mixin.dart'; import 'package:loftify/Utils/hive_util.dart'; import '../../Models/post_detail_response.dart'; +import '../../Models/download_task.dart'; +import '../../Screens/Download/batch_download_screen.dart'; +import '../../Utils/app_provider.dart'; import '../../Utils/enums.dart'; +import '../../Utils/like_archive_util.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/PostItem/common_info_post_item_builder.dart'; +import '../../Widgets/PostItem/loftify_post_archive_grid.dart'; +import '../../Widgets/PostItem/general_post_item.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class ShareScreen extends StatefulWidgetForNested { @@ -20,6 +27,8 @@ class ShareScreen extends StatefulWidgetForNested { this.blogId, this.blogName, super.nested = false, + super.refreshListenable, + super.refreshId = 'recommend', }) { if (infoMode == InfoMode.other) { assert(blogName != null); @@ -37,107 +46,148 @@ class ShareScreen extends StatefulWidgetForNested { } class _ShareScreenState extends BaseDynamicState - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with + TickerProviderStateMixin, + AutomaticKeepAliveClientMixin, + NestedRefreshSignalMixin { @override bool get wantKeepAlive => true; final List _shareList = []; List _archiveDataList = []; - int _total = 0; bool _loading = false; final EasyRefreshController _refreshController = EasyRefreshController(); bool _noMore = false; - InitPhase _initPhase = InitPhase.haveNotConnected; + int _total = 0; + int _dataRevision = 0; + bool _clearingInvalid = false; + InitPhase _initPhase = InitPhase.connecting; @override void initState() { super.initState(); + bindNestedRefreshSignal(() { + _refreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 140), + ); + }); if (widget.nested) { WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 300), () => _onRefresh()); + if (mounted) _onRefresh(); }); - } else { - _initPhase = InitPhase.successful; - setState(() {}); } } - _fetchShare({bool refresh = false}) async { - if (_loading) return; - if (refresh) _noMore = false; + @override + void dispose() { + unbindNestedRefreshSignal(); + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchShare({bool refresh = false}) async { + if (_loading || !mounted) return IndicatorResult.none; + final token = appProvider.token; + final revision = _dataRevision; + bool isCurrentAccount() => mounted && appProvider.token == token; + bool isCurrentRequest() => isCurrentAccount() && _dataRevision == revision; _loading = true; - int offset = refresh ? 0 : _shareList.length; - if (_initPhase != InitPhase.successful) { + final offset = refresh ? 0 : _shareList.length; + if (_shareList.isEmpty) { _initPhase = InitPhase.connecting; setState(() {}); } - return await HiveUtil.getUserInfo().then((blogInfo) async { - String blogName = widget.infoMode == InfoMode.me - ? blogInfo!.blogName - : widget.blogName!; - return await UserApi.getShareList(blogName: blogName, offset: offset) - .then((value) { - try { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - _total = value['response']['count']; - if (value['response']['archives'] != null) { - _archiveDataList = []; - List archiveItems = []; - List t = value['response']['archives']; - for (var e in t) { - archiveItems.add(ArchiveItem.fromJson(e)); - } - for (var e in archiveItems) { - for (var item in e.monthCount) { - if (item > 0) { - int month = e.monthCount.indexOf(item); - _archiveDataList.add(ArchiveData( - desc: appLocalizations.yearAndMonth(e.year, month + 1), - count: item, - endTime: 0, - startTime: 0, - )); - } - } - } - _archiveDataList.sort((a, b) => b.desc.compareTo(a.desc)); - } - List t = value['response']['items']; - if (refresh) _shareList.clear(); - for (var e in t) { - if (e != null) { - _shareList.add(PostDetailData.fromJson(e)); - } - } - if (mounted) setState(() {}); - _initPhase = InitPhase.successful; - if (_shareList.length >= _total && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; + try { + final blogInfo = + widget.infoMode == InfoMode.me ? await HiveUtil.getUserInfo() : null; + if (!isCurrentRequest()) return IndicatorResult.none; + final blogName = + widget.infoMode == InfoMode.me ? blogInfo?.blogName : widget.blogName; + if (blogName == null || blogName.isEmpty) { + if (_shareList.isEmpty) _initPhase = InitPhase.failed; + return IndicatorResult.fail; + } + final value = await UserApi.getShareList( + blogName: blogName, + offset: offset, + ); + if (!isCurrentRequest()) return IndicatorResult.none; + if (value['meta']['status'] != 200) { + if (_shareList.isEmpty) _initPhase = InitPhase.failed; + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return IndicatorResult.fail; + } + final response = value['response']; + final total = response['count'] as int; + var archiveData = refresh ? [] : _archiveDataList; + if (response['archives'] != null) { + final months = <({int year, int month, int count})>[]; + for (final archive in response['archives'] as List) { + final item = ArchiveItem.fromJson(archive); + for (var month = 0; month < item.monthCount.length; month++) { + final count = item.monthCount[month]; + if (count > 0) { + months.add((year: item.year, month: month + 1, count: count)); } } - } catch (e, t) { - _initPhase = InitPhase.failed; - ILogger.error("Failed to load share list", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; } - }); - }); + months.sort((a, b) { + final yearOrder = b.year.compareTo(a.year); + return yearOrder != 0 ? yearOrder : b.month.compareTo(a.month); + }); + archiveData = [ + for (final month in months) + ArchiveData( + desc: appLocalizations.yearAndMonth(month.month, month.year), + count: month.count, + endTime: 0, + startTime: 0, + ), + ]; + } + final page = (response['items'] as List) + .where((item) => item != null) + .map((item) => PostDetailData.fromJson(item)) + .toList(); + final posts = refresh ? [] : [..._shareList]; + posts.addAll(page); + _archiveDataList = archiveData; + _shareList + ..clear() + ..addAll(posts); + _initPhase = InitPhase.successful; + _noMore = page.isEmpty || posts.length >= total; + _total = total; + return !refresh && _noMore + ? IndicatorResult.noMore + : IndicatorResult.success; + } catch (error, stackTrace) { + if (!isCurrentRequest()) return IndicatorResult.none; + if (_shareList.isEmpty) _initPhase = InitPhase.failed; + ILogger.error('Failed to load share list', error, stackTrace); + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + if (mounted) { + setState(() { + if (!isCurrentAccount()) { + _shareList.clear(); + _archiveDataList = []; + _noMore = false; + _total = 0; + _initPhase = InitPhase.failed; + } + }); + } + _loading = false; + } } - _onRefresh() async { + Future _onRefresh() async { return await _fetchShare(refresh: true); } - _onLoad() async { + Future _onLoad() async { return await _fetchShare(); } @@ -153,88 +203,109 @@ class _ShareScreenState extends BaseDynamicState ); } - _buildBody() { - switch (_initPhase) { - case InitPhase.connecting: - return const LoadingWidget(background: Colors.transparent); - case InitPhase.failed: - return CustomErrorWidget( - onTap: _onRefresh, - ); - case InitPhase.successful: - return Stack( - children: [ - EasyRefresh.builder( - refreshOnStart: true, - controller: _refreshController, - onRefresh: _onRefresh, - onLoad: _onLoad, - triggerAxis: Axis.vertical, - childBuilder: (context, physics) { - return _archiveDataList.isNotEmpty - ? _buildNineGridGroup(physics) - : EmptyPlaceholder( - text: appLocalizations.noRecommend, physics: physics); - }, - ), - Positioned( - right: ResponsiveUtil.isLandscapeLayout() ? 16 : 12, - bottom: ResponsiveUtil.isLandscapeLayout() ? 16 : 76, - child: _buildFloatingButtons(), - ), - ], - ); - default: - return Container(); - } + Widget _buildBody() { + return Stack( + children: [ + EasyRefresh.builder( + header: widget.nested ? buildNestedRefreshHeader() : null, + refreshOnStart: !widget.nested, + controller: _refreshController, + onRefresh: _onRefresh, + onLoad: _noMore ? null : _onLoad, + triggerAxis: Axis.vertical, + childBuilder: (context, physics) { + if (_initPhase != InitPhase.successful) { + final loading = _initPhase == InitPhase.connecting; + return CustomScrollView( + controller: widget.scrollController, + physics: physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: loading + ? LoftifyStateVisual.loading + : LoftifyStateVisual.error, + title: loading + ? appLocalizations.loading + : appLocalizations.loadFailed, + scrollWhenConstrained: false, + actionLabel: loading ? null : chewieLocalizations.retry, + onAction: loading + ? null + : () => _refreshController.callRefresh(), + ), + ), + ], + ); + } + return _shareList.isNotEmpty + ? _buildNineGridGroup(physics) + : EmptyPlaceholder( + text: appLocalizations.noRecommend, + physics: physics, + shrinkWrap: false, + ); + }, + ), + if (_initPhase == InitPhase.successful) + Positioned( + right: ResponsiveUtil.isLandscapeLayout() ? 16 : 12, + bottom: ResponsiveUtil.isLandscapeLayout() ? 16 : 76, + child: _buildFloatingButtons(), + ), + ], + ); } Widget _buildNineGridGroup(ScrollPhysics physics) { - List widgets = []; + final slivers = []; int startIndex = 0; for (var e in _archiveDataList) { - if (_shareList.length < startIndex) { + if (_shareList.length <= startIndex) { break; } - if (e.count == 0) continue; + if (e.count <= 0) continue; int count = e.count; if (_shareList.length < startIndex + count) { count = _shareList.length - startIndex; } - widgets.add(ItemBuilder.buildTitle( - context, - title: appLocalizations.descriptionWithPostCount(e.desc, e.count.toString()), - topMargin: 16, - bottomMargin: 0, + slivers.add(SliverToBoxAdapter( + child: ItemBuilder.buildTitle( + context, + title: appLocalizations.descriptionWithPostCount( + e.desc, e.count.toString()), + topMargin: 16, + bottomMargin: 0, + ), )); - widgets.add(_buildNineGrid(startIndex, count)); + slivers.add(_buildNineGrid(startIndex, count)); startIndex += e.count; } - return LoadMoreNotification( - noMore: _noMore, - onLoad: _onLoad, - child: ListView( - physics: physics, - padding: const EdgeInsets.only(bottom: 20), - children: widgets, - ), + if (startIndex < _shareList.length) { + slivers.add(_buildNineGrid(startIndex, _shareList.length - startIndex)); + } + slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 20))); + return CustomScrollView( + controller: widget.scrollController, + physics: physics, + slivers: slivers, ); } Widget _buildNineGrid(int startIndex, int count) { - return GridView.extent( + return LoftifyPostArchiveSliverGrid( padding: const EdgeInsets.only(top: 12, left: 12, right: 12), - shrinkWrap: true, - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(count, (index) { - int trueIndex = startIndex + index; + itemCount: count, + addAutomaticKeepAlives: false, + itemBuilder: (context, index, tileExtent) { + final trueIndex = startIndex + index; return CommonInfoItemBuilder.buildNineGridPostItem( - context, _shareList[trueIndex], - wh: 160); - }), + context, + _shareList[trueIndex], + wh: tileExtent, + ); + }, ); } @@ -243,46 +314,131 @@ class _ShareScreenState extends BaseDynamicState showBack: true, title: appLocalizations.myRecommends, actions: [ - CircleIconButton( - icon: Icon(Icons.more_vert_rounded, - color: Theme.of(context).iconTheme.color), - onTap: () { - BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()); - }), + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + onPressed: () => + BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()), + ), ], ); } - _buildMoreButtons() { + FlutterContextMenu _buildMoreButtons() { return FlutterContextMenu( entries: [ FlutterContextMenuItem( - appLocalizations.clearInvalidContent, - iconData: Icons.delete_outline_rounded, - onPressed: () async { - UserApi.deleteInvalidShare(blogId: await HiveUtil.getUserId()) - .then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } else { - _shareList - .removeWhere((e) => CommonInfoItemBuilder.isInvalid(e)); - setState(() {}); - IToast.showTop(appLocalizations.clearSuccess); - } - }); - }, + appLocalizations.batchDownload, + iconData: LoftifyIcons.download, + onPressed: _openBatchDownload, ), + if (widget.infoMode == InfoMode.me) + FlutterContextMenuItem( + appLocalizations.clearInvalidContent, + iconData: LoftifyIcons.delete, + status: MenuItemStatus.error, + onPressed: _clearInvalidShare, + ), ], ); } - _buildFloatingButtons() { + Future _clearInvalidShare() async { + if (_clearingInvalid || !mounted) return; + _clearingInvalid = true; + final token = appProvider.token; + bool isCurrentAccount() => mounted && appProvider.token == token; + try { + final blogId = await HiveUtil.getUserId(); + if (!isCurrentAccount()) return; + final value = await UserApi.deleteInvalidShare(blogId: blogId); + if (!isCurrentAccount()) return; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return; + } + final removedIndices = []; + for (var index = 0; index < _shareList.length; index++) { + if (CommonInfoItemBuilder.isInvalid(_shareList[index])) { + removedIndices.add(index); + } + } + _dataRevision++; + decrementLikeArchives(_archiveDataList, removedIndices); + _shareList.removeWhere(CommonInfoItemBuilder.isInvalid); + _total = (_total - removedIndices.length).clamp(0, _total); + setState(() {}); + IToast.showTop(appLocalizations.clearSuccess); + } catch (error, stackTrace) { + ILogger.error( + 'Failed to clear invalid recommendations', error, stackTrace); + if (isCurrentAccount()) IToast.showTop(appLocalizations.loadFailed); + } finally { + _clearingInvalid = false; + } + } + + void _openBatchDownload() { + RouteUtil.pushPanelCupertinoRoute( + context, + BatchDownloadScreen( + sourceTitle: appLocalizations.myRecommends, + source: DownloadSourceDescriptor( + type: DownloadSourceType.recommendations, + sourceId: widget.blogId?.toString() ?? 'self', + title: appLocalizations.myRecommends, + metadata: widget.blogId == null + ? const {} + : { + 'blogId': widget.blogId.toString(), + if (widget.blogName?.trim().isNotEmpty == true) + 'blogName': widget.blogName!.trim(), + }, + ), + initialItems: + _shareList.map(CommonInfoItemBuilder.getGeneralPostItem).toList(), + loadAllItems: _loadAllBatchItems, + ), + ); + } + + Future> _loadAllBatchItems() async { + if (_shareList.isEmpty) { + final result = await _onRefresh(); + if (result != IndicatorResult.success && + result != IndicatorResult.noMore) { + throw StateError('Could not load recommended posts'); + } + } + while (!_noMore) { + final previousLength = _shareList.length; + final result = await _onLoad(); + if (result == IndicatorResult.fail || + result == IndicatorResult.none || + _shareList.length == previousLength) { + throw StateError('Could not load all recommended posts'); + } + } + final items = _shareList + .map(CommonInfoItemBuilder.getGeneralPostItem) + .toList(growable: false); + if (items + .where((item) => item.postId > 0) + .map((item) => item.postId) + .toSet() + .length < + _total) { + throw StateError('Recommended posts are incomplete'); + } + return items; + } + + Widget _buildFloatingButtons() { return ResponsiveUtil.isLandscapeLayout() ? Column( children: [ ShadowIconButton( - icon: const Icon(Icons.more_vert_rounded), + icon: const ChewieIcon(LoftifyIcons.moreVertical), onTap: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); diff --git a/lib/Screens/Info/supporter_screen.dart b/lib/Screens/Info/supporter_screen.dart index fd305a14..46c09808 100644 --- a/lib/Screens/Info/supporter_screen.dart +++ b/lib/Screens/Info/supporter_screen.dart @@ -1,16 +1,14 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import '../../l10n/l10n.dart'; -import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:loftify/Api/user_api.dart'; import 'package:loftify/Utils/hive_util.dart'; import '../../Models/user_response.dart'; import '../../Utils/enums.dart'; -import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/Profile/supporter_list_item.dart'; import 'user_detail_screen.dart'; class SupporterScreen extends StatefulWidget { @@ -42,57 +40,53 @@ class _SupporterScreenState extends BaseDynamicState final EasyRefreshController _refreshController = EasyRefreshController(); bool _noMore = false; - _fetchList({bool refresh = false}) async { + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchList({bool refresh = false}) async { if (_supporterList.isNotEmpty && !refresh) return IndicatorResult.noMore; - if (_loading) return; + if (_loading) return IndicatorResult.none; if (refresh) _noMore = false; _loading = true; - return await HiveUtil.getUserInfo().then((blogInfo) async { - int blogId = widget.infoMode == InfoMode.me + try { + final blogId = widget.infoMode == InfoMode.me ? await HiveUtil.getUserId() : widget.blogId!; - return await UserApi.getSupporterList( - blogId: blogId, - ).then((value) { - try { - if (value['code'] != 200) { - IToast.showTop(value['msg']); - return IndicatorResult.fail; - } else { - List t = value['data']['ranks']; - if (refresh) _supporterList.clear(); - for (var e in t) { - if (e != null) { - _supporterList.add(SupporterItem.fromJson(e)); - } - } - if (mounted) setState(() {}); - if (refresh) { - return IndicatorResult.success; - } else { - _noMore = true; - return IndicatorResult.noMore; - } - } - } catch (e, t) { - ILogger.error("Failed to load supporter list", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; - } + if (!mounted) return IndicatorResult.none; + final value = await UserApi.getSupporterList(blogId: blogId); + if (!mounted) return IndicatorResult.none; + if (value['code'] != 200) { + IToast.showTop(value['msg']); + return IndicatorResult.fail; + } + final ranks = value['data']['ranks'] as List; + final supporters = ranks + .whereType() + .map( + (rank) => SupporterItem.fromJson(Map.from(rank))) + .toList(); + setState(() { + _supporterList + ..clear() + ..addAll(supporters); + _noMore = true; }); - }); + return refresh ? IndicatorResult.success : IndicatorResult.noMore; + } catch (error, stackTrace) { + ILogger.error('Failed to load supporter list', error, stackTrace); + if (mounted) IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + _loading = false; + } } - _onRefresh() async { - return await _fetchList(refresh: true); - } + Future _onRefresh() => _fetchList(refresh: true); - _onLoad() async { - return await _fetchList(); - } + Future _onLoad() => _fetchList(); @override Widget build(BuildContext context) { @@ -104,7 +98,7 @@ class _SupporterScreenState extends BaseDynamicState refreshOnStart: true, controller: _refreshController, onRefresh: _onRefresh, - onLoad: _onLoad, + onLoad: _noMore ? null : _onLoad, triggerAxis: Axis.vertical, childBuilder: (context, physics) { return _buildBody(physics); @@ -117,19 +111,27 @@ class _SupporterScreenState extends BaseDynamicState return LoadMoreNotification( noMore: _noMore, onLoad: _onLoad, - child: WaterfallFlow.extent( - maxCrossAxisExtent: 600, - padding: EdgeInsets.zero, + child: WaterfallFlow.builder( physics: physics, - children: List.generate(_supporterList.length, (index) { - return _buildItem(index, _supporterList[index]); - }), + gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 560, + mainAxisSpacing: 0, + crossAxisSpacing: 8, + ), + itemCount: _supporterList.length, + itemBuilder: (context, index) => _buildItem(_supporterList[index]), ), ); } - _buildItem(int index, SupporterItem item) { - return ClickableGestureDetector( + Widget _buildItem(SupporterItem item) { + return LoftifySupporterListItem( + blogId: item.blogInfo.blogId, + avatarUrl: item.blogInfo.bigAvaImg, + name: item.blogInfo.blogNickName, + blogName: item.blogInfo.blogName, + intro: item.blogInfo.selfIntro, + score: item.score, onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -139,53 +141,6 @@ class _SupporterScreenState extends BaseDynamicState ), ); }, - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - child: Row( - children: [ - ItemBuilder.buildAvatar( - context: context, - size: 40, - imageUrl: item.blogInfo.bigAvaImg, - tagPrefix: "$index", - ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.blogInfo.blogNickName, - style: Theme.of(context).textTheme.titleMedium, - ), - if (item.blogInfo.selfIntro!.isNotEmpty) - const SizedBox(height: 5), - if (item.blogInfo.selfIntro!.isNotEmpty) - Text( - item.blogInfo.selfIntro!, - style: Theme.of(context).textTheme.labelMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Container( - margin: const EdgeInsets.only(right: 5), - child: Icon( - Icons.workspace_premium_rounded, - size: 22, - color: ChewieColors.getHotTagTextColor(context), - ), - ), - Text( - item.score.toString(), - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - ), ); } diff --git a/lib/Screens/Info/system_notice_screen.dart b/lib/Screens/Info/system_notice_screen.dart index 456f9f3b..17a96e09 100644 --- a/lib/Screens/Info/system_notice_screen.dart +++ b/lib/Screens/Info/system_notice_screen.dart @@ -1,4 +1,3 @@ - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/message_api.dart'; @@ -7,10 +6,173 @@ import 'package:loftify/Screens/Info/user_detail_screen.dart'; import 'package:loftify/Screens/Post/post_detail_screen.dart'; import 'package:loftify/Utils/hive_util.dart'; -import '../../Utils/utils.dart'; +import '../../Utils/tab_state_util.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; +/// Full-viewport placeholder used by every notification tab. +/// +/// Keeping this scrollable even when empty preserves pull-to-refresh while the +/// content is loading or when the server returns no messages. +class SystemNoticeTabPlaceholder extends StatelessWidget { + const SystemNoticeTabPlaceholder({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return EmptyPlaceholder( + text: text, + physics: const AlwaysScrollableScrollPhysics(), + shrinkWrap: false, + topPadding: 0, + ); + } +} + +class SystemNoticeMessageTile extends StatelessWidget { + const SystemNoticeMessageTile({ + super.key, + required this.nickname, + required this.message, + required this.timestamp, + required this.avatarUrl, + required this.thumbnailUrl, + required this.onTap, + required this.onAvatarTap, + }); + + final String nickname; + final String message; + final int timestamp; + final String avatarUrl; + final String thumbnailUrl; + final VoidCallback onTap; + final VoidCallback onAvatarTap; + + @override + Widget build(BuildContext context) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 360 || textScale > 1.35; + final avatarSize = compact ? 44.0 : 50.0; + final avatarTapSize = compact ? 48.0 : 50.0; + final messageText = Text.rich( + key: const Key('system-notice-message'), + TextSpan( + children: [ + TextSpan( + text: nickname, + style: Theme.of(context).textTheme.titleSmall?.apply( + fontSizeDelta: 1, + ), + ), + TextSpan( + text: message.replaceFirst(nickname, ''), + style: Theme.of(context).textTheme.titleSmall?.apply( + fontSizeDelta: 1, + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ], + ), + ); + final timeText = Text( + TimeUtil.formatTimestamp(timestamp), + style: Theme.of(context).textTheme.labelMedium?.apply( + fontSizeDelta: 1, + ), + ); + final thumbnail = ClipRRect( + key: const Key('system-notice-thumbnail'), + borderRadius: BorderRadius.circular(8), + child: ChewieItemBuilder.buildCachedImage( + imageUrl: thumbnailUrl, + context: context, + height: 50, + width: 50, + fit: BoxFit.cover, + showLoading: false, + ), + ); + final content = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + messageText, + const SizedBox(height: 8), + timeText, + ], + ), + ), + SizedBox(width: compact ? 8 : 12), + thumbnail, + ], + ); + return ClickableGestureDetector( + onTap: onTap, + child: Padding( + padding: EdgeInsets.fromLTRB( + compact ? 12 : 15, + 12, + compact ? 12 : 15, + 12, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + key: const Key('system-notice-avatar-action'), + behavior: HitTestBehavior.opaque, + onTap: onAvatarTap, + child: Semantics( + button: true, + label: nickname, + child: SizedBox( + width: avatarTapSize, + height: avatarTapSize, + child: Align( + alignment: Alignment.topLeft, + child: ExcludeSemantics( + child: ItemBuilder.buildAvatar( + context: context, + size: avatarSize, + imageUrl: avatarUrl, + ), + ), + ), + ), + ), + ), + SizedBox(width: compact ? 8 : 12), + Expanded( + child: Container( + padding: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 0.5, + ), + ), + ), + child: content, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + class SystemNoticeScreen extends StatefulWidget { const SystemNoticeScreen({super.key}); @@ -32,17 +194,58 @@ class _SystemNoticeScreenState extends BaseDynamicState final List _subscribeMessages = []; final List _collectionMessages = []; final List _otherMessages = []; - bool _loading = false; + final Set _loadingTabs = {}; + final Set _completedTabs = {}; + final Set _failedTabs = {}; + + IndicatorResult _noticeFailure(String key) { + _failedTabs.add(key); + return IndicatorResult.fail; + } + + Widget _buildPlaceholder(int index) { + final key = _tabIdList[index]; + final loading = _loadingTabs.contains(key) || !_completedTabs.contains(key); + if (!loading && !_failedTabs.contains(key)) { + return SystemNoticeTabPlaceholder(text: appLocalizations.noNotice); + } + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: LoftifyStateView( + visual: + loading ? LoftifyStateVisual.loading : LoftifyStateVisual.error, + title: loading + ? appLocalizations.loading + : appLocalizations.loadFailed, + scrollWhenConstrained: false, + actionLabel: loading ? null : chewieLocalizations.retry, + onAction: + loading ? null : () => _refreshControllers[index].callRefresh(), + ), + ), + ], + ); + } + + // A refresh supersedes in-flight pagination; only its own request may update + // the list or release the tab's loading lock. + final Map _requestVersions = {}; + + bool _isCurrentRequest(String key, int version) => + mounted && _requestVersions[key] == version; final EasyRefreshController _allRefreshController = EasyRefreshController(); final EasyRefreshController _likeRefreshController = EasyRefreshController(); final EasyRefreshController _recommendRefreshController = - EasyRefreshController(); + EasyRefreshController(); final EasyRefreshController _giftRefreshController = EasyRefreshController(); final EasyRefreshController _atRefreshController = EasyRefreshController(); final EasyRefreshController _subscribeRefreshController = - EasyRefreshController(); + EasyRefreshController(); final EasyRefreshController _collectionRefreshController = - EasyRefreshController(); + EasyRefreshController(); final EasyRefreshController _otherRefreshController = EasyRefreshController(); bool _allNoMore = false; bool _likeNoMore = false; @@ -52,122 +255,217 @@ class _SystemNoticeScreenState extends BaseDynamicState bool _subscribeNoMore = false; bool _collectionNoMore = false; bool _otherNoMore = false; - final List _tabLabelList = [ - appLocalizations.all, - appLocalizations.like, - appLocalizations.recommend, - appLocalizations.gift, - appLocalizations.atMe, - appLocalizations.subscribe, - appLocalizations.favorite, - appLocalizations.other, - ]; + List get _tabLabelList { + final localizations = AppLocalizations.of(context)!; + return [ + localizations.all, + localizations.like, + localizations.recommend, + localizations.gift, + localizations.atMe, + localizations.subscribe, + localizations.favorite, + localizations.other, + ]; + } + late TabController _tabController; + late final LazyTabLoadState _tabLoadState; int _currentTabIndex = 0; + static const List _tabIdList = [ + 'all', + 'like', + 'recommend', + 'gift', + 'at', + 'subscribe', + 'collection', + 'other', + ]; + + List get _refreshControllers => [ + _allRefreshController, + _likeRefreshController, + _recommendRefreshController, + _giftRefreshController, + _atRefreshController, + _subscribeRefreshController, + _collectionRefreshController, + _otherRefreshController, + ]; @override void initState() { super.initState(); initTab(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _ensureTabLoaded(_currentTabIndex); + }); } - initTab() { - _tabController = TabController(length: _tabLabelList.length, vsync: this); - _tabController.animation?.addListener(() { - int indexChange = - _tabController.offset.abs() > 0.8 ? _tabController.offset.round() : 0; - int index = _tabController.index + indexChange; - if (index != _currentTabIndex) { - setState(() => _currentTabIndex = index); - } + @override + void dispose() { + _tabController.dispose(); + for (final controller in _refreshControllers) { + controller.dispose(); + } + super.dispose(); + } + + void initTab() { + final restored = PersistentTabState.restore( + idKey: HiveUtil.systemNoticeTabIdKey, + legacyIndexKey: HiveUtil.systemNoticeTabIndexKey, + itemIds: _tabIdList, + ); + _tabLoadState = LazyTabLoadState( + itemIds: _tabIdList, + savedId: restored.id, + ); + _currentTabIndex = _tabLoadState.currentIndex; + _tabController = TabController( + length: _tabIdList.length, + initialIndex: _currentTabIndex, + vsync: this, + ); + _tabController.addListener(() { + final index = + (_tabController.animation?.value ?? _tabController.index).round(); + if (index != _currentTabIndex) _setCurrentTab(index); }); } - _fetchLikeMessages({bool refresh = false}) async { - if (_loading) return; + void _setCurrentTab(int index) { + final safeIndex = TabStatePreference.restoreIndex(index, _tabIdList.length); + if (safeIndex != _currentTabIndex && mounted) { + setState(() => _currentTabIndex = safeIndex); + } + PersistentTabState.save( + idKey: HiveUtil.systemNoticeTabIdKey, + legacyIndexKey: HiveUtil.systemNoticeTabIndexKey, + itemIds: _tabIdList, + index: safeIndex, + ); + _ensureTabLoaded(safeIndex); + } + + void _ensureTabLoaded(int index) { + if (!_tabLoadState.selectAndShouldLoad(index)) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || index < 0 || index >= _refreshControllers.length) return; + _refreshControllers[index].callRefresh(); + }); + } + + Future _fetchLikeMessages({bool refresh = false}) async { + const loadingKey = 'like'; + if (!refresh && _loadingTabs.contains(loadingKey)) { + return IndicatorResult.none; + } + _loadingTabs.add(loadingKey); + _failedTabs.remove(loadingKey); + if (mounted) setState(() {}); + final version = (_requestVersions[loadingKey] ?? 0) + 1; + _requestVersions[loadingKey] = version; if (refresh) _likeNoMore = false; - _loading = true; int offset = refresh ? 0 : _likeMessages.length; - return await HiveUtil.getUserInfo().then((blogInfo) async { - return await MessageApi.getLikeMessages( - blogId: blogInfo!.blogId, offset: offset) - .then((value) { - try { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - List t = []; - t = (value['response'] as List) - .map((e) => MessageItem.fromJson(e)) - .toList(); - if (refresh) _likeMessages.clear(); - _likeMessages.addAll(t); - if (mounted) setState(() {}); - if (t.isEmpty && !refresh) { - _likeNoMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - ILogger.error("Failed to load system notice list", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; + try { + final blogInfo = await HiveUtil.getUserInfo(); + if (!_isCurrentRequest(loadingKey, version)) return IndicatorResult.none; + if (blogInfo == null) return _noticeFailure(loadingKey); + final value = await MessageApi.getLikeMessages( + blogId: blogInfo.blogId, offset: offset); + if (!_isCurrentRequest(loadingKey, version)) return IndicatorResult.none; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return _noticeFailure(loadingKey); + } else { + List t = []; + t = (value['response'] as List) + .map((e) => MessageItem.fromJson(e)) + .toList(); + if (refresh) _likeMessages.clear(); + _likeMessages.addAll(t); + if (mounted) setState(() {}); + if (t.isEmpty && !refresh) { + _likeNoMore = true; + return IndicatorResult.noMore; + } else { + return IndicatorResult.success; } - }); - }); + } + } catch (e, t) { + if (!_isCurrentRequest(loadingKey, version)) return IndicatorResult.none; + ILogger.error("Failed to load system notice list", e, t); + if (mounted) IToast.showTop(appLocalizations.loadFailed); + return _noticeFailure(loadingKey); + } finally { + if (_requestVersions[loadingKey] == version) { + _loadingTabs.remove(loadingKey); + _completedTabs.add(loadingKey); + if (mounted) setState(() {}); + } + } } - _fetchSystemNotices(MessageType type, - List list, { - bool refresh = false, - Function()? resetNoMore, - Function()? onNoMore, - }) async { - if (_loading) return; + Future _fetchSystemNotices( + MessageType type, + List list, { + bool refresh = false, + Function()? resetNoMore, + Function()? onNoMore, + }) async { + final loadingKey = type.name; + if (!refresh && _loadingTabs.contains(loadingKey)) { + return IndicatorResult.none; + } + _loadingTabs.add(loadingKey); + _failedTabs.remove(loadingKey); + if (mounted) setState(() {}); + final version = (_requestVersions[loadingKey] ?? 0) + 1; + _requestVersions[loadingKey] = version; if (refresh) resetNoMore?.call(); - _loading = true; - int offset = refresh ? 0 : _allMessages.length; - return await HiveUtil.getUserInfo().then((blogInfo) async { - return await MessageApi.getSystemNoticeList( - blogId: blogInfo!.blogId, + int offset = refresh ? 0 : list.length; + try { + final blogInfo = await HiveUtil.getUserInfo(); + if (!_isCurrentRequest(loadingKey, version)) return IndicatorResult.none; + if (blogInfo == null) return _noticeFailure(loadingKey); + final value = await MessageApi.getSystemNoticeList( + blogId: blogInfo.blogId, type: type, offset: offset, - ).then((value) { - try { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - List t = []; - t = (value['response'] as List) - .map((e) => MessageItem.fromJson(e)) - .toList(); - if (refresh) list.clear(); - list.addAll(t); - if (mounted) setState(() {}); - if (t.isEmpty && !refresh) { - onNoMore?.call(); - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - ILogger.error("Failed to load system notice list", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; + ); + if (!_isCurrentRequest(loadingKey, version)) return IndicatorResult.none; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return _noticeFailure(loadingKey); + } else { + List t = []; + t = (value['response'] as List) + .map((e) => MessageItem.fromJson(e)) + .toList(); + if (refresh) list.clear(); + list.addAll(t); + if (mounted) setState(() {}); + if (t.isEmpty && !refresh) { + onNoMore?.call(); + return IndicatorResult.noMore; + } else { + return IndicatorResult.success; } - }); - }); + } + } catch (e, t) { + if (!_isCurrentRequest(loadingKey, version)) return IndicatorResult.none; + ILogger.error("Failed to load system notice list", e, t); + if (mounted) IToast.showTop(appLocalizations.loadFailed); + return _noticeFailure(loadingKey); + } finally { + if (_requestVersions[loadingKey] == version) { + _loadingTabs.remove(loadingKey); + _completedTabs.add(loadingKey); + if (mounted) setState(() {}); + } + } } Widget _buildTabView() { @@ -196,7 +494,7 @@ class _SystemNoticeScreenState extends BaseDynamicState ); } - _buildAllTab() { + Widget _buildAllTab() { return EasyRefresh( controller: _allRefreshController, onRefresh: () async { @@ -208,7 +506,7 @@ class _SystemNoticeScreenState extends BaseDynamicState onNoMore: () => _allNoMore = true, ); }, - refreshOnStart: true, + refreshOnStart: false, onLoad: () async { return await _fetchSystemNotices( MessageType.all, @@ -219,53 +517,53 @@ class _SystemNoticeScreenState extends BaseDynamicState }, triggerAxis: Axis.vertical, child: _allMessages.isEmpty - ? EmptyPlaceholder(text: appLocalizations.noNotice) + ? _buildPlaceholder(0) : LoadMoreNotification( - noMore: _allNoMore, - onLoad: () { - _fetchSystemNotices(MessageType.all, _allMessages, - resetNoMore: () => _allNoMore = false, - onNoMore: () => _allNoMore = true); - }, - child: ListView.builder( - padding: const EdgeInsets.only(top: 10), - itemBuilder: (context, index) => - _buildItem(_allMessages[index]), - itemCount: _allMessages.length, - ), - ), + noMore: _allNoMore, + onLoad: () { + _fetchSystemNotices(MessageType.all, _allMessages, + resetNoMore: () => _allNoMore = false, + onNoMore: () => _allNoMore = true); + }, + child: ListView.builder( + padding: const EdgeInsets.only(top: 10), + itemBuilder: (context, index) => + _buildItem(_allMessages[index]), + itemCount: _allMessages.length, + ), + ), ); } - _buildLikeTab() { + Widget _buildLikeTab() { return EasyRefresh( controller: _likeRefreshController, onRefresh: () async { return await _fetchLikeMessages(refresh: true); }, - refreshOnStart: true, + refreshOnStart: false, onLoad: () async { return await _fetchLikeMessages(); }, triggerAxis: Axis.vertical, child: _likeMessages.isEmpty - ? EmptyPlaceholder(text: appLocalizations.noNotice) + ? _buildPlaceholder(1) : LoadMoreNotification( - noMore: _likeNoMore, - onLoad: () { - _fetchLikeMessages(); - }, - child: ListView.builder( - padding: const EdgeInsets.only(top: 10), - itemBuilder: (context, index) => - _buildItem(_likeMessages[index]), - itemCount: _likeMessages.length, - ), - ), + noMore: _likeNoMore, + onLoad: () { + _fetchLikeMessages(); + }, + child: ListView.builder( + padding: const EdgeInsets.only(top: 10), + itemBuilder: (context, index) => + _buildItem(_likeMessages[index]), + itemCount: _likeMessages.length, + ), + ), ); } - _buildRecommendTab() { + Widget _buildRecommendTab() { return EasyRefresh( controller: _recommendRefreshController, onRefresh: () async { @@ -277,7 +575,7 @@ class _SystemNoticeScreenState extends BaseDynamicState onNoMore: () => _recommendNoMore = true, ); }, - refreshOnStart: true, + refreshOnStart: false, onLoad: () async { return await _fetchSystemNotices( MessageType.recommend, @@ -288,25 +586,25 @@ class _SystemNoticeScreenState extends BaseDynamicState }, triggerAxis: Axis.vertical, child: _recommendMessages.isEmpty - ? EmptyPlaceholder(text: appLocalizations.noNotice) + ? _buildPlaceholder(2) : LoadMoreNotification( - noMore: _recommendNoMore, - onLoad: () { - _fetchSystemNotices(MessageType.recommend, _recommendMessages, - resetNoMore: () => _recommendNoMore = false, - onNoMore: () => _recommendNoMore = true); - }, - child: ListView.builder( - padding: const EdgeInsets.only(top: 10), - itemBuilder: (context, index) => - _buildItem(_recommendMessages[index]), - itemCount: _recommendMessages.length, - ), - ), + noMore: _recommendNoMore, + onLoad: () { + _fetchSystemNotices(MessageType.recommend, _recommendMessages, + resetNoMore: () => _recommendNoMore = false, + onNoMore: () => _recommendNoMore = true); + }, + child: ListView.builder( + padding: const EdgeInsets.only(top: 10), + itemBuilder: (context, index) => + _buildItem(_recommendMessages[index]), + itemCount: _recommendMessages.length, + ), + ), ); } - _buildGiftTab() { + Widget _buildGiftTab() { return EasyRefresh( controller: _giftRefreshController, onRefresh: () async { @@ -318,7 +616,7 @@ class _SystemNoticeScreenState extends BaseDynamicState onNoMore: () => _giftNoMore = true, ); }, - refreshOnStart: true, + refreshOnStart: false, onLoad: () async { return await _fetchSystemNotices( MessageType.gift, @@ -329,25 +627,25 @@ class _SystemNoticeScreenState extends BaseDynamicState }, triggerAxis: Axis.vertical, child: _giftMessages.isEmpty - ? EmptyPlaceholder(text: appLocalizations.noNotice) + ? _buildPlaceholder(3) : LoadMoreNotification( - noMore: _giftNoMore, - onLoad: () { - _fetchSystemNotices(MessageType.gift, _giftMessages, - resetNoMore: () => _giftNoMore = false, - onNoMore: () => _giftNoMore = true); - }, - child: ListView.builder( - padding: const EdgeInsets.only(top: 10), - itemBuilder: (context, index) => - _buildItem(_giftMessages[index]), - itemCount: _giftMessages.length, - ), - ), + noMore: _giftNoMore, + onLoad: () { + _fetchSystemNotices(MessageType.gift, _giftMessages, + resetNoMore: () => _giftNoMore = false, + onNoMore: () => _giftNoMore = true); + }, + child: ListView.builder( + padding: const EdgeInsets.only(top: 10), + itemBuilder: (context, index) => + _buildItem(_giftMessages[index]), + itemCount: _giftMessages.length, + ), + ), ); } - _buildAtTab() { + Widget _buildAtTab() { return EasyRefresh( controller: _atRefreshController, onRefresh: () async { @@ -359,7 +657,7 @@ class _SystemNoticeScreenState extends BaseDynamicState onNoMore: () => _atNoMore = true, ); }, - refreshOnStart: true, + refreshOnStart: false, onLoad: () async { return await _fetchSystemNotices( MessageType.at, @@ -370,24 +668,24 @@ class _SystemNoticeScreenState extends BaseDynamicState }, triggerAxis: Axis.vertical, child: _atMessages.isEmpty - ? EmptyPlaceholder(text: appLocalizations.noNotice) + ? _buildPlaceholder(4) : LoadMoreNotification( - noMore: _atNoMore, - onLoad: () { - _fetchSystemNotices(MessageType.at, _atMessages, - resetNoMore: () => _atNoMore = false, - onNoMore: () => _atNoMore = true); - }, - child: ListView.builder( - padding: const EdgeInsets.only(top: 10), - itemBuilder: (context, index) => _buildItem(_atMessages[index]), - itemCount: _atMessages.length, - ), - ), + noMore: _atNoMore, + onLoad: () { + _fetchSystemNotices(MessageType.at, _atMessages, + resetNoMore: () => _atNoMore = false, + onNoMore: () => _atNoMore = true); + }, + child: ListView.builder( + padding: const EdgeInsets.only(top: 10), + itemBuilder: (context, index) => _buildItem(_atMessages[index]), + itemCount: _atMessages.length, + ), + ), ); } - _buildSubscribeTab() { + Widget _buildSubscribeTab() { return EasyRefresh( controller: _subscribeRefreshController, onRefresh: () async { @@ -399,7 +697,7 @@ class _SystemNoticeScreenState extends BaseDynamicState onNoMore: () => _subscribeNoMore = true, ); }, - refreshOnStart: true, + refreshOnStart: false, onLoad: () async { return await _fetchSystemNotices( MessageType.subscribe, @@ -410,25 +708,25 @@ class _SystemNoticeScreenState extends BaseDynamicState }, triggerAxis: Axis.vertical, child: _subscribeMessages.isEmpty - ? EmptyPlaceholder(text: appLocalizations.noNotice) + ? _buildPlaceholder(5) : LoadMoreNotification( - noMore: _subscribeNoMore, - onLoad: () { - _fetchSystemNotices(MessageType.subscribe, _subscribeMessages, - resetNoMore: () => _subscribeNoMore = false, - onNoMore: () => _subscribeNoMore = true); - }, - child: ListView.builder( - padding: const EdgeInsets.only(top: 10), - itemBuilder: (context, index) => - _buildItem(_subscribeMessages[index]), - itemCount: _subscribeMessages.length, - ), - ), + noMore: _subscribeNoMore, + onLoad: () { + _fetchSystemNotices(MessageType.subscribe, _subscribeMessages, + resetNoMore: () => _subscribeNoMore = false, + onNoMore: () => _subscribeNoMore = true); + }, + child: ListView.builder( + padding: const EdgeInsets.only(top: 10), + itemBuilder: (context, index) => + _buildItem(_subscribeMessages[index]), + itemCount: _subscribeMessages.length, + ), + ), ); } - _buildCollectionTab() { + Widget _buildCollectionTab() { return EasyRefresh( controller: _collectionRefreshController, onRefresh: () async { @@ -440,7 +738,7 @@ class _SystemNoticeScreenState extends BaseDynamicState onNoMore: () => _collectionNoMore = true, ); }, - refreshOnStart: true, + refreshOnStart: false, onLoad: () async { return await _fetchSystemNotices( MessageType.collection, @@ -451,25 +749,25 @@ class _SystemNoticeScreenState extends BaseDynamicState }, triggerAxis: Axis.vertical, child: _collectionMessages.isEmpty - ? EmptyPlaceholder(text: appLocalizations.noNotice) + ? _buildPlaceholder(6) : LoadMoreNotification( - noMore: _collectionNoMore, - onLoad: () { - _fetchSystemNotices(MessageType.collection, _collectionMessages, - resetNoMore: () => _collectionNoMore = false, - onNoMore: () => _collectionNoMore = true); - }, - child: ListView.builder( - padding: const EdgeInsets.only(top: 10), - itemBuilder: (context, index) => - _buildItem(_collectionMessages[index]), - itemCount: _collectionMessages.length, - ), - ), + noMore: _collectionNoMore, + onLoad: () { + _fetchSystemNotices(MessageType.collection, _collectionMessages, + resetNoMore: () => _collectionNoMore = false, + onNoMore: () => _collectionNoMore = true); + }, + child: ListView.builder( + padding: const EdgeInsets.only(top: 10), + itemBuilder: (context, index) => + _buildItem(_collectionMessages[index]), + itemCount: _collectionMessages.length, + ), + ), ); } - _buildOtherTab() { + Widget _buildOtherTab() { return EasyRefresh( controller: _otherRefreshController, onRefresh: () async { @@ -481,7 +779,7 @@ class _SystemNoticeScreenState extends BaseDynamicState onNoMore: () => _otherNoMore = true, ); }, - refreshOnStart: true, + refreshOnStart: false, onLoad: () async { return await _fetchSystemNotices( MessageType.other, @@ -492,26 +790,31 @@ class _SystemNoticeScreenState extends BaseDynamicState }, triggerAxis: Axis.vertical, child: _otherMessages.isEmpty - ? EmptyPlaceholder(text: appLocalizations.noNotice) + ? _buildPlaceholder(7) : LoadMoreNotification( - noMore: _otherNoMore, - onLoad: () { - _fetchSystemNotices(MessageType.other, _otherMessages, - resetNoMore: () => _otherNoMore = false, - onNoMore: () => _otherNoMore = true); - }, - child: ListView.builder( - padding: const EdgeInsets.only(top: 10), - itemBuilder: (context, index) => - _buildItem(_otherMessages[index]), - itemCount: _otherMessages.length, - ), - ), + noMore: _otherNoMore, + onLoad: () { + _fetchSystemNotices(MessageType.other, _otherMessages, + resetNoMore: () => _otherNoMore = false, + onNoMore: () => _otherNoMore = true); + }, + child: ListView.builder( + padding: const EdgeInsets.only(top: 10), + itemBuilder: (context, index) => + _buildItem(_otherMessages[index]), + itemCount: _otherMessages.length, + ), + ), ); } - _buildItem(MessageItem item) { - return ClickableGestureDetector( + Widget _buildItem(MessageItem item) { + return SystemNoticeMessageTile( + nickname: item.actUserBlogInfo.blogNickName, + message: item.defString, + timestamp: item.publishTime, + avatarUrl: item.actUserBlogInfo.bigAvaImg, + thumbnailUrl: item.thumbnail, onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -521,115 +824,15 @@ class _SystemNoticeScreenState extends BaseDynamicState ), ); }, - child: Container( - padding: const EdgeInsets.only(left: 5, right: 5, top: 5, bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 10), - GestureDetector( - child: ItemBuilder.buildAvatar( - context: context, - size: 50, - imageUrl: item.actUserBlogInfo.bigAvaImg, - ), - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - UserDetailScreen( - blogId: item.actUserId, - blogName: item.actUserBlogInfo.blogName, - ), - ); - }, - ), - const SizedBox(width: 10), - Expanded( - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme - .of(context) - .dividerColor, - width: 0.5, - ), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - children: [ - TextSpan( - text: item.actUserBlogInfo.blogNickName, - style: Theme - .of(context) - .textTheme - .titleSmall - ?.apply( - fontSizeDelta: 1, - ), - ), - TextSpan( - text: item.defString.replaceFirst( - item.actUserBlogInfo.blogNickName, ""), - style: Theme - .of(context) - .textTheme - .titleSmall - ?.apply( - fontSizeDelta: 1, - color: Theme - .of(context) - .textTheme - .bodySmall - ?.color, - ), - ), - ], - ), - ), - const SizedBox(height: 10), - Text( - TimeUtil.formatTimestamp(item.publishTime), - style: Theme - .of(context) - .textTheme - .labelMedium - ?.apply( - fontSizeDelta: 1, - ), - ), - const SizedBox(height: 10), - ], - ), - ), - const SizedBox(width: 10), - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: ChewieItemBuilder.buildCachedImage( - imageUrl: item.thumbnail, - context: context, - height: 50, - width: 50, - fit: BoxFit.cover, - showLoading: false, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 10), - ], - ), - ), + onAvatarTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + UserDetailScreen( + blogId: item.actUserId, + blogName: item.actUserBlogInfo.blogName, + ), + ); + }, ); } @@ -644,22 +847,19 @@ class _SystemNoticeScreenState extends BaseDynamicState .asMap() .entries .map( - (entry) => - ItemBuilder.buildAnimatedTab(context, + (entry) => ItemBuilder.buildAnimatedTab(context, selected: entry.key == _currentTabIndex, text: entry.value, + controller: _tabController, + tabIndex: entry.key, normalUserBold: true, sameFontSize: true), - ) + ) .toList(), onTap: (index) { - setState(() { - _currentTabIndex = index; - }); + _setCurrentTab(index); }, - width: MediaQuery - .sizeOf(context) - .width, + width: MediaQuery.sizeOf(context).width, background: ChewieTheme.getBackground(context), showBorder: ResponsiveUtil.isLandscapeLayout(), ), diff --git a/lib/Screens/Info/user_detail_screen.dart b/lib/Screens/Info/user_detail_screen.dart index cfc3f061..8395a112 100644 --- a/lib/Screens/Info/user_detail_screen.dart +++ b/lib/Screens/Info/user_detail_screen.dart @@ -1,7 +1,6 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:blur/blur.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; -import 'package:flutter/material.dart' hide AnimatedSlide; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:loftify/Models/show_case_response.dart'; @@ -17,11 +16,19 @@ import 'package:loftify/Screens/Suit/user_market_screen.dart'; import 'package:loftify/Utils/enums.dart'; import 'package:loftify/Utils/hive_util.dart'; import 'package:loftify/Widgets/Item/item_builder.dart'; +import 'package:loftify/Widgets/Design/loftify_surfaces.dart'; +import 'package:loftify/Widgets/Design/loftify_media_overlays.dart'; +import 'package:loftify/Widgets/Profile/profile_overview_card.dart'; +import 'package:loftify/Widgets/Profile/profile_header_components.dart'; +import 'package:loftify/Widgets/Profile/profile_more_action_sheet.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; import '../../Api/user_api.dart'; import '../../Models/user_response.dart'; import '../../Utils/asset_util.dart'; +import '../../Utils/tab_state_util.dart'; import '../../Utils/utils.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../l10n/l10n.dart'; import '../Post/collection_detail_screen.dart'; @@ -30,10 +37,12 @@ class UserDetailScreen extends StatefulWidget { super.key, required this.blogId, required this.blogName, + this.onBack, }); final int blogId; final String blogName; + final VoidCallback? onBack; static const String routeName = "/user/detail"; @@ -46,35 +55,62 @@ class UserDetailScreenState extends BaseDynamicState TotalBlogData? _fullBlogData; bool? isMe = false; late TabController _tabController; + bool _tabControllerInitialized = false; List tabList = []; + final List _tabIdList = []; + int _currentTabIndex = 0; List showCases = []; String _followButtonText = appLocalizations.follow; - Color? _followButtonColor; - SubordinateScrollController? controller; - double _expandedHeight = 270; + bool _usesWideHeaderLayout(BuildContext context) { + return LoftifyProfileHeaderLayout.usesSideBySide( + context, + hasShowcase: showCases.isNotEmpty, + ); + } + + double _profileHeaderHeight( + BuildContext context, { + required bool includesAppBar, + }) { + final scale = MediaQuery.textScalerOf(context).scale(14) / 14; + final wide = _usesWideHeaderLayout(context); + final base = showCases.isEmpty + ? (includesAppBar ? 350.0 : 240.0) + : wide + ? (includesAppBar ? 390.0 : 285.0) + : (includesAppBar ? 540.0 : 412.0); + final accessibilityGrowth = + ((scale - 1).clamp(0, 1.25) * 128) + (scale > 1.45 ? 96 : 0); + return base + accessibilityGrowth; + } InfoMode get infoMode => isMe == true ? InfoMode.me : InfoMode.other; - _fetchData() { - UserApi.getUserDetail(blogId: widget.blogId, blogName: widget.blogName) - .then((value) async { - try { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } else { - _fullBlogData = TotalBlogData.fromJson(value['response']); - isMe = _fullBlogData!.blogInfo.blogId == await HiveUtil.getUserId(); - initTab(); - updateFollowStatus(); - _fetchShowCases(); - setState(() {}); - } - } catch (e, t) { - ILogger.error("Failed to get user detail", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); + Future _fetchData() async { + try { + final value = await UserApi.getUserDetail( + blogId: widget.blogId, + blogName: widget.blogName, + ); + if (!mounted) return; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return; } - if (mounted) setState(() {}); - }); + + final blogData = TotalBlogData.fromJson(value['response']); + final userId = await HiveUtil.getUserId(); + if (!mounted) return; + _fullBlogData = blogData; + isMe = blogData.blogInfo.blogId == userId; + initTab(); + updateFollowStatus(rebuild: false); + setState(() {}); + _fetchShowCases(); + } catch (e, t) { + ILogger.error("Failed to get user detail", e, t); + if (mounted) IToast.showTop(appLocalizations.loadFailed); + } } @override @@ -83,81 +119,148 @@ class UserDetailScreenState extends BaseDynamicState _fetchData(); } - initTab() { + void initTab() { + if (_tabControllerInitialized) { + _tabController.dispose(); + } tabList.clear(); + _tabIdList.clear(); tabList.add(Tab(text: appLocalizations.article)); + _tabIdList.add('article'); if (_fullBlogData!.showLike == 1) { tabList.add(Tab(text: appLocalizations.like)); + _tabIdList.add('like'); } if (_fullBlogData!.showShare == 1) { tabList.add(Tab(text: appLocalizations.recommend)); + _tabIdList.add('recommend'); } tabList.add(Tab(text: appLocalizations.collection)); + _tabIdList.add('collection'); if (_fullBlogData!.showFoods == 1) { tabList.add(Tab(text: appLocalizations.grain)); + _tabIdList.add('grain'); } - _tabController = TabController(length: tabList.length, vsync: this); + _currentTabIndex = PersistentTabState.restore( + idKey: HiveUtil.userDetailTabIdKey, + legacyIndexKey: HiveUtil.userDetailTabIndexKey, + itemIds: _tabIdList, + ).index; + _tabController = TabController( + length: tabList.length, + initialIndex: _currentTabIndex, + vsync: this, + ); + _tabController.addListener(() { + if (_tabController.indexIsChanging || + _tabController.offset.abs() > 0.001) { + return; + } + _setCurrentTab(_tabController.index); + }); + _tabControllerInitialized = true; + } + + void _setCurrentTab(int index) { + final safeIndex = TabStatePreference.restoreIndex(index, tabList.length); + if (safeIndex != _currentTabIndex && mounted) { + setState(() => _currentTabIndex = safeIndex); + } + PersistentTabState.save( + idKey: HiveUtil.userDetailTabIdKey, + legacyIndexKey: HiveUtil.userDetailTabIndexKey, + itemIds: _tabIdList, + index: safeIndex, + ); + } + + @override + void dispose() { + if (_tabControllerInitialized) { + _tabController.dispose(); + } + super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: context.design.colors.page, resizeToAvoidBottomInset: false, appBar: ResponsiveUtil.isLandscapeLayout() ? ResponsiveAppBar( - showBack: true, title: appLocalizations.personalHomepage) + showBack: true, + title: appLocalizations.personalHomepage, + onTapBack: widget.onBack, + ) : null, body: _fullBlogData != null ? ExtendedNestedScrollView( + onlyOneScrollInBody: true, headerSliverBuilder: (_, __) => _buildHeaderSlivers(), - body: _mainContent()) + body: _mainContent(), + ) : LoadingWidget( background: ChewieTheme.getBackground(context), ), ); } - _buildHeaderSlivers() { + List _buildHeaderSlivers() { if (!ResponsiveUtil.isLandscapeLayout()) { return [ SliverAppBarWrapper( context: context, - expandedHeight: _expandedHeight, + onBack: widget.onBack, + expandedHeight: _profileHeaderHeight( + context, + includesAppBar: true, + ), systemOverlayStyle: SystemUiOverlayStyle.light, collapsedHeight: 56, - backgroundWidget: _buildBackground(height: _expandedHeight + 60), + backgroundWidget: _buildHeaderBackground(), actions: _appBarActions(), centerTitle: !ResponsiveUtil.isLandscapeLayout(), title: Text( appLocalizations.personalHomepage, - style: Theme.of(context).textTheme.titleMedium?.apply( + style: Theme.of(context) + .textTheme + .titleMedium + ?.apply( color: Colors.white, fontWeightDelta: 2, + ) + .copyWith( + shadows: const [ + Shadow( + color: Color(0x99000000), + blurRadius: 5, + offset: Offset(0, 1), ), + ], + ), ), flexibleSpace: FlexibleSpaceBar( background: Stack( + fit: StackFit.expand, children: [ - _buildBackground(height: _expandedHeight + 60), + _buildHeaderBackground(), _buildInfo(), ], ), ), bottom: PreferredSize( - preferredSize: const Size.fromHeight(40), - child: Container( - decoration: BoxDecoration( - color: ChewieTheme.getBackground(context), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), + preferredSize: const Size.fromHeight(56), + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), ), child: TabBarWrapper( tabController: _tabController, tabs: tabList, width: MediaQuery.sizeOf(context).width, - forceUnscrollable: !ResponsiveUtil.isLandscapeLayout(), + isScrollable: false, + onTap: _setCurrentTab, ), ), ), @@ -166,25 +269,33 @@ class UserDetailScreenState extends BaseDynamicState } else { return [ SliverToBoxAdapter( - child: Stack( - children: [ - _buildBackground(height: _expandedHeight - 84), - _buildInfo(10), - ], + child: SizedBox( + height: _profileHeaderHeight( + context, + includesAppBar: false, + ), + child: Stack( + fit: StackFit.expand, + children: [ + _buildHeaderBackground(), + _buildInfo(12), + ], + ), ), ), SliverPersistentHeader( - key: ValueKey(StringUtil.getRandomString()), + key: const ValueKey('user-profile-tabs'), pinned: true, delegate: SliverAppBarDelegate( radius: 0, - background: ChewieTheme.getBackground(context), + background: context.design.colors.page, tabBar: TabBarWrapper( tabController: _tabController, tabs: tabList, width: MediaQuery.sizeOf(context).width, + isScrollable: false, showBorder: true, - forceUnscrollable: !ResponsiveUtil.isLandscapeLayout(), + onTap: _setCurrentTab, ), ), ), @@ -193,21 +304,30 @@ class UserDetailScreenState extends BaseDynamicState } Widget _mainContent() { - return _buildTabView(); + return ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular(context.design.radii.panel), + ), + child: ColoredBox( + color: context.design.colors.page, + child: _buildTabView(), + ), + ); } - _buildMoreButtons() { + FlutterContextMenu _buildMoreButtons() { return FlutterContextMenu( entries: [ FlutterContextMenuItem( appLocalizations.viewThemeBg, - iconData: Icons.color_lens_outlined, + iconData: LoftifyIcons.image, onPressed: () { RouteUtil.pushDialogRoute( context, showClose: false, fullScreen: true, useFade: true, + opaque: false, HeroPhotoViewScreen( tagPrefix: StringUtil.getRandomString(), imageUrls: [Utils.removeImageParam(backgroudUrl)], @@ -220,7 +340,7 @@ class UserDetailScreenState extends BaseDynamicState ), FlutterContextMenuItem( appLocalizations.viewShop, - iconData: Icons.shopping_bag_outlined, + iconData: LoftifyIcons.shop, onPressed: () { RouteUtil.pushPanelCupertinoRoute(context, UserMarketScreen(blogId: _fullBlogData!.blogInfo.blogId)); @@ -233,19 +353,21 @@ class UserDetailScreenState extends BaseDynamicState _fullBlogData!.blogInfo.avatarBoxImage ? appLocalizations.undressAvatarBox : appLocalizations.dressAvatarBox, - iconData: Icons.account_box, + iconData: LoftifyIcons.avatarFrame, onPressed: () async { String? currentAvatarImg = ChewieHiveUtil.getString(HiveUtil.customAvatarBoxKey); if (currentAvatarImg == _fullBlogData!.blogInfo.avatarBoxImage) { await ChewieHiveUtil.put(HiveUtil.customAvatarBoxKey, ""); + if (!mounted) return; currentAvatarImg = ""; setState(() {}); IToast.showTop(appLocalizations.unDressSuccess); } else { await ChewieHiveUtil.put(HiveUtil.customAvatarBoxKey, _fullBlogData!.blogInfo.avatarBoxImage); + if (!mounted) return; currentAvatarImg = _fullBlogData!.blogInfo.avatarBoxImage; setState(() {}); IToast.showTop(appLocalizations.dressSuccess); @@ -254,7 +376,7 @@ class UserDetailScreenState extends BaseDynamicState ), FlutterContextMenuItem( appLocalizations.setRemark, - iconData: Icons.credit_card, + iconData: LoftifyIcons.edit, onPressed: () { BottomSheetBuilder.showBottomSheet( context, @@ -267,6 +389,7 @@ class UserDetailScreenState extends BaseDynamicState blogId: _fullBlogData!.blogInfo.blogId, remark: text, ).then((value) { + if (!mounted) return; if (value['meta']['status'] != 200) { IToast.showTop( value['meta']['desc'] ?? value['meta']['msg']); @@ -283,23 +406,27 @@ class UserDetailScreenState extends BaseDynamicState ); }, ), - FlutterContextMenuItem.divider(), + const MenuDivider( + thickness: 0.6, + indent: 46, + endIndent: 8, + ), FlutterContextMenuItem( - status: MenuItemStatus.warning, + status: MenuItemStatus.error, _fullBlogData!.isBlackBlog ? appLocalizations.unlockBlacklist : appLocalizations.blockBlacklist, - iconData: Icons.block_rounded, + iconData: LoftifyIcons.block, onPressed: () { _doBlockUser( isBlock: !_fullBlogData!.isBlackBlog, onSuccess: () { + if (!mounted) return; if (_fullBlogData!.isBlackBlog) { IToast.showTop(appLocalizations.blockBlacklistSuccess); } else { IToast.showTop(appLocalizations.unblockBlacklistSuccess); } - setState(() {}); updateFollowStatus(); }, ); @@ -307,16 +434,17 @@ class UserDetailScreenState extends BaseDynamicState ), if (_fullBlogData!.following) ...[ FlutterContextMenuItem( - status: MenuItemStatus.warning, + status: MenuItemStatus.error, _fullBlogData!.isShieldRecom == 1 ? appLocalizations.recoverViewRecommend : appLocalizations.shieldViewRecommend, - iconData: Icons.block_rounded, + iconData: LoftifyIcons.block, onPressed: () { UserApi.shieldRecommendOrUnShield( blogId: _fullBlogData!.blogInfo.blogId, isShield: !(_fullBlogData!.isShieldRecom == 1), ).then((value) { + if (!mounted) return; if (value['meta']['status'] != 200) { IToast.showTop( value['meta']['desc'] ?? value['meta']['msg']); @@ -329,16 +457,17 @@ class UserDetailScreenState extends BaseDynamicState }, ), FlutterContextMenuItem( - status: MenuItemStatus.warning, + status: MenuItemStatus.error, _fullBlogData!.shieldUserTimeline ? appLocalizations.recoverViewDynamic : appLocalizations.shieldViewDynamic, - iconData: Icons.block_rounded, + iconData: LoftifyIcons.block, onPressed: () { UserApi.shieldBlogOrUnShield( blogId: _fullBlogData!.blogInfo.blogId, isShield: !_fullBlogData!.shieldUserTimeline, ).then((value) { + if (!mounted) return; if (value['code'] != 0) { IToast.showTop(value['msg']); } else { @@ -351,45 +480,77 @@ class UserDetailScreenState extends BaseDynamicState ), ], ], - FlutterContextMenuItem.divider(), + const MenuDivider( + thickness: 0.6, + indent: 46, + endIndent: 8, + ), FlutterContextMenuItem( appLocalizations.copyHomepageLink, - iconData: Icons.copy_rounded, + iconData: LoftifyIcons.copy, onPressed: () { ChewieUtils.copy(context, _fullBlogData!.blogInfo.homePageUrl); }, ), FlutterContextMenuItem(appLocalizations.openWithBrowser, - iconData: Icons.open_in_browser_rounded, onPressed: () { + iconData: LoftifyIcons.openExternal, onPressed: () { UriUtil.openExternal(_fullBlogData!.blogInfo.homePageUrl); }), FlutterContextMenuItem(appLocalizations.shareToOtherApps, - iconData: Icons.share_rounded, onPressed: () { + iconData: LoftifyIcons.share, onPressed: () { UriUtil.share(_fullBlogData!.blogInfo.homePageUrl); }), ], ); } - _appBarActions() { - return [ - CircleIconButton( - onTap: () { - BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()); + void _showMoreButtons() { + final menu = _buildMoreButtons(); + if (ResponsiveUtil.isDesktop()) { + BottomSheetBuilder.showContextMenu(context, menu); + return; + } + final items = menu.entries.whereType().toList(); + BottomSheetBuilder.showBottomSheet( + context, + (sheetContext) => LoftifyProfileMoreActionSheet( + privacyTitle: appLocalizations.privacySetting, + cancelLabel: appLocalizations.cancel, + actions: + items.where((item) => item.status != MenuItemStatus.error).toList(), + privacyActions: + items.where((item) => item.status == MenuItemStatus.error).toList(), + onSelected: (item) { + Navigator.of(sheetContext).pop(); + item.onPressed?.call(); }, - icon: const Icon( - Icons.more_vert_rounded, - color: Colors.white, - ), + onCancel: () => Navigator.of(sheetContext).pop(), + ), + responsive: true, + backgroundColor: context.design.colors.surfaceRaised, + preferMinWidth: 450, + topRadius: Radius.circular(context.design.radii.dialog + 4), + ); + } + + List _appBarActions() { + return [ + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + foregroundColor: Colors.white, + onPressed: _showMoreButtons, ), ]; } - _fetchShowCases() { - UserApi.getShowCases( - blogId: _fullBlogData!.blogInfo.blogId, - blogName: _fullBlogData!.blogInfo.blogName) - .then((value) { + Future _fetchShowCases() async { + try { + final value = await UserApi.getShowCases( + blogId: _fullBlogData!.blogInfo.blogId, + blogName: _fullBlogData!.blogInfo.blogName, + ); + if (!mounted) return; if (value['code'] != 200) { IToast.showTop(value['msg']); } else if (value['data']['showCaseList'] != null) { @@ -400,16 +561,15 @@ class UserDetailScreenState extends BaseDynamicState .where( (e) => !(e.postCollection == null && e.postSimpleData == null)) .toList(); - updateFollowStatus(); - if (showCases.isNotEmpty) { - _expandedHeight = 430; - } + updateFollowStatus(rebuild: false); setState(() {}); } - }); + } catch (e, t) { + ILogger.error("Failed to get user showcases", e, t); + } } - getAvatarBoxImage() { + String getAvatarBoxImage() { if (infoMode == InfoMode.me) { String url = ChewieHiveUtil.getString(HiveUtil.customAvatarBoxKey) ?? ""; return url.isNotEmpty ? url : _fullBlogData!.blogInfo.avatarBoxImage; @@ -419,375 +579,309 @@ class UserDetailScreenState extends BaseDynamicState } Widget _buildInfo([double? topMargin]) { - bool hasRemarkName = + final design = context.design; + final hasRemarkName = StringUtil.isNotEmpty(_fullBlogData!.blogInfo.remarkName); - return Container( - margin: EdgeInsets.only( - top: - topMargin ?? kToolbarHeight + MediaQuery.of(context).padding.top), - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: const BoxDecoration( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), + final hasDescription = StringUtil.isNotEmpty( + StringUtil.clearBlank(_fullBlogData!.blogInfo.selfIntro), + ); + return Padding( + padding: EdgeInsets.fromLTRB( + design.spacing.xl, + topMargin ?? MediaQuery.paddingOf(context).top + design.spacing.xl, + design.spacing.xl, + design.spacing.lg, + ), + child: LoftifyProfileHeaderLayout( + summary: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _buildIdentitySummary( + hasRemarkName: hasRemarkName, + hasDescription: hasDescription, + ), + SizedBox(height: design.spacing.lg), + _buildStatisticsCard(), + SizedBox(height: design.spacing.sectionTop), + _buildProfileAction(), + ], ), + showcase: showCases.isNotEmpty ? _buildShowCases() : null, ), - width: MediaQuery.sizeOf(context).width, - child: Column( + ); + } + + Widget _buildIdentitySummary({ + required bool hasRemarkName, + required bool hasDescription, + }) { + final info = _fullBlogData!.blogInfo; + final idLabel = 'ID: ${info.blogName}' + '${hasRemarkName ? appLocalizations.remarkSuffix(info.remarkName) : ''}'; + final gender = info.gendar == 1 + ? appLocalizations.male + : info.gendar == 2 + ? appLocalizations.female + : appLocalizations.confidential; + final metadata = '${appLocalizations.gender}: $gender' + '${StringUtil.isNotEmpty(info.ipLocation) ? '${appLocalizations.ipSuffix}${info.ipLocation}' : ''}'; + return LoftifyProfileIdentity( + key: const ValueKey('user-profile-identity'), + avatar: ItemBuilder.buildAvatar( + context: context, + size: StringUtil.isNotEmpty(getAvatarBoxImage()) ? 54 : 80, + showBorder: false, + showDetailMode: ShowDetailMode.avatar, + imageUrl: Utils.removeImageParam(info.bigAvaImg), + avatarBoxImageUrl: getAvatarBoxImage(), + title: appLocalizations.personalAvatar, + caption: '「${info.blogNickName}」', + tagPrefix: 'user-profile-${info.blogId}', + ), + displayName: info.blogNickName, + idLabel: idLabel, + metadata: metadata, + onDisplayNameLongPress: () => ChewieUtils.copy( + context, + info.blogNickName, + toastText: appLocalizations.haveCopiedNickName, + ), + onIdLongPress: () => ChewieUtils.copy( + context, + info.blogName, + toastText: appLocalizations.haveCopiedLofterID, + ), + descriptionLabel: hasDescription ? appLocalizations.moreInfo : null, + onDescriptionPressed: hasDescription ? _showDescription : null, + trailing: ResponsiveUtil.isLandscapeLayout() + ? ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + foregroundColor: Colors.white, + onPressed: _showMoreButtons, + ) + : null, + ); + } + + void _showDescription() { + DialogBuilder.showInfoDialog( + context, + title: appLocalizations.descriptionTitle( + _fullBlogData!.blogInfo.blogNickName, + ), + message: _fullBlogData!.blogInfo.selfIntro, + onTapDismiss: () {}, + customDialogType: CustomDialogType.normal, + ); + } + + Widget _buildStatisticsCard() { + final info = _fullBlogData!.blogInfo; + return ProfileOverviewCard( + backgroundColor: Colors.black.withValues(alpha: 0.22), + foregroundColor: Colors.white, + statistics: [ + ProfileStatisticData( + title: appLocalizations.following, + count: info.blogStat.followingCount, + onTap: _openFollowing, + ), + ProfileStatisticData( + title: appLocalizations.follower, + count: info.blogStat.followedCount, + onTap: _openFollowers, + ), + ProfileStatisticData( + title: appLocalizations.hotCount, + count: info.hot.hotCount, + onTap: _showHotDetails, + ), + ProfileStatisticData( + title: appLocalizations.supporter, + count: info.blogStat.supporterCount, + onTap: _openSupporters, + ), + ], + ); + } + + Widget _buildProfileAction() { + final isOwnProfile = infoMode == InfoMode.me; + return LoftifyProfileAction( + onPressed: isOwnProfile + ? () => UriUtil.openExternal(_fullBlogData!.blogInfo.homePageUrl) + : _processFollow, + label: isOwnProfile + ? appLocalizations.openWithBrowser + : _followButtonText.trim(), + icon: isOwnProfile + ? LoftifyIcons.openExternal + : _fullBlogData!.isBlackBlog + ? LoftifyIcons.block + : LoftifyIcons.follow, + emphasized: !isOwnProfile && + !_fullBlogData!.following && + !_fullBlogData!.isBlackBlog, + danger: _fullBlogData!.isBlackBlog, + ); + } + + void _openFollowing() { + if (_fullBlogData!.showFollow == 1 || infoMode == InfoMode.me) { + RouteUtil.pushPanelCupertinoRoute( + context, + FollowingFollowerScreen( + infoMode: infoMode, + followingMode: infoMode == InfoMode.me + ? FollowingMode.following + : FollowingMode.timeline, + blogId: _fullBlogData!.blogInfo.blogId, + blogName: _fullBlogData!.blogInfo.blogName, + total: _fullBlogData!.blogInfo.blogStat.followingCount, + ), + ); + } else { + IToast.showTop(appLocalizations.cannotViewFollowingList); + } + } + + void _openFollowers() { + if (_fullBlogData!.showFans == 1 || infoMode == InfoMode.me) { + RouteUtil.pushPanelCupertinoRoute( + context, + FollowingFollowerScreen( + infoMode: infoMode, + followingMode: FollowingMode.follower, + blogId: _fullBlogData!.blogInfo.blogId, + blogName: _fullBlogData!.blogInfo.blogName, + total: _fullBlogData!.blogInfo.blogStat.followedCount, + ), + ); + } else { + IToast.showTop(appLocalizations.cannotViewFollowerList); + } + } + + void _openSupporters() { + if (_fullBlogData!.showSupport == 1 || infoMode == InfoMode.me) { + RouteUtil.pushPanelCupertinoRoute( + context, + SupporterScreen( + infoMode: infoMode, + blogId: _fullBlogData!.blogInfo.blogId, + ), + ); + } else { + IToast.showTop(appLocalizations.cannotViewSupporterList); + } + } + + void _showHotDetails() { + final hot = _fullBlogData!.blogInfo.hot; + DialogBuilder.showInfoDialog( + context, + title: '${appLocalizations.totalHotCount}${hot.hotCount}', + messageChild: Column( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const SizedBox(height: 10), - Row( - children: [ - const SizedBox(width: 12), - ItemBuilder.buildAvatar( - context: context, - size: StringUtil.isNotEmpty(getAvatarBoxImage()) ? 54 : 80, - showBorder: false, - showDetailMode: ShowDetailMode.avatar, - imageUrl: - Utils.removeImageParam(_fullBlogData!.blogInfo.bigAvaImg), - avatarBoxImageUrl: getAvatarBoxImage(), - title: appLocalizations.personalAvatar, - caption: "「${_fullBlogData!.blogInfo.blogNickName}」", - tagPrefix: StringUtil.getRandomString(), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ItemBuilder.buildCopyable( - context, - child: Text( - _fullBlogData!.blogInfo.blogNickName, - style: Theme.of(context).textTheme.titleLarge?.apply( - fontSizeDelta: 2, - color: Colors.white, - ), - ), - text: _fullBlogData!.blogInfo.blogNickName, - toastText: appLocalizations.haveCopiedNickName, - ), - Text.rich( - TextSpan( - children: [ - WidgetSpan( - child: ItemBuilder.buildCopyable( - context, - child: Text( - textAlign: TextAlign.center, - 'ID: ${_fullBlogData!.blogInfo.blogName}', - style: Theme.of(context) - .textTheme - .labelMedium - ?.apply(color: Colors.white70), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - text: _fullBlogData!.blogInfo.blogName, - toastText: appLocalizations.haveCopiedLofterID, - ), - ), - if (hasRemarkName) - WidgetSpan( - child: ItemBuilder.buildCopyable( - context, - child: Text( - textAlign: TextAlign.center, - appLocalizations.remarkSuffix( - _fullBlogData!.blogInfo.remarkName), - style: Theme.of(context) - .textTheme - .labelMedium - ?.apply(color: Colors.white70), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - text: _fullBlogData!.blogInfo.remarkName, - toastText: appLocalizations.haveCopiedRemark, - ), - ), - ], - ), - ), - const SizedBox(height: 5), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - fit: FlexFit.loose, - child: Text( - textAlign: TextAlign.center, - '${appLocalizations.gender}: ${_fullBlogData!.blogInfo.gendar == 1 ? appLocalizations.male : _fullBlogData!.blogInfo.gendar == 2 ? appLocalizations.female : appLocalizations.confidential}${StringUtil.isNotEmpty(_fullBlogData!.blogInfo.ipLocation) ? "${appLocalizations.ipSuffix}${_fullBlogData!.blogInfo.ipLocation}" : ""}', - style: Theme.of(context) - .textTheme - .labelMedium - ?.apply(color: Colors.white70), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 5), - if (StringUtil.isNotEmpty(StringUtil.clearBlank( - _fullBlogData!.blogInfo.selfIntro))) - ClickableGestureDetector( - onTap: () { - DialogBuilder.showInfoDialog( - context, - title: appLocalizations.descriptionTitle( - _fullBlogData!.blogInfo.blogNickName), - message: _fullBlogData!.blogInfo.selfIntro, - onTapDismiss: () {}, - customDialogType: CustomDialogType.normal, - ); - }, - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: appLocalizations.moreInfo, - style: Theme.of(context) - .textTheme - .labelMedium - ?.apply(color: Colors.white), - ), - const WidgetSpan( - child: Icon( - Icons.keyboard_arrow_down_rounded, - size: 16, - color: Colors.white, - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - const Spacer(), - if (ResponsiveUtil.isLandscapeLayout()) ...[ - CircleIconButton( - onTap: () { - BottomSheetBuilder.showContextMenu( - context, _buildMoreButtons()); - }, - icon: const Icon( - Icons.more_vert_rounded, - color: Colors.white, - ), - ), - const SizedBox(width: 10), - ], - ], + _buildHotItem( + icon: LoftifyIcons.favorite, + title: appLocalizations.postLikes, + count: hot.favoriteCount, ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ItemBuilder.buildStatisticItem( - context, - title: appLocalizations.following, - count: _fullBlogData!.blogInfo.blogStat.followingCount, - countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.9), - onTap: () { - if (_fullBlogData!.showFollow == 1 || - infoMode == InfoMode.me) { - RouteUtil.pushPanelCupertinoRoute( - context, - FollowingFollowerScreen( - infoMode: infoMode, - followingMode: infoMode == InfoMode.me - ? FollowingMode.following - : FollowingMode.timeline, - blogId: _fullBlogData!.blogInfo.blogId, - blogName: _fullBlogData!.blogInfo.blogName, - total: _fullBlogData!.blogInfo.blogStat.followingCount, - ), - ); - } else { - IToast.showTop(appLocalizations.cannotViewFollowingList); - } - }, - ), - ItemBuilder.buildStatisticItem( - context, - title: appLocalizations.follower, - count: _fullBlogData!.blogInfo.blogStat.followedCount, - countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.9), - onTap: () { - if (_fullBlogData!.showFans == 1 || infoMode == InfoMode.me) { - RouteUtil.pushPanelCupertinoRoute( - context, - FollowingFollowerScreen( - infoMode: infoMode, - followingMode: FollowingMode.follower, - blogId: _fullBlogData!.blogInfo.blogId, - blogName: _fullBlogData!.blogInfo.blogName, - total: _fullBlogData!.blogInfo.blogStat.followedCount, - ), - ); - } else { - IToast.showTop(appLocalizations.cannotViewFollowerList); - } - }, - ), - ItemBuilder.buildStatisticItem( - context, - title: appLocalizations.hotCount, - count: _fullBlogData!.blogInfo.hot.hotCount, - countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.9), - onTap: () { - DialogBuilder.showInfoDialog( - context, - title: - "${appLocalizations.totalHotCount}${_fullBlogData!.blogInfo.hot.hotCount}", - messageChild: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildHotItem( - icon: Icons.favorite_rounded, - title: appLocalizations.postLikes, - count: _fullBlogData!.blogInfo.hot.favoriteCount, - ), - _buildHotItem( - icon: Icons.thumb_up_rounded, - title: appLocalizations.postRecommends, - count: _fullBlogData!.blogInfo.hot.shareCount, - ), - _buildHotItem( - icon: Icons.bookmark_rounded, - title: appLocalizations.postFavorites, - count: _fullBlogData!.blogInfo.hot.subscribeCount, - ), - _buildHotItem( - icon: Icons.mode_comment_rounded, - title: appLocalizations.commentLikes, - count: - _fullBlogData!.blogInfo.hot.tagChatFavoriteCount, - ), - ], - ), - buttonText: appLocalizations.comeOn, - onTapDismiss: () {}, - customDialogType: CustomDialogType.custom, - ); - }, - ), - ItemBuilder.buildStatisticItem( - context, - title: appLocalizations.supporter, - count: _fullBlogData!.blogInfo.blogStat.supporterCount, - countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.9), - onTap: () { - if (_fullBlogData!.showSupport == 1 || - infoMode == InfoMode.me) { - RouteUtil.pushPanelCupertinoRoute( - context, - SupporterScreen( - infoMode: infoMode, - blogId: _fullBlogData!.blogInfo.blogId, - ), - ); - } else { - IToast.showTop(appLocalizations.cannotViewSupporterList); - } - }, - ), - if (infoMode == InfoMode.other) - RoundIconTextButton( - onPressed: _processFollow, - text: _followButtonText, - background: _followButtonColor, - fontSizeDelta: 2, - ), - if (infoMode == InfoMode.me) - RoundIconTextButton( - onPressed: () {}, - text: appLocalizations.editProfile, - background: Colors.white.withOpacity(0.2), - fontSizeDelta: 2, - ), - ], + _buildHotItem( + icon: LoftifyIcons.recommend, + title: appLocalizations.postRecommends, + count: hot.shareCount, + ), + _buildHotItem( + icon: LoftifyIcons.bookmark, + title: appLocalizations.postFavorites, + count: hot.subscribeCount, + ), + _buildHotItem( + icon: LoftifyIcons.comment, + title: appLocalizations.commentLikes, + count: hot.tagChatFavoriteCount, ), - const SizedBox(height: 16), - if (showCases.isNotEmpty) _buildShowCases(), ], ), + buttonText: appLocalizations.comeOn, + onTapDismiss: () {}, + customDialogType: CustomDialogType.custom, ); } - updateFollowStatus() { + void updateFollowStatus({bool rebuild = true}) { if (_fullBlogData!.following) { _followButtonText = _fullBlogData!.specialfollowing ? appLocalizations.specialFollowed : appLocalizations.followed; - _followButtonColor = Colors.white.withOpacity(0.4); } else { _followButtonText = " ${appLocalizations.follow} "; - _followButtonColor = Colors.white.withOpacity(0.2); } if (_fullBlogData!.isBlackBlog) { _followButtonText = appLocalizations.blacklisted; - _followButtonColor = Colors.red.withOpacity(0.4); } - setState(() {}); + if (rebuild && mounted) setState(() {}); } - _buildHotItem({ + Widget _buildHotItem({ Color? color, required IconData icon, required String title, required int count, }) { + final scheme = Theme.of(context).colorScheme; + final accent = color ?? scheme.primary; return Container( - width: 180, - margin: const EdgeInsets.all(3), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, children: [ Container( - padding: const EdgeInsets.all(3), + width: 32, + height: 32, + alignment: Alignment.center, decoration: BoxDecoration( - color: color ?? ChewieColors.getHotTagTextColor(context), - shape: BoxShape.circle, + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), ), - child: Icon(icon, size: 12, color: Colors.white), - ), - const SizedBox(width: 10), - Text( - title, - style: Theme.of(context).textTheme.labelMedium, + child: ChewieIcon(icon, size: 18, color: accent), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( - child: Text( - count.toString(), - style: Theme.of(context).textTheme.titleMedium, - ), + child: Text(title, style: Theme.of(context).textTheme.bodyMedium), + ), + const SizedBox(width: 8), + Text( + StringUtil.formatCount(count), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), ], ), ); } - _doFollow({required bool isFollow}) { + void _doFollow({required bool isFollow}) { UserApi.followOrUnfollow( isFollow: isFollow, blogId: _fullBlogData!.blogInfo.blogId, blogName: _fullBlogData!.blogInfo.blogName, ).then((value) { + if (!mounted) return; if (value['meta']['status'] != 200) { IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); } else { _fullBlogData!.following = !_fullBlogData!.following; - setState(() {}); updateFollowStatus(); } }); @@ -801,6 +895,7 @@ class UserDetailScreenState extends BaseDynamicState isBlock: isBlock, blogId: _fullBlogData!.blogInfo.blogId, ).then((value) { + if (!mounted) return; if (value['meta']['status'] != 200) { IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); } else { @@ -814,23 +909,23 @@ class UserDetailScreenState extends BaseDynamicState }); } - _doSpecialFollow({required bool isSpecialFollow}) { + void _doSpecialFollow({required bool isSpecialFollow}) { UserApi.specialFollowOrSpecialUnfollow( isSpecialFollow: isSpecialFollow, blogId: _fullBlogData!.blogInfo.blogId, blogName: _fullBlogData!.blogInfo.blogName, ).then((value) { + if (!mounted) return; if (value['meta']['status'] != 200) { IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); } else { _fullBlogData!.specialfollowing = !_fullBlogData!.specialfollowing; - setState(() {}); updateFollowStatus(); } }); } - _processFollow() { + void _processFollow() { if (_fullBlogData!.isBlackBlog) { DialogBuilder.showConfirmDialog( context, @@ -843,7 +938,7 @@ class UserDetailScreenState extends BaseDynamicState _doBlockUser( isBlock: !_fullBlogData!.isBlackBlog, onSuccess: () { - setState(() {}); + if (!mounted) return; updateFollowStatus(); }); }, @@ -860,16 +955,18 @@ class UserDetailScreenState extends BaseDynamicState } } - _buildFollowButtons() { + FlutterContextMenu _buildFollowButtons() { return FlutterContextMenu(entries: [ FlutterContextMenuItem( _fullBlogData!.specialfollowing ? appLocalizations.unSpecialFollow - : appLocalizations.specialFollow, onPressed: () { + : appLocalizations.specialFollow, + iconData: LoftifyIcons.specialFollow, onPressed: () { _doSpecialFollow(isSpecialFollow: !_fullBlogData!.specialfollowing); }), FlutterContextMenuItem( appLocalizations.unfollow, + iconData: LoftifyIcons.unfollow, onPressed: () { _doFollow(isFollow: !_fullBlogData!.following); }, @@ -877,7 +974,7 @@ class UserDetailScreenState extends BaseDynamicState ]); } - _buildTabView() { + Widget _buildTabView() { List children = []; children.add( PostScreen( @@ -935,45 +1032,47 @@ class UserDetailScreenState extends BaseDynamicState ); } - _buildShowCases() { - return Container( - margin: const EdgeInsets.only(left: 12, right: 12), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(10), - ), + Widget _buildShowCases() { + final design = context.design; + final scale = MediaQuery.textScalerOf(context).scale(14) / 14; + final itemExtent = 112.0 + ((scale - 1).clamp(0, 1) * 24); + return LoftifyCard( + backgroundColor: Colors.black.withValues(alpha: 0.22), + radius: design.radii.card, + padding: EdgeInsets.all(design.spacing.lg), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const Icon( - Icons.workspace_premium_rounded, + const ChewieIcon( + LoftifyIcons.premium, size: 16, color: Colors.white, ), - const SizedBox(width: 3), + SizedBox(width: design.spacing.sm), Expanded( child: Text( appLocalizations.masterpiece, - style: Theme.of(context) - .textTheme - .titleSmall - ?.apply(fontSizeDelta: -1, color: Colors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), ), ), ], ), - const SizedBox(height: 10), + SizedBox(height: design.spacing.md), SizedBox( - height: 100, + height: itemExtent, child: ListView.builder( physics: const BouncingScrollPhysics(), scrollDirection: Axis.horizontal, itemCount: showCases.length, - itemBuilder: (context, index) { - return _buildShowCaseItem(showCases[index]); - }, + itemBuilder: (context, index) => + _buildShowCaseItem(showCases[index], itemExtent), ), ), ], @@ -981,7 +1080,7 @@ class UserDetailScreenState extends BaseDynamicState ); } - _buildShowCaseItem(ShowCaseItem item) { + Widget _buildShowCaseItem(ShowCaseItem item, double extent) { late String title; late String backgroundUrl; late String hotCount; @@ -1023,26 +1122,26 @@ class UserDetailScreenState extends BaseDynamicState } else { ILogger.info("Loftify", item.toJson()); } - return ClickableGestureDetector( - onTap: onTap, - child: Container( - height: 100, - width: 100, - margin: const EdgeInsets.only(right: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: StringUtil.isNotEmpty(backgroundUrl) + final design = context.design; + return Padding( + padding: EdgeInsets.only(right: design.spacing.md), + child: SizedBox( + height: extent, + width: extent, + child: LoftifyCard( + onTap: onTap, + semanticLabel: '$title, $hotCount', + radius: design.radii.control, + backgroundColor: Colors.black.withValues(alpha: 0.18), + child: Stack( + fit: StackFit.expand, + children: [ + StringUtil.isNotEmpty(backgroundUrl) ? ChewieItemBuilder.buildCachedImage( context: context, imageUrl: backgroundUrl, - height: 100, - width: 100, + height: extent, + width: extent, fit: BoxFit.cover, showLoading: false, placeholderBackground: Colors.transparent, @@ -1051,82 +1150,87 @@ class UserDetailScreenState extends BaseDynamicState context, AssetUtil.lofterDarkIllust, AssetUtil.lofterDarkIllust, - size: 100, + size: extent, fit: BoxFit.cover, ), - ), - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.1), - Colors.black.withOpacity(0.4), - ], - ), - ), - child: Column( - children: [ - const SizedBox(height: 20), - const Spacer(), - Center( - child: Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.apply( - color: Colors.white, - ), - ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.05), + Colors.black.withValues(alpha: 0.68), + ], ), - const Spacer(), - Row( + ), + child: Padding( + padding: EdgeInsets.all(design.spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - Icons.local_fire_department_rounded, - size: 12, - color: Colors.white, - ), - const SizedBox(width: 3), - Expanded( - child: Text( - hotCount, - style: Theme.of(context) - .textTheme - .titleSmall - ?.apply(fontSizeDelta: -1, color: Colors.white), + SizedBox(height: design.spacing.xxl), + const Spacer(), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + style: design.typography.cardTitle.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, ), ), + SizedBox(height: design.spacing.xs), + Row( + children: [ + const ChewieIcon( + LoftifyIcons.hot, + size: 12, + color: Colors.white, + ), + SizedBox(width: design.spacing.xs), + Expanded( + child: Text( + hotCount, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: Colors.white, + ), + ), + ), + ], + ), ], ), - ], - ), - ), - Positioned( - top: -4, - right: -6, - child: ChewieItemBuilder.buildCachedImage( - context: context, - imageUrl: item.icon, - width: 40, - height: 40, - showLoading: false, - placeholderBackground: Colors.transparent, + ), ), - ), - if (item.postCollection != null) Positioned( - top: 6, - left: 6, - child: AssetUtil.load( - AssetUtil.collectionWhiteIcon, - size: 12, + top: 2, + right: 2, + child: ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: item.icon, + width: 36, + height: 36, + fit: BoxFit.contain, + showLoading: false, + placeholderBackground: Colors.transparent, ), ), - ], + if (item.postCollection != null) + Positioned( + top: design.spacing.sm, + left: design.spacing.sm, + child: const ChewieIcon( + LoftifyIcons.collection, + size: 12, + color: Colors.white, + ), + ), + ], + ), ), ), ); @@ -1135,23 +1239,27 @@ class UserDetailScreenState extends BaseDynamicState String get backgroudUrl => _fullBlogData!.blogcover.customBlogCover ?? _fullBlogData!.blogcover.url; - Widget _buildBackground({ - double blurRadius = 10, - double? height, - }) { - return Blur( - blur: blurRadius, - blurColor: Colors.black12, - child: ChewieItemBuilder.buildCachedImage( - context: context, - imageUrl: Utils.removeImageParam(backgroudUrl), - fit: BoxFit.cover, - width: MediaQuery.sizeOf(context).width * 2, - height: height ?? 600, - placeholderBackground: Theme.of(context).textTheme.labelSmall?.color, - bottomPadding: 50, - showLoading: false, - ), + Widget _buildHeaderBackground() { + return Stack( + fit: StackFit.expand, + children: [ + Blur( + blur: 10, + blurColor: Colors.transparent, + child: SizedBox.expand( + child: ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: Utils.removeImageParam(backgroudUrl), + fit: BoxFit.cover, + placeholderBackground: + Theme.of(context).colorScheme.surfaceContainerHighest, + bottomPadding: 50, + showLoading: false, + ), + ), + ), + const Opacity(opacity: 0.6, child: LoftifyCoverScrim()), + ], ); } } diff --git a/lib/Screens/Lock/pin_change_screen.dart b/lib/Screens/Lock/pin_change_screen.dart index 7bb86dad..77d1168f 100644 --- a/lib/Screens/Lock/pin_change_screen.dart +++ b/lib/Screens/Lock/pin_change_screen.dart @@ -60,54 +60,89 @@ class PinChangeScreenState extends BaseDynamicState { @override Widget build(BuildContext context) { + final mediaSize = MediaQuery.sizeOf(context); + final textScale = MediaQuery.textScalerOf(context).scale(1); + final compact = mediaSize.width < 360 || textScale > 1.35; + final gestureSize = (compact + ? min(240.0, max(0.0, mediaSize.width - 32)) + : min(mediaSize.width, 400)) + .toDouble(); return Scaffold( appBar: ResponsiveAppBar(showBack: true), - body: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 50), - Text( - _notifier.gestureText, - style: Theme.of(context).textTheme.titleMedium, + body: SafeArea( + top: false, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: compact ? 16 : 28, ), - const SizedBox(height: 30), - GestureUnlockIndicator( - key: _indicator, - size: 30, - roundSpace: 4, - defaultColor: Colors.grey.withOpacity(0.5), - selectedColor: Theme.of(context).primaryColor.withOpacity(0.6), - ), - Flexible( - child: GestureUnlockView( - key: _gestureUnlockView, - size: min(MediaQuery.sizeOf(context).width, 400), - padding: 60, - roundSpace: 40, - defaultColor: Colors.grey.withOpacity(0.5), - selectedColor: Theme.of(context).primaryColor, - failedColor: Theme.of(context).colorScheme.error, - disableColor: Colors.grey, - solidRadiusRatio: 0.3, - lineWidth: 2, - touchRadiusRatio: 0.3, - onCompleted: _gestureComplete, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: max( + 0, + constraints.maxHeight - (compact ? 32 : 56), + ), ), - ), - if (_isEditMode && _isUseBiometric) - RoundIconTextButton( - text: ResponsiveUtil.isWindows() - ? appLocalizations.biometricVerifyPin - : appLocalizations.biometric, - onPressed: () { - auth(); - }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _notifier.gestureText, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + SizedBox(height: compact ? 12 : 24), + GestureUnlockIndicator( + key: _indicator, + size: 30, + roundSpace: 4, + defaultColor: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), + selectedColor: + Theme.of(context).primaryColor.withValues(alpha: 0.6), + ), + SizedBox(height: compact ? 8 : 14), + SizedBox.square( + dimension: gestureSize, + child: GestureUnlockView( + key: _gestureUnlockView, + size: gestureSize, + padding: compact ? 44 : 60, + roundSpace: compact ? 32 : 40, + defaultColor: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), + selectedColor: Theme.of(context).primaryColor, + failedColor: Theme.of(context).colorScheme.error, + disableColor: + Theme.of(context).colorScheme.onSurfaceVariant, + solidRadiusRatio: 0.3, + lineWidth: 2, + touchRadiusRatio: 0.3, + onCompleted: _gestureComplete, + ), + ), + if (_isEditMode && _isUseBiometric) ...[ + SizedBox(height: compact ? 12 : 20), + RoundIconTextButton( + text: ResponsiveUtil.isWindows() + ? appLocalizations.biometricVerifyPin + : appLocalizations.biometric, + onPressed: () { + auth(); + }, + ), + ], + ], ), - const SizedBox(height: 50), - ], + ), + ), ), ), ); diff --git a/lib/Screens/Lock/pin_verify_screen.dart b/lib/Screens/Lock/pin_verify_screen.dart index d8e91ca2..9cd49827 100644 --- a/lib/Screens/Lock/pin_verify_screen.dart +++ b/lib/Screens/Lock/pin_verify_screen.dart @@ -39,7 +39,8 @@ class PinVerifyScreenState extends BaseWindowState late final bool _isUseBiometric = ChewieHiveUtil.getBool(HiveUtil.enableBiometricKey); late final GestureNotifier _notifier = GestureNotifier( - status: GestureStatus.verify, gestureText: appLocalizations.verifyGestureLock); + status: GestureStatus.verify, + gestureText: appLocalizations.verifyGestureLock); final GlobalKey _gestureUnlockView = GlobalKey(); @override @@ -81,6 +82,13 @@ class PinVerifyScreenState extends BaseWindowState Widget build(BuildContext context) { ChewieUtils.setSafeMode(ChewieHiveUtil.getBool(HiveUtil.enableSafeModeKey, defaultValue: defaultEnableSafeMode)); + final mediaSize = MediaQuery.sizeOf(context); + final textScale = MediaQuery.textScalerOf(context).scale(1); + final compact = mediaSize.width < 360 || textScale > 1.35; + final gestureSize = (compact + ? min(240.0, max(0.0, mediaSize.width - 32)) + : min(mediaSize.width, 400)) + .toDouble(); return Scaffold( backgroundColor: ChewieTheme.background, appBar: ResponsiveUtil.isDesktop() && widget.showWindowTitle @@ -107,47 +115,70 @@ class PinVerifyScreenState extends BaseWindowState color: ChewieTheme.background, ) : null, - body: Center( + body: SafeArea( + top: false, child: PopScope( canPop: !widget.isModal, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 50), - Text( - _notifier.gestureText, - style: Theme.of(context).textTheme.titleMedium, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: compact ? 16 : 28, ), - const SizedBox(height: 30), - Flexible( - child: GestureUnlockView( - key: _gestureUnlockView, - size: min(MediaQuery.sizeOf(context).width, 400), - padding: 60, - roundSpace: 40, - defaultColor: Colors.grey.withOpacity(0.5), - selectedColor: Theme.of(context).primaryColor, - failedColor: Colors.redAccent, - disableColor: Colors.grey, - solidRadiusRatio: 0.3, - lineWidth: 2, - touchRadiusRatio: 0.3, - onCompleted: _gestureComplete, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: max( + 0, + constraints.maxHeight - (compact ? 32 : 56), + ), ), - ), - if (_isUseBiometric) - RoundIconTextButton( - text: ResponsiveUtil.isWindows() - ? appLocalizations.biometricVerifyPin - : appLocalizations.biometric, - onPressed: () { - auth(); - }, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + _notifier.gestureText, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + SizedBox(height: compact ? 12 : 24), + SizedBox.square( + dimension: gestureSize, + child: GestureUnlockView( + key: _gestureUnlockView, + size: gestureSize, + padding: compact ? 44 : 60, + roundSpace: compact ? 32 : 40, + defaultColor: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), + selectedColor: Theme.of(context).primaryColor, + failedColor: Theme.of(context).colorScheme.error, + disableColor: + Theme.of(context).colorScheme.onSurfaceVariant, + solidRadiusRatio: 0.3, + lineWidth: 2, + touchRadiusRatio: 0.3, + onCompleted: _gestureComplete, + ), + ), + if (_isUseBiometric) ...[ + SizedBox(height: compact ? 12 : 20), + RoundIconTextButton( + text: ResponsiveUtil.isWindows() + ? appLocalizations.biometricVerifyPin + : appLocalizations.biometric, + onPressed: () { + auth(); + }, + ), + ], + ], ), - const SizedBox(height: 50), - ], + ), + ), ), ), ), diff --git a/lib/Screens/Login/login_by_captcha_screen.dart b/lib/Screens/Login/login_by_captcha_screen.dart index 77370f66..85230da5 100644 --- a/lib/Screens/Login/login_by_captcha_screen.dart +++ b/lib/Screens/Login/login_by_captcha_screen.dart @@ -14,7 +14,10 @@ import 'package:window_manager/window_manager.dart'; import '../../Models/simple_response.dart'; import '../../Utils/constant.dart'; import '../../Utils/request_util.dart'; -import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/Design/loftify_controls.dart'; +import '../../Widgets/Item/login_input_item.dart'; +import '../../Widgets/Login/loftify_login_layout.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class LoginByCaptchaScreen extends StatefulWidget { @@ -33,6 +36,10 @@ class _LoginByCaptchaScreenState extends BaseDynamicState late TextEditingController _mobileController; late TextEditingController _captchaController; late TextEditingController _captchaCodeController; + final FocusNode _mobileFocusNode = FocusNode(); + final FocusNode _photoCaptchaFocusNode = FocusNode(); + final FocusNode _captchaCodeFocusNode = FocusNode(); + Timer? _captchaTimer; dynamic _photoCaptcha; bool _isFetchingCaptchaCode = false; String _captchaText = appLocalizations.getCaptcha; @@ -48,8 +55,22 @@ class _LoginByCaptchaScreenState extends BaseDynamicState _refreshPhotoCaptcha(); } + @override + void dispose() { + _captchaTimer?.cancel(); + WindowManager.instance.removeListener(this); + _mobileController.dispose(); + _captchaController.dispose(); + _captchaCodeController.dispose(); + _mobileFocusNode.dispose(); + _photoCaptchaFocusNode.dispose(); + _captchaCodeFocusNode.dispose(); + super.dispose(); + } + void _refreshPhotoCaptcha() { LoginApi.getPhotoCaptcha().then((value) { + if (!mounted) return; setState(() { _photoCaptcha = value; }); @@ -77,7 +98,8 @@ class _LoginByCaptchaScreenState extends BaseDynamicState setState(() { _captchaText = appLocalizations.resendAfterSeconds(60); }); - Timer.periodic(const Duration(seconds: 1), (timer) { + _captchaTimer?.cancel(); + _captchaTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (timer.tick == 60) { timer.cancel(); if (mounted) { @@ -91,7 +113,8 @@ class _LoginByCaptchaScreenState extends BaseDynamicState } else { if (mounted) { setState(() { - _captchaText = appLocalizations.resendAfterSeconds(60 - timer.tick); + _captchaText = + appLocalizations.resendAfterSeconds(60 - timer.tick); }); } } @@ -102,6 +125,7 @@ class _LoginByCaptchaScreenState extends BaseDynamicState } void _login() { + FocusManager.instance.primaryFocus?.unfocus(); String mobile = _mobileController.text; String password = _captchaCodeController.text; if (mobile.isEmpty || password.isEmpty) { @@ -134,144 +158,112 @@ class _LoginByCaptchaScreenState extends BaseDynamicState return Container( color: Colors.transparent, child: Scaffold( - resizeToAvoidBottomInset: false, + resizeToAvoidBottomInset: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: ResponsiveAppBar( title: appLocalizations.loginByCaptcha, + showBack: !ResponsiveUtil.isLandscapeLayout(), + leadingIcon: LoftifyIcons.close, + onTapBack: () => Navigator.maybeOf(context)?.maybePop(), + showBorder: false, titleLeftMargin: ResponsiveUtil.isLandscapeLayout() ? 15 : 5, ), - body: Container( - margin: const EdgeInsets.symmetric(horizontal: 20), - child: Stack( - children: [ - ScrollConfiguration( - behavior: NoShadowScrollBehavior(), - child: ListView( - children: [ - const SizedBox(height: 50), - InputItem( - hint: appLocalizations.inputPhone, - textInputAction: TextInputAction.next, - controller: _mobileController, - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.clear, - ), - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.phone_android_rounded, - ), - keyboardType: TextInputType.number, - ), - InputItem( - hint: appLocalizations.inputImageCaptcha, - textInputAction: TextInputAction.next, - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.verified_outlined, - ), - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.widget, - widget: _photoCaptcha != null - ? GestureDetector( - onTap: () { - _refreshPhotoCaptcha(); - }, - child: ClipRRect( - borderRadius: const BorderRadius.all( - Radius.circular(8)), - child: Image.memory(_photoCaptcha, - width: 80, height: 40), - ), - ) - : const SizedBox(width: 80, height: 40), - ), - controller: _captchaController, - keyboardType: TextInputType.number, - ), - InputItem( - hint: appLocalizations.inputCodeCaptcha, - textInputAction: TextInputAction.next, - controller: _captchaCodeController, - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.text, - text: _captchaText, - enable: !_isFetchingCaptchaCode, - onTap: _fetchCaptchaCode, - ), - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.password_rounded, - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 30), - Container( - margin: const EdgeInsets.symmetric(horizontal: 50), - child: RoundIconTextButton( - text: appLocalizations.login, - onPressed: _login, - background: Theme.of(context).primaryColor, - color: Colors.white, - fontSizeDelta: 2, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ], - ), + body: LoftifyLoginLayout( + formChildren: [ + LoginInputItem( + hint: appLocalizations.inputPhone, + textInputAction: TextInputAction.next, + controller: _mobileController, + focusNode: _mobileFocusNode, + autofillHints: const [AutofillHints.telephoneNumber], + onSubmitted: (_) => _photoCaptchaFocusNode.requestFocus(), + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.clear, + ), + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.phone, + ), + keyboardType: TextInputType.number, + ), + LoginInputItem( + hint: appLocalizations.inputImageCaptcha, + textInputAction: TextInputAction.next, + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.verification, + ), + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.widget, + widget: _photoCaptcha != null + ? GestureDetector( + onTap: _refreshPhotoCaptcha, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + _photoCaptcha, + width: 80, + height: 40, + fit: BoxFit.cover, + ), + ), + ) + : const SizedBox(width: 80, height: 40), + ), + controller: _captchaController, + focusNode: _photoCaptchaFocusNode, + onSubmitted: (_) => _captchaCodeFocusNode.requestFocus(), + keyboardType: TextInputType.number, + ), + LoginInputItem( + hint: appLocalizations.inputCodeCaptcha, + textInputAction: TextInputAction.done, + controller: _captchaCodeController, + focusNode: _captchaCodeFocusNode, + autofillHints: const [AutofillHints.oneTimeCode], + onSubmitted: (_) => _login(), + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.text, + text: _captchaText, + enable: !_isFetchingCaptchaCode, + onTap: _fetchCaptchaCode, ), - Positioned( - left: 0, - right: 0, - bottom: 50, - child: Column( - children: [ - ItemBuilder.buildTextDivider( - context: context, - text: appLocalizations.otherLoginMethods, - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ToolButton( - context: context, - icon: Icons.password_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - LoginByPasswordScreen( - initPhone: _mobileController.text, - ), - ); - }), - const SizedBox(width: 30), - ToolButton( - context: context, - icon: Icons.card_membership_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - const LoginByLofterIDScreen(), - ); - }), - // const SizedBox(width: 30), - // ToolButton( - // context: context, - // icon: Icons.mail_outline_rounded, - // onTap: () { - // RouteUtil.pushCupertinoRoute( - // context, - // const LoginByMailScreen(), - // ); - // }), - ], - ), - ], - ), + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.verification, ), - ], + keyboardType: TextInputType.number, + ), + ], + primaryAction: LoftifyButton( + label: appLocalizations.login, + onPressed: _login, + size: LoftifyButtonSize.large, + expand: true, ), + alternativeTitle: appLocalizations.otherLoginMethods, + alternativeMethods: [ + LoftifyLoginMethod( + label: appLocalizations.loginByPassword, + icon: LoftifyIcons.password, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + LoginByPasswordScreen(initPhone: _mobileController.text), + ); + }, + ), + LoftifyLoginMethod( + label: appLocalizations.loginByLofterID, + icon: LoftifyIcons.lofterId, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + const LoginByLofterIDScreen(), + ); + }, + ), + ], ), ), ); diff --git a/lib/Screens/Login/login_by_lofterid_screen.dart b/lib/Screens/Login/login_by_lofterid_screen.dart index c9a45c06..158623a0 100644 --- a/lib/Screens/Login/login_by_lofterid_screen.dart +++ b/lib/Screens/Login/login_by_lofterid_screen.dart @@ -10,7 +10,10 @@ import '../../Models/login_lofterid_response.dart'; import '../../Utils/app_provider.dart'; import '../../Utils/constant.dart'; import '../../Utils/request_util.dart'; -import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/Design/loftify_controls.dart'; +import '../../Widgets/Item/login_input_item.dart'; +import '../../Widgets/Login/loftify_login_layout.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class LoginByLofterIDScreen extends StatefulWidget { @@ -29,6 +32,8 @@ class _LoginByLofterIDScreenState with TickerProviderStateMixin { late TextEditingController _lofterIDController; late TextEditingController _passwordController; + final FocusNode _lofterIDFocusNode = FocusNode(); + final FocusNode _passwordFocusNode = FocusNode(); @override void initState() { @@ -39,7 +44,17 @@ class _LoginByLofterIDScreenState _passwordController.text = widget.initPassword ?? defaultPassword; } + @override + void dispose() { + _lofterIDController.dispose(); + _passwordController.dispose(); + _lofterIDFocusNode.dispose(); + _passwordFocusNode.dispose(); + super.dispose(); + } + void _login() { + FocusManager.instance.primaryFocus?.unfocus(); String lofterID = _lofterIDController.text; String password = _passwordController.text; if (lofterID.isEmpty || password.isEmpty) { @@ -73,115 +88,80 @@ class _LoginByLofterIDScreenState return Container( color: Colors.transparent, child: Scaffold( - resizeToAvoidBottomInset: false, + resizeToAvoidBottomInset: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: ResponsiveAppBar( title: appLocalizations.loginByLofterID, + showBack: !ResponsiveUtil.isLandscapeLayout(), + leadingIcon: LoftifyIcons.close, + onTapBack: () => Navigator.maybeOf(context)?.maybePop(), + showBorder: false, titleLeftMargin: ResponsiveUtil.isLandscapeLayout() ? 15 : 5, ), - body: Container( - margin: const EdgeInsets.symmetric(horizontal: 20), - child: Stack( - children: [ - ScrollConfiguration( - behavior: NoShadowScrollBehavior(), - child: ListView( - children: [ - const SizedBox(height: 50), - InputItem( - hint: appLocalizations.inputLofterID, - textInputAction: TextInputAction.next, - controller: _lofterIDController, - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.card_membership_rounded, - ), - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.clear, - ), - ), - InputItem( - hint: appLocalizations.inputPassword, - textInputAction: TextInputAction.next, - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.verified_outlined, - ), - controller: _passwordController, - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.password, - ), - ), - const SizedBox(height: 30), - Container( - margin: const EdgeInsets.symmetric(horizontal: 50), - child: RoundIconTextButton( - text: appLocalizations.login, - onPressed: _login, - background: Theme.of(context).primaryColor, - color: Colors.white, - fontSizeDelta: 2, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ], - ), + body: LoftifyLoginLayout( + formChildren: [ + LoginInputItem( + hint: appLocalizations.inputLofterID, + textInputAction: TextInputAction.next, + controller: _lofterIDController, + focusNode: _lofterIDFocusNode, + autofillHints: const [AutofillHints.username], + onSubmitted: (_) => _passwordFocusNode.requestFocus(), + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.lofterId, + ), + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.clear, + ), + ), + LoginInputItem( + hint: appLocalizations.inputPassword, + textInputAction: TextInputAction.done, + focusNode: _passwordFocusNode, + autofillHints: const [AutofillHints.password], + onSubmitted: (_) => _login(), + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.password, ), - Positioned( - left: 0, - right: 0, - bottom: 50, - child: Column( - children: [ - ItemBuilder.buildTextDivider( - context: context, - text: appLocalizations.otherLoginMethods, - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ToolButton( - context: context, - icon: Icons.phone_android_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - const LoginByCaptchaScreen(), - ); - }), - const SizedBox(width: 30), - ToolButton( - context: context, - icon: Icons.password_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - LoginByPasswordScreen( - initPassword: _passwordController.text, - ), - ); - }), - // const SizedBox(width: 30), - // ToolButton( - // context: context, - // icon: Icons.mail_outline_rounded, - // onTap: () { - // RouteUtil.pushCupertinoRoute( - // context, - // LoginByMailScreen( - // initPassword: _passwordController.text, - // ), - // ); - // }), - ], - ), - ], - ), + controller: _passwordController, + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.password, ), - ], + ), + ], + primaryAction: LoftifyButton( + label: appLocalizations.login, + onPressed: _login, + size: LoftifyButtonSize.large, + expand: true, ), + alternativeTitle: appLocalizations.otherLoginMethods, + alternativeMethods: [ + LoftifyLoginMethod( + label: appLocalizations.loginByCaptcha, + icon: LoftifyIcons.phone, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + const LoginByCaptchaScreen(), + ); + }, + ), + LoftifyLoginMethod( + label: appLocalizations.loginByPassword, + icon: LoftifyIcons.password, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + LoginByPasswordScreen( + initPassword: _passwordController.text, + ), + ); + }, + ), + ], ), ), ); diff --git a/lib/Screens/Login/login_by_mail_screen.dart b/lib/Screens/Login/login_by_mail_screen.dart index 3bb58eda..55ea9972 100644 --- a/lib/Screens/Login/login_by_mail_screen.dart +++ b/lib/Screens/Login/login_by_mail_screen.dart @@ -9,7 +9,10 @@ import 'package:loftify/Utils/hive_util.dart'; import '../../Utils/app_provider.dart'; import '../../Utils/constant.dart'; import '../../Utils/request_util.dart'; -import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/Design/loftify_controls.dart'; +import '../../Widgets/Item/login_input_item.dart'; +import '../../Widgets/Login/loftify_login_layout.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; import 'login_by_lofterid_screen.dart'; @@ -28,6 +31,8 @@ class _LoginByMailScreenState extends BaseDynamicState with TickerProviderStateMixin { late TextEditingController _mailController; late TextEditingController _passwordController; + final FocusNode _mailFocusNode = FocusNode(); + final FocusNode _passwordFocusNode = FocusNode(); var mailPower = {}; @override @@ -39,7 +44,17 @@ class _LoginByMailScreenState extends BaseDynamicState _passwordController.text = widget.initPassword ?? defaultPassword; } + @override + void dispose() { + _mailController.dispose(); + _passwordController.dispose(); + _mailFocusNode.dispose(); + _passwordFocusNode.dispose(); + super.dispose(); + } + Future _login() async { + FocusManager.instance.primaryFocus?.unfocus(); try { String mail = _mailController.text; String password = _passwordController.text; @@ -64,7 +79,8 @@ class _LoginByMailScreenState extends BaseDynamicState await RequestUtil.clearCookie(); await ChewieHiveUtil.put(HiveUtil.userIdKey, resL['userId']); await ChewieHiveUtil.put(HiveUtil.tokenKey, resL['token']); - await ChewieHiveUtil.put(HiveUtil.tokenTypeKey, TokenType.lofterID.index); + await ChewieHiveUtil.put( + HiveUtil.tokenTypeKey, TokenType.lofterID.index); mainScreenState?.login(); } else if (resL['ret'] == "413" && resL['dt'] == "01") { IToast.showTop(appLocalizations.retryLoginLater); @@ -92,115 +108,92 @@ class _LoginByMailScreenState extends BaseDynamicState return Container( color: Colors.transparent, child: Scaffold( - resizeToAvoidBottomInset: false, + resizeToAvoidBottomInset: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: ResponsiveAppBar( title: appLocalizations.loginByEmail, + showBack: !ResponsiveUtil.isLandscapeLayout(), + leadingIcon: LoftifyIcons.close, + onTapBack: () => Navigator.maybeOf(context)?.maybePop(), + showBorder: false, titleLeftMargin: ResponsiveUtil.isLandscapeLayout() ? 15 : 5, ), - body: Container( - margin: const EdgeInsets.symmetric(horizontal: 20), - child: Stack( - children: [ - ScrollConfiguration( - behavior: NoShadowScrollBehavior(), - child: ListView( - children: [ - const SizedBox(height: 50), - InputItem( - hint: appLocalizations.inputEmail, - textInputAction: TextInputAction.next, - controller: _mailController, - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.mail_outline_rounded, - ), - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.clear, - ), - ), - InputItem( - hint: appLocalizations.inputPassword, - textInputAction: TextInputAction.next, - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.verified_outlined, - ), - controller: _passwordController, - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.password, - ), - ), - const SizedBox(height: 30), - Container( - margin: const EdgeInsets.symmetric(horizontal: 50), - child: RoundIconTextButton( - text: appLocalizations.login, - onPressed: _login, - background: Theme.of(context).primaryColor, - color: Colors.white, - fontSizeDelta: 2, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ], - ), + body: LoftifyLoginLayout( + formChildren: [ + LoginInputItem( + hint: appLocalizations.inputEmail, + textInputAction: TextInputAction.next, + controller: _mailController, + focusNode: _mailFocusNode, + autofillHints: const [AutofillHints.email], + onSubmitted: (_) => _passwordFocusNode.requestFocus(), + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.email, + ), + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.clear, + ), + ), + LoginInputItem( + hint: appLocalizations.inputPassword, + textInputAction: TextInputAction.done, + focusNode: _passwordFocusNode, + autofillHints: const [AutofillHints.password], + onSubmitted: (_) => _login(), + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.password, ), - Positioned( - left: 0, - right: 0, - bottom: 50, - child: Column( - children: [ - ItemBuilder.buildTextDivider( - context: context, - text: appLocalizations.otherLoginMethods, - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ToolButton( - context: context, - icon: Icons.phone_android_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - const LoginByCaptchaScreen(), - ); - }), - const SizedBox(width: 30), - ToolButton( - context: context, - icon: Icons.password_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - LoginByPasswordScreen( - initPassword: _passwordController.text, - ), - ); - }), - const SizedBox(width: 30), - ToolButton( - context: context, - icon: Icons.card_membership_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - LoginByLofterIDScreen( - initPassword: _passwordController.text, - ), - ); - }), - ], - ), - ], - ), + controller: _passwordController, + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.password, ), - ], + ), + ], + primaryAction: LoftifyButton( + label: appLocalizations.login, + onPressed: _login, + size: LoftifyButtonSize.large, + expand: true, ), + alternativeTitle: appLocalizations.otherLoginMethods, + alternativeMethods: [ + LoftifyLoginMethod( + label: appLocalizations.loginByCaptcha, + icon: LoftifyIcons.phone, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + const LoginByCaptchaScreen(), + ); + }, + ), + LoftifyLoginMethod( + label: appLocalizations.loginByPassword, + icon: LoftifyIcons.password, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + LoginByPasswordScreen( + initPassword: _passwordController.text, + ), + ); + }, + ), + LoftifyLoginMethod( + label: appLocalizations.loginByLofterID, + icon: LoftifyIcons.lofterId, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + LoginByLofterIDScreen( + initPassword: _passwordController.text, + ), + ); + }, + ), + ], ), ), ); diff --git a/lib/Screens/Login/login_by_password_screen.dart b/lib/Screens/Login/login_by_password_screen.dart index 686a7e15..50960bc2 100644 --- a/lib/Screens/Login/login_by_password_screen.dart +++ b/lib/Screens/Login/login_by_password_screen.dart @@ -10,7 +10,10 @@ import 'package:loftify/Utils/hive_util.dart'; import '../../Utils/app_provider.dart'; import '../../Utils/request_util.dart'; -import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/Design/loftify_controls.dart'; +import '../../Widgets/Item/login_input_item.dart'; +import '../../Widgets/Login/loftify_login_layout.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class LoginByPasswordScreen extends StatefulWidget { @@ -25,10 +28,13 @@ class LoginByPasswordScreen extends StatefulWidget { State createState() => _LoginByPasswordScreenState(); } -class _LoginByPasswordScreenState extends BaseDynamicState +class _LoginByPasswordScreenState + extends BaseDynamicState with TickerProviderStateMixin { late TextEditingController _mobileController; late TextEditingController _passwordController; + final FocusNode _mobileFocusNode = FocusNode(); + final FocusNode _passwordFocusNode = FocusNode(); @override void initState() { @@ -39,7 +45,17 @@ class _LoginByPasswordScreenState extends BaseDynamicState Navigator.maybeOf(context)?.maybePop(), + showBorder: false, titleLeftMargin: ResponsiveUtil.isLandscapeLayout() ? 15 : 5, ), - body: Container( - margin: const EdgeInsets.symmetric(horizontal: 20), - child: Stack( - children: [ - ScrollConfiguration( - behavior: NoShadowScrollBehavior(), - child: ListView( - children: [ - const SizedBox(height: 50), - InputItem( - hint: appLocalizations.inputPhone, - textInputAction: TextInputAction.next, - controller: _mobileController, - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.phone_android_rounded, - ), - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.clear, - ), - keyboardType: TextInputType.number, - ), - InputItem( - hint: appLocalizations.inputPassword, - textInputAction: TextInputAction.next, - leadingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.icon, - icon: Icons.verified_outlined, - ), - controller: _passwordController, - tailingConfig: InputItemLeadingTailingConfig( - type: InputItemLeadingTailingType.password, - ), - ), - const SizedBox(height: 30), - Container( - margin: const EdgeInsets.symmetric(horizontal: 50), - child: RoundIconTextButton( - text: appLocalizations.login, - background: Theme.of(context).primaryColor, - onPressed: _login, - color: Colors.white, - fontSizeDelta: 2, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ], - ), + body: LoftifyLoginLayout( + formChildren: [ + LoginInputItem( + hint: appLocalizations.inputPhone, + textInputAction: TextInputAction.next, + controller: _mobileController, + focusNode: _mobileFocusNode, + autofillHints: const [AutofillHints.telephoneNumber], + onSubmitted: (_) => _passwordFocusNode.requestFocus(), + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.phone, + ), + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.clear, + ), + keyboardType: TextInputType.number, + ), + LoginInputItem( + hint: appLocalizations.inputPassword, + textInputAction: TextInputAction.done, + focusNode: _passwordFocusNode, + autofillHints: const [AutofillHints.password], + onSubmitted: (_) => _login(), + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.password, ), - Positioned( - left: 0, - right: 0, - bottom: 50, - child: Column( - children: [ - ItemBuilder.buildTextDivider( - context: context, - text: appLocalizations.otherLoginMethods, - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ToolButton( - context: context, - icon: Icons.phone_android_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - LoginByCaptchaScreen( - initPhone: _mobileController.text), - ); - }), - const SizedBox(width: 30), - ToolButton( - context: context, - icon: Icons.card_membership_rounded, - onPressed: () { - RouteUtil.pushCupertinoRoute( - context, - LoginByLofterIDScreen( - initPassword: _passwordController.text, - ), - ); - }), - // const SizedBox(width: 30), - // ToolButton( - // context: context, - // icon: Icons.mail_outline_rounded, - // onTap: () { - // RouteUtil.pushCupertinoRoute( - // context, - // LoginByMailScreen( - // initPassword: _passwordController.text, - // ), - // ); - // }), - ], - ), - ], - ), + controller: _passwordController, + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.password, ), - ], + ), + ], + primaryAction: LoftifyButton( + label: appLocalizations.login, + onPressed: _login, + size: LoftifyButtonSize.large, + expand: true, ), + alternativeTitle: appLocalizations.otherLoginMethods, + alternativeMethods: [ + LoftifyLoginMethod( + label: appLocalizations.loginByCaptcha, + icon: LoftifyIcons.phone, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + LoginByCaptchaScreen(initPhone: _mobileController.text), + ); + }, + ), + LoftifyLoginMethod( + label: appLocalizations.loginByLofterID, + icon: LoftifyIcons.lofterId, + onPressed: () { + RouteUtil.pushCupertinoRoute( + context, + LoginByLofterIDScreen( + initPassword: _passwordController.text, + ), + ); + }, + ), + ], ), ), ); diff --git a/lib/Screens/Navigation/dynamic_screen.dart b/lib/Screens/Navigation/dynamic_screen.dart index ef8a2fdb..9cec4ac8 100644 --- a/lib/Screens/Navigation/dynamic_screen.dart +++ b/lib/Screens/Navigation/dynamic_screen.dart @@ -12,18 +12,36 @@ import 'package:loftify/Screens/Post/collection_detail_screen.dart'; import 'package:loftify/Screens/Post/grain_detail_screen.dart'; import 'package:loftify/Screens/Post/post_detail_screen.dart'; import 'package:loftify/Screens/Post/tag_detail_screen.dart'; -import 'package:loftify/Utils/asset_util.dart'; import 'package:loftify/Utils/enums.dart'; import '../../Api/tag_api.dart'; import '../../Models/grain_response.dart'; import '../../Utils/app_provider.dart'; +import '../../Utils/hive_util.dart'; +import '../../Utils/paged_data_controller.dart'; +import '../../Utils/tab_state_util.dart'; +import '../../Widgets/Dynamic/dynamic_collection_card_frame.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; import '../../Widgets/PostItem/grain_post_item_builder.dart'; +import '../../Widgets/loftify_icons.dart'; +import '../../Widgets/loftify_reaction_icon.dart'; import '../../l10n/l10n.dart'; import 'home_screen.dart'; +typedef _TimelineCursor = ({int show, int publish, int share}); + +Map _requireDynamicData(dynamic value) { + final code = value is Map ? (value['code'] as num?)?.toInt() : null; + if (code != 0) { + throw PagedDataException( + value is Map ? value['msg']?.toString() ?? '' : '', + ); + } + final data = value['data']; + return data is Map ? Map.from(data) : {}; +} + class DynamicScreen extends StatefulWidget { const DynamicScreen({ super.key, @@ -47,12 +65,23 @@ class DynamicScreenState extends BaseDynamicState @override bool get wantKeepAlive => true; late TabController _tabController; + late final LazyTabLoadState _tabLoadState; int _currentTabIndex = 0; - final List _tabLabelList = [ - appLocalizations.follow, - appLocalizations.tag, - appLocalizations.collection, - appLocalizations.grain + List get _tabLabelList { + final localizations = AppLocalizations.of(context)!; + return [ + localizations.follow, + localizations.tag, + localizations.collection, + localizations.grain, + ]; + } + + static const List _tabIdList = [ + 'follow', + 'tag', + 'collection', + 'grain', ]; int lastRefreshTime = 0; final GlobalKey _tagTabKey = GlobalKey(); @@ -70,12 +99,7 @@ class DynamicScreenState extends BaseDynamicState @override List getScrollControllers() { - return [ - _tagScrollController, - _collectionScrollController, - _grainScrollController, - _followScrollController, - ]; + return [getCurrentController()]; } @override @@ -93,6 +117,7 @@ class DynamicScreenState extends BaseDynamicState } void scrollToTopOrRefresh() { + if (appProvider.token.isEmpty) return; ScrollController controller = getCurrentController(); if (controller.hasClients && controller.offset > 30) { controller.animateTo(0, @@ -121,32 +146,28 @@ class DynamicScreenState extends BaseDynamicState return controller; } - Function getCurrentCallRefresh() { - late Function callRefresh; + VoidCallback? getCurrentCallRefresh() { switch (_currentTabIndex) { case 1: - callRefresh = - (_tagTabKey.currentState as SubscribeTagTabState).callRefresh; - break; + final state = _tagTabKey.currentState; + return state is SubscribeTagTabState ? state.callRefresh : null; case 2: - callRefresh = - (_collectionTabKey.currentState as SubscribeCollectionTabState) - .callRefresh; - break; + final state = _collectionTabKey.currentState; + return state is SubscribeCollectionTabState ? state.callRefresh : null; case 3: - callRefresh = - (_grainTabKey.currentState as SubscribeGrainTabState).callRefresh; - break; + final state = _grainTabKey.currentState; + return state is SubscribeGrainTabState ? state.callRefresh : null; case 0: - callRefresh = - (_followTabKey.currentState as FollowTabState).callRefresh; - break; + final state = _followTabKey.currentState; + return state is FollowTabState ? state.callRefresh : null; + default: + return null; } - return callRefresh; } void refresh() { - getCurrentCallRefresh()(); + if (appProvider.token.isEmpty) return; + getCurrentCallRefresh()?.call(); } void scrollToTop() { @@ -162,8 +183,22 @@ class DynamicScreenState extends BaseDynamicState ); super.initState(); initTab(); - WidgetsBinding.instance.addPostFrameCallback( - (_) => panelScreenState?.refreshScrollControllers()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + panelScreenState?.refreshScrollControllers(); + _ensureTabLoaded(_currentTabIndex); + }); + } + + @override + void dispose() { + _tabController.dispose(); + _refreshRotationController.dispose(); + _tagScrollController.dispose(); + _collectionScrollController.dispose(); + _grainScrollController.dispose(); + _followScrollController.dispose(); + super.dispose(); } @override @@ -220,7 +255,7 @@ class DynamicScreenState extends BaseDynamicState icon: RotationTransition( turns: Tween(begin: 0.0, end: 1.0) .animate(_refreshRotationController), - child: const Icon(Icons.refresh_rounded), + child: const ChewieIcon(LoftifyIcons.refresh), ), onTap: () async { refresh(); @@ -228,7 +263,7 @@ class DynamicScreenState extends BaseDynamicState ), const SizedBox(height: 10), ShadowIconButton( - icon: const Icon(Icons.arrow_upward_rounded), + icon: const ChewieIcon(LoftifyIcons.scrollTop), onTap: () { scrollToTop(); }, @@ -238,47 +273,147 @@ class DynamicScreenState extends BaseDynamicState : emptyWidget; } - initTab() { - _tabController = TabController(length: _tabLabelList.length, vsync: this); - _tabController.animation?.addListener(() { - int indexChange = - _tabController.offset.abs() > 0.8 ? _tabController.offset.round() : 0; - int index = _tabController.index + indexChange; - if (index != _currentTabIndex) { - setState(() => _currentTabIndex = index); + void initTab() { + final restored = PersistentTabState.restore( + idKey: HiveUtil.dynamicTabIdKey, + legacyIndexKey: HiveUtil.dynamicTabIndexKey, + itemIds: _tabIdList, + ); + _tabLoadState = LazyTabLoadState( + itemIds: _tabIdList, + savedId: restored.id, + ); + _currentTabIndex = _tabLoadState.currentIndex; + _tabController = TabController( + length: _tabIdList.length, + initialIndex: _currentTabIndex, + vsync: this, + ); + _tabController.addListener(() { + final offset = _tabController.offset; + final preloadIndex = _tabController.indexIsChanging + ? _tabController.index + : offset > 0.001 + ? _tabController.index + 1 + : offset < -0.001 + ? _tabController.index - 1 + : _tabController.index; + if (preloadIndex >= 0 && preloadIndex < _tabIdList.length) { + _ensureTabLoaded(preloadIndex); } + final index = + (_tabController.animation?.value ?? _tabController.index).round(); + if (index != _currentTabIndex) _setCurrentTab(index); }); } + void _setCurrentTab(int index) { + final safeIndex = TabStatePreference.restoreIndex( + index, + _tabIdList.length, + ); + final changed = safeIndex != _currentTabIndex; + if (changed) { + setState(() => _currentTabIndex = safeIndex); + _scrollToHideController.show(); + panelScreenState?.showBottomNavigationBar(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) panelScreenState?.refreshScrollControllers(); + }); + } + PersistentTabState.save( + idKey: HiveUtil.dynamicTabIdKey, + legacyIndexKey: HiveUtil.dynamicTabIndexKey, + itemIds: _tabIdList, + index: safeIndex, + ); + _ensureTabLoaded(safeIndex); + } + + void _ensureTabLoaded(int index) { + if (!_tabLoadState.selectAndShouldLoad(index)) return; + unawaited(_startTabRefreshWhenMounted(index)); + } + + Future _startTabRefreshWhenMounted(int index) async { + for (var attempt = 0; attempt < 8; attempt++) { + WidgetsBinding.instance.scheduleFrame(); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final state = switch (index) { + 0 => _followTabKey.currentState, + 1 => _tagTabKey.currentState, + 2 => _collectionTabKey.currentState, + 3 => _grainTabKey.currentState, + _ => null, + }; + final refreshReady = switch (state) { + FollowTabState state => state.refreshReady, + SubscribeTagTabState state => state.refreshReady, + SubscribeCollectionTabState state => state.refreshReady, + SubscribeGrainTabState state => state.refreshReady, + _ => false, + }; + if (state != null && refreshReady) { + switch (index) { + case 0: + (state as FollowTabState).callRefresh(); + break; + case 1: + (state as SubscribeTagTabState).callRefresh(); + break; + case 2: + (state as SubscribeCollectionTabState).callRefresh(); + break; + case 3: + (state as SubscribeGrainTabState).callRefresh(); + break; + } + return; + } + if (state != null && attempt == 7) { + await switch (state) { + FollowTabState state => state.loadInitial(), + SubscribeTagTabState state => state.loadInitial(), + SubscribeCollectionTabState state => state.loadInitial(), + SubscribeGrainTabState state => state.loadInitial(), + _ => Future.value(), + }; + return; + } + } + _tabLoadState.markLoadFailed(index); + } + PreferredSizeWidget _buildAppBar() { return ResponsiveAppBar( titleLeftMargin: 15, titleWidget: TabBar( - padding: const EdgeInsets.symmetric(horizontal: 10), controller: _tabController, - tabs: _tabLabelList - .asMap() - .entries - .map((entry) => ItemBuilder.buildAnimatedTab(context, - selected: entry.key == _currentTabIndex, text: entry.value)) - .toList(), - overlayColor: WidgetStateProperty.all(Colors.transparent), + padding: const EdgeInsets.symmetric(horizontal: 10), labelPadding: const EdgeInsets.only(right: 32), - enableFeedback: true, - dividerHeight: 0, isScrollable: true, tabAlignment: TabAlignment.start, + dividerHeight: 0, physics: const BouncingScrollPhysics(), - indicator: - UnderlinedTabIndicator(borderColor: Theme.of(context).primaryColor), - onTap: (index) { - if (_currentTabIndex == index) { - return; - } - setState(() { - _currentTabIndex = index; - }); - }, + overlayColor: WidgetStateProperty.all(Colors.transparent), + indicator: UnderlinedTabIndicator( + borderColor: Theme.of(context).primaryColor, + ), + tabs: _tabLabelList + .asMap() + .entries + .map( + (entry) => ItemBuilder.buildAnimatedTab( + context, + selected: entry.key == _currentTabIndex, + text: entry.value, + controller: _tabController, + tabIndex: entry.key, + ), + ) + .toList(), + onTap: _setCurrentTab, ), ); } @@ -300,20 +435,22 @@ class FollowTabState extends BaseDynamicState with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; - final List _postList = []; - final List _timelineBlogList = []; final EasyRefreshController _refreshController = EasyRefreshController(); - int _showOffset = 0; - int _shareOffset = 0; - int _publishOffset = 0; - bool _loading = false; + late final PagedDataController> _pagingController; late final ScrollController _scrollController = widget.scrollController ?? ScrollController(); - bool _noMore = false; - ScrollController primaryScrollController = ScrollController(); + + List get _postList => _pagingController.items; + List get _timelineBlogList => + _pagingController.metadata ?? const []; + bool get refreshReady => _refreshController.headerState != null; + + Future loadInitial() async => _fetchResult(refresh: true); callRefresh() { - if (_scrollController.offset > MediaQuery.sizeOf(context).height) { + if (_scrollController.hasClients && + _scrollController.offset > MediaQuery.sizeOf(context).height) { _scrollController .animateTo(0, duration: const Duration(milliseconds: 500), @@ -326,72 +463,74 @@ class FollowTabState extends BaseDynamicState } } - _fetchResult({bool refresh = false}) async { - if (_loading) return; - if (refresh) _noMore = false; - _loading = true; - return await RecommendApi.getTimeline( - showOffset: refresh ? 0 : _showOffset, - publishOffset: refresh ? 0 : _publishOffset, - shareOffset: refresh ? 0 : _shareOffset, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - List tmp = []; - if (value['data'] != null) { - _showOffset = value['data']['showOffset'] ?? 0; - _publishOffset = value['data']['publishOffset'] ?? 0; - _shareOffset = value['data']['shareOffset'] ?? 0; - if (value['data']['items'] != null) { - var list = value['data']['items'] as List; - list = - list.where((element) => element['postData'] != null).toList(); - tmp = list.map((e) => GrainPostItem.fromJson(e)).toList(); - if (refresh) _postList.clear(); - _postList.addAll(tmp); - } - if (value['data']['timelineBlogList'] != null) { - if (refresh) _timelineBlogList.clear(); - _timelineBlogList.addAll( - (value['data']['timelineBlogList'] as List) - .map((e) => TimelineBlog.fromJson(e)) - .toList()); - } - } - if (mounted) setState(() {}); - if (tmp.isEmpty && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load tag dynamic", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; - } - }); + Future>> + _loadPage(_TimelineCursor cursor, bool refresh) async { + final value = await RecommendApi.getTimeline( + showOffset: refresh ? 0 : cursor.show, + publishOffset: refresh ? 0 : cursor.publish, + shareOffset: refresh ? 0 : cursor.share, + ); + final data = _requireDynamicData(value); + final rawItems = data['items']; + final posts = parsePagedDataItems( + rawItems, + GrainPostItem.fromTimelineJson, + onMalformed: (error, stackTrace) => + ILogger.error('Skipped malformed timeline post', error, stackTrace), + ); + final timelineBlogs = parsePagedDataItems( + data['timelineBlogList'], + TimelineBlog.fromJson, + onMalformed: (error, stackTrace) => + ILogger.error('Skipped malformed timeline blog', error, stackTrace), + ); + return PagedDataPage( + items: posts, + nextCursor: ( + show: (data['showOffset'] as num?)?.toInt() ?? cursor.show, + publish: (data['publishOffset'] as num?)?.toInt() ?? cursor.publish, + share: (data['shareOffset'] as num?)?.toInt() ?? cursor.share, + ), + hasMore: rawItems is List && rawItems.isNotEmpty, + metadata: refresh ? timelineBlogs : null, + ); } + Future _fetchResult({bool refresh = false}) => + refresh ? _pagingController.refresh() : _pagingController.load(); + @override - initState() { + void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - primaryScrollController = PrimaryScrollController.of(context); - }); - _scrollController.addListener(() { - if (!_noMore && - _scrollController.position.pixels > - _scrollController.position.maxScrollExtent - kLoadExtentOffset) { - _fetchResult(); - } - }); + _pagingController = PagedDataController( + initialCursor: (show: 0, publish: 0, share: 0), + keyOf: (item) => item.postData.postView.id, + loader: _loadPage, + onError: (error, stackTrace) { + ILogger.error('Failed to load follow dynamics', error, stackTrace); + if (mounted) { + IToast.showTop( + error is PagedDataException && StringUtil.isNotEmpty(error.message) + ? error.message + : appLocalizations.loadFailed, + ); + } + }, + )..addListener(_handlePagingChanged); + } + + void _handlePagingChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _pagingController + ..removeListener(_handlePagingChanged) + ..dispose(); + _refreshController.dispose(); + if (widget.scrollController == null) _scrollController.dispose(); + super.dispose(); } @override @@ -490,34 +629,43 @@ class FollowTabState extends BaseDynamicState _buildEasyRefresh(ERChildBuilder builder) { return EasyRefresh.builder( - refreshOnStart: true, + refreshOnStart: false, controller: _refreshController, scrollController: _scrollController, onRefresh: () async { return await _fetchResult(refresh: true); }, - onLoad: () async { - return await _fetchResult(); - }, + onLoad: _pagingController.noMore + ? null + : () async { + return await _fetchResult(); + }, triggerAxis: Axis.vertical, childBuilder: builder, ); } _buildPostList() { - return SliverWaterfallFlow.extent( - maxCrossAxisExtent: 600, - children: List.generate( - _postList.length, - (int index) { - return ClickableWrapper( - child: GrainPostItemBuilder.buildTilePostItem( - context, - _postList[index], - isFirst: ResponsiveUtil.isLandscapeLayout() && index == 0, + return SliverWaterfallFlow( + gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 600, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = _postList[index]; + return KeyedSubtree( + key: ValueKey('dynamic-follow-${item.postData.postView.id}'), + child: ClickableWrapper( + child: GrainPostItemBuilder.buildTilePostItem( + context, + item, + isFirst: ResponsiveUtil.isLandscapeLayout() && index == 0, + ), ), ); }, + childCount: _postList.length, + addAutomaticKeepAlives: false, ), ); } @@ -539,17 +687,22 @@ class SubscribeTagTabState extends BaseDynamicState with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; - final List _subscribeList = []; - final List _recentVisitList = []; final EasyRefreshController _refreshController = EasyRefreshController(); - int _offset = 0; - bool _loading = false; + late final PagedDataController> _pagingController; late final ScrollController _scrollController = widget.scrollController ?? ScrollController(); - bool _noMore = false; + + List get _subscribeList => _pagingController.items; + List get _recentVisitList => + _pagingController.metadata ?? const []; + bool get refreshReady => _refreshController.headerState != null; + + Future loadInitial() async => _fetchResult(refresh: true); callRefresh() { - if (_scrollController.offset > MediaQuery.sizeOf(context).height) { + if (_scrollController.hasClients && + _scrollController.offset > MediaQuery.sizeOf(context).height) { _scrollController .animateTo(0, duration: const Duration(milliseconds: 500), @@ -562,82 +715,90 @@ class SubscribeTagTabState extends BaseDynamicState } } - _fetchResult({bool refresh = false}) async { - if (_loading) return; - if (refresh) _noMore = false; - _loading = true; - return await TagApi.getFullSubscribdTagList( - offset: refresh ? 0 : _offset, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - List tmp = []; - if (value['data'] != null) { - if (value['data']['offset'] != null) { - _offset = value['data']['offset']; - } - if (value['data']['favoriteTags'] != null) { - tmp = (value['data']['favoriteTags'] as List) - .map((e) => FullSubscribeTagItem.fromJson(e)) - .toList(); - if (refresh) _subscribeList.clear(); - for (var exist in _subscribeList) { - tmp.removeWhere((element) => element.name == exist.name); - } - _subscribeList.addAll(tmp); - } - if (value['data']['recentVisitTags'] != null) { - if (refresh) _recentVisitList.clear(); - _recentVisitList.addAll((value['data']['recentVisitTags'] as List) - .map((e) => FullSubscribeTagItem.fromJson(e)) - .toList()); - } - } - if (mounted) setState(() {}); - if (tmp.isEmpty && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load tag dynamic", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; - } - }); + Future>> + _loadPage(int cursor, bool refresh) async { + final value = await TagApi.getFullSubscribdTagList( + offset: refresh ? 0 : cursor, + ); + final data = _requireDynamicData(value); + final rawItems = data['favoriteTags']; + final subscribed = parsePagedDataItems( + rawItems, + FullSubscribeTagItem.fromJson, + onMalformed: (error, stackTrace) => ILogger.error( + 'Skipped malformed subscribed tag', + error, + stackTrace, + ), + ); + final recent = parsePagedDataItems( + data['recentVisitTags'], + FullSubscribeTagItem.fromJson, + onMalformed: (error, stackTrace) => ILogger.error( + 'Skipped malformed recent tag', + error, + stackTrace, + ), + ); + return PagedDataPage( + items: subscribed, + nextCursor: (data['offset'] as num?)?.toInt() ?? cursor, + hasMore: rawItems is List && rawItems.isNotEmpty, + metadata: refresh ? recent : null, + ); } + Future _fetchResult({bool refresh = false}) => + refresh ? _pagingController.refresh() : _pagingController.load(); + @override - initState() { + void initState() { super.initState(); - _scrollController.addListener(() { - if (!_noMore && - _scrollController.position.pixels > - _scrollController.position.maxScrollExtent - kLoadExtentOffset) { - _fetchResult(); - } - }); + _pagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.name, + loader: _loadPage, + onError: (error, stackTrace) { + ILogger.error('Failed to load subscribed tags', error, stackTrace); + if (mounted) { + IToast.showTop( + error is PagedDataException && StringUtil.isNotEmpty(error.message) + ? error.message + : appLocalizations.loadFailed, + ); + } + }, + )..addListener(_handlePagingChanged); + } + + void _handlePagingChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _pagingController + ..removeListener(_handlePagingChanged) + ..dispose(); + _refreshController.dispose(); + if (widget.scrollController == null) _scrollController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { super.build(context); return EasyRefresh.builder( - refreshOnStart: true, + refreshOnStart: false, controller: _refreshController, onRefresh: () async { return await _fetchResult(refresh: true); }, - onLoad: () async { - return await _fetchResult(); - }, + onLoad: _pagingController.noMore + ? null + : () async { + return await _fetchResult(); + }, triggerAxis: Axis.vertical, childBuilder: (context, physics) => CustomScrollView( controller: _scrollController, @@ -746,14 +907,23 @@ class SubscribeTagTabState extends BaseDynamicState } _buildSubscribeTagList(ScrollPhysics physics) { - return SliverWaterfallFlow.extent( - maxCrossAxisExtent: 600, - mainAxisSpacing: 12, - crossAxisSpacing: 6, - children: List.generate(_subscribeList.length, (int index) { - return ClickableWrapper( - child: _buildSubscribeTagItem(_subscribeList[index])); - }), + return SliverWaterfallFlow( + gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 600, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = _subscribeList[index]; + return KeyedSubtree( + key: ValueKey('dynamic-tag-${item.name}'), + child: ClickableWrapper(child: _buildSubscribeTagItem(item)), + ); + }, + childCount: _subscribeList.length, + addAutomaticKeepAlives: false, + ), ); } @@ -770,58 +940,55 @@ class SubscribeTagTabState extends BaseDynamicState children: [ Row( children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).iconTheme.color, - shape: BoxShape.circle, - ), - padding: const EdgeInsets.all(3), - child: AssetUtil.loadDouble( - context, - AssetUtil.tagWhiteIcon, - AssetUtil.tagLightIcon, - size: 10, - ), + ChewieIcon( + LoftifyIcons.hash, + size: 18, ), const SizedBox(width: 6), - Text( - item.name, - style: Theme.of(context).textTheme.titleLarge?.apply( - fontWeightDelta: 2, + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + Text( + item.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, ), - ), - if (StringUtil.isNotEmpty(item.tagRankName)) - const SizedBox(width: 8), - if (StringUtil.isNotEmpty(item.tagRankName)) - ItemBuilder.buildTagItem( - context, - item.tagRankName, - TagType.normal, - backgroundColor: - Theme.of(context).primaryColor.withAlpha(30), - color: ChewieColors.likeButtonColor, - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - showTagLabel: false, - jumpToTag: false, - fontSizeDelta: -1, - ), - if (item.unreadCount > 0) const SizedBox(width: 8), - if (item.unreadCount > 0) - ItemBuilder.buildTagItem( - context, - "+${item.unreadCount}", - TagType.normal, - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - color: Theme.of(context).textTheme.bodySmall?.color, - showTagLabel: false, - jumpToTag: false, - fontSizeDelta: -1, + if (StringUtil.isNotEmpty(item.tagRankName)) + ItemBuilder.buildTagItem( + context, + item.tagRankName, + TagType.normal, + backgroundColor: + Theme.of(context).primaryColor.withAlpha(30), + color: ChewieColors.likeButtonColor, + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + showTagLabel: false, + jumpToTag: false, + fontSizeDelta: -1, + ), + if (item.unreadCount > 0) + ItemBuilder.buildTagItem( + context, + "+${item.unreadCount}", + TagType.normal, + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + color: Theme.of(context).textTheme.bodySmall?.color, + showTagLabel: false, + jumpToTag: false, + fontSizeDelta: -1, + ), + ], ), - const Spacer(), - Icon( - Icons.keyboard_arrow_right_rounded, + ), + const SizedBox(width: 8), + ChewieIcon( + LoftifyIcons.next, color: Theme.of(context).textTheme.labelSmall?.color, size: 16, ), @@ -908,7 +1075,8 @@ class SubscribeTagTabState extends BaseDynamicState Text( digest, style: Theme.of(context).textTheme.labelMedium?.apply( - color: Colors.grey, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -1103,18 +1271,22 @@ class SubscribeCollectionTabState with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; - final List _subscribeList = []; - final List _guessLikeList = []; final EasyRefreshController _refreshController = EasyRefreshController(); - bool _loading = false; - int _total = 0; + late final PagedDataController> _pagingController; late final ScrollController _scrollController = widget.scrollController ?? ScrollController(); - bool _noMore = false; - bool _noMoreSubscribeItem = false; + + List get _subscribeList => _pagingController.items; + List get _guessLikeList => + _pagingController.metadata ?? const []; + bool get refreshReady => _refreshController.headerState != null; + + Future loadInitial() async => _fetchResult(refresh: true); callRefresh() { - if (_scrollController.offset > MediaQuery.sizeOf(context).height) { + if (_scrollController.hasClients && + _scrollController.offset > MediaQuery.sizeOf(context).height) { _scrollController .animateTo(0, duration: const Duration(milliseconds: 500), @@ -1128,103 +1300,105 @@ class SubscribeCollectionTabState } @override - initState() { - super.initState(); - _scrollController.addListener(() { - if (!_noMore && - _scrollController.position.pixels > - _scrollController.position.maxScrollExtent - kLoadExtentOffset) { - _fetchResult(); - } - }); + void dispose() { + _pagingController + ..removeListener(_handlePagingChanged) + ..dispose(); + _refreshController.dispose(); + if (widget.scrollController == null) _scrollController.dispose(); + super.dispose(); } - _fetchResult({bool refresh = false}) async { - if (_loading) return; - if (refresh) { - _noMore = false; - _noMoreSubscribeItem = false; - } - _loading = true; - return await CollectionApi.getSubscribdCollectionList( - offset: refresh ? 0 : _subscribeList.length, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - if (refresh) _guessLikeList.clear(); - List tmp = []; - List uniqueSubscribeList = []; - if (value['data'] != null) { - if (value['data']['total'] != null) { - _total = value['data']['subscribeCollectionCount']; - } - if (value['data']['collections'] != null) { - tmp = (value['data']['collections'] as List) - .map((e) => TimelineCollection.fromJson(e)) - .toList(); - if (refresh) _subscribeList.clear(); - for (var exist in _subscribeList) { - tmp.removeWhere( - (element) => element.collectionId == exist.collectionId); - } - _subscribeList.addAll(tmp); - } - if (tmp.isEmpty) { - _noMoreSubscribeItem = true; - } - Set seenIds = {}; - for (var item in _subscribeList) { - if (!seenIds.contains(item.collectionId)) { - seenIds.add(item.collectionId); - uniqueSubscribeList.add(item); - } - } - _subscribeList.clear(); - _subscribeList.addAll(uniqueSubscribeList); - if (_noMoreSubscribeItem && - value['data']['guessLikeList'] != null) { - List tmp = - (value['data']['guessLikeList'] as List) - .map((e) => TimelineGuessCollection.fromJson(e)) - .toList(); - tmp.removeWhere((e) => _guessLikeList - .any((element) => element.collectionId == e.collectionId)); - _guessLikeList.addAll(tmp); - } - } - if (mounted) setState(() {}); - if ((tmp.isEmpty) && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } + Future>> + _loadPage( + int cursor, + bool refresh, + ) async { + final offset = refresh ? 0 : cursor; + final value = await CollectionApi.getSubscribdCollectionList( + offset: offset, + ); + final data = _requireDynamicData(value); + final rawItems = data['collections']; + final collections = parsePagedDataItems( + rawItems, + TimelineCollection.fromJson, + onMalformed: (error, stackTrace) => ILogger.error( + 'Skipped malformed subscribed collection', + error, + stackTrace, + ), + ); + final guesses = rawItems is List && rawItems.isEmpty + ? parsePagedDataItems( + data['guessLikeList'], + TimelineGuessCollection.fromJson, + onMalformed: (error, stackTrace) => ILogger.error( + 'Skipped malformed guessed collection', + error, + stackTrace, + ), + ) + : []; + return PagedDataPage( + items: collections, + nextCursor: offset + (rawItems is List ? rawItems.length : 0), + hasMore: rawItems is List && rawItems.isNotEmpty, + metadata: guesses, + ); + } + + Future _fetchResult({bool refresh = false}) => + refresh ? _pagingController.refresh() : _pagingController.load(); + + @override + void initState() { + super.initState(); + _pagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.collectionId, + loader: _loadPage, + metadataMerger: (current, incoming, refresh) { + final merged = [ + ...?current, + ...?incoming, + ]; + final seen = {}; + merged.removeWhere((item) => !seen.add(item.collectionId)); + return merged; + }, + onError: (error, stackTrace) { + ILogger.error( + 'Failed to load subscribed collections', error, stackTrace); + if (mounted) { + IToast.showTop( + error is PagedDataException && StringUtil.isNotEmpty(error.message) + ? error.message + : appLocalizations.loadFailed, + ); } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load collection dynamic", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; - } - }); + }, + )..addListener(_handlePagingChanged); + } + + void _handlePagingChanged() { + if (mounted) setState(() {}); } @override Widget build(BuildContext context) { super.build(context); return EasyRefresh.builder( - refreshOnStart: true, + refreshOnStart: false, controller: _refreshController, onRefresh: () async { return await _fetchResult(refresh: true); }, - onLoad: () async { - return await _fetchResult(); - }, + onLoad: _pagingController.noMore + ? null + : () async { + return await _fetchResult(); + }, triggerAxis: Axis.vertical, childBuilder: (context, physics) => CustomScrollView( controller: _scrollController, @@ -1267,18 +1441,36 @@ class SubscribeCollectionTabState } _buildSubscribeCollectionList(ScrollPhysics physics) { - return SliverWaterfallFlow.extent( - maxCrossAxisExtent: 560, - children: List.generate(_subscribeList.length, (index) { - return ClickableWrapper( - child: _buildSubscribeCollectionItem(_subscribeList[index])); - }), + final width = MediaQuery.sizeOf(context).width; + final spacing = DynamicCollectionGridLayout.spacingFor(width); + return SliverPadding( + padding: DynamicCollectionGridLayout.paddingFor(width), + sliver: SliverWaterfallFlow( + gridDelegate: SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 560, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = _subscribeList[index]; + return KeyedSubtree( + key: ValueKey('dynamic-collection-${item.collectionId}'), + child: ClickableWrapper( + child: _buildSubscribeCollectionItem(item), + ), + ); + }, + childCount: _subscribeList.length, + addAutomaticKeepAlives: false, + ), + ), ); } _buildSubscribeCollectionItem(TimelineCollection item) { bool hasLastRead = item.lastReadBlogId != 0 && item.lastReadPostId != 0; - return GestureDetector( + return DynamicCollectionCardFrame( onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -1290,153 +1482,161 @@ class SubscribeCollectionTabState ), ); }, - child: Container( - height: 118, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: Colors.transparent, - child: Row( - children: [ - Stack( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).dividerColor, - width: 0.8, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: ChewieItemBuilder.buildCachedImage( - imageUrl: item.coverUrl, - context: context, - width: 100, - height: 100, - fit: BoxFit.cover, - showLoading: false, - ), - ), + cover: Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).dividerColor, + width: 0.8, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ChewieItemBuilder.buildCachedImage( + imageUrl: item.coverUrl, + context: context, + width: 100, + height: 100, + fit: BoxFit.cover, + showLoading: false, + ), + ), + ), + if (item.recentlyRead == 1) + Positioned( + top: 4, + left: 4, + child: ItemBuilder.buildTranslucentTag( + context, + text: appLocalizations.viewRecently, + fontSizeDelta: -2, + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, ), - if (item.recentlyRead == 1) - Positioned( - top: 4, - left: 4, - child: ItemBuilder.buildTranslucentTag( - context, - text: appLocalizations.viewRecently, - fontSizeDelta: -2, - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - ), - ), - ], + ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - item.name, - style: Theme.of(context) - .textTheme - .titleMedium - ?.apply(fontWeightDelta: 2), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (item.unreadCount > 0) const SizedBox(width: 3), - if (item.unreadCount > 0) - ItemBuilder.buildTagItem( - context, - appLocalizations.updateCount( - item.unreadCount > 100 ? 99 : item.unreadCount), - showTagLabel: false, - jumpToTag: false, - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 1), - TagType.normal, - fontSizeDelta: -2, - backgroundColor: - Theme.of(context).primaryColor.withAlpha(30), - color: Theme.of(context).primaryColor, - ), - ], - ), - const SizedBox(height: 3), - if (item.latestPosts != null) - Text( - item.latestPosts!.join("\n"), - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(fontSizeDelta: 1), - maxLines: 3, - ), - const SizedBox(height: 6), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(), - ItemBuilder.buildIconTextButton( - context, - text: hasLastRead - ? appLocalizations.continueRead - : appLocalizations.startRead, - color: Theme.of(context).primaryColor, - fontWeightDelta: 2, - onTap: !hasLastRead - ? null - : () { - RouteUtil.pushPanelCupertinoRoute( - context, - PostDetailScreen( - meta: { - "postId": NumberUtil.intToHex( - item.lastReadPostId), - "blogId": NumberUtil.intToHex( - item.lastReadBlogId), - "blogName": "", - }, - isArticle: false, - ), - ); - }, - ), - ], - ), - ], + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.apply(fontWeightDelta: 2), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), + if (item.unreadCount > 0) const SizedBox(width: 3), + if (item.unreadCount > 0) + ItemBuilder.buildTagItem( + context, + appLocalizations.updateCount( + item.unreadCount > 100 ? 99 : item.unreadCount), + showTagLabel: false, + jumpToTag: false, + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + TagType.normal, + fontSizeDelta: -2, + backgroundColor: Theme.of(context).primaryColor.withAlpha(30), + color: Theme.of(context).primaryColor, + ), + ], + ), + const SizedBox(height: 3), + if (item.latestPosts != null) + Text( + item.latestPosts!.join("\n"), + style: Theme.of(context) + .textTheme + .bodySmall + ?.apply(fontSizeDelta: 1), + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - ], - ), + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(), + ItemBuilder.buildIconTextButton( + context, + text: hasLastRead + ? appLocalizations.continueRead + : appLocalizations.startRead, + color: Theme.of(context).primaryColor, + fontWeightDelta: 2, + onTap: !hasLastRead + ? null + : () { + RouteUtil.pushPanelCupertinoRoute( + context, + PostDetailScreen( + meta: { + "postId": + NumberUtil.intToHex(item.lastReadPostId), + "blogId": + NumberUtil.intToHex(item.lastReadBlogId), + "blogName": "", + }, + isArticle: false, + ), + ); + }, + ), + ], + ), + ], ), ); } _buildGuessLikeCollectionList(ScrollPhysics physics) { - return SliverWaterfallFlow.extent( - maxCrossAxisExtent: 560, - children: List.generate(_guessLikeList.length, (index) { - return ClickableWrapper( - child: _buildGuessLikeCollectionItem(_guessLikeList[index])); - }), + final width = MediaQuery.sizeOf(context).width; + final spacing = DynamicCollectionGridLayout.spacingFor(width); + return SliverPadding( + padding: DynamicCollectionGridLayout.paddingFor(width), + sliver: SliverWaterfallFlow( + gridDelegate: SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 560, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = _guessLikeList[index]; + return KeyedSubtree( + key: ValueKey( + 'dynamic-guess-collection-${item.collectionId}', + ), + child: ClickableWrapper( + child: _buildGuessLikeCollectionItem(item), + ), + ); + }, + childCount: _guessLikeList.length, + addAutomaticKeepAlives: false, + ), + ), ); } _buildGuessLikeCollectionItem(TimelineGuessCollection item) { List tags = []; tags = item.tags.split(","); - return GestureDetector( + return DynamicCollectionCardFrame( onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -1448,156 +1648,141 @@ class SubscribeCollectionTabState ), ); }, - child: Container( - height: 125, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: Colors.transparent, - child: Row( - children: [ - Stack( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).dividerColor, - width: 0.8, + cover: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).dividerColor, + width: 0.8, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ChewieItemBuilder.buildCachedImage( + imageUrl: item.coverUrl, + context: context, + width: 100, + height: 100, + fit: BoxFit.cover, + showLoading: false, + ), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.apply(fontWeightDelta: 2), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Row( + children: [ + const SizedBox(width: 3), + if (StringUtil.isNotEmpty(item.reason)) + RoundIconTextButton( + text: item.reason, + padding: const EdgeInsets.symmetric( + horizontal: 3, vertical: 2), + radius: 3, + color: ChewieColors.likeButtonColor, + fontSizeDelta: -2, ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: ChewieItemBuilder.buildCachedImage( - imageUrl: item.coverUrl, - context: context, - width: 100, - height: 100, - fit: BoxFit.cover, - showLoading: false, + ], + ), + ], + ), + const SizedBox(height: 3), + Text( + item.latestPost, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + SizedBox( + height: 16, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + ...List.generate( + tags.length, + (index) => Container( + margin: const EdgeInsets.only(right: 5), + child: ItemBuilder.buildTagItem( + context, + tags[index], + TagType.normal, + showIcon: false, + fontSizeDelta: -3, + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 0, + ), ), ), ), ], ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - item.name, - style: Theme.of(context) - .textTheme - .titleMedium - ?.apply(fontWeightDelta: 2), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Row( - children: [ - const SizedBox(width: 3), - if (StringUtil.isNotEmpty(item.reason)) - RoundIconTextButton( - text: item.reason, - padding: const EdgeInsets.symmetric( - horizontal: 3, vertical: 2), - radius: 3, - color: ChewieColors.likeButtonColor, - fontSizeDelta: -2, - ), - ], - ), - ], - ), - const SizedBox(height: 3), - Text( - item.latestPost, - style: Theme.of(context).textTheme.bodySmall, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 6), - SizedBox( - height: 16, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - ...List.generate( - tags.length, - (index) => Container( - margin: const EdgeInsets.only(right: 5), - child: ItemBuilder.buildTagItem( - context, - tags[index], - TagType.normal, - showIcon: false, - fontSizeDelta: -3, - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 0, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 6), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "${item.postCount}${appLocalizations.chapter} · ${StringUtil.formatCount(item.subscribeCount)}${appLocalizations.subscribe}", - style: Theme.of(context) - .textTheme - .titleSmall - ?.apply(fontSizeDelta: -1), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const Spacer(), - ItemBuilder.buildIconTextButton( - context, - text: item.subscribed - ? appLocalizations.unsubscribe - : appLocalizations.subscribe, - icon: Icon( - item.subscribed - ? Icons.bookmark_added_rounded - : Icons.bookmark_add_outlined, - size: 15, - color: Theme.of(context).primaryColor, - ), - color: Theme.of(context).primaryColor, - fontWeightDelta: 2, - onTap: () { - HapticFeedback.mediumImpact(); - CollectionApi.subscribeOrUnSubscribe( - isSubscribe: !item.subscribed, - collectionId: item.collectionId, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? - value['meta']['msg']); - } else { - item.subscribed = !item.subscribed; - setState(() {}); - } - }); - }, - ), - ], - ), - ], + ), + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "${item.postCount}${appLocalizations.chapter} · ${StringUtil.formatCount(item.subscribeCount)}${appLocalizations.subscribe}", + style: Theme.of(context) + .textTheme + .titleSmall + ?.apply(fontSizeDelta: -1), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - ], - ), + const Spacer(), + ItemBuilder.buildIconTextButton( + context, + text: item.subscribed + ? appLocalizations.unsubscribe + : appLocalizations.subscribe, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.bookmark, + selected: item.subscribed, + size: 15, + color: item.subscribed + ? LoftifyReactionColors.bookmark + : Theme.of(context).primaryColor, + ), + color: Theme.of(context).primaryColor, + fontWeightDelta: 2, + onTap: () { + HapticFeedback.mediumImpact(); + CollectionApi.subscribeOrUnSubscribe( + isSubscribe: !item.subscribed, + collectionId: item.collectionId, + ).then((value) { + if (value['meta']['status'] != 200) { + IToast.showTop( + value['meta']['desc'] ?? value['meta']['msg']); + } else { + item.subscribed = !item.subscribed; + setState(() {}); + } + }); + }, + ), + ], + ), + ], ), ); } @@ -1619,28 +1804,30 @@ class SubscribeGrainTabState extends BaseDynamicState with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; - final List _subscribeList = []; final EasyRefreshController _refreshController = EasyRefreshController(); - int _total = 0; - bool _loading = false; + late final PagedDataController + _pagingController; late final ScrollController _scrollController = widget.scrollController ?? ScrollController(); - bool _noMore = false; + + List get _subscribeList => _pagingController.items; + bool get refreshReady => _refreshController.headerState != null; + + Future loadInitial() async => _fetchResult(refresh: true); @override - initState() { - super.initState(); - _scrollController.addListener(() { - if (!_noMore && - _scrollController.position.pixels > - _scrollController.position.maxScrollExtent - kLoadExtentOffset) { - _fetchResult(); - } - }); + void dispose() { + _pagingController + ..removeListener(_handlePagingChanged) + ..dispose(); + _refreshController.dispose(); + if (widget.scrollController == null) _scrollController.dispose(); + super.dispose(); } callRefresh() { - if (_scrollController.offset > MediaQuery.sizeOf(context).height) { + if (_scrollController.hasClients && + _scrollController.offset > MediaQuery.sizeOf(context).height) { _scrollController .animateTo(0, duration: const Duration(milliseconds: 500), @@ -1653,65 +1840,77 @@ class SubscribeGrainTabState extends BaseDynamicState } } - _fetchResult({bool refresh = false}) async { - if (_loading) return; - if (refresh) _noMore = false; - _loading = true; - return await GrainApi.listSubscribdGrainList( - offset: refresh ? 0 : _subscribeList.length, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - List tmp = []; - if (value['data'] != null) { - if (value['data']['total'] != null) { - _total = value['data']['grainCount']; - } - if (value['data']['grains'] != null) { - tmp = (value['data']['grains'] as List) - .map((e) => SubscribeGrainItem.fromJson(e)) - .toList(); - if (refresh) _subscribeList.clear(); - for (var exist in _subscribeList) { - tmp.removeWhere( - (element) => element.grain.id == exist.grain.id); - } - _subscribeList.addAll(tmp); - } - } - if (mounted) setState(() {}); - if ((tmp.isEmpty || _subscribeList.length >= _total) && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } + Future> _loadPage( + int cursor, + bool refresh, + ) async { + final offset = refresh ? 0 : cursor; + final value = await GrainApi.listSubscribdGrainList(offset: offset); + final data = _requireDynamicData(value); + final rawItems = data['grains']; + final items = parsePagedDataItems( + rawItems, + SubscribeGrainItem.fromJson, + onMalformed: (error, stackTrace) => ILogger.error( + 'Skipped malformed subscribed grain', + error, + stackTrace, + ), + ); + final totalValue = data['grainCount'] ?? data['total']; + final total = (totalValue as num?)?.toInt(); + final nextOffset = offset + (rawItems is List ? rawItems.length : 0); + return PagedDataPage( + items: items, + nextCursor: nextOffset, + hasMore: rawItems is List && + rawItems.isNotEmpty && + (total == null || nextOffset < total), + total: total, + ); + } + + Future _fetchResult({bool refresh = false}) => + refresh ? _pagingController.refresh() : _pagingController.load(); + + @override + void initState() { + super.initState(); + _pagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.grain.id, + loader: _loadPage, + onError: (error, stackTrace) { + ILogger.error('Failed to load subscribed grains', error, stackTrace); + if (mounted) { + IToast.showTop( + error is PagedDataException && StringUtil.isNotEmpty(error.message) + ? error.message + : appLocalizations.loadFailed, + ); } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load grain dynamic", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; - } - }); + }, + )..addListener(_handlePagingChanged); + } + + void _handlePagingChanged() { + if (mounted) setState(() {}); } @override Widget build(BuildContext context) { super.build(context); return EasyRefresh.builder( - refreshOnStart: true, + refreshOnStart: false, controller: _refreshController, onRefresh: () async { return await _fetchResult(refresh: true); }, - onLoad: () async { - return await _fetchResult(); - }, + onLoad: _pagingController.noMore + ? null + : () async { + return await _fetchResult(); + }, triggerAxis: Axis.vertical, childBuilder: (context, physics) => CustomScrollView( controller: _scrollController, @@ -1738,12 +1937,21 @@ class SubscribeGrainTabState extends BaseDynamicState } _buildSubscribeGrainList(ScrollPhysics physics) { - return SliverWaterfallFlow.extent( - maxCrossAxisExtent: 560, - children: List.generate(_subscribeList.length, (index) { - return ClickableWrapper( - child: _buildSubscribeGrainItem(_subscribeList[index])); - }), + return SliverWaterfallFlow( + gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 560, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = _subscribeList[index]; + return KeyedSubtree( + key: ValueKey('dynamic-grain-${item.grain.id}'), + child: ClickableWrapper(child: _buildSubscribeGrainItem(item)), + ); + }, + childCount: _subscribeList.length, + addAutomaticKeepAlives: false, + ), ); } diff --git a/lib/Screens/Navigation/home_screen.dart b/lib/Screens/Navigation/home_screen.dart index 7f23670c..d06b2efd 100644 --- a/lib/Screens/Navigation/home_screen.dart +++ b/lib/Screens/Navigation/home_screen.dart @@ -1,16 +1,25 @@ import 'dart:async'; import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/recommend_api.dart'; import 'package:loftify/Widgets/PostItem/recommend_flow_item_builder.dart'; +import 'package:provider/provider.dart'; import '../../Models/recommend_response.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../Utils/app_provider.dart'; +import '../../Utils/lottie_files.dart'; +import '../../Utils/paged_data_controller.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; +import 'search_screen.dart'; int krefreshTimeout = 300; +typedef _ExploreCursor = ({int offset, int page, int feed}); + class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, @@ -33,170 +42,349 @@ class HomeScreenState extends BaseDynamicState BottomNavgationMixin { @override bool get wantKeepAlive => true; - final List _recommendPosts = []; - bool _loading = false; int lastRefreshTime = 0; final EasyRefreshController _refreshController = EasyRefreshController(); late final ScrollController _scrollController = widget.scrollController ?? ScrollController(); - int _currentPage = 0; - int _currentOffset = 0; - int _currentFeed = 0; + final ScrollController _nestedScrollController = ScrollController(); + ScrollController? _nestedInnerScrollController; + late final PagedDataController + _pagingController; late AnimationController _refreshRotationController; final ScrollToHideController _scrollToHideController = ScrollToHideController(); - - refresh() { - _refreshController.callRefresh(); + Future refresh() async { + await _scrollHomeToTop(); + if (mounted) await _triggerRefresh(); } @override void initState() { + super.initState(); _refreshRotationController = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, ); - super.initState(); - _scrollController.addListener(() { - if (_scrollController.position.pixels > - _scrollController.position.maxScrollExtent - kLoadExtentOffset) { - _onLoad(); - } + _pagingController = PagedDataController( + initialCursor: (offset: 0, page: 0, feed: 0), + keyOf: (item) => item.postData?.postView.id ?? item.itemId, + loader: _loadExplorePage, + onError: (error, stackTrace) { + ILogger.error( + 'Failed to load explore recommendations', error, stackTrace); + if (!mounted) return; + IToast.showTop( + error is PagedDataException && StringUtil.isNotEmpty(error.message) + ? error.message + : appLocalizations.loadFailed, + ); + }, + )..addListener(_handlePagingChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) panelScreenState?.refreshScrollControllers(); }); - WidgetsBinding.instance.addPostFrameCallback( - (_) => panelScreenState?.refreshScrollControllers()); - } - - _fetchData({bool refresh = false}) async { - if (_loading) return; - _loading = true; - if (!refresh) { - _currentFeed++; - } else { - _currentFeed = 0; - _currentOffset = 0; + } + + Future> _loadExplorePage( + _ExploreCursor cursor, + bool refresh, + ) async { + final page = refresh ? 1 : cursor.page + 1; + final feed = refresh ? 0 : cursor.feed + 1; + final value = await RecommendApi.getExploreRecomend( + offset: refresh ? 0 : cursor.offset, + page: page, + feed: feed, + ); + final code = (value['code'] as num?)?.toInt(); + if (code == 4009) { + return PagedDataPage( + items: const [], + nextCursor: cursor, + hasMore: false, + ); + } + if (code != 0) { + throw PagedDataException(value['msg']?.toString() ?? ''); + } + + final data = value['data']; + if (data is! Map) { + throw const PagedDataException(''); } - _currentPage++; - return await RecommendApi.getExploreRecomend( - offset: _currentOffset, - page: _currentPage, - feed: _currentFeed, - ).then((value) { + final rawItems = data['list'] is List + ? List.from(data['list'] as List) + : const []; + final items = []; + for (final rawItem in rawItems) { try { - if (value['code'] != 0) { - if (value['code'] != 4009) { - IToast.showTop(value['msg']); - } - return IndicatorResult.fail; - } else { - _currentOffset = value['data']['offset']; - List tmp = value['data']['list']; - if (refresh) _recommendPosts.clear(); - _recommendPosts - .addAll(tmp.map((e) => PostListItem.fromJson(e)).toList()); - return IndicatorResult.success; + if (rawItem is Map) { + items.add(PostListItem.fromJson( + Map.from(rawItem), + )); } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load data", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; + } catch (error, stackTrace) { + ILogger.error('Skipped malformed explore card', error, stackTrace); } - }); + } + final nextOffset = (data['offset'] as num?)?.toInt() ?? cursor.offset; + return PagedDataPage( + items: items, + nextCursor: (offset: nextOffset, page: page, feed: feed), + hasMore: rawItems.isNotEmpty, + ); } - _onRefresh() async { - return await _fetchData(refresh: true); + void _handlePagingChanged() { + if (mounted) setState(() {}); } - _onLoad() async { - return await _fetchData(); + Future _onRefresh() => _pagingController.refresh(); + + Future _onLoad() => _pagingController.load(); + + @override + void dispose() { + _pagingController + ..removeListener(_handlePagingChanged) + ..dispose(); + _refreshController.dispose(); + _refreshRotationController.dispose(); + _nestedScrollController.dispose(); + if (widget.scrollController == null) _scrollController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { super.build(context); + final design = context.design; + final hideAppBar = context.select( + (provider) => provider.hideHomeAppBarOnScroll, + ); + final showSearchAction = context.select( + (provider) => provider.hideSearchNavigation, + ); return Scaffold( - backgroundColor: ChewieTheme.getBackground(context), - appBar: ResponsiveAppBar( - title: appLocalizations.home, - titleLeftMargin: 15, - ), - body: Stack( - children: [ - EasyRefresh( - refreshOnStart: true, - controller: _refreshController, - onRefresh: _onRefresh, - onLoad: _onLoad, - child: WaterfallFlow.builder( - controller: _scrollController, - cacheExtent: 9999, - padding: const EdgeInsets.only(top: 8, left: 8, right: 8), - gridDelegate: - const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 6, - crossAxisSpacing: 6, - maxCrossAxisExtent: 300, + backgroundColor: design.colors.page, + appBar: hideAppBar ? null : _buildHomeAppBar(showSearchAction), + body: LayoutBuilder( + builder: (context, constraints) { + final viewportWidth = constraints.maxWidth; + final centeredInset = + ((viewportWidth - design.grid.maximumContentWidth) / 2) + .clamp(0.0, double.infinity); + final pageInset = design.grid.denseFeedPagePaddingFor(viewportWidth); + final horizontalInset = centeredInset + pageInset; + final gutter = design.grid.gutterFor(viewportWidth); + + if (!hideAppBar) { + return _buildFeed( + design: design, + horizontalInset: horizontalInset, + gutter: gutter, + scrollController: _scrollController, + hideAppBar: false, + ); + } + + // Keep the toolbar in the outer scroll view, as in CloudOTP. The + // refresh header belongs to the inner list, so it opens below the + // visible toolbar while the list can scroll under the status bar. + return ExtendedNestedScrollView( + controller: _nestedScrollController, + floatHeaderSlivers: true, + onlyOneScrollInBody: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverAppBar( + key: const ValueKey('home-floating-app-bar'), + floating: true, + snap: true, + pinned: false, + toolbarHeight: 48, + titleSpacing: 15, + elevation: 0, + scrolledUnderElevation: 0, + surfaceTintColor: Colors.transparent, + backgroundColor: design.colors.page, + title: Text( + appLocalizations.home, + style: ChewieTheme.titleMedium.apply(fontWeightDelta: 2), + ), + actions: showSearchAction ? [_buildSearchAction()] : const [], ), - itemBuilder: (BuildContext context, int index) { - return RecommendFlowItemBuilder.buildWaterfallFlowPostItem( - context, - _recommendPosts[index], - showMoreButton: true, - // onShieldContent: () { - // _recommendPosts.remove(_recommendPosts[index]); - // setState(() {}); - // }, - // onShieldTag: (tag) { - // _recommendPosts.remove(_recommendPosts[index]); - // setState(() {}); - // }, - // onShieldUser: () { - // _recommendPosts.remove(_recommendPosts[index]); - // setState(() {}); - // }, + ], + body: Builder( + builder: (context) { + final innerController = PrimaryScrollController.of(context); + if (!identical(_nestedInnerScrollController, innerController)) { + _nestedInnerScrollController = innerController; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) panelScreenState?.refreshScrollControllers(); + }); + } + return _buildFeed( + design: design, + horizontalInset: horizontalInset, + gutter: gutter, + scrollController: innerController, + hideAppBar: true, ); }, - itemCount: _recommendPosts.length, ), + ); + }, + ), + ); + } + + Widget _buildFeed({ + required LoftifyDesignThemeData design, + required double horizontalInset, + required double gutter, + required ScrollController scrollController, + required bool hideAppBar, + }) { + return Stack( + children: [ + EasyRefresh.builder( + refreshOnStart: true, + controller: _refreshController, + scrollController: scrollController, + header: hideAppBar + ? LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + safeArea: false, + clamping: true, + hapticFeedback: true, + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ) + : null, + onRefresh: _onRefresh, + onLoad: _pagingController.noMore ? null : _onLoad, + childBuilder: (context, physics) => CustomScrollView( + controller: scrollController, + physics: physics, + cacheExtent: MediaQuery.sizeOf(context).height, + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + top: 8, + left: horizontalInset, + right: horizontalInset, + ), + sliver: SliverWaterfallFlow( + gridDelegate: + SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: gutter, + crossAxisSpacing: gutter, + maxCrossAxisExtent: design.grid.maximumDenseCardExtent, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final item = _pagingController.items[index]; + return KeyedSubtree( + key: ValueKey( + 'explore-${item.postData?.postView.id ?? item.itemId}', + ), + child: + RecommendFlowItemBuilder.buildWaterfallFlowPostItem( + context, + item, + showMoreButton: true, + ), + ); + }, + childCount: _pagingController.items.length, + addAutomaticKeepAlives: false, + ), + ), + ), + ], ), - Positioned( - right: ResponsiveUtil.isLandscapeLayout() ? 16 : 12, - bottom: ResponsiveUtil.isLandscapeLayout() ? 16 : 76, - child: ScrollToHide.multi( - controller: _scrollToHideController, - scrollControllers: [_scrollController], - hideDirection: Axis.vertical, - child: _buildFloatingButtons(), - ), + ), + Positioned( + right: horizontalInset, + bottom: ResponsiveUtil.isLandscapeLayout() ? design.spacing.xl : 76, + child: ScrollToHide.multi( + controller: _scrollToHideController, + scrollControllers: hideAppBar + ? [_nestedScrollController, scrollController] + : [scrollController], + hideDirection: Axis.vertical, + child: _buildFloatingButtons(), ), - ], + ), + ], + ); + } + + ResponsiveAppBar _buildHomeAppBar(bool showSearchAction) { + return ResponsiveAppBar( + title: appLocalizations.home, + titleLeftMargin: 15, + actions: showSearchAction ? [_buildSearchAction()] : const [], + landscapeActions: showSearchAction ? [_buildSearchAction()] : const [], + ); + } + + Widget _buildSearchAction() { + return ChewieIconButton( + icon: LoftifyIcons.search, + tooltip: appLocalizations.search, + onPressed: () => RouteUtil.pushPanelCupertinoRoute( + context, + const SearchScreen(showBack: true), ), ); } - void scrollToTopAndRefresh() { - int nowTime = DateTime.now().millisecondsSinceEpoch; - if (lastRefreshTime == 0 || (nowTime - lastRefreshTime) > krefreshTimeout) { - lastRefreshTime = nowTime; - if (_scrollController.offset > MediaQuery.sizeOf(context).height) { - _scrollController - .animateTo(0, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut) - .then((_) { - _refreshController.callRefresh(); - }); - } else { - _refreshController.callRefresh(); + Future _refreshFromHomeNavigation() async { + final distanceFromTop = _homeDistanceFromTop; + final shouldRefreshAfterScroll = + distanceFromTop <= MediaQuery.sizeOf(context).height / 2; + if (distanceFromTop > 1) { + await _scrollHomeToTop(); + if (!mounted || !shouldRefreshAfterScroll) { + return; } } + await _triggerRefresh(); + } + + double get _homeDistanceFromTop { + final feed = _feedScrollController; + final feedDistance = feed.hasClients && feed.offset > 0 ? feed.offset : 0.0; + final appBarDistance = appProvider.hideHomeAppBarOnScroll && + _nestedScrollController.hasClients && + !identical(feed, _nestedScrollController) + ? (_nestedScrollController.offset > 0 + ? _nestedScrollController.offset + : 0.0) + : 0.0; + return feedDistance + appBarDistance; + } + + Future _triggerRefresh() async { + final feed = _feedScrollController; + if (!mounted || !feed.hasClients || _pagingController.loading) return; + final nowTime = DateTime.now().millisecondsSinceEpoch; + if (lastRefreshTime != 0 && nowTime - lastRefreshTime <= krefreshTimeout) { + return; + } + lastRefreshTime = nowTime; + await _refreshController.callRefresh( + scrollController: feed, + jumpToEdge: !appProvider.hideHomeAppBarOnScroll, + ); } - _buildFloatingButtons() { + void scrollToTopAndRefresh() => unawaited(refresh()); + + Widget _buildFloatingButtons() { return ResponsiveUtil.isLandscapeLayout() ? Column( children: [ @@ -204,7 +392,7 @@ class HomeScreenState extends BaseDynamicState icon: RotationTransition( turns: Tween(begin: 0.0, end: 1.0) .animate(_refreshRotationController), - child: const Icon(Icons.refresh_rounded), + child: const ChewieIcon(LoftifyIcons.refresh), ), onTap: () async { refresh(); @@ -212,7 +400,7 @@ class HomeScreenState extends BaseDynamicState ), const SizedBox(height: 10), ShadowIconButton( - icon: const Icon(Icons.arrow_upward_rounded), + icon: const ChewieIcon(LoftifyIcons.scrollTop), onTap: () { scrollToTop(); }, @@ -222,26 +410,43 @@ class HomeScreenState extends BaseDynamicState : emptyWidget; } - void scrollToTop() { - _scrollController.animateTo(0, - duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); + ScrollController get _feedScrollController => + appProvider.hideHomeAppBarOnScroll + ? _nestedInnerScrollController ?? _nestedScrollController + : _scrollController; + + Future _scrollHomeToTop() async { + final feed = _feedScrollController; + if (feed.hasClients && feed.offset > 0) { + await feed.animateTo(0, + duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); + } + if (appProvider.hideHomeAppBarOnScroll && + _nestedScrollController.hasClients && + _nestedScrollController.offset > 0) { + await _nestedScrollController.animateTo(0, + duration: const Duration(milliseconds: 220), curve: Curves.easeOut); + } } + void scrollToTop() => unawaited(_scrollHomeToTop()); + void scrollToTopOrRefresh() { - if (_scrollController.offset > 30) { - scrollToTop(); - } else { - _refreshController.callRefresh(); - } + unawaited(_refreshFromHomeNavigation()); } @override List getScrollControllers() { - return [_scrollController]; + if (!appProvider.hideHomeAppBarOnScroll) return [_scrollController]; + final inner = _nestedInnerScrollController; + return [ + _nestedScrollController, + if (inner != null && !identical(inner, _nestedScrollController)) inner, + ]; } @override FutureOr onTapBottomNavigation() { - scrollToTopOrRefresh(); + return _refreshFromHomeNavigation(); } } diff --git a/lib/Screens/Navigation/mine_screen.dart b/lib/Screens/Navigation/mine_screen.dart index 0e770798..6b46395e 100644 --- a/lib/Screens/Navigation/mine_screen.dart +++ b/lib/Screens/Navigation/mine_screen.dart @@ -2,6 +2,7 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/user_api.dart'; import 'package:loftify/Models/account_response.dart'; +import 'package:loftify/Screens/Download/download_management_screen.dart'; import 'package:loftify/Screens/Info/collection_screen.dart'; import 'package:loftify/Screens/Info/favorite_folder_list_screen.dart'; import 'package:loftify/Screens/Info/grain_screen.dart'; @@ -11,7 +12,6 @@ import 'package:loftify/Screens/Info/post_screen.dart'; import 'package:loftify/Screens/Info/share_screen.dart'; import 'package:loftify/Screens/Info/user_detail_screen.dart'; import 'package:loftify/Screens/Login/login_by_captcha_screen.dart'; -import 'package:loftify/Utils/asset_util.dart'; import 'package:loftify/Utils/enums.dart'; import 'package:loftify/Utils/hive_util.dart'; import 'package:loftify/Utils/lottie_files.dart'; @@ -23,6 +23,8 @@ import '../../Utils/app_provider.dart'; import '../../Utils/cloud_control_provider.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; +import '../../Widgets/Design/loftify_lottie.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; import '../Info/following_follower_screen.dart'; import '../Info/system_notice_screen.dart'; @@ -39,10 +41,7 @@ class MineScreen extends StatefulWidget { } class _MineScreenState extends BaseDynamicState - with - TickerProviderStateMixin, - AutomaticKeepAliveClientMixin, - ScrollToHideMixin { + with AutomaticKeepAliveClientMixin, ScrollToHideMixin { @override bool get wantKeepAlive => true; FullBlogInfo? blogInfo; @@ -53,79 +52,66 @@ class _MineScreenState extends BaseDynamicState final ScrollController _scrollController = ScrollController(); - late AnimationController darkModeController; - Widget? darkModeWidget; - @override void dispose() { - darkModeController.dispose(); + _refreshController.dispose(); + _scrollController.dispose(); super.dispose(); } @override void initState() { super.initState(); - darkModeController = AnimationController(vsync: this); WidgetsBinding.instance.addPostFrameCallback((_) { - darkModeWidget = LottieUtil.load( - LottieFiles.sunLight, - size: 25, - autoForward: !ColorUtil.isDark(context), - controller: darkModeController, - ); + if (!mounted) return; panelScreenState?.refreshScrollControllers(); }); _fetchUserInfo(); - if (appProvider.token.isNotEmpty) { - _fetchFollowingOrFolllowerList(FollowingMode.following, refresh: true); - _fetchFollowingOrFolllowerList(FollowingMode.follower, refresh: true); - } } - _fetchUserInfo() async { - if (appProvider.token.isNotEmpty) { - return await UserApi.getUserInfo().then((value) async { - try { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - AccountResponse accountResponse = - AccountResponse.fromJson(value['response']); - await HiveUtil.setUserInfo(accountResponse.blogs[0].blogInfo); - setState(() { - blogInfo = accountResponse.blogs[0].blogInfo; - }); - return await UserApi.getMeInfo(blogName: blogInfo!.blogName) - .then((value) async { - try { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - setState(() { - meInfoData = MeInfoData.fromJson(value['response']); - }); - return IndicatorResult.success; - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load me info", e, t); - return IndicatorResult.fail; - } - }); - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load user info", e, t); - return IndicatorResult.fail; - } - }); + Future _fetchUserInfo() async { + if (appProvider.token.isEmpty) return IndicatorResult.success; + final token = appProvider.token; + bool isCurrent() => mounted && appProvider.token == token; + try { + final value = await UserApi.getUserInfo(); + if (!isCurrent()) return IndicatorResult.fail; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return IndicatorResult.fail; + } + final account = AccountResponse.fromJson(value['response']); + if (account.blogs.isEmpty) { + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } + final info = account.blogs.first.blogInfo; + if (info == null) { + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } + await HiveUtil.setUserInfo(info); + if (!isCurrent()) return IndicatorResult.fail; + setState(() => blogInfo = info); + _fetchFollowingOrFolllowerList(FollowingMode.following, refresh: true); + _fetchFollowingOrFolllowerList(FollowingMode.follower, refresh: true); + final details = await UserApi.getMeInfo(blogName: info.blogName); + if (!isCurrent()) return IndicatorResult.fail; + if (details['meta']['status'] != 200) { + IToast.showTop(details['meta']['desc'] ?? details['meta']['msg']); + return IndicatorResult.fail; + } + final data = MeInfoData.fromJson(details['response']); + setState(() => meInfoData = data); + return IndicatorResult.success; + } catch (error, stackTrace) { + if (isCurrent()) IToast.showTop(appLocalizations.loadFailed); + ILogger.error('Failed to load user info', error, stackTrace); + return IndicatorResult.fail; } - return IndicatorResult.success; } - _onRefresh() async { + Future _onRefresh() async { return await _fetchUserInfo(); } @@ -136,21 +122,12 @@ class _MineScreenState extends BaseDynamicState backgroundColor: appProvider.token.isNotEmpty ? Theme.of(context).scaffoldBackgroundColor : ChewieTheme.getBackground(context), - appBar: ResponsiveUtil.isLandscapeLayout() - ? appProvider.token.isNotEmpty - ? ResponsiveAppBar( - title: appLocalizations.mine, - titleLeftMargin: ResponsiveUtil.isLandscapeLayout() ? 15 : 10, - ) - : null - : PreferredSize( - preferredSize: const Size.fromHeight(56), - child: SafeArea(child: _buildAppBar())), + appBar: _buildAppBar(), body: _buildMainBody(), ); } - _buildMainBody() { + Widget _buildMainBody() { return appProvider.token.isNotEmpty ? ScreenTypeLayout.builder( breakpoints: const ScreenBreakpoints( @@ -164,22 +141,23 @@ class _MineScreenState extends BaseDynamicState : LoftifyItemBuilder.buildUnLoginMainBody(context); } - _buildMobileMainBody() { + Widget _buildMobileMainBody() { return EasyRefresh( controller: _refreshController, onRefresh: _onRefresh, child: Container( margin: const EdgeInsets.symmetric(horizontal: 10), child: ListView( - cacheExtent: 9999, + cacheExtent: MediaQuery.sizeOf(context).height, controller: _scrollController, children: [ const SizedBox(height: 10), _buildUserCard(), _buildStatsticRow(), - if (blogInfo != null) ..._buildContent(), + ..._buildContent(), // if (blogInfo != null) ..._buildMessage(), - if (blogInfo != null) ..._buildCreation(), + ..._buildCreation(), + ..._buildAccountActions(), const SizedBox(height: 20), ], ), @@ -187,7 +165,7 @@ class _MineScreenState extends BaseDynamicState ); } - _buildTabletMainBody() { + Widget _buildTabletMainBody() { return Row( children: [ Expanded( @@ -198,13 +176,14 @@ class _MineScreenState extends BaseDynamicState child: Container( margin: const EdgeInsets.symmetric(horizontal: 10), child: ListView( - cacheExtent: 9999, + cacheExtent: MediaQuery.sizeOf(context).height, children: [ const SizedBox(height: 20), _buildUserCard(), - if (blogInfo != null) ..._buildContent(), + ..._buildContent(), // if (blogInfo != null) ..._buildMessage(), - if (blogInfo != null) ..._buildCreation(), + ..._buildCreation(), + ..._buildAccountActions(), const SizedBox(height: 20), ], ), @@ -227,7 +206,7 @@ class _MineScreenState extends BaseDynamicState behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: ListView( - cacheExtent: 9999, + cacheExtent: MediaQuery.sizeOf(context).height, children: [ const SizedBox(height: 10), if (meInfoData != null) _buildFollowingCard(), @@ -243,7 +222,11 @@ class _MineScreenState extends BaseDynamicState ); } - _processResult(value, FollowingMode followingMode, {bool refresh = false}) { + IndicatorResult _processResult( + dynamic value, + FollowingMode followingMode, { + bool refresh = false, + }) { try { if (value['meta']['status'] != 200) { IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); @@ -289,21 +272,30 @@ class _MineScreenState extends BaseDynamicState } } - _fetchFollowingOrFolllowerList( + Future _fetchFollowingOrFolllowerList( FollowingMode followingMode, { bool refresh = false, }) async { - int offset = refresh ? 0 : _followingList.length; - return await HiveUtil.getUserInfo().then((blogInfo) async { - String blogName = blogInfo!.blogName; - return await UserApi.getFollowingList( - blogName: blogName, + final info = blogInfo; + if (info == null || !mounted) return IndicatorResult.fail; + final token = appProvider.token; + final offset = refresh + ? 0 + : followingMode == FollowingMode.following + ? _followingList.length + : _followerList.length; + try { + final value = await UserApi.getFollowingList( + blogName: info.blogName, offset: offset, followingMode: followingMode, - ).then((value) { - return _processResult(value, followingMode, refresh: refresh); - }); - }); + ); + if (!mounted || appProvider.token != token) return IndicatorResult.fail; + return _processResult(value, followingMode, refresh: refresh); + } catch (error, stackTrace) { + ILogger.error('Failed to load relationship list', error, stackTrace); + return IndicatorResult.fail; + } } Widget _buildFollowingCard() { @@ -315,7 +307,7 @@ class _MineScreenState extends BaseDynamicState context, title: appLocalizations .myFollowingWithCount(meInfoData!.blogInfo.attentionCount), - icon: Icons.keyboard_arrow_right_rounded, + icon: LoftifyIcons.next, onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -359,7 +351,7 @@ class _MineScreenState extends BaseDynamicState context, title: appLocalizations .myFollowerWithCount(meInfoData!.blogInfo.followerCount), - icon: Icons.keyboard_arrow_right_rounded, + icon: LoftifyIcons.next, onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -393,7 +385,7 @@ class _MineScreenState extends BaseDynamicState ); } - getAvatarBoxImage() { + String getAvatarBoxImage() { String url = ChewieHiveUtil.getString(HiveUtil.customAvatarBoxKey) ?? ""; return url.isNotEmpty ? url : blogInfo?.avatarBoxImage ?? ""; } @@ -427,62 +419,64 @@ class _MineScreenState extends BaseDynamicState size: getAvatarBoxImage().isNotEmpty ? 48 : 72, ), const SizedBox(width: 15), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - ItemBuilder.buildCopyable( - context, - toastText: appLocalizations.haveCopiedNickName, - text: blogInfo != null ? blogInfo!.blogNickName : "", - copyable: blogInfo != null, - child: Text( - blogInfo != null - ? blogInfo!.blogNickName - : appLocalizations.login, - style: Theme.of(context).textTheme.titleLarge?.apply( - fontSizeDelta: 2, - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + ItemBuilder.buildCopyable( + context, + toastText: appLocalizations.haveCopiedNickName, + text: blogInfo != null ? blogInfo!.blogNickName : "", + copyable: blogInfo != null, + child: Text( + blogInfo != null + ? blogInfo!.blogNickName + : appLocalizations.login, + style: Theme.of(context).textTheme.titleLarge?.apply( + fontSizeDelta: 2, + ), + ), ), - ), - const SizedBox(height: 5), - ItemBuilder.buildCopyable( - context, - toastText: appLocalizations.haveCopiedLofterID, - text: blogInfo != null ? blogInfo!.blogName : "", - copyable: blogInfo != null, - child: Text( - blogInfo != null - ? appLocalizations.lofterId(blogInfo!.blogName) - : appLocalizations.loginToGetPersonalizedService, + const SizedBox(height: 5), + ItemBuilder.buildCopyable( + context, + toastText: appLocalizations.haveCopiedLofterID, + text: blogInfo != null ? blogInfo!.blogName : "", + copyable: blogInfo != null, + child: Text( + blogInfo != null + ? appLocalizations.lofterId(blogInfo!.blogName) + : appLocalizations.loginToGetPersonalizedService, + style: Theme.of(context).textTheme.titleSmall?.apply( + color: + Theme.of(context).textTheme.labelSmall?.color, + fontSizeDelta: -1, + fontWeightDelta: 2, + ), + ), + ), + const SizedBox(height: 5), + Text( + meInfoData != null + ? appLocalizations.userMeta( + "${meInfoData!.blogInfo.postCount}", + "${meInfoData!.collectionCount}") + : appLocalizations.userMeta("-", "-"), style: Theme.of(context).textTheme.titleSmall?.apply( - color: - Theme.of(context).textTheme.labelSmall?.color, - fontSizeDelta: -1, + color: Theme.of(context).textTheme.bodySmall?.color, + fontSizeDelta: 0, fontWeightDelta: 2, ), ), - ), - const SizedBox(height: 5), - Text( - meInfoData != null - ? appLocalizations.userMeta( - "${meInfoData!.blogInfo.postCount}", - "${meInfoData!.collectionCount}") - : appLocalizations.userMeta("-", "-"), - style: Theme.of(context).textTheme.titleSmall?.apply( - color: Theme.of(context).textTheme.bodySmall?.color, - fontSizeDelta: 0, - fontWeightDelta: 2, - ), - ), - ], + ], + ), ), - const Spacer(), - const Icon( - Icons.keyboard_arrow_right_rounded, - color: Colors.grey, + const SizedBox(width: 8), + ChewieIcon( + LoftifyIcons.next, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ], ), @@ -491,67 +485,82 @@ class _MineScreenState extends BaseDynamicState ); } - _buildStatsticRow() { - return Container( - margin: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - ItemBuilder.buildStatisticItem( - context, - title: appLocalizations.hotCount, - count: meInfoData?.blogInfo.hot.hotCount, - onTap: () {}, - labelFontWeightDelta: 2, - countColor: Theme.of(context).textTheme.titleLarge?.color, - labelColor: Theme.of(context).textTheme.labelSmall?.color, - ), - ItemBuilder.buildStatisticItem( - context, - title: appLocalizations.follower, - count: meInfoData?.blogInfo.followerCount, - onTap: () { - if (blogInfo != null && meInfoData != null) { - RouteUtil.pushPanelCupertinoRoute( - context, - FollowingFollowerScreen( - infoMode: InfoMode.me, - followingMode: FollowingMode.follower, - blogId: blogInfo!.blogId, - blogName: blogInfo!.blogName, - total: meInfoData!.blogInfo.followerCount, - ), - ); - } - }, - countColor: Theme.of(context).textTheme.titleLarge?.color, - labelColor: Theme.of(context).textTheme.labelSmall?.color, - labelFontWeightDelta: 2, - ), - ItemBuilder.buildStatisticItem( - context, - title: appLocalizations.following, - count: meInfoData?.blogInfo.attentionCount, - countColor: Theme.of(context).textTheme.titleLarge?.color, - labelColor: Theme.of(context).textTheme.labelSmall?.color, - labelFontWeightDelta: 2, - onTap: () { - if (blogInfo != null && meInfoData != null) { - RouteUtil.pushPanelCupertinoRoute( - context, - FollowingFollowerScreen( - infoMode: InfoMode.me, - followingMode: FollowingMode.following, - blogId: blogInfo!.blogId, - blogName: blogInfo!.blogName, - total: meInfoData!.blogInfo.attentionCount, - ), - ); - } - }, - ), - ], + Widget _buildStatsticRow() { + final items = [ + ItemBuilder.buildStatisticItem( + context, + title: appLocalizations.hotCount, + count: meInfoData?.blogInfo.hot.hotCount, + onTap: () {}, + labelFontWeightDelta: 2, + countColor: Theme.of(context).textTheme.titleLarge?.color, + labelColor: Theme.of(context).textTheme.labelSmall?.color, ), + ItemBuilder.buildStatisticItem( + context, + title: appLocalizations.follower, + count: meInfoData?.blogInfo.followerCount, + onTap: () { + if (blogInfo != null && meInfoData != null) { + RouteUtil.pushPanelCupertinoRoute( + context, + FollowingFollowerScreen( + infoMode: InfoMode.me, + followingMode: FollowingMode.follower, + blogId: blogInfo!.blogId, + blogName: blogInfo!.blogName, + total: meInfoData!.blogInfo.followerCount, + ), + ); + } + }, + countColor: Theme.of(context).textTheme.titleLarge?.color, + labelColor: Theme.of(context).textTheme.labelSmall?.color, + labelFontWeightDelta: 2, + ), + ItemBuilder.buildStatisticItem( + context, + title: appLocalizations.following, + count: meInfoData?.blogInfo.attentionCount, + countColor: Theme.of(context).textTheme.titleLarge?.color, + labelColor: Theme.of(context).textTheme.labelSmall?.color, + labelFontWeightDelta: 2, + onTap: () { + if (blogInfo != null && meInfoData != null) { + RouteUtil.pushPanelCupertinoRoute( + context, + FollowingFollowerScreen( + infoMode: InfoMode.me, + followingMode: FollowingMode.following, + blogId: blogInfo!.blogId, + blogName: blogInfo!.blogName, + total: meInfoData!.blogInfo.attentionCount, + ), + ); + } + }, + ), + ]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: LayoutBuilder(builder: (context, constraints) { + final largeText = MediaQuery.textScalerOf(context).scale(14) > 19; + if (largeText && constraints.maxWidth < 420) { + return Column( + children: [ + for (final item in items) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: item, + ), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [for (final item in items) Expanded(child: item)], + ); + }), ); } @@ -559,6 +568,7 @@ class _MineScreenState extends BaseDynamicState return [ const SizedBox(height: 10), CaptionItem( + context: context, title: appLocalizations.contentCenter, children: [ EntryItem( @@ -570,7 +580,7 @@ class _MineScreenState extends BaseDynamicState LikeScreen(), ); }, - leading: Icons.favorite_border_rounded, + leading: LoftifyIcons.favorite, ), EntryItem( title: appLocalizations.myRecommends, @@ -581,7 +591,7 @@ class _MineScreenState extends BaseDynamicState ShareScreen(), ); }, - leading: Icons.thumb_up_off_alt, + leading: LoftifyIcons.recommend, ), EntryItem( title: appLocalizations.myFavorites, @@ -592,7 +602,7 @@ class _MineScreenState extends BaseDynamicState const FavoriteFolderListScreen(), ); }, - leading: Icons.bookmark_outline_rounded, + leading: LoftifyIcons.bookmark, ), EntryItem( title: appLocalizations.myHistory, @@ -603,8 +613,18 @@ class _MineScreenState extends BaseDynamicState const HistoryScreen(), ); }, - roundBottom: true, - leading: Icons.history_rounded, + leading: LoftifyIcons.history, + ), + EntryItem( + title: appLocalizations.downloadManagement, + showLeading: true, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + const DownloadManagementScreen(), + ); + }, + leading: LoftifyIcons.download, ), ], ), @@ -613,8 +633,8 @@ class _MineScreenState extends BaseDynamicState List _buildCreation() { return [ - const SizedBox(height: 10), CaptionItem( + context: context, title: appLocalizations.myCreative, children: [ EntryItem( @@ -626,7 +646,7 @@ class _MineScreenState extends BaseDynamicState PostScreen(), ); }, - leading: Icons.article_outlined, + leading: LoftifyIcons.article, ), EntryItem( title: appLocalizations.myCollections, @@ -637,123 +657,209 @@ class _MineScreenState extends BaseDynamicState CollectionScreen(), ); }, - leading: Icons.bookmarks_outlined, + leading: LoftifyIcons.collection, ), EntryItem( title: appLocalizations.myGrains, showLeading: true, - roundBottom: true, onTap: () { RouteUtil.pushPanelCupertinoRoute( context, GrainScreen(), ); }, - leading: Icons.grain_rounded, + leading: LoftifyIcons.grain, ), ], ), ]; } - changeMode() { + List _buildAccountActions() { + return [ + CaptionItem( + context: context, + title: appLocalizations.other, + children: [ + EntryItem( + title: appLocalizations.logout, + showLeading: true, + showTrailing: false, + leading: LoftifyIcons.logout, + titleColor: Theme.of(context).colorScheme.error, + onTap: () => HiveUtil.confirmLogout(context), + ), + ], + ), + ]; + } + + void changeMode() { if (ColorUtil.isDark(context)) { appProvider.themeMode = ActiveThemeMode.light; - darkModeController.forward(); } else { appProvider.themeMode = ActiveThemeMode.dark; - darkModeController.reverse(); } } PreferredSizeWidget _buildAppBar() { - return ResponsiveAppBar( - backgroundColor: Colors.transparent, - actions: [ - if (appProvider.token.isNotEmpty) - CircleIconButton( - icon: Icon( - Icons.exit_to_app_rounded, - size: 23, - color: Theme.of(context).iconTheme.color, - ), - onTap: () { - HiveUtil.confirmLogout(context); - }, - ), - const SizedBox(width: 5), - ItemBuilder.buildDynamicIconButton( - context: context, - icon: darkModeWidget, - onTap: changeMode, - onChangemode: (context, themeMode, child) { - if (darkModeController.duration != null) { - if (themeMode == ActiveThemeMode.light) { - darkModeController.forward(); - } else if (themeMode == ActiveThemeMode.dark) { - darkModeController.reverse(); - } else { - if (ColorUtil.isDark(context)) { - darkModeController.reverse(); - } else { - darkModeController.forward(); - } - } - } - }), - const SizedBox(width: 5), - Consumer( - builder: (_, cloudControlProvider, __) => - cloudControlProvider.globalControl.showDress - ? Row( - children: [ - CircleIconButton( - icon: AssetUtil.loadDouble( - context, - AssetUtil.dressLightIcon, - AssetUtil.dressDarkIcon, - ), - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - const SuitScreen(), - ); - }), - const SizedBox(width: 5), - ], - ) - : emptyWidget, + final actions = [ + Selector( + selector: (_, provider) => provider.themeMode, + builder: (context, themeMode, _) => _MineThemeModeButton( + isDark: themeMode == ActiveThemeMode.dark || + (themeMode == ActiveThemeMode.system && + MediaQuery.platformBrightnessOf(context) == Brightness.dark), + tooltip: appLocalizations.themeMode, + onPressed: changeMode, ), - CircleIconButton( - icon: Icon( - Icons.notifications_on_outlined, - size: 23, - color: Theme.of(context).iconTheme.color, - ), - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - const SystemNoticeScreen(), - ); - }, + ), + const SizedBox(width: 5), + Consumer( + builder: (_, cloudControlProvider, __) => + cloudControlProvider.globalControl.showDress + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + _mineAppBarIconButton( + icon: LoftifyIcons.dress, + tooltip: appLocalizations.dress, + onPressed: () => RouteUtil.pushPanelCupertinoRoute( + context, + const SuitScreen(), + ), + ), + const SizedBox(width: 5), + ], + ) + : emptyWidget, + ), + _mineAppBarIconButton( + icon: LoftifyIcons.notifications, + tooltip: appLocalizations.notice, + onPressed: () => RouteUtil.pushPanelCupertinoRoute( + context, + const SystemNoticeScreen(), ), - const SizedBox(width: 5), - ItemBuilder.buildDynamicIconButton( - context: context, - icon: AssetUtil.loadDouble( - context, - AssetUtil.settingLightIcon, - AssetUtil.settingDarkIcon, - ), - onTap: () { - RouteUtil.pushPanelCupertinoRoute(context, const SettingScreen()); - }), - ], + ), + const SizedBox(width: 5), + _mineAppBarIconButton( + icon: LoftifyIcons.settings, + tooltip: appLocalizations.setting, + onPressed: () => RouteUtil.pushPanelCupertinoRoute( + context, + const SettingScreen(), + ), + ), + ]; + return ResponsiveAppBar( + titleWidget: const SizedBox.shrink(), + actions: actions, + landscapeActions: actions, ); } + Widget _mineAppBarIconButton({ + required IconData icon, + required String tooltip, + required VoidCallback onPressed, + }) => + ChewieIconButtonVisualScope( + visualSize: ChewieIconButtonVisualScope.appBarVisualSize, + maximumIconSize: 22, + child: ChewieIconButton( + icon: icon, + foregroundColor: Theme.of(context).colorScheme.onSurface, + tooltip: tooltip, + onPressed: onPressed, + ), + ); + @override List getScrollControllers() { return [_scrollController]; } } + +/// Keeps the theme Lottie mounted while the app theme and its colors change. +class _MineThemeModeButton extends StatefulWidget { + const _MineThemeModeButton({ + required this.isDark, + required this.tooltip, + required this.onPressed, + }); + + final bool isDark; + final String tooltip; + final VoidCallback onPressed; + + @override + State<_MineThemeModeButton> createState() => _MineThemeModeButtonState(); +} + +class _MineThemeModeButtonState extends State<_MineThemeModeButton> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController(vsync: this); + bool _loaded = false; + bool? _tapTargetIsDark; + + void _animateTo(bool isDark) { + if (!_loaded) return; + if (LoftifyLottie.shouldReduceMotion(context)) { + _controller.value = isDark ? 0 : 1; + } else if (isDark) { + _controller.reverse(); + } else { + _controller.forward(); + } + } + + @override + void didUpdateWidget(covariant _MineThemeModeButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isDark != widget.isDark) { + if (_tapTargetIsDark != widget.isDark) { + _animateTo(widget.isDark); + } + _tapTargetIsDark = null; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => IconButton( + tooltip: widget.tooltip, + onPressed: () { + // Start the Lottie before the app-wide theme rebuilds. The selector + // still handles theme changes initiated outside this button. + _tapTargetIsDark = !widget.isDark; + _animateTo(_tapTargetIsDark!); + widget.onPressed(); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 44, height: 44), + style: const ButtonStyle( + shape: WidgetStatePropertyAll(CircleBorder()), + ), + icon: SizedBox.square( + dimension: ChewieIconButtonVisualScope.appBarVisualSize, + child: Center( + child: LottieFiles.buildAnimation( + LottieFiles.sunLight, + size: 25, + controller: _controller, + onLoaded: () { + if (_loaded) return; + _loaded = true; + _controller.value = widget.isDark ? 0 : 1; + }, + ), + ), + ), + ); +} diff --git a/lib/Screens/Navigation/search_screen.dart b/lib/Screens/Navigation/search_screen.dart index d561ab8d..f0fcd428 100644 --- a/lib/Screens/Navigation/search_screen.dart +++ b/lib/Screens/Navigation/search_screen.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math'; import 'package:awesome_chewie/awesome_chewie.dart'; -import 'package:card_swiper/card_swiper.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/search_api.dart'; import 'package:loftify/Models/search_response.dart'; @@ -17,14 +16,18 @@ import 'package:loftify/Utils/enums.dart'; import 'package:provider/provider.dart'; import '../../Utils/hive_util.dart'; +import '../../Utils/tab_state_util.dart'; import '../../Utils/uri_util.dart'; import '../../Utils/utils.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class SearchScreen extends StatefulWidget { - const SearchScreen({super.key}); + const SearchScreen({super.key, this.showBack = false}); + + final bool showBack; static const String routeName = "/search"; @@ -45,19 +48,27 @@ class SearchScreenState extends BaseDynamicState List _rankList = []; List _configList = []; List _sugList = []; - late TabController _tabController; - final SwiperController _swiperController = SwiperController(); + int _suggestRequest = 0; + String _suggestQuery = ''; + int _guessRequest = 0; + int _rankRequest = 0; + TabController? _tabController; + PageController? _rankPageController; final ScrollController _scrollController = ScrollController(); + final ScrollController _suggestScrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final List _tabLabelList = []; + final List _tabIdList = []; int _currentTabIndex = 0; final FocusNode _focusNode = FocusNode(); bool get hasSearchFocus => _focusNode.hasFocus; + void focusSearch() => _focusSearch(); + @override FutureOr onTapBottomNavigation() { - FocusScope.of(context).requestFocus(_focusNode); + _focusSearch(); } @override @@ -66,57 +77,112 @@ class SearchScreenState extends BaseDynamicState fetchGuessList(); fetchRankList(); _searchController.addListener(() { - if (_searchController.text.isEmpty) { - _sugList.clear(); - if (mounted) setState(() {}); - } else { - _performSuggest(_searchController.text); - } + final query = _searchController.text; + // Selection and composing changes must not repeat the same request. + if (query == _suggestQuery) return; + _suggestQuery = query; + final request = ++_suggestRequest; + _setSuggestions([]); + if (query.isNotEmpty) _performSuggest(query, request); }); if (ResponsiveUtil.isDesktop()) { Future.delayed(const Duration(milliseconds: 200), () { - FocusScope.of(context).requestFocus(_focusNode); + if (mounted) FocusScope.of(context).requestFocus(_focusNode); }); } WidgetsBinding.instance.addPostFrameCallback( - (_) => panelScreenState?.refreshScrollControllers()); + (_) => panelScreenState?.refreshScrollControllers()); } - initTab() { + void initTab() { _tabLabelList.clear(); + _tabIdList.clear(); for (var e in _rankList) { _tabLabelList.add(e.listName); + _tabIdList.add('rank:${e.type}:${e.sortNo}'); } - _tabController = TabController(length: _tabLabelList.length, vsync: this); - _tabController.animation?.addListener(() { - int indexChange = - _tabController.offset.abs() > 0.8 ? _tabController.offset.round() : 0; - int index = _tabController.index + indexChange; - if (index != _currentTabIndex) { - setState(() => _currentTabIndex = index); - } + _tabController?.dispose(); + _currentTabIndex = PersistentTabState.restore( + idKey: HiveUtil.searchRankTabIdKey, + legacyIndexKey: HiveUtil.searchRankTabIndexKey, + itemIds: _tabIdList, + ).index; + final oldPageController = _rankPageController; + _rankPageController = PageController(initialPage: _currentTabIndex); + if (oldPageController != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + oldPageController.dispose(); + }); + } + _tabController = TabController( + length: _tabLabelList.length, + initialIndex: _currentTabIndex, + vsync: this, + ); + } + + void _setCurrentTab(int index) { + final safeIndex = TabStatePreference.restoreIndex( + index, + _tabLabelList.length, + ); + if (safeIndex == _currentTabIndex) return; + setState(() => _currentTabIndex = safeIndex); + PersistentTabState.save( + idKey: HiveUtil.searchRankTabIdKey, + legacyIndexKey: HiveUtil.searchRankTabIndexKey, + itemIds: _tabIdList, + index: safeIndex, + ); + } + + @override + void dispose() { + _tabController?.dispose(); + _rankPageController?.dispose(); + _scrollController.dispose(); + _suggestScrollController.dispose(); + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _focusSearch() { + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) FocusScope.of(context).requestFocus(_focusNode); }); } - fetchGuessList() { - SearchApi.getGuessList().then((value) { + Future fetchGuessList() async { + final request = ++_guessRequest; + bool isCurrent() => mounted && request == _guessRequest; + try { + final value = await SearchApi.getGuessList(); + if (!isCurrent()) return; if (value['code'] != 0) { - IToast.showTop(value['msg']); + IToast.showTop(value['msg'] ?? appLocalizations.loadFailed); } else { if (value['data']['guessKeywords'] != null) { _guessList = (value['data']['guessKeywords'] as List) .map((e) => GuessKeyword.fromJson(e)) .toList(); } - if (mounted) setState(() {}); + setState(() {}); } - }); + } catch (_) { + if (isCurrent()) IToast.showTop(appLocalizations.loadFailed); + } } - fetchRankList() { - SearchApi.getRankList().then((value) { + Future fetchRankList() async { + final request = ++_rankRequest; + bool isCurrent() => mounted && request == _rankRequest; + try { + final value = await SearchApi.getRankList(); + if (!isCurrent()) return; if (value['code'] != 0) { - IToast.showTop(value['msg']); + IToast.showTop(value['msg'] ?? appLocalizations.loadFailed); } else { if (value['data']['rankList'] != null) { _rankList = (value['data']['rankList'] as List) @@ -129,9 +195,11 @@ class SearchScreenState extends BaseDynamicState .map((e) => ConfigListItem.fromJson(e)) .toList(); } - if (mounted) setState(() {}); + setState(() {}); } - }); + } catch (_) { + if (isCurrent()) IToast.showTop(appLocalizations.loadFailed); + } } @override @@ -140,6 +208,7 @@ class SearchScreenState extends BaseDynamicState return Scaffold( backgroundColor: ChewieTheme.getBackground(context), appBar: ResponsiveAppBar( + showBack: widget.showBack, titleWidget: _buildSearchBar(), titleLeftMargin: 0, rightSpacing: 0, @@ -158,6 +227,7 @@ class SearchScreenState extends BaseDynamicState if (str.isEmpty) return; Utils.addSearchHistory(str); bool processed = await UriUtil.processUrl(context, str, quiet: true); + if (!mounted) return; if (!processed) { RouteUtil.pushPanelCupertinoRoute( context, SearchResultScreen(searchKey: str)); @@ -169,31 +239,48 @@ class SearchScreenState extends BaseDynamicState RouteUtil.pushPanelCupertinoRoute(context, TagDetailScreen(tag: tag)); } - _performSuggest(String str) { - SearchApi.getSuggestList(key: str).then((value) { + Future _performSuggest(String str, int request) async { + bool isCurrent() => mounted && request == _suggestRequest; + try { + final value = await SearchApi.getSuggestList(key: str); + if (!isCurrent()) return; if (value['code'] != 0) { - IToast.showTop(value['msg']); + IToast.showTop(value['msg'] ?? appLocalizations.loadFailed); } else { - if (value['data']['items'] != null && - _searchController.text.isNotEmpty) { - _sugList = (value['data']['items'] as List) - .map((e) => SearchSuggestItem.fromJson(e)) - .toList(); - } - if (mounted) setState(() {}); + final items = (value['data']?['items'] as List? ?? []) + .map((e) => SearchSuggestItem.fromJson(e)) + .toList(); + _setSuggestions(items); } - }); + } catch (_) { + if (isCurrent()) IToast.showTop(appLocalizations.loadFailed); + } + } + + void _setSuggestions(List items) { + final visibilityChanged = _sugList.isNotEmpty != items.isNotEmpty; + setState(() => _sugList = items); + if (visibilityChanged) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) panelScreenState?.refreshScrollControllers(); + }); + } } _buildSuggestList() { return Container( color: ChewieTheme.getBackground(context), - padding: const EdgeInsets.only(top: 8), child: ListView.builder( + controller: _suggestScrollController, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), itemCount: _sugList.length, itemBuilder: (context, index) { - return ClickableWrapper(child: - _buildSuggestItem(index, _sugList[index])); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClickableWrapper( + child: _buildSuggestItem(index, _sugList[index]), + ), + ); }, ), ); @@ -202,41 +289,49 @@ class SearchScreenState extends BaseDynamicState _buildSuggestItem(int index, SearchSuggestItem item) { switch (item.type) { case 0: - return LoftifyItemBuilder.buildRankTagRow(context, item.tagInfo!, - onTap: () { - _jumpToTag(item.tagInfo!.tagName); - }); + return LoftifyItemBuilder.buildSearchSuggestionSurface( + context, + LoftifyItemBuilder.buildRankTagRow(context, item.tagInfo!, onTap: () { + _jumpToTag(item.tagInfo!.tagName); + }), + ); case 1: - return LoftifyItemBuilder.buildTagRow(context, item.tagInfo!, - onTap: () { - _performSearch(item.tagInfo!.tagName); - }); + return LoftifyItemBuilder.buildSearchSuggestionSurface( + context, + LoftifyItemBuilder.buildTagRow( + context, + item.tagInfo!, + horizontalPadding: 12, + onTap: () => _performSearch(item.tagInfo!.tagName), + ), + ); case 2: return LoftifyItemBuilder.buildUserRow(context, item.blogData!, onTap: () { - Utils.addSearchHistory(_searchController.text); - RouteUtil.pushPanelCupertinoRoute( - context, - UserDetailScreen( - blogId: item.blogData!.blogInfo.blogId, - blogName: item.blogData!.blogInfo.blogName, - ), - ); - }); + Utils.addSearchHistory(_searchController.text); + RouteUtil.pushPanelCupertinoRoute( + context, + UserDetailScreen( + blogId: item.blogData!.blogInfo.blogId, + blogName: item.blogData!.blogInfo.blogName, + ), + ); + }); default: return emptyWidget; } } _buildMainBody() { - bool showSearchHistory = - ChewieHiveUtil.getBool(HiveUtil.showSearchHistoryKey, defaultValue: false); - bool showSearchGuess = - ChewieHiveUtil.getBool(HiveUtil.showSearchGuessKey, defaultValue: false); - bool showSearchConfig = - ChewieHiveUtil.getBool(HiveUtil.showSearchConfigKey, defaultValue: true); + bool showSearchHistory = ChewieHiveUtil.getBool( + HiveUtil.showSearchHistoryKey, + defaultValue: false); + bool showSearchGuess = ChewieHiveUtil.getBool(HiveUtil.showSearchGuessKey, + defaultValue: false); + bool showSearchConfig = ChewieHiveUtil.getBool(HiveUtil.showSearchConfigKey, + defaultValue: true); bool showSearchRank = - ChewieHiveUtil.getBool(HiveUtil.showSearchRankKey, defaultValue: false); + ChewieHiveUtil.getBool(HiveUtil.showSearchRankKey, defaultValue: false); return CustomScrollView( controller: _scrollController, slivers: [ @@ -248,41 +343,42 @@ class SearchScreenState extends BaseDynamicState if (showSearchHistory) Selector>( selector: (context, globalProvider) => - globalProvider.searchHistoryList, + globalProvider.searchHistoryList, builder: (context, searchHistoryList, child) => - searchHistoryList.isNotEmpty - ? ItemBuilder.buildTitle( - context, - title: appLocalizations.searchRecently, - icon: Icons.delete_outline_rounded, - onTap: () { - DialogBuilder.showConfirmDialog( - context, - title: appLocalizations.clearSearchHistory, - message: appLocalizations.clearSearchHistoryMessage, - onTapConfirm: () { - appProvider.searchHistoryList = []; - }, - ); - }, - ) - : emptyWidget, + searchHistoryList.isNotEmpty + ? ItemBuilder.buildTitle( + context, + title: appLocalizations.searchRecently, + icon: LoftifyIcons.delete, + onTap: () { + DialogBuilder.showConfirmDialog( + context, + title: appLocalizations.clearSearchHistory, + message: appLocalizations + .clearSearchHistoryMessage, + onTapConfirm: () { + appProvider.searchHistoryList = []; + }, + ); + }, + ) + : emptyWidget, ), if (showSearchHistory) Selector>( selector: (context, globalProvider) => - globalProvider.searchHistoryList, + globalProvider.searchHistoryList, builder: (context, searchHistoryList, child) => ItemBuilder.buildWrapTagList(context, searchHistoryList, onTap: (str) { - _performSearch(str); - }), + _performSearch(str); + }), ), if (showSearchGuess && _guessList.isNotEmpty) ItemBuilder.buildTitle( context, title: appLocalizations.guessYouSearch, - icon: Icons.refresh_rounded, + icon: LoftifyIcons.refresh, onTap: () { fetchGuessList(); }, @@ -291,8 +387,8 @@ class SearchScreenState extends BaseDynamicState ItemBuilder.buildWrapTagList( context, _guessList.map((e) => e.keyword).toList(), onTap: (str) { - _performSearch(str); - }), + _performSearch(str); + }), if (showSearchConfig && _configList.isNotEmpty) _buildConfigList(), ], @@ -300,32 +396,35 @@ class SearchScreenState extends BaseDynamicState ), if (showSearchRank && _tabLabelList.isNotEmpty) SliverPersistentHeader( - key: ValueKey(StringUtil.getRandomString()), + key: const ValueKey('search-rank-tabs'), pinned: true, delegate: SliverAppBarDelegate( radius: 0, background: ChewieTheme.getBackground(context), tabBar: TabBarWrapper( - tabController: _tabController, + tabController: _tabController!, tabs: _tabLabelList .asMap() .entries .map( - (entry) => - ItemBuilder.buildAnimatedTab( + (entry) => ItemBuilder.buildAnimatedTab( context, selected: entry.key == _currentTabIndex, text: entry.value, + controller: _tabController, + tabIndex: entry.key, fontSizeDelta: -2, normalUserBold: true, ), - ) + ) .toList(), onTap: (index) { - setState(() { - _currentTabIndex = index; - }); - _swiperController.move(index); + _setCurrentTab(index); + _rankPageController?.animateToPage( + index, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); }, ), ), @@ -339,47 +438,52 @@ class SearchScreenState extends BaseDynamicState ); } + double get _rankRowHeight => + max(32, MediaQuery.textScalerOf(context).scale(24)); + + List _visibleRankItems(RankListItem item) => item.hotLists + .where((entry) => entry.pv != 0 || entry.score != null) + .toList(); + _buildRankList() { + final count = _rankList.fold( + 0, (count, item) => max(count, _visibleRankItems(item).length)); return SizedBox( - height: 945, - child: Swiper( - controller: _swiperController, - loop: false, - control: null, - viewportFraction: 0.93, + height: count * (_rankRowHeight + 16) + 32, + child: PageView.builder( + controller: _rankPageController, scrollDirection: Axis.horizontal, itemCount: _rankList.length, itemBuilder: (context, index) { - return _buildRankListItem(index, _rankList[index]); + return _buildRankListItem(_rankList[index]); }, - onIndexChanged: (index) { - _tabController.animateTo(index); + onPageChanged: (index) { + _setCurrentTab(index); + _tabController?.animateTo(index); }, ), ); } - Widget _buildRankListItem(int rankIndex, RankListItem item) { - item.hotLists.removeWhere((e) => e.pv == 0 && e.score == null); + Widget _buildRankListItem(RankListItem item) { + final hotLists = _visibleRankItems(item); return Container( - margin: - EdgeInsets.only(right: rankIndex == _rankList.length - 1 ? 0 : 16), - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - color: Theme - .of(context) - .cardColor - .withAlpha(200), + color: Theme.of(context).cardColor.withAlpha(200), ), child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: ListView.builder( + padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), - itemCount: item.hotLists.length, + itemCount: hotLists.length, itemBuilder: (context, index) { - return ClickableWrapper(child: - _buildRankItem(index, item.rankListType, item.hotLists[index])); + return ClickableWrapper( + child: + _buildRankItem(index, item.rankListType, hotLists[index])); }, ), ), @@ -388,7 +492,7 @@ class SearchScreenState extends BaseDynamicState Widget _buildRankItem(int index, RankListType type, RankItem item) { bool showImage = ((type == RankListType.post && item.postType != 1) || - (type == RankListType.collection)) && + (type == RankListType.collection)) && StringUtil.isNotEmpty(item.img); bool showText = type == RankListType.post && item.postType == 1; return GestureDetector( @@ -404,7 +508,8 @@ class SearchScreenState extends BaseDynamicState break; case RankListType.post: if (LoftifyUriUtil.isPostUrl(item.url)) { - Map map = LoftifyUriUtil.extractPostInfo(item.url); + Map map = + LoftifyUriUtil.extractPostInfo(item.url); RouteUtil.pushPanelCupertinoRoute( context, PostDetailScreen( @@ -413,7 +518,8 @@ class SearchScreenState extends BaseDynamicState ), ); } else if (LoftifyUriUtil.isVideoUrl(item.url)) { - Map map = LoftifyUriUtil.extractVideoInfo(item.url); + Map map = + LoftifyUriUtil.extractVideoInfo(item.url); if (ResponsiveUtil.isDesktop()) { IToast.showTop(appLocalizations.unSupportVideoInDesktop); } else { @@ -444,7 +550,8 @@ class SearchScreenState extends BaseDynamicState }, child: Container( margin: const EdgeInsets.symmetric(vertical: 8), - height: 30, + width: double.infinity, + height: _rankRowHeight, child: Row( children: [ if (item.icon.isNotEmpty) @@ -463,19 +570,16 @@ class SearchScreenState extends BaseDynamicState ), if (item.icon.isEmpty) Container( - width: 20, + width: max(20, MediaQuery.textScalerOf(context).scale(20)), alignment: Alignment.center, child: Text( "${index + 1}", - style: Theme - .of(context) - .textTheme - .labelLarge - ?.apply( - fontWeightDelta: 2, - color: - index > 2 ? Colors.grey : ChewieColors.likeButtonColor, - ), + style: Theme.of(context).textTheme.labelLarge?.apply( + fontWeightDelta: 2, + color: index > 2 + ? Theme.of(context).colorScheme.onSurfaceVariant + : ChewieColors.likeButtonColor, + ), ), ), const SizedBox(width: 8), @@ -501,17 +605,14 @@ class SearchScreenState extends BaseDynamicState decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all( - color: Theme - .of(context) - .dividerColor, width: 2), + color: Theme.of(context).dividerColor, width: 2), ), child: Text( StringUtil.clearBlank( HtmlUtil.extractTextFromHtml(item.postDigest ?? "")), maxLines: 3, overflow: TextOverflow.ellipsis, - style: Theme - .of(context) + style: Theme.of(context) .textTheme .bodyMedium ?.apply(fontSizeDelta: -9), @@ -523,44 +624,40 @@ class SearchScreenState extends BaseDynamicState item.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme - .of(context) - .textTheme - .bodyMedium - ?.apply( - fontWeightDelta: 2, - fontSizeDelta: -1, - ), + style: Theme.of(context).textTheme.bodyMedium?.apply( + fontWeightDelta: 2, + fontSizeDelta: -1, + ), ), ), - const SizedBox(width: 20), - Align( - alignment: Alignment.centerRight, + const SizedBox(width: 8), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.3, + ), child: Text( item.pv != 0 - ? appLocalizations.searchingCount(StringUtil.formatCount(item.pv)) + ? appLocalizations + .searchingCount(StringUtil.formatCount(item.pv)) : "${item.score}", maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme - .of(context) - .textTheme - .labelSmall - ?.apply( - fontWeightDelta: 2, - ), + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.labelSmall?.apply( + fontWeightDelta: 2, + ), ), ), const SizedBox(width: 3), if (item.trend == 1) - const Icon( - Icons.arrow_upward_rounded, + const ChewieIcon( + LoftifyIcons.trendUp, color: ChewieColors.likeButtonColor, size: 12, ), if (item.trend == 2) - const Icon( - Icons.arrow_downward_rounded, + const ChewieIcon( + LoftifyIcons.trendDown, color: ChewieColors.likeButtonColor, size: 12, ), @@ -624,8 +721,7 @@ class SearchScreenState extends BaseDynamicState item.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme - .of(context) + style: Theme.of(context) .textTheme .titleSmall ?.apply(fontSizeDelta: -1), @@ -637,8 +733,7 @@ class SearchScreenState extends BaseDynamicState item.content, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme - .of(context) + style: Theme.of(context) .textTheme .bodySmall ?.apply(fontSizeDelta: -1), @@ -653,16 +748,16 @@ class SearchScreenState extends BaseDynamicState } Widget _buildSearchBar() { - double width = ResponsiveUtil.isLandscapeLayout() - ? searchBarWidth - 80 - : min(MediaQuery - .of(context) - .size - .width, searchBarWidth); + final isCompactWidth = MediaQuery.sizeOf(context).width < 600; return Container( + key: const ValueKey('search-navigation-bar'), margin: const EdgeInsets.all(10), - constraints: - BoxConstraints(maxWidth: width, minWidth: width, maxHeight: 56), + constraints: BoxConstraints( + maxWidth: !isCompactWidth && ResponsiveUtil.isLandscapeLayout() + ? searchBarWidth - 80 + : double.infinity, + maxHeight: 56, + ), child: ItemBuilder.buildSearchBar( context: context, borderRadius: 8, @@ -670,7 +765,6 @@ class SearchScreenState extends BaseDynamicState hintFontSizeDelta: 1, focusNode: _focusNode, controller: _searchController, - background: Colors.grey.withAlpha(40), hintText: appLocalizations.searchHint, onSubmitted: (text) async { _performSearch(text); @@ -681,6 +775,8 @@ class SearchScreenState extends BaseDynamicState @override List getScrollControllers() { - return [_scrollController]; + // The covered landing list must not counteract suggestion scroll events + // when the navigation bar changes the viewport padding. + return [_sugList.isNotEmpty ? _suggestScrollController : _scrollController]; } } diff --git a/lib/Screens/Post/collection_detail_screen.dart b/lib/Screens/Post/collection_detail_screen.dart index fc36cc17..e9dcc003 100644 --- a/lib/Screens/Post/collection_detail_screen.dart +++ b/lib/Screens/Post/collection_detail_screen.dart @@ -8,12 +8,18 @@ import 'package:loftify/Screens/Post/post_detail_screen.dart'; import 'package:loftify/Widgets/Item/item_builder.dart'; import '../../Api/collection_api.dart'; +import '../../Models/download_task.dart'; import '../../Models/history_response.dart'; import '../../Models/post_detail_response.dart'; import '../../Models/recommend_response.dart'; -import '../../Utils/asset_util.dart'; +import '../../Screens/Download/batch_download_screen.dart'; import '../../Utils/enums.dart'; import '../../Widgets/PostItem/common_info_post_item_builder.dart'; +import '../../Widgets/PostItem/general_post_item.dart'; +import '../../Widgets/PostItem/loftify_post_archive_grid.dart'; +import '../../Widgets/PostDetail/detail_bottom_bar.dart'; +import '../../Widgets/Design/loftify_media_overlays.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class CollectionDetailScreen extends StatefulWidget { @@ -73,9 +79,11 @@ class CollectionDetailScreenState } _fetchData({bool refresh = false, bool showLoading = false}) async { - if (loading) return; + if (loading || (!refresh && noMore)) return IndicatorResult.none; if (refresh) noMore = false; - if (showLoading) CustomLoadingDialog.showLoading(title: appLocalizations.loading); + if (showLoading) { + CustomLoadingDialog.showLoading(title: appLocalizations.loading); + } loading = true; int offset = refresh ? 0 : posts.length; return await CollectionApi.getCollectionDetail( @@ -112,7 +120,7 @@ class CollectionDetailScreenState posts.addAll(newPosts); Map monthCount = {}; for (var e in posts) { - String yearMonth = TimeUtil.formatYearMonth(e.post!.publishTime); + String yearMonth = formatLocalizedYearMonth(e.post!.publishTime); monthCount.putIfAbsent(yearMonth, () => 0); monthCount[yearMonth] = monthCount[yearMonth]! + 1; } @@ -126,8 +134,9 @@ class CollectionDetailScreenState )); } if (mounted) setState(() {}); - if (posts.length >= postCollection!.postCount || newPosts.isEmpty) { - noMore = true; + noMore = + posts.length >= postCollection!.postCount || newPosts.isEmpty; + if (noMore && !refresh) { return IndicatorResult.noMore; } else { return IndicatorResult.success; @@ -146,7 +155,7 @@ class CollectionDetailScreenState } _onRefresh() async { - await _fetchData(refresh: true); + return await _fetchData(refresh: true); } _onLoad() async { @@ -160,24 +169,45 @@ class CollectionDetailScreenState _fetchIncantation(); } + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: ChewieTheme.getBackground(context), appBar: ResponsiveUtil.isLandscapeLayout() - ? ResponsiveAppBar(showBack: true, title: appLocalizations.collectionDetail) + ? ResponsiveAppBar( + showBack: true, title: appLocalizations.collectionDetail) : null, bottomNavigationBar: blogInfo != null && postCollection != null ? _buildFooter() : null, body: blogInfo != null && postCollection != null - ? NestedScrollView( - headerSliverBuilder: (_, __) => _buildHeaderSlivers(), - body: _buildNineGridGroup()) + ? _buildScrollableBody() : const LoadingWidget(background: Colors.transparent), ); } - _buildHeaderSlivers() { + Widget _buildScrollableBody() { + return EasyRefresh.builder( + controller: _refreshController, + onRefresh: _onRefresh, + onLoad: noMore ? null : _onLoad, + triggerAxis: Axis.vertical, + childBuilder: (context, physics) => CustomScrollView( + physics: physics, + slivers: [ + ..._buildHeaderSlivers(), + ..._buildPostSlivers(), + ], + ), + ); + } + + List _buildHeaderSlivers() { if (!ResponsiveUtil.isLandscapeLayout()) { return [ SliverAppBarWrapper( @@ -185,15 +215,14 @@ class CollectionDetailScreenState expandedHeight: 265, backgroundWidget: _buildBackground(), actions: [ - CircleIconButton( - onTap: () { + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + foregroundColor: Colors.white, + onPressed: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); }, - icon: const Icon( - Icons.more_vert_rounded, - color: Colors.white, - ), ), ], centerTitle: !ResponsiveUtil.isLandscapeLayout(), @@ -240,7 +269,7 @@ class CollectionDetailScreenState ), ), SliverPersistentHeader( - key: ValueKey(StringUtil.getRandomString()), + key: const ValueKey('collection-detail-fixed-header'), pinned: true, delegate: SliverAppBarDelegate( radius: 0, @@ -290,12 +319,18 @@ class CollectionDetailScreenState const SizedBox(width: 5), ItemBuilder.buildIconTextButton( context, - text: isOldest ? appLocalizations.order : appLocalizations.reverseOrder, - icon: AssetUtil.load( - isOldest - ? AssetUtil.orderDownDarkIcon - : AssetUtil.orderUpDarkIcon, - size: 15, + text: isOldest + ? appLocalizations.order + : appLocalizations.reverseOrder, + icon: AnimatedRotation( + turns: isOldest ? 0 : 0.5, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: ChewieIcon( + LoftifyIcons.sortDirection, + size: 16, + color: Theme.of(context).textTheme.labelMedium?.color, + ), ), fontSizeDelta: 1, color: Theme.of(context).textTheme.labelMedium?.color, @@ -322,68 +357,54 @@ class CollectionDetailScreenState } Widget _buildFooter() { - return Container( - height: 65, - width: MediaQuery.sizeOf(context).width, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: RoundIconTextButton( - text: subscribed - ? appLocalizations.unsubscribe - : appLocalizations.subscribeCollection, - background: Theme.of(context).primaryColor.withAlpha(40), - padding: const EdgeInsets.symmetric(vertical: 15), - color: Theme.of(context).primaryColor, - onPressed: () { - HapticFeedback.mediumImpact(); - CollectionApi.subscribeOrUnSubscribe( - collectionId: widget.collectionId, - isSubscribe: !subscribed, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop( - value['meta']['desc'] ?? value['meta']['msg']); - } else { - subscribed = !subscribed; - setState(() {}); - } - }); - }, - fontSizeDelta: 2, - ), - ), - const SizedBox(width: 12), - Expanded( - child: RoundIconTextButton( - text: appLocalizations.continueRead, - background: Theme.of(context).primaryColor, - padding: const EdgeInsets.symmetric(vertical: 15), - onPressed: () { - if (posts.isNotEmpty) { - RouteUtil.pushPanelCupertinoRoute( - context, - PostDetailScreen( - postDetailData: posts[0], - isArticle: CommonInfoItemBuilder.getPostType(posts[0]) == - PostType.article, - ), - ); - } else { - IToast.showTop(appLocalizations.noPostInCollection); - } - }, - fontSizeDelta: 2, - ), - ), - ], - ), + return DetailBottomBar( + horizontalPadding: 12, + spacing: 10, + children: [ + RoundIconTextButton( + text: subscribed + ? appLocalizations.unsubscribe + : appLocalizations.subscribeCollection, + background: Theme.of(context).primaryColor.withAlpha(40), + padding: const EdgeInsets.symmetric(vertical: 15), + color: Theme.of(context).primaryColor, + onPressed: () { + HapticFeedback.mediumImpact(); + CollectionApi.subscribeOrUnSubscribe( + collectionId: widget.collectionId, + isSubscribe: !subscribed, + ).then((value) { + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } else { + subscribed = !subscribed; + setState(() {}); + } + }); + }, + fontSizeDelta: 2, + ), + RoundIconTextButton( + text: appLocalizations.continueRead, + background: Theme.of(context).primaryColor, + padding: const EdgeInsets.symmetric(vertical: 15), + onPressed: () { + if (posts.isNotEmpty) { + RouteUtil.pushPanelCupertinoRoute( + context, + PostDetailScreen( + postDetailData: posts[0], + isArticle: CommonInfoItemBuilder.getPostType(posts[0]) == + PostType.article, + ), + ); + } else { + IToast.showTop(appLocalizations.noPostInCollection); + } + }, + fontSizeDelta: 2, + ), + ], ); } @@ -463,15 +484,14 @@ class CollectionDetailScreenState ), ), if (ResponsiveUtil.isLandscapeLayout()) ...[ - CircleIconButton( - onTap: () { + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + foregroundColor: Colors.white, + onPressed: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); }, - icon: const Icon( - Icons.more_vert_rounded, - color: Colors.white, - ), ), const SizedBox(width: 5), ] @@ -489,34 +509,34 @@ class CollectionDetailScreenState title: appLocalizations.postCount, count: postCollection!.postCount, countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.6), + labelColor: LoftifyCoverScrim.secondaryForeground, ), ItemBuilder.buildStatisticItem( context, title: appLocalizations.subscribeCount, count: postCollection!.subscribedCount, countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.6), + labelColor: LoftifyCoverScrim.secondaryForeground, ), ItemBuilder.buildStatisticItem( context, title: appLocalizations.totalHotCount, count: postCollection!.postCollectionHot, countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.6), + labelColor: LoftifyCoverScrim.secondaryForeground, ), ItemBuilder.buildStatisticItem( context, title: appLocalizations.viewCountLong, count: postCollection!.viewCount, countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.6), + labelColor: LoftifyCoverScrim.secondaryForeground, ), ], ); } - Widget _buildNineGridGroup() { + List _buildPostSlivers() { List widgets = []; int startIndex = 0; for (var e in _archiveDataList) { @@ -530,93 +550,131 @@ class CollectionDetailScreenState } widgets.add(ItemBuilder.buildTitle( context, - title: appLocalizations.descriptionWithPostCount(e.desc, e.count.toString()), + title: appLocalizations.descriptionWithPostCount( + e.desc, e.count.toString()), topMargin: 16, bottomMargin: 0, )); widgets.add(_buildNineGrid(startIndex, count)); startIndex += e.count; } - return EasyRefresh.builder( - controller: _refreshController, - onRefresh: _onRefresh, - onLoad: _onLoad, - childBuilder: (context, physics) { - return Container( - height: MediaQuery.sizeOf(context).height, - color: ChewieTheme.getBackground(context), - child: LoadMoreNotification( - child: ListView( - physics: physics, - shrinkWrap: true, - padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20), - children: widgets, - ), - noMore: noMore, - onLoad: _onLoad, - ), - ); - }, - ); + if (widgets.isEmpty) { + return [ + SliverEmptyPlaceholder(text: appLocalizations.noArticle), + ]; + } + return [ + SliverPadding( + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20), + sliver: SliverList.list(children: widgets), + ), + ]; } Widget _buildNineGrid(int startIndex, int count) { - return GridView.extent( - padding: const EdgeInsets.only(top: 12), - shrinkWrap: true, - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(count, (index) { - int trueIndex = startIndex + index; + return LoftifyPostArchiveGrid( + itemCount: count, + itemBuilder: (context, index, tileExtent) { + final trueIndex = startIndex + index; return CommonInfoItemBuilder.buildNineGridPostItem( context, posts[trueIndex], - wh: 160, + wh: tileExtent, activePostId: widget.postId, ); - }), + }, ); } _buildMoreButtons() { return FlutterContextMenu( entries: [ + FlutterContextMenuItem( + appLocalizations.batchDownload, + iconData: LoftifyIcons.download, + onPressed: _openBatchDownload, + ), FlutterContextMenuItem( appLocalizations.copyLink, - iconData: Icons.copy_rounded, + iconData: LoftifyIcons.copy, onPressed: () { ChewieUtils.copy(context, collectionUrl); }, ), FlutterContextMenuItem(appLocalizations.openWithBrowser, - iconData: Icons.open_in_browser_rounded, onPressed: () { + iconData: LoftifyIcons.openExternal, onPressed: () { UriUtil.openExternal(collectionUrl); }), FlutterContextMenuItem(appLocalizations.shareToOtherApps, - iconData: Icons.share_rounded, onPressed: () { + iconData: LoftifyIcons.share, onPressed: () { UriUtil.share(collectionUrl); }), ], ); } + void _openBatchDownload() { + RouteUtil.pushPanelCupertinoRoute( + context, + BatchDownloadScreen( + sourceTitle: postCollection?.name ?? appLocalizations.collection, + source: DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: widget.collectionId.toString(), + title: postCollection?.name ?? appLocalizations.collection, + thumbnailUrl: postCollection?.coverUrl, + metadata: { + 'collectionId': widget.collectionId.toString(), + 'postId': widget.postId.toString(), + 'blogId': widget.blogId.toString(), + 'blogName': widget.blogName, + }, + ), + initialItems: + posts.map(CommonInfoItemBuilder.getGeneralPostItem).toList(), + loadAllItems: _loadAllBatchItems, + ), + ); + } + + Future> _loadAllBatchItems() async { + if (posts.isEmpty) await _onRefresh(); + while (!noMore) { + final previousLength = posts.length; + final result = await _onLoad(); + if (result == IndicatorResult.fail || + result == IndicatorResult.none || + posts.length == previousLength) { + break; + } + } + return posts + .map(CommonInfoItemBuilder.getGeneralPostItem) + .toList(growable: false); + } + Widget _buildBackground({double? height}) { String backgroudUrl = postCollection!.coverUrl; - return Blur( - blur: 20, - blurColor: Colors.black12, - child: ChewieItemBuilder.buildCachedImage( - context: context, - imageUrl: backgroudUrl, - showLoading: false, - fit: BoxFit.cover, - width: MediaQuery.sizeOf(context).width * 2, - height: height ?? MediaQuery.sizeOf(context).height * 0.7, - placeholderBackground: Theme.of(context).textTheme.labelSmall?.color, - bottomPadding: 50, - ), + return Stack( + fit: StackFit.expand, + children: [ + Blur( + blur: 20, + blurColor: Colors.black12, + child: ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: backgroudUrl, + showLoading: false, + fit: BoxFit.cover, + width: MediaQuery.sizeOf(context).width * 2, + height: height ?? MediaQuery.sizeOf(context).height * 0.7, + placeholderBackground: + Theme.of(context).textTheme.labelSmall?.color, + bottomPadding: 50, + ), + ), + const LoftifyCoverScrim(), + ], ); } } diff --git a/lib/Screens/Post/grain_detail_screen.dart b/lib/Screens/Post/grain_detail_screen.dart index 5ccfffa3..810f70d5 100644 --- a/lib/Screens/Post/grain_detail_screen.dart +++ b/lib/Screens/Post/grain_detail_screen.dart @@ -6,13 +6,18 @@ import 'package:flutter/services.dart'; import 'package:loftify/Api/grain_api.dart'; import 'package:loftify/Models/grain_response.dart'; import 'package:loftify/Screens/Info/user_detail_screen.dart'; -import 'package:loftify/Screens/Post/post_detail_screen.dart'; import 'package:loftify/Widgets/Item/item_builder.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item_builder.dart'; import 'package:loftify/Widgets/PostItem/grain_post_item_builder.dart'; +import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; import '../../Models/history_response.dart'; -import '../../Utils/asset_util.dart'; -import '../../Utils/enums.dart'; +import '../../Models/download_task.dart'; +import '../../Screens/Download/batch_download_screen.dart'; +import '../../Utils/post_sequence_source.dart'; +import '../../Widgets/PostDetail/detail_bottom_bar.dart'; +import '../../Widgets/Design/loftify_media_overlays.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class GrainDetailScreen extends StatefulWidget { @@ -41,6 +46,7 @@ class GrainDetailScreenState extends BaseDynamicState bool loading = false; List posts = []; final List _archiveDataList = []; + late final PostSequenceSource _postSequenceSource; bool isOldest = false; bool noMore = false; @@ -67,9 +73,11 @@ class GrainDetailScreenState extends BaseDynamicState } _fetchData({bool refresh = false, bool showLoading = false}) async { - if (loading) return; + if (loading || (!refresh && noMore)) return IndicatorResult.none; if (refresh) noMore = false; - if (showLoading) CustomLoadingDialog.showLoading(title: appLocalizations.loading); + if (showLoading) { + CustomLoadingDialog.showLoading(title: appLocalizations.loading); + } loading = true; int offset = refresh ? 0 : grainDetailData?.offset ?? 0; return await GrainApi.getGrainDetail( @@ -94,7 +102,7 @@ class GrainDetailScreenState extends BaseDynamicState if (refresh) posts.clear(); for (var e in t.posts) { if (posts.indexWhere((element) => - element.postData.postView.id == e.postData.postView.id) == + element.postData.postView.id == e.postData.postView.id) == -1) { newPosts.add(e); } @@ -102,7 +110,7 @@ class GrainDetailScreenState extends BaseDynamicState posts.addAll(newPosts); Map monthCount = {}; for (var e in posts) { - String yearMonth = TimeUtil.formatYearMonth(e.opTime); + String yearMonth = formatLocalizedYearMonth(e.opTime); monthCount.putIfAbsent(yearMonth, () => 0); monthCount[yearMonth] = monthCount[yearMonth]! + 1; } @@ -116,9 +124,10 @@ class GrainDetailScreenState extends BaseDynamicState )); } if (mounted) setState(() {}); - if (posts.length >= grainDetailData!.grainInfo.postCount || - newPosts.isEmpty) { - noMore = true; + noMore = posts.length >= grainDetailData!.grainInfo.postCount || + newPosts.isEmpty; + _synchronizePostSequence(); + if (noMore && !refresh) { return IndicatorResult.noMore; } else { return IndicatorResult.success; @@ -137,7 +146,7 @@ class GrainDetailScreenState extends BaseDynamicState } _onRefresh() async { - await _fetchData(refresh: true); + return await _fetchData(refresh: true); } _onLoad() async { @@ -147,29 +156,55 @@ class GrainDetailScreenState extends BaseDynamicState @override void initState() { super.initState(); + _postSequenceSource = PostSequenceSource( + loadMore: () async { + await _fetchData(); + }, + ); _fetchData(refresh: true); _fetchIncantation(); } + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: ChewieTheme.getBackground(context), appBar: ResponsiveUtil.isLandscapeLayout() - ? ResponsiveAppBar(showBack: true, title: appLocalizations.grainDetail) + ? ResponsiveAppBar( + showBack: true, title: appLocalizations.grainDetail) : null, bottomNavigationBar: grainDetailData != null ? _buildFooter() : null, body: grainDetailData != null - ? NestedScrollView( - headerSliverBuilder: (_, __) => _buildHeaderSlivers(), - body: _buildNineGridGroup()) + ? _buildScrollableBody() : LoadingWidget( - background: Colors.transparent, + background: Colors.transparent, + ), + ); + } + + Widget _buildScrollableBody() { + return EasyRefresh.builder( + controller: _refreshController, + onRefresh: _onRefresh, + onLoad: noMore ? null : _onLoad, + triggerAxis: Axis.vertical, + childBuilder: (context, physics) => CustomScrollView( + physics: physics, + slivers: [ + ..._buildHeaderSlivers(), + ..._buildPostSlivers(), + ], ), ); } - _buildHeaderSlivers() { + List _buildHeaderSlivers() { if (!ResponsiveUtil.isLandscapeLayout()) { return [ SliverAppBarWrapper( @@ -177,27 +212,22 @@ class GrainDetailScreenState extends BaseDynamicState expandedHeight: 265, backgroundWidget: _buildBackground(), actions: [ - CircleIconButton( - onTap: () { + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + foregroundColor: Colors.white, + onPressed: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); }, - icon: const Icon( - Icons.more_vert_rounded, - color: Colors.white, - ), ), ], title: Text( appLocalizations.grain, - style: Theme - .of(context) - .textTheme - .titleMedium - ?.apply( - color: Colors.white, - fontWeightDelta: 2, - ), + style: Theme.of(context).textTheme.titleMedium?.apply( + color: Colors.white, + fontWeightDelta: 2, + ), ), centerTitle: !ResponsiveUtil.isLandscapeLayout(), flexibleSpace: FlexibleSpaceBar( @@ -208,10 +238,7 @@ class GrainDetailScreenState extends BaseDynamicState children: [ SizedBox( height: kToolbarHeight + - MediaQuery - .of(context) - .padding - .top), + MediaQuery.of(context).padding.top), _buildInfoRow(), _buildStatisticRow(), ], @@ -239,7 +266,7 @@ class GrainDetailScreenState extends BaseDynamicState ), ), SliverPersistentHeader( - key: ValueKey(StringUtil.getRandomString()), + key: const ValueKey('grain-detail-fixed-header'), pinned: true, delegate: SliverAppBarDelegate( radius: 0, @@ -254,28 +281,72 @@ class GrainDetailScreenState extends BaseDynamicState _buildMoreButtons() { return FlutterContextMenu( entries: [ + FlutterContextMenuItem( + appLocalizations.batchDownload, + iconData: LoftifyIcons.download, + onPressed: _openBatchDownload, + ), FlutterContextMenuItem( appLocalizations.copyLink, - iconData: Icons.copy_rounded, + iconData: LoftifyIcons.copy, onPressed: () { ChewieUtils.copy(context, grainUrl); }, ), FlutterContextMenuItem(appLocalizations.openWithBrowser, - iconData: Icons.open_in_browser_rounded, onPressed: () { - UriUtil.openExternal(grainUrl); - }), + iconData: LoftifyIcons.openExternal, onPressed: () { + UriUtil.openExternal(grainUrl); + }), FlutterContextMenuItem(appLocalizations.shareToOtherApps, - iconData: Icons.share_rounded, onPressed: () { - UriUtil.share(grainUrl); - }), + iconData: LoftifyIcons.share, onPressed: () { + UriUtil.share(grainUrl); + }), ], ); } + void _openBatchDownload() { + RouteUtil.pushPanelCupertinoRoute( + context, + BatchDownloadScreen( + sourceTitle: grainDetailData?.grainInfo.name ?? appLocalizations.grain, + source: DownloadSourceDescriptor( + type: DownloadSourceType.grain, + sourceId: widget.grainId.toString(), + title: grainDetailData?.grainInfo.name ?? appLocalizations.grain, + thumbnailUrl: grainDetailData?.grainInfo.coverUrl, + metadata: { + 'grainId': widget.grainId.toString(), + 'blogId': widget.blogId.toString(), + }, + ), + initialItems: posts + .map(GrainPostItemBuilder.getGeneralPostItem) + .toList(growable: false), + loadAllItems: _loadAllBatchItems, + ), + ); + } + + Future> _loadAllBatchItems() async { + if (posts.isEmpty) await _onRefresh(); + while (!noMore) { + final previousLength = posts.length; + final result = await _onLoad(); + if (result == IndicatorResult.fail || + result == IndicatorResult.none || + posts.length == previousLength) { + break; + } + } + return posts + .map(GrainPostItemBuilder.getGeneralPostItem) + .toList(growable: false); + } + PreferredSize _buildFixedBar([double height = 56]) { - bool hasDesc = StringUtil.isNotEmpty( - grainDetailData!.grainInfo.description); + bool hasDesc = + StringUtil.isNotEmpty(grainDetailData!.grainInfo.description); return PreferredSize( preferredSize: Size.fromHeight(height), child: Container( @@ -287,9 +358,7 @@ class GrainDetailScreenState extends BaseDynamicState topRight: Radius.circular(20), ), ), - width: MediaQuery - .sizeOf(context) - .width, + width: MediaQuery.sizeOf(context).width, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -304,17 +373,9 @@ class GrainDetailScreenState extends BaseDynamicState hasDesc ? grainDetailData!.grainInfo.description : appLocalizations.noDescription, - style: Theme - .of(context) - .textTheme - .labelLarge - ?.apply( - color: Theme - .of(context) - .textTheme - .bodySmall - ?.color, - ), + style: Theme.of(context).textTheme.labelLarge?.apply( + color: Theme.of(context).textTheme.bodySmall?.color, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -322,19 +383,21 @@ class GrainDetailScreenState extends BaseDynamicState const SizedBox(width: 5), ItemBuilder.buildIconTextButton( context, - text: isOldest ? appLocalizations.order : appLocalizations.reverseOrder, - icon: AssetUtil.load( - isOldest - ? AssetUtil.orderDownDarkIcon - : AssetUtil.orderUpDarkIcon, - size: 15, + text: isOldest + ? appLocalizations.order + : appLocalizations.reverseOrder, + icon: AnimatedRotation( + turns: isOldest ? 0 : 0.5, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: ChewieIcon( + LoftifyIcons.sortDirection, + size: 16, + color: Theme.of(context).textTheme.labelMedium?.color, + ), ), fontSizeDelta: 1, - color: Theme - .of(context) - .textTheme - .labelMedium - ?.color, + color: Theme.of(context).textTheme.labelMedium?.color, onTap: () { HapticFeedback.mediumImpact(); setState(() { @@ -357,78 +420,54 @@ class GrainDetailScreenState extends BaseDynamicState } Widget _buildFooter() { - return Container( - height: 65, - width: MediaQuery - .sizeOf(context) - .width, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - decoration: BoxDecoration( - color: Theme - .of(context) - .cardColor, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: RoundIconTextButton( - text: - subscribed ? appLocalizations.unsubscribe : appLocalizations.subscribeGrain, - background: Theme - .of(context) - .primaryColor - .withAlpha(40), - padding: const EdgeInsets.symmetric(vertical: 15), - color: Theme - .of(context) - .primaryColor, - onPressed: () { - HapticFeedback.mediumImpact(); - GrainApi.subscribeOrUnSubscribe( - grainId: widget.grainId, - blogId: widget.blogId, - isSubscribe: !subscribed, - ).then((value) { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - subscribed = !subscribed; - setState(() {}); - } - }); - }, - fontSizeDelta: 2, - ), - ), - const SizedBox(width: 12), - Expanded( - child: RoundIconTextButton( - text: appLocalizations.startRead, - background: Theme - .of(context) - .primaryColor, - padding: const EdgeInsets.symmetric(vertical: 15), - onPressed: () { - if (posts.isNotEmpty) { - RouteUtil.pushPanelCupertinoRoute( - context, - PostDetailScreen( - grainPostItem: posts[0], - isArticle: GrainPostItemBuilder.getPostType(posts[0]) == - PostType.article, - ), - ); - } else { - IToast.showTop(appLocalizations.noPostInGrain); - } - }, - fontSizeDelta: 2, - ), - ), - ], - ), + return DetailBottomBar( + horizontalPadding: 12, + spacing: 10, + children: [ + RoundIconTextButton( + text: subscribed + ? appLocalizations.unsubscribe + : appLocalizations.subscribeGrain, + background: Theme.of(context).primaryColor.withAlpha(40), + padding: const EdgeInsets.symmetric(vertical: 15), + color: Theme.of(context).primaryColor, + onPressed: () { + HapticFeedback.mediumImpact(); + GrainApi.subscribeOrUnSubscribe( + grainId: widget.grainId, + blogId: widget.blogId, + isSubscribe: !subscribed, + ).then((value) { + if (value['code'] != 0) { + IToast.showTop(value['msg']); + } else { + subscribed = !subscribed; + setState(() {}); + } + }); + }, + fontSizeDelta: 2, + ), + RoundIconTextButton( + text: appLocalizations.startRead, + background: Theme.of(context).primaryColor, + padding: const EdgeInsets.symmetric(vertical: 15), + onPressed: () { + if (posts.isNotEmpty) { + GeneralPostItemBuilder.onTapItem( + context, + GrainPostItemBuilder.getGeneralPostItem( + posts.first, + sequenceSource: _postSequenceSource, + ), + ); + } else { + IToast.showTop(appLocalizations.noPostInGrain); + } + }, + fontSizeDelta: 2, + ), + ], ); } @@ -459,71 +498,64 @@ class GrainDetailScreenState extends BaseDynamicState children: [ Text( grainDetailData!.grainInfo.name, - style: Theme - .of(context) - .textTheme - .titleMedium - ?.apply( - fontSizeDelta: 2, - color: Colors.white, - fontWeightDelta: 2, - ), + style: Theme.of(context).textTheme.titleMedium?.apply( + fontSizeDelta: 2, + color: Colors.white, + fontWeightDelta: 2, + ), ), const SizedBox(height: 6), - ClickableWrapper(child: - GestureDetector( - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - UserDetailScreen( - blogId: widget.blogId, - blogName: grainDetailData!.blogInfo.blogName, - ), - ); - }, - child: Row( - children: [ - Container( - margin: const EdgeInsets.only(right: 5), - child: ItemBuilder.buildAvatar( - context: context, - imageUrl: grainDetailData!.blogInfo.bigAvaImg, - size: 20, - showBorder: false, - showLoading: false, + ClickableWrapper( + child: GestureDetector( + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + UserDetailScreen( + blogId: widget.blogId, + blogName: grainDetailData!.blogInfo.blogName, ), - ), - Expanded( - child: Text( - "${grainDetailData!.blogInfo.blogNickName} · ${appLocalizations.updateAt}${TimeUtil.formatTimestamp( - grainDetailData!.grainInfo.updateTime)}", - style: Theme - .of(context) - .textTheme - .labelMedium - ?.apply(color: Colors.white, fontSizeDelta: -1), - maxLines: 1, - overflow: TextOverflow.ellipsis, + ); + }, + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 5), + child: ItemBuilder.buildAvatar( + context: context, + imageUrl: grainDetailData!.blogInfo.bigAvaImg, + size: 20, + showBorder: false, + showLoading: false, + ), ), - ), - ], + Expanded( + child: Text( + "${grainDetailData!.blogInfo.blogNickName} · ${appLocalizations.updateAt}${TimeUtil.formatTimestamp(grainDetailData!.grainInfo.updateTime)}", + style: Theme.of(context) + .textTheme + .labelMedium + ?.apply(color: Colors.white, fontSizeDelta: -1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), ), ), - ), const SizedBox(height: 6), ], ), ), if (ResponsiveUtil.isLandscapeLayout()) ...[ - CircleIconButton( - onTap: () { + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + foregroundColor: Colors.white, + onPressed: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); }, - icon: const Icon( - Icons.more_vert_rounded, - color: Colors.white, - ), ), const SizedBox(width: 5), ], @@ -541,61 +573,34 @@ class GrainDetailScreenState extends BaseDynamicState title: appLocalizations.postCount, count: grainDetailData!.grainInfo.postCount, countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.6), + labelColor: LoftifyCoverScrim.secondaryForeground, ), ItemBuilder.buildStatisticItem( context, title: appLocalizations.subscribeCount, count: grainDetailData!.grainInfo.subscribedCount, countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.6), + labelColor: LoftifyCoverScrim.secondaryForeground, ), ItemBuilder.buildStatisticItem( context, title: appLocalizations.coCreatorCount, count: grainDetailData!.grainInfo.joinCount, countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.6), + labelColor: LoftifyCoverScrim.secondaryForeground, ), ItemBuilder.buildStatisticItem( context, title: appLocalizations.viewCountLong, count: grainDetailData!.grainInfo.viewCount, countColor: Colors.white, - labelColor: Colors.white.withOpacity(0.6), + labelColor: LoftifyCoverScrim.secondaryForeground, ), ], ); } - Widget _buildTagList() { - Map tags = {}; - for (var e in grainDetailData!.grainInfo.tags) { - tags[e] = TagType.normal; - } - List> sortedTags = tags.entries.toList(); - sortedTags.sort((a, b) => b.value.index.compareTo(a.value.index)); - return Container( - padding: const EdgeInsets.only(top: 8, bottom: 8), - height: 42, - child: ListView( - scrollDirection: Axis.horizontal, - children: List.generate(sortedTags.length, (index) { - return Container( - margin: const EdgeInsets.only(right: 8), - child: ItemBuilder.buildTagItem( - context, - sortedTags[index].key, - sortedTags[index].value, - showIcon: false, - ), - ); - }), - ), - ); - } - - Widget _buildNineGridGroup() { + List _buildPostSlivers() { List widgets = []; int startIndex = 0; for (var e in _archiveDataList) { @@ -609,78 +614,78 @@ class GrainDetailScreenState extends BaseDynamicState } widgets.add(ItemBuilder.buildTitle( context, - title: appLocalizations.descriptionWithPostCount(e.desc, e.count.toString()), + title: appLocalizations.descriptionWithPostCount( + e.desc, e.count.toString()), topMargin: 16, bottomMargin: 0, )); widgets.add(_buildNineGrid(startIndex, count)); startIndex += e.count; } - return EasyRefresh.builder( - controller: _refreshController, - onRefresh: _onRefresh, - onLoad: _onLoad, - childBuilder: (context, physics) { - return Container( - height: MediaQuery - .sizeOf(context) - .height, - color: ChewieTheme.getBackground(context), - child: LoadMoreNotification( - child: ListView( - padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20), - children: widgets, - ), - noMore: noMore, - onLoad: _onLoad, - ), - ); - }, - ); + if (widgets.isEmpty) { + return [ + SliverEmptyPlaceholder(text: appLocalizations.noArticle), + ]; + } + return [ + SliverPadding( + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20), + sliver: SliverList.list(children: widgets), + ), + ]; } Widget _buildNineGrid(int startIndex, int count) { - return GridView.extent( - padding: const EdgeInsets.only(top: 12), - shrinkWrap: true, - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(count, (index) { - int trueIndex = startIndex + index; + return LoftifyPostArchiveGrid( + itemCount: count, + itemBuilder: (context, index, tileExtent) { + final trueIndex = startIndex + index; return GrainPostItemBuilder.buildNineGridPostItem( context, posts[trueIndex], - wh: 160, + wh: tileExtent, + sequenceSource: _postSequenceSource, ); - }), + }, + ); + } + + void _synchronizePostSequence() { + _postSequenceSource.synchronize( + posts.map( + (item) => PostSequenceEntry( + postId: item.postData.postView.id, + blogId: item.postData.postView.blogId, + blogName: item.postData.blogInfo.blogName, + type: GrainPostItemBuilder.getPostType(item), + ), + ), + hasMore: !noMore, ); } Widget _buildBackground({double? height}) { String backgroudUrl = grainDetailData!.grainInfo.coverUrl; - return Blur( - blur: 20, - blurColor: Colors.black12, - child: ChewieItemBuilder.buildCachedImage( - context: context, - imageUrl: backgroudUrl, - fit: BoxFit.cover, - showLoading: false, - width: MediaQuery - .sizeOf(context) - .width * 2, - height: height ?? MediaQuery - .sizeOf(context) - .height * 0.7, - placeholderBackground: Theme - .of(context) - .textTheme - .labelSmall - ?.color, - bottomPadding: 50, - ), + return Stack( + fit: StackFit.expand, + children: [ + Blur( + blur: 20, + blurColor: Colors.black12, + child: ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: backgroudUrl, + fit: BoxFit.cover, + showLoading: false, + width: MediaQuery.sizeOf(context).width * 2, + height: height ?? MediaQuery.sizeOf(context).height * 0.7, + placeholderBackground: + Theme.of(context).textTheme.labelSmall?.color, + bottomPadding: 50, + ), + ), + const LoftifyCoverScrim(), + ], ); } } diff --git a/lib/Screens/Post/post_detail_screen.dart b/lib/Screens/Post/post_detail_screen.dart index 44ddf284..f025c5f8 100644 --- a/lib/Screens/Post/post_detail_screen.dart +++ b/lib/Screens/Post/post_detail_screen.dart @@ -4,10 +4,12 @@ import 'dart:math'; import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:card_swiper/card_swiper.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:loftify/Api/post_api.dart'; import 'package:loftify/Api/user_api.dart'; import 'package:loftify/Models/grain_response.dart'; +import 'package:loftify/Models/download_task.dart'; import 'package:loftify/Models/illust.dart'; import 'package:loftify/Models/message_response.dart'; import 'package:loftify/Models/post_detail_response.dart'; @@ -18,7 +20,7 @@ import 'package:loftify/Utils/hive_util.dart'; import 'package:loftify/Widgets/BottomSheet/collection_bottom_sheet.dart'; import 'package:loftify/Widgets/BottomSheet/comment_bottom_sheet.dart'; import 'package:loftify/Widgets/BottomSheet/subscribe_post_bottom_sheet.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item.dart'; import 'package:provider/provider.dart'; import 'package:responsive_builder/responsive_builder.dart'; import 'package:window_manager/window_manager.dart'; @@ -28,16 +30,27 @@ import '../../Api/recommend_api.dart'; import '../../Models/return_gift_response.dart'; import '../../Models/search_response.dart'; import '../../Utils/app_provider.dart'; -import '../../Utils/asset_util.dart'; import '../../Utils/cloud_control_provider.dart'; import '../../Utils/constant.dart'; import '../../Utils/lottie_files.dart'; +import '../../Utils/loftify_file_util.dart'; +import '../../Utils/post_sequence_source.dart'; +import '../../Utils/post_swipe_gesture.dart'; import '../../Utils/uri_util.dart'; import '../../Utils/utils.dart'; +import '../../Theme/loftify_design_theme.dart'; +import '../../Widgets/Design/loftify_content_reference.dart'; +import '../../Widgets/Design/loftify_reading.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; -import '../../Widgets/PostItem/general_post_item_builder.dart'; import '../../Widgets/PostItem/recommend_flow_item_builder.dart'; +import '../../Widgets/PostDetail/detail_bottom_bar.dart'; +import '../../Widgets/PostDetail/lazy_comment_jump.dart'; +import '../../Widgets/PostDetail/post_content_section.dart'; +import '../../Widgets/PostDetail/post_download_action_icon.dart'; +import '../../Widgets/PostDetail/post_swipe_gesture_detector.dart'; +import '../../Widgets/loftify_icons.dart'; +import '../../Widgets/loftify_reaction_icon.dart'; import '../../l10n/l10n.dart'; import '../Info/user_detail_screen.dart'; import 'grain_detail_screen.dart'; @@ -53,6 +66,7 @@ class PostDetailScreen extends StatefulWidget { this.searchPost, this.grainPostItem, this.generalPostItem, + this.sequenceSource, required this.isArticle, this.simpleMessagePost, }); @@ -67,6 +81,7 @@ class PostDetailScreen extends StatefulWidget { final Map? meta; final SimpleMessagePost? simpleMessagePost; final GeneralPostItem? generalPostItem; + final PostSequenceSource? sequenceSource; static const String routeName = "/post/detail"; @override @@ -96,6 +111,8 @@ class _PostDetailScreenState extends BaseDynamicState int _myBlogId = 0; bool _loadingInfo = false; bool _loadingRecommend = false; + int _recommendRequestToken = 0; + bool _recommendNoMore = false; int blogId = 0; int postId = 0; int collectionId = 0; @@ -110,29 +127,41 @@ class _PostDetailScreenState extends BaseDynamicState bool _showDoubleTapLike = true; Widget? doubleTapLikeWidget; List mainColors = []; - late AnimationController _shareController; - late AnimationController _likeController; int totalHotOrNewComments = 0; List hotComments = []; List newComments = []; GlobalKey commentKey = GlobalKey(); + final GlobalKey _collectionViewportKey = GlobalKey(); + final GlobalKey _grainViewportKey = GlobalKey(); + final GlobalKey _tagViewportKey = GlobalKey(); + final GlobalKey _operationViewportKey = GlobalKey(); + final GlobalKey _commentListViewportKey = GlobalKey(); + final GlobalKey _commentEndViewportKey = GlobalKey(); + final GlobalKey _commonContentSliverKey = GlobalKey(); + final GlobalKey _imageSwiperViewportKey = GlobalKey(); final ResizableController _resizableController = ResizableController(); - late dynamic downloadIcon; DownloadState downloadState = DownloadState.none; + double _downloadProgress = 0; bool isArticle = false; InitPhase _inited = InitPhase.haveNotConnected; - final ScrollToHideController _scrollToHideController = - ScrollToHideController(); - final bool _showPostDetailFloatingOperationBar = - ChewieHiveUtil.getBool(HiveUtil.showPostDetailFloatingOperationBarKey); - final bool _showPostDetailFloatingOperationBarOnlyInArticle = - ChewieHiveUtil.getBool( - HiveUtil.showPostDetailFloatingOperationBarOnlyInArticleKey, - defaultValue: false); - - bool get _showBottomBar => - _showPostDetailFloatingOperationBar && - (!_showPostDetailFloatingOperationBarOnlyInArticle || isArticle); + final ValueNotifier _floatingOperationBarVisible = ValueNotifier(true); + bool _scrollAllowsFloatingOperationBar = true; + bool _floatingOperationBarSyncScheduled = false; + late final AnimationController _postSwipeAnimationController; + Animation? _postSwipeAnimation; + final Map> _adjacentPostLoads = {}; + double _postSwipeOffset = 0; + double _postSwipeRawOffset = 0; + bool? _postSwipePrevious; + bool _postSwipeReady = false; + bool _postSwipeAtBoundary = false; + bool _postSwipeBoundaryReady = false; + bool _switchingPost = false; + + bool get _isPostContentReady => + !_switchingPost && + _inited == InitPhase.successful && + _postDetailData?.post != null; @override void initState() { @@ -140,33 +169,36 @@ class _PostDetailScreenState extends BaseDynamicState _scrollController = ScrollController(); windowManager.addListener(this); super.initState(); + _postSwipeAnimationController = AnimationController(vsync: this) + ..addListener(() { + final animation = _postSwipeAnimation; + if (animation != null && mounted) { + setState(() => _postSwipeOffset = animation.value); + } + }); + initLottie(); setDownloadState(DownloadState.none, recover: false); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { if (ResponsiveUtil.isDesktop()) { appProvider.windowSize = await windowManager.getSize(); } - Future.delayed(const Duration(milliseconds: 500), initLottie); - initLottie(); if (isArticle) { - Future.delayed(const Duration(milliseconds: 500), initData); + Future.delayed(const Duration(milliseconds: 350), () { + if (mounted) initData(); + }); } else { initData(); } }); - _scrollController.addListener(() { - if (_scrollController.position.pixels > - _scrollController.position.maxScrollExtent - kLoadExtentOffset) { - _onLoad(); - } - }); } @override void dispose() { _scrollController.dispose(); + _tabletScrollController.dispose(); _doubleTapLikeController.dispose(); - _shareController.dispose(); - _likeController.dispose(); + _postSwipeAnimationController.dispose(); + _floatingOperationBarVisible.dispose(); windowManager.removeListener(this); super.dispose(); } @@ -174,25 +206,26 @@ class _PostDetailScreenState extends BaseDynamicState initLottie() { _doubleTapLikeController = AnimationController(duration: const Duration(seconds: 3), vsync: this); - doubleTapLikeWidget = LottieUtil.load( + doubleTapLikeWidget = LottieFiles.buildAnimation( LottieFiles.likeDoubleClickLight, size: doubleTapLikeSize, controller: _doubleTapLikeController, ); - _shareController = - AnimationController(duration: const Duration(seconds: 1), vsync: this); - _likeController = AnimationController( - duration: const Duration(milliseconds: 2500), vsync: this); } initData() async { _inited = InitPhase.connecting; - setState(() {}); + if (mounted) setState(() {}); _initParams(); - _fetchPostDetail(); - _fetchRecommendPosts(); - setState(() {}); + if (_inited == InitPhase.failed) { + if (mounted) setState(() {}); + return; + } + final detailFuture = Future.sync(_fetchPostDetail); + final recommendFuture = Future.sync(_fetchRecommendPosts); + await Future.wait([detailFuture, recommendFuture]); _myBlogId = await HiveUtil.getUserId(); + if (mounted) setState(() {}); } _initParams() { @@ -241,13 +274,17 @@ class _PostDetailScreenState extends BaseDynamicState } _uploadHistory() async { + final historyPostId = postId; + final historyBlogId = blogId; + final historyPostType = _postDetailData!.post!.type; + final historyCollectionId = _postDetailData!.post!.collectionId; int userId = await HiveUtil.getUserId(); PostApi.uploadHistory( - postId: postId, - blogId: blogId, + postId: historyPostId, + blogId: historyBlogId, userId: userId, - postType: _postDetailData!.post!.type, - collectionId: _postDetailData!.post!.collectionId, + postType: historyPostType, + collectionId: historyCollectionId, ).then((value) { if (value['code'] != 200) { IToast.showTop(value['msg']); @@ -259,29 +296,45 @@ class _PostDetailScreenState extends BaseDynamicState if (_loadingInfo) return; _loadingInfo = true; try { - var t1 = await PostApi.getDetail( + final value = await PostApi.getDetail( postId: postId, blogId: blogId, blogName: blogName, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - _postDetailData = - PostDetailData.fromJson(value['response']['posts'][0]); - _updateMeta(swipeToFirst: false); - if (mounted) setState(() {}); - _uploadHistory(); - return IndicatorResult.success; - } - }); - var t2 = await _fetchGift(); - await _fetchHotComments(); + ).timeout(const Duration(seconds: 20)); + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + _inited = InitPhase.failed; + return IndicatorResult.fail; + } + final posts = value['response']?['posts']; + if (posts is! List || posts.isEmpty || posts.first is! Map) { + throw const FormatException('Post detail response has no post data'); + } + final parsed = PostDetailData.fromJson( + Map.from(posts.first as Map), + ); + if (parsed.post == null) { + throw const FormatException('Post detail response has no post body'); + } + _postDetailData = parsed; + _updateMeta(swipeToFirst: false); _inited = InitPhase.successful; - return t1 == IndicatorResult.success && t2 == IndicatorResult.success - ? IndicatorResult.success - : IndicatorResult.fail; + if (mounted) setState(() {}); + unawaited(_uploadHistory()); + + // Gift and comment data enrich the page, but must not turn a valid + // article into a full-page error when either auxiliary endpoint fails. + try { + await _fetchGift(); + } catch (error, stackTrace) { + ILogger.error('Failed to load post gift', error, stackTrace); + } + try { + await _fetchHotComments(); + } catch (error, stackTrace) { + ILogger.error('Failed to load post comments', error, stackTrace); + } + return IndicatorResult.success; } catch (e, t) { _inited = InitPhase.failed; ILogger.error("Failed to fetch post detail", e, t); @@ -292,11 +345,14 @@ class _PostDetailScreenState extends BaseDynamicState } } - _fetchGift() async { + _fetchGift({int? expectedPostId}) async { + final requestPostId = expectedPostId ?? postId; + final requestBlogId = blogId; return await PostApi.getGifts( - postId: postId, - blogId: blogId, + postId: requestPostId, + blogId: requestBlogId, ).then((value) { + if (!mounted || postId != requestPostId) return IndicatorResult.none; if (value == null) return IndicatorResult.fail; if (value['code'] != 200 || value['ok'] != true) { IToast.showTop(value['msg']); @@ -363,7 +419,8 @@ class _PostDetailScreenState extends BaseDynamicState for (var gift in defaultGifts) { idToCoinMap[gift.id ?? LIANGPIAO_GIFTID] = gift.coin ?? 0; if ((gift.coin ?? 0) > 0) { - unlockCost.add("${gift.name}(${gift.coin}${appLocalizations.coinCount})"); + unlockCost + .add("${gift.name}(${gift.coin}${appLocalizations.coinCount})"); } else { unlockCost.add("${gift.name}"); } @@ -386,13 +443,19 @@ class _PostDetailScreenState extends BaseDynamicState _giftCost = " ${unlockCost.join(appLocalizations.or)} "; } - _fetchHotComments() async { + _fetchHotComments({int? expectedPostId}) async { + final requestPostId = expectedPostId ?? postId; + final requestBlogId = blogId; + final requestPublishTime = _postDetailData!.post!.publishTime; return await PostApi.getHotComments( - postId: postId, - blogId: blogId, - postPublishTime: _postDetailData!.post!.publishTime, + postId: requestPostId, + blogId: requestBlogId, + postPublishTime: requestPublishTime, ).then((value) { try { + if (!mounted || postId != requestPostId) { + return IndicatorResult.none; + } if (value == null) return IndicatorResult.fail; if (value['code'] != 0) { IToast.showTop(value['msg']); @@ -454,50 +517,342 @@ class _PostDetailScreenState extends BaseDynamicState }); } - _fetchPreOrNextPost({required bool isPre}) async { - if (_loadingInfo) return; - _loadingInfo = true; - var t1 = await CollectionApi.getPreOrNextPost( - isPre: isPre, - postId: postId, - blogId: blogId, - blogName: blogName, - collectionId: collectionId, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - _postDetailData = PostDetailData.fromJson(value['response'][0]); - _scrollController.animateTo(0, - duration: const Duration(milliseconds: 300), curve: Curves.ease); - if (_tabletScrollController.hasClients) { - _tabletScrollController.animateTo(0, - duration: const Duration(milliseconds: 300), curve: Curves.ease); - } - _updateMeta(); - setState(() {}); - _uploadHistory(); - return IndicatorResult.success; + Future _fetchPreOrNextPost({required bool isPre}) async { + final switched = await _switchToAdjacentPost(previous: isPre); + return switched ? IndicatorResult.success : IndicatorResult.fail; + } + + bool get _supportsPostSwipe => _postDetailData?.post != null; + + bool get _hasPostSequenceContext => + widget.sequenceSource != null || hasCollection(); + + bool _canNavigateAdjacent({required bool previous}) { + final source = widget.sequenceSource; + if (source != null) { + return source.canNavigateFrom(postId, previous: previous); + } + if (!hasCollection()) return false; + final post = _postDetailData!.post!; + return previous ? post.pos > 1 : post.pos < post.postCollection!.postCount; + } + + Future _loadAdjacentPost({required bool previous}) { + final existing = _adjacentPostLoads[previous]; + if (existing != null) return existing; + final request = widget.sequenceSource != null + ? _loadSequenceAdjacentPost(previous: previous) + : _loadCollectionAdjacentPost(previous: previous); + final task = request.timeout( + const Duration(seconds: 15), + onTimeout: () => null, + ); + _adjacentPostLoads[previous] = task; + task.then((value) { + if (value == null && identical(_adjacentPostLoads[previous], task)) { + _adjacentPostLoads.remove(previous); } }); - var t2 = await _fetchGift(); - _loadingInfo = false; - return t1 == IndicatorResult.success && t2 == IndicatorResult.success - ? IndicatorResult.success - : IndicatorResult.fail; + return task; + } + + Future _loadCollectionAdjacentPost({ + required bool previous, + }) async { + if (!hasCollection() || !_canNavigateAdjacent(previous: previous)) { + return null; + } + try { + final value = await CollectionApi.getPreOrNextPost( + isPre: previous, + postId: postId, + blogId: blogId, + blogName: blogName, + collectionId: collectionId, + ).timeout(const Duration(seconds: 20)); + if (value['meta']?['status'] != 200) return null; + final response = value['response']; + if (response is! List || response.isEmpty || response.first is! Map) { + return null; + } + return PostDetailData.fromJson( + Map.from(response.first as Map), + ); + } catch (error, stackTrace) { + ILogger.error('Failed to preload collection post', error, stackTrace); + return null; + } + } + + Future _loadSequenceAdjacentPost({ + required bool previous, + }) async { + final source = widget.sequenceSource; + if (source == null) return null; + try { + final entry = await source + .adjacentTo(postId, previous: previous) + .timeout(const Duration(seconds: 20)); + if (entry == null) return null; + final value = await PostApi.getDetail( + postId: entry.postId, + blogId: entry.blogId, + blogName: entry.blogName, + ).timeout(const Duration(seconds: 20)); + if (value['meta']?['status'] != 200) return null; + final posts = value['response']?['posts']; + if (posts is! List || posts.isEmpty || posts.first is! Map) return null; + return PostDetailData.fromJson( + Map.from(posts.first as Map), + ); + } catch (error, stackTrace) { + ILogger.error('Failed to preload grain post', error, stackTrace); + return null; + } + } + + void _scheduleAdjacentPostPreload() { + _adjacentPostLoads.clear(); + if (!_supportsPostSwipe) return; + for (final previous in const [true, false]) { + if (_canNavigateAdjacent(previous: previous)) { + unawaited(_loadAdjacentPost(previous: previous)); + } + } } - _fetchRecommendPosts({bool append = true}) async { - if (_loadingRecommend) return; + Future _switchToAdjacentPost({required bool previous}) async { + if (_switchingPost || !_supportsPostSwipe) return false; + if (!_canNavigateAdjacent(previous: previous)) { + IToast.showTop(previous + ? appLocalizations.haveAtFirstPost + : appLocalizations.haveAtLastPost); + await _animatePostSwipeOffset(0); + return false; + } + + final current = _postDetailData; + if (current?.post == null) return false; + final nextTask = _loadAdjacentPost(previous: previous); + _switchingPost = true; + _postSwipePrevious = previous; + if (mounted) setState(() {}); + HapticFeedback.mediumImpact(); + final width = MediaQuery.sizeOf(context).width; + final exitOffset = previous ? width : -width; + await _animatePostSwipeOffset( + exitOffset, + duration: const Duration(milliseconds: 170), + curve: Curves.easeInCubic, + ); + if (!mounted) return false; + + _postDetailData = null; + _inited = InitPhase.connecting; + _postSwipeAnimation = null; + setState(() { + _postSwipeOffset = 0; + _postSwipeRawOffset = 0; + _postSwipePrevious = null; + _postSwipeReady = false; + _postSwipeAtBoundary = false; + _postSwipeBoundaryReady = false; + }); + + final result = await Future.wait([ + nextTask, + Future.delayed(const Duration(milliseconds: 180)), + ]); + if (!mounted) return false; + final next = result.first as PostDetailData?; + if (next?.post == null) { + _postDetailData = current; + _inited = InitPhase.successful; + _postSwipeOffset = exitOffset; + setState(() {}); + await _animatePostSwipeOffset( + 0, + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + ); + if (!mounted) return false; + _switchingPost = false; + setState(() {}); + IToast.showTop(appLocalizations.adjacentPostLoadFailed); + return false; + } + + _resetPostScopedState(); + _postDetailData = next; + _inited = InitPhase.successful; + _updateMeta(schedulePreload: false); + _jumpPostScrollToTop(); + _postSwipeAnimation = null; + setState(() { + _postSwipeOffset = -exitOffset; + _postSwipeRawOffset = 0; + _postSwipeReady = false; + _postSwipeAtBoundary = false; + _postSwipeBoundaryReady = false; + }); + await _animatePostSwipeOffset( + 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + if (!mounted) return false; + + _switchingPost = false; + _postSwipePrevious = null; + _scheduleAdjacentPostPreload(); + setState(() {}); + unawaited(_uploadHistory()); + unawaited(_loadCurrentPostExtras()); + return true; + } + + void _resetPostScopedState() { + _previewImages = []; + _giftInfoData = null; + _giftTypeString = ''; + _giftPreviewDescription = ''; + _giftCost = ''; + totalHotOrNewComments = 0; + hotComments.clear(); + newComments.clear(); + _recommendPosts.clear(); + _currentPage = 0; + _recommendNoMore = false; + _recommendRequestToken++; + _loadingRecommend = false; + mainColors = []; + } + + Future _loadCurrentPostExtras() async { + final expectedPostId = postId; + await Future.wait([ + _fetchGift(expectedPostId: expectedPostId), + _fetchHotComments(expectedPostId: expectedPostId), + _fetchRecommendPosts(append: false, expectedPostId: expectedPostId), + ]); + } + + void _jumpPostScrollToTop() { + if (_scrollController.hasClients) _scrollController.jumpTo(0); + if (_tabletScrollController.hasClients) { + _tabletScrollController.jumpTo(0); + } + _scrollAllowsFloatingOperationBar = true; + _floatingOperationBarVisible.value = true; + } + + bool _handlePostScrollNotification(ScrollNotification notification) { + if (notification.metrics.axis != Axis.vertical) return false; + var delta = 0.0; + if (notification is ScrollUpdateNotification) { + delta = notification.scrollDelta ?? 0; + } else if (notification is OverscrollNotification) { + delta = notification.overscroll; + } + if (notification.metrics.pixels <= 16 || delta < -1) { + _scrollAllowsFloatingOperationBar = true; + } else if (delta > 1) { + _scrollAllowsFloatingOperationBar = false; + } + _scheduleFloatingOperationBarVisibilitySync(); + return false; + } + + void _scheduleFloatingOperationBarVisibilitySync() { + if (!mounted || _floatingOperationBarSyncScheduled) return; + _floatingOperationBarSyncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _floatingOperationBarSyncScheduled = false; + if (mounted) _syncFloatingOperationBarVisibility(); + }); + } + + void _syncFloatingOperationBarVisibility() { + if (!mounted) return; + final shouldShow = _scrollAllowsFloatingOperationBar && + !_isInlineOperationSectionInViewport(); + if (shouldShow != _floatingOperationBarVisible.value) { + _floatingOperationBarVisible.value = shouldShow; + } + } + + bool _isInlineOperationSectionInViewport() { + return [ + _collectionViewportKey, + _grainViewportKey, + _tagViewportKey, + _operationViewportKey, + commentKey, + _commentListViewportKey, + _commentEndViewportKey, + ].any(_isViewportKeyVisible); + } + + bool _isViewportKeyVisible(GlobalKey key) { + final renderObject = key.currentContext?.findRenderObject(); + if (renderObject is! RenderBox || + !renderObject.attached || + !renderObject.hasSize) { + return false; + } + final top = renderObject.localToGlobal(Offset.zero).dy; + final bottom = top + renderObject.size.height; + final viewportHeight = MediaQuery.sizeOf(context).height; + return bottom > 0 && top < viewportHeight; + } + + Future _animatePostSwipeOffset( + double target, { + Duration duration = const Duration(milliseconds: 210), + Curve curve = Curves.easeOutCubic, + }) async { + _postSwipeAnimationController.stop(); + _postSwipeAnimationController.duration = duration; + _postSwipeAnimation = Tween( + begin: _postSwipeOffset, + end: target, + ).animate(CurvedAnimation( + parent: _postSwipeAnimationController, + curve: curve, + )); + try { + await _postSwipeAnimationController.forward(from: 0).orCancel; + } on TickerCanceled { + return; + } + _postSwipeAnimation = null; + _postSwipeOffset = target; + } + + _fetchRecommendPosts({ + bool append = true, + int? expectedPostId, + }) async { + final requestPostId = expectedPostId ?? postId; + final requestBlogId = blogId; + if (requestPostId != postId) return IndicatorResult.none; + if (_loadingRecommend || (append && _recommendNoMore)) { + return IndicatorResult.none; + } _loadingRecommend = true; - if (append) _currentPage++; + final requestToken = ++_recommendRequestToken; + final requestPage = append ? _currentPage + 1 : 1; return await RecommendApi.getPostRecomend( - page: _currentPage, - postId: postId, - blogId: blogId, + page: requestPage, + postId: requestPostId, + blogId: requestBlogId, ).then((value) { try { + if (!mounted || + postId != requestPostId || + requestToken != _recommendRequestToken) { + return IndicatorResult.none; + } if (value['code'] != 0) { if (value['code'] != 4009) { IToast.showTop(value['msg']); @@ -506,8 +861,18 @@ class _PostDetailScreenState extends BaseDynamicState } else { List tmp = value['data']['list']; if (append == false) _recommendPosts.clear(); - _recommendPosts - .addAll(tmp.map((e) => PostListItem.fromJson(e)).toList()); + final newPosts = tmp + .map((e) => PostListItem.fromJson(e)) + .where( + (post) => !_recommendPosts.any( + (current) => current.itemId == post.itemId, + ), + ) + .toList(); + _recommendPosts.addAll(newPosts); + _currentPage = requestPage; + _recommendNoMore = tmp.isEmpty || newPosts.isEmpty; + if (_recommendNoMore && append) return IndicatorResult.noMore; return IndicatorResult.success; } } catch (e, t) { @@ -515,35 +880,28 @@ class _PostDetailScreenState extends BaseDynamicState ILogger.error("Failed to load recommend post", e, t); return IndicatorResult.fail; } finally { + if (requestToken == _recommendRequestToken) { + _loadingRecommend = false; + } if (mounted) setState(() {}); - _loadingRecommend = false; } }); } - _updateMeta({bool swipeToFirst = true}) { + _updateMeta({ + bool swipeToFirst = true, + bool schedulePreload = true, + }) { if (_postDetailData == null) return; - setState(() { - isArticle = _postDetailData!.post!.type == 1; - }); + isArticle = _postDetailData!.post!.type == 1; collectionId = _postDetailData!.post!.postCollection != null ? _postDetailData!.post!.postCollection!.id : 0; postId = _postDetailData!.post!.id; blogId = _postDetailData!.post!.blogId; blogName = _postDetailData!.post!.blogInfo!.blogName; - _shareController.value = _postDetailData!.shared == true ? 1 : 0; - _likeController.value = _postDetailData!.liked == true ? 1 : 0; + final colorPostId = postId; setDownloadState(DownloadState.none, recover: false); - int count = 3; - while (count-- > 0) { - Future.delayed(const Duration(milliseconds: 300), - () => _likeController.value = _postDetailData!.liked == true ? 1 : 0); - Future.delayed( - const Duration(milliseconds: 300), - () => - _shareController.value = _postDetailData!.shared == true ? 1 : 0); - } if (swipeToFirst) { setState(() { _currentIndex = 1; @@ -556,8 +914,9 @@ class _PostDetailScreenState extends BaseDynamicState context, photoLinks.map((e) => e.middle).toList(), ).then((value) { - if (mounted) setState(() {}); + if (!mounted || postId != colorPostId) return; mainColors = value; + setState(() {}); }); } else { List imageUrls = _getArticleImages(); @@ -565,14 +924,21 @@ class _PostDetailScreenState extends BaseDynamicState context, imageUrls, ).then((value) { - if (mounted) setState(() {}); + if (!mounted || postId != colorPostId) return; mainColors = value; + setState(() {}); + }); + } + if (schedulePreload) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scheduleAdjacentPostPreload(); }); } } _onRefresh() async { _currentPage = 0; + _recommendNoMore = false; var t1 = await _fetchPostDetail(); var t2 = await _fetchRecommendPosts(append: false); return t1 == IndicatorResult.success && t2 == IndicatorResult.success @@ -587,16 +953,17 @@ class _PostDetailScreenState extends BaseDynamicState @override Widget build(BuildContext context) { super.build(context); + final design = context.design; return Scaffold( appBar: _buildAppBar(), - backgroundColor: ChewieTheme.getBackground(context), - body: _buildBody(), - extendBody: true, - bottomNavigationBar: _showBottomBar && - _postDetailData != null && - _postDetailData!.post != null - ? _buildFloatingOperationRow() - : null, + backgroundColor: design.colors.page, + body: Stack( + fit: StackFit.expand, + children: [ + _buildPostSwipeLayer(_buildBody()), + _buildFloatingOperationOverlay(), + ], + ), ); } @@ -622,11 +989,246 @@ class _PostDetailScreenState extends BaseDynamicState } } + Widget _buildPostSwipeLayer(Widget child) { + if (!_supportsPostSwipe) return child; + final width = MediaQuery.sizeOf(context).width; + final contentOpacity = + (1 - min(0.16, _postSwipeOffset.abs() / max(width, 1) * 0.16)) + .toDouble(); + return PostSwipeGestureDetector( + behavior: HitTestBehavior.translucent, + excludedRegions: [_imageSwiperViewportKey], + onHorizontalDragStart: _handlePostSwipeStart, + onHorizontalDragUpdate: _handlePostSwipeUpdate, + onHorizontalDragEnd: _handlePostSwipeEnd, + onHorizontalDragCancel: _handlePostSwipeCancel, + child: Stack( + fit: StackFit.expand, + children: [ + Transform.translate( + offset: Offset(_postSwipeOffset, 0), + child: Opacity(opacity: contentOpacity, child: child), + ), + _buildPostSwipeHint(), + ], + ), + ); + } + + void _handlePostSwipeStart(DragStartDetails details) { + if (_switchingPost) return; + _postSwipeAnimationController.stop(); + _postSwipeAnimation = null; + setState(() { + _postSwipeRawOffset = 0; + _postSwipeOffset = 0; + _postSwipePrevious = null; + _postSwipeReady = false; + _postSwipeAtBoundary = false; + _postSwipeBoundaryReady = false; + }); + } + + void _handlePostSwipeUpdate(DragUpdateDetails details) { + if (_switchingPost) return; + _postSwipeRawOffset += details.primaryDelta ?? 0; + if (_postSwipeRawOffset.abs() < 1) return; + final previous = _postSwipeRawOffset > 0; + final available = _canNavigateAdjacent(previous: previous); + final width = MediaQuery.sizeOf(context).width; + final reachedCommitDistance = + PostSwipeGesturePolicy.hasReachedCommitDistance( + rawOffset: _postSwipeRawOffset, + viewportWidth: width, + ); + final ready = available && reachedCommitDistance; + final boundaryReady = + !available && _hasPostSequenceContext && reachedCommitDistance; + if (ready && !_postSwipeReady) HapticFeedback.selectionClick(); + if (boundaryReady && !_postSwipeBoundaryReady) { + HapticFeedback.lightImpact(); + } + setState(() { + _postSwipePrevious = previous; + _postSwipeReady = ready; + _postSwipeAtBoundary = !available; + _postSwipeBoundaryReady = boundaryReady; + _postSwipeOffset = PostSwipeGesturePolicy.visualOffset( + rawOffset: _postSwipeRawOffset, + viewportWidth: width, + available: available, + ); + }); + } + + void _handlePostSwipeEnd(DragEndDetails details) { + if (_switchingPost || _postSwipePrevious == null) return; + final previous = _postSwipePrevious!; + final available = _canNavigateAdjacent(previous: previous); + final shouldCommit = PostSwipeGesturePolicy.shouldCommit( + rawOffset: _postSwipeRawOffset, + velocity: details.primaryVelocity ?? 0, + viewportWidth: MediaQuery.sizeOf(context).width, + available: available, + ); + if (shouldCommit) { + unawaited(_switchToAdjacentPost(previous: previous)); + return; + } + if (!available && _hasPostSequenceContext && _postSwipeBoundaryReady) { + IToast.showTop(previous + ? appLocalizations.haveAtFirstPost + : appLocalizations.haveAtLastPost); + } + unawaited(_reboundPostSwipe()); + } + + void _handlePostSwipeCancel() { + if (!_switchingPost) unawaited(_reboundPostSwipe()); + } + + Future _reboundPostSwipe() async { + setState(() { + _postSwipeRawOffset = 0; + _postSwipeReady = false; + _postSwipeBoundaryReady = false; + }); + await _animatePostSwipeOffset(0); + if (!mounted) return; + setState(() { + _postSwipePrevious = null; + _postSwipeAtBoundary = false; + _postSwipeBoundaryReady = false; + }); + } + + Widget _buildPostSwipeHint() { + final previous = _postSwipePrevious; + if (previous == null) return const SizedBox.shrink(); + if (_postSwipeAtBoundary && !_hasPostSequenceContext) { + return const SizedBox.shrink(); + } + final revealProgress = _switchingPost + ? 0.0 + : _postSwipeAtBoundary + ? PostSwipeGesturePolicy.boundaryHintRevealProgress( + rawOffset: _postSwipeRawOffset, + viewportWidth: MediaQuery.sizeOf(context).width, + hasSequenceContext: _hasPostSequenceContext, + ) + : PostSwipeGesturePolicy.hintRevealProgress(_postSwipeRawOffset); + final postLabel = + previous ? appLocalizations.prePost : appLocalizations.nextPost; + final label = _postSwipeAtBoundary + ? previous + ? appLocalizations.haveAtFirstPost + : appLocalizations.haveAtLastPost + : _postSwipeReady + ? appLocalizations.releaseToSwitchPost(postLabel) + : appLocalizations.continueSwipeToPost(postLabel); + final color = _postSwipeReady && !_postSwipeAtBoundary + ? Theme.of(context).primaryColor + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: _postSwipeAtBoundary ? 0.55 : 0.82); + final shadows = [ + Shadow( + color: ChewieTheme.getBackground(context).withValues(alpha: 0.98), + blurRadius: 8, + ), + Shadow( + color: ChewieTheme.getBackground(context).withValues(alpha: 0.9), + blurRadius: 3, + ), + ]; + final icon = ChewieIcon( + _postSwipeAtBoundary + ? LoftifyIcons.block + : previous + ? LoftifyIcons.previousPost + : LoftifyIcons.nextPost, + size: 21, + color: color, + shadows: shadows, + ); + final isChinese = Localizations.localeOf(context).languageCode == 'zh'; + final hintText = isChinese ? label.split('').join('\n') : label; + final text = Text( + hintText, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .labelLarge + ?.apply( + color: color, + fontWeightDelta: _postSwipeReady ? 2 : 1, + ) + .copyWith( + height: isChinese ? 1.08 : null, + shadows: shadows, + ), + ); + return Positioned( + left: previous ? 16 : null, + right: previous ? null : 16, + top: 0, + bottom: 0, + child: IgnorePointer( + child: Center( + child: TweenAnimationBuilder( + tween: Tween(end: revealProgress), + duration: const Duration(milliseconds: 110), + curve: Curves.easeOutCubic, + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.scale( + scale: 0.72 + value * 0.28, + child: child, + ), + ), + child: Semantics( + liveRegion: true, + label: label, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 120), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.9, end: 1).animate(animation), + child: child, + ), + ), + child: Column( + key: ValueKey(label), + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(height: 7), + if (isChinese) + text + else + RotatedBox( + quarterTurns: previous ? 3 : 1, + child: text, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + Widget _buildNormalBody() { return ScreenTypeLayout.builder( mobile: (context) => EasyRefresh.builder( onRefresh: _onRefresh, - onLoad: _onLoad, + onLoad: _recommendNoMore ? null : _onLoad, triggerAxis: Axis.vertical, childBuilder: (context, physics) => Stack( children: [ @@ -674,7 +1276,7 @@ class _PostDetailScreenState extends BaseDynamicState Selector( selector: (context, appProvider) => appProvider.windowSize, builder: (context, windowSize, child) => - windowSize.width > minimumSize.width || + windowSize.width > postDetailTwoPaneMinWindowSize.width || ResponsiveUtil.isLandscapeTablet() ? ScreenTypeLayout.builder( mobile: (context) => _buildMobileMainBody(physics), @@ -682,35 +1284,24 @@ class _PostDetailScreenState extends BaseDynamicState ) : _buildMobileMainBody(physics), ), - if (!_showBottomBar) - Positioned( - right: 16, - bottom: 16, - child: ScrollToHide.multi( - controller: _scrollToHideController, - scrollControllers: [_scrollController], - hideDirection: Axis.vertical, - child: _buildFloatingButtons(), - ), - ), ], ); } _buildMobileMainBody(ScrollPhysics physics) { - return CustomScrollView( - controller: _scrollController, - physics: physics, - slivers: [ - SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + return NotificationListener( + onNotification: _handlePostScrollNotification, + child: CustomScrollView( + controller: _scrollController, + physics: physics, + slivers: [ + SliverList.list( + key: _commonContentSliverKey, children: _buildCommonContent(false), ), - ), - _buildRecommendFlow(), - ], + _buildRecommendFlow(), + ], + ), ); } @@ -738,15 +1329,18 @@ class _PostDetailScreenState extends BaseDynamicState : max(MediaQuery.sizeOf(context).width * 1 / 3, 400), ), // minSize: 300, - child: ListView( - controller: _tabletScrollController, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + child: EasyRefresh.builder( + onRefresh: _onRefresh, + triggerAxis: Axis.vertical, + childBuilder: (context, physics) => + NotificationListener( + onNotification: _handlePostScrollNotification, + child: ListView( + controller: _tabletScrollController, + physics: physics, children: _buildCommonContent(true), ), - ], + ), ), ), ResizableChild( @@ -815,41 +1409,44 @@ class _PostDetailScreenState extends BaseDynamicState } } - _handleLike({ + Future _handleLike({ bool? isLike, - }) { + }) async { HapticFeedback.mediumImpact(); - PostApi.likeOrUnLike( - isLike: isLike ?? !(_postDetailData!.liked == true), - postId: _postDetailData!.post!.id, - blogId: _postDetailData!.post!.blogId) - .then((value) { - setState(() { - if (value['meta']['status'] != 200) { - if (StringUtil.isNotEmpty(value['meta']['desc']) && - StringUtil.isNotEmpty(value['meta']['msg'])) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } - if (value['meta']['status'] == 4071) { - Utils.validSlideCaptcha(context); - } - } else { - _postDetailData!.liked = isLike ?? !(_postDetailData!.liked == true); - if (_postDetailData!.liked == true) { - _likeController.forward(); - } else { - _likeController.value = 0; - } - _postDetailData!.post!.postCount!.favoriteCount += - (_postDetailData!.liked == true) ? 1 : -1; - if (_postDetailData!.post!.postCount!.postHot != null) { - _postDetailData!.post!.postCount!.postHot = - _postDetailData!.post!.postCount!.postHot! + - ((_postDetailData!.liked == true) ? 1 : -1); - } - } - }); - }); + final previousLiked = _postDetailData!.liked == true; + final targetLiked = isLike ?? !previousLiked; + final value = await PostApi.likeOrUnLike( + isLike: targetLiked, + postId: _postDetailData!.post!.id, + blogId: _postDetailData!.post!.blogId, + ); + if (!mounted) return; + + final status = value['meta']['status']; + if (status != 200) { + final message = value['meta']['desc'] ?? value['meta']['msg']; + if (StringUtil.isNotEmpty(message)) IToast.showTop(message); + if (status == 4071) Utils.validSlideCaptcha(context); + return; + } + + final delta = targetLiked == previousLiked ? 0 : (targetLiked ? 1 : -1); + _postDetailData!.liked = targetLiked; + final postCount = _postDetailData!.post!.postCount!; + postCount.favoriteCount = + (postCount.favoriteCount + delta).clamp(0, 100000000000000000); + if (postCount.postHot != null) { + postCount.postHot = + (postCount.postHot! + delta).clamp(0, 100000000000000000); + } + final sourceItem = widget.generalPostItem; + if (sourceItem != null) { + sourceItem + ..liked = targetLiked + ..likeCount = postCount.favoriteCount; + sourceItem.onLikeChanged?.call(targetLiked); + } + setState(() {}); } _handleRecommend({ @@ -873,11 +1470,6 @@ class _PostDetailScreenState extends BaseDynamicState } else { _postDetailData!.shared = isRecommend ?? !(_postDetailData!.shared == true); - if (_postDetailData!.shared == true) { - _shareController.forward(); - } else { - _shareController.value = 0; - } _postDetailData!.post!.postCount!.shareCount += (_postDetailData!.shared == true) ? 1 : -1; if (_postDetailData!.post!.postCount!.postHot != null) { @@ -925,12 +1517,18 @@ class _PostDetailScreenState extends BaseDynamicState IToast.showTop(appLocalizations.unsupportDownloadCurrentImageinArticle); return; } - if (downloadState == DownloadState.none) { + if (downloadState == DownloadState.none || + downloadState == DownloadState.failed) { + final downloadPostId = postId; setDownloadState(DownloadState.loading, recover: false); - FileUtil.saveIllust( + LoftifyFileUtil.saveIllust( context, _getIllusts()[_currentIndex - 1], + onReceiveProgress: (received, total) { + _setDownloadProgress(received, total, downloadPostId); + }, ).then((res) { + if (!mounted || postId != downloadPostId) return; if (res) { setDownloadState(DownloadState.succeed); _handleDownloadSuccessAction(); @@ -946,9 +1544,19 @@ class _PostDetailScreenState extends BaseDynamicState IToast.showTop(appLocalizations.noImageToDownload); return; } - if (downloadState == DownloadState.none) { + if (downloadState == DownloadState.none || + downloadState == DownloadState.failed) { + final downloadPostId = postId; setDownloadState(DownloadState.loading, recover: false); - FileUtil.saveIllusts(context, _getIllusts()).then((res) { + LoftifyFileUtil.saveIllusts( + context, + _getIllusts(), + source: _postAllDownloadSource(), + onReceiveProgress: (received, total) { + _setDownloadProgress(received, total, downloadPostId); + }, + ).then((res) { + if (!mounted || postId != downloadPostId) return; if (res) { _handleDownloadSuccessAction(); setDownloadState(DownloadState.succeed); @@ -959,19 +1567,39 @@ class _PostDetailScreenState extends BaseDynamicState } } + DownloadSourceDescriptor _postAllDownloadSource() { + final post = _postDetailData!.post!; + final title = StringUtil.clearBlank(post.title); + final illusts = _getIllusts(); + return DownloadSourceDescriptor( + type: DownloadSourceType.postAll, + sourceId: postId.toString(), + title: title.isEmpty ? appLocalizations.postDetail : title, + thumbnailUrl: illusts.isEmpty ? null : illusts.first.url, + metadata: { + 'postId': postId.toString(), + 'blogId': blogId.toString(), + 'blogName': blogName, + 'permalink': post.permalink, + }, + ); + } + _buildCommonContent(bool isTablet) { return [ - GestureDetector( - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - UserDetailScreen( - blogId: _postDetailData!.post!.blogId, - blogName: _postDetailData!.post!.blogInfo!.blogName, - ), - ); - }, - child: _buildUserRow(), + _buildReadingRail( + GestureDetector( + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + UserDetailScreen( + blogId: _postDetailData!.post!.blogId, + blogName: _postDetailData!.post!.blogInfo!.blogName, + ), + ); + }, + child: _buildUserRow(), + ), ), if (_hasImage()) _buildImageList(), GestureDetector( @@ -979,96 +1607,147 @@ class _PostDetailScreenState extends BaseDynamicState onDoubleTap: _handleDoubleTap, child: _buildPostContent(), ), - GestureDetector( - onDoubleTapDown: _handleDoubleTapDown, - onDoubleTap: _handleDoubleTap, - child: _buildEggContent(), + _buildReadingRail( + GestureDetector( + onDoubleTapDown: _handleDoubleTapDown, + onDoubleTap: _handleDoubleTap, + child: _buildEggContent(), + ), ), - if (hasCollection()) _buildCollectionItem(), - if (hasGrain()) _buildGrainItem(), - GestureDetector( - onDoubleTapDown: _handleDoubleTapDown, - onDoubleTap: _handleDoubleTap, - child: _buildTagList(), + if (hasCollection()) + _buildReadingRail( + _buildCollectionItem(key: _collectionViewportKey), + ), + if (hasGrain()) + _buildReadingRail(_buildGrainItem(key: _grainViewportKey)), + _buildReadingRail( + GestureDetector( + key: _tagViewportKey, + onDoubleTapDown: _handleDoubleTapDown, + onDoubleTap: _handleDoubleTap, + child: _buildTagList(), + ), ), - _buildMarkInfo(), - Stack( - children: [ - MyDivider(), - _buildOperationRow(), - ], + _buildReadingRail(_buildMarkInfo()), + _buildReadingRail( + Column( + key: _operationViewportKey, + children: [ + MyDivider(), + _buildOperationRow(), + ], + ), ), - Container( - key: commentKey, - child: ItemBuilder.buildTitle( - context, - title: hotComments.isNotEmpty - ? appLocalizations.hotComment - : appLocalizations.latestComment, - bottomMargin: 12, - topMargin: 24, + _buildReadingRail( + Container( + key: commentKey, + child: ItemBuilder.buildTitle( + context, + title: hotComments.isNotEmpty + ? appLocalizations.hotComment + : appLocalizations.latestComment, + bottomMargin: 12, + topMargin: 24, + ), ), ), - Flexible( - fit: FlexFit.loose, - child: - _buildComments(hotComments.isNotEmpty ? hotComments : newComments), + _buildReadingRail( + _buildComments( + hotComments.isNotEmpty ? hotComments : newComments, + key: _commentListViewportKey, + ), ), if (totalHotOrNewComments <= 0) - Container( - alignment: Alignment.center, - margin: const EdgeInsets.symmetric(vertical: 24), - child: EmptyPlaceholder(text: appLocalizations.noComment, topPadding: 0), + _buildReadingRail( + Container( + key: _commentEndViewportKey, + alignment: Alignment.center, + margin: const EdgeInsets.symmetric(vertical: 24), + child: EmptyPlaceholder( + text: appLocalizations.noComment, + topPadding: 0, + ), + ), ), if (totalHotOrNewComments > 0) - Center( - child: Container( - margin: EdgeInsets.only( - left: isTablet ? 0 : MediaQuery.sizeOf(context).width / 5, - right: isTablet ? 0 : MediaQuery.sizeOf(context).width / 5, - top: 12, - bottom: isTablet ? 20 : 0, - ), - width: isTablet ? 240 : null, - child: RoundIconTextButton( - padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 12), - text: appLocalizations.viewAllComments, - onPressed: () { - BottomSheetBuilder.showBottomSheet( - context, - (context) => SingleChildScrollView( - controller: ModalScrollController.of(context), - child: CommentBottomSheet( + _buildReadingRail( + Center( + key: _commentEndViewportKey, + child: Container( + margin: EdgeInsets.only( + left: isTablet ? 0 : MediaQuery.sizeOf(context).width / 5, + right: isTablet ? 0 : MediaQuery.sizeOf(context).width / 5, + top: 12, + bottom: isTablet ? 20 : 0, + ), + width: isTablet ? 240 : null, + child: RoundIconTextButton( + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + text: appLocalizations.viewAllComments, + onPressed: () { + BottomSheetBuilder.showBottomSheet( + context, + (context) => CommentBottomSheet( postId: postId, blogId: blogId, publishTime: _postDetailData!.post!.publishTime, ), - ), - enableDrag: false, - backgroundColor: ChewieTheme.getBackground(context), - ); - }, + enableDrag: false, + backgroundColor: ChewieTheme.getBackground(context), + topRadius: const Radius.circular(24), + ); + }, + ), ), ), ), if (!isTablet) - ItemBuilder.buildTitle( - context, - title: appLocalizations.moreRecommend, - bottomMargin: 12, - topMargin: 24, + _buildReadingRail( + ItemBuilder.buildTitle( + context, + title: appLocalizations.moreRecommend, + bottomMargin: 12, + topMargin: 24, + ), ), ]; } - jumpToComment() { - if (commentKey.currentContext != null) { - Scrollable.ensureVisible( - commentKey.currentContext!, - duration: const Duration(milliseconds: 300), - curve: Curves.ease, - ); - } + Widget _buildReadingRail(Widget child) { + return LoftifyReadingFrame( + applyHorizontalPadding: false, + child: child, + ); + } + + Future jumpToComment() async { + final tabletBody = _tabletScrollController.hasClients; + final controller = tabletBody + ? _tabletScrollController + : _scrollController.hasClients + ? _scrollController + : null; + if (controller == null) return; + final requestedPostId = postId; + + // The heading may be unmounted by the lazy sliver. Its parent sliver + // remains mounted, so approach the end of the post content first. This + // works from both a long article above and recommendations below. + final mainSliver = + _commonContentSliverKey.currentContext?.findRenderObject(); + final contentExtent = tabletBody + ? controller.position.maxScrollExtent + + controller.position.viewportDimension + : mainSliver is RenderSliver + ? mainSliver.geometry?.scrollExtent + : null; + await revealLazyComment( + controller: controller, + anchorKey: commentKey, + contentExtent: contentExtent, + isActive: () => mounted && requestedPostId == postId, + ); } _buildEggTitle(String tag, String title) { @@ -1081,9 +1760,14 @@ class _PostDetailScreenState extends BaseDynamicState margin: const EdgeInsets.only(left: 16, right: 8), child: RoundIconTextButton( text: tag, + textStyle: Theme.of(context).textTheme.labelSmall?.apply( + color: Colors.white, + fontWeightDelta: 1, + ), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), background: ChewieColors.biliPinkPrimaryColor, radius: 4, + height: 32, ), ), ), @@ -1151,8 +1835,8 @@ class _PostDetailScreenState extends BaseDynamicState child: _buildRichIconTextButton( icon: RotatedBox( quarterTurns: 2, - child: Icon( - Icons.format_quote, + child: ChewieIcon( + LoftifyIcons.quote, size: 16, color: labelSmall?.color, ), @@ -1260,7 +1944,8 @@ class _PostDetailScreenState extends BaseDynamicState if (liangpiaoCount > 0) { DialogBuilder.showConfirmDialog( context, - title: appLocalizations.presentToUnlock(appLocalizations.liangpiao), + title: appLocalizations + .presentToUnlock(appLocalizations.liangpiao), message: appLocalizations.presentToUnlockMessage( "$liangpiaoCount${appLocalizations.liangpiaoCount}"), onTapConfirm: () async { @@ -1318,16 +2003,20 @@ class _PostDetailScreenState extends BaseDynamicState } _buildUserRow() { + final design = context.design; bool hasAvatarBox = (_postDetailData!.post?.blogInfo!.bigAvaImg ?? "").isNotEmpty; return ClickableWrapper( child: Container( color: Colors.transparent, - padding: EdgeInsets.only( - left: 16, - right: ResponsiveUtil.isLandscapeLayout() ? 10 : 16, - top: 10, - bottom: 10), + padding: EdgeInsetsDirectional.fromSTEB( + design.spacing.xl, + design.spacing.lg, + ResponsiveUtil.isLandscapeLayout() + ? design.spacing.md + : design.spacing.xl, + design.spacing.lg, + ), child: Row( children: [ ItemBuilder.buildAvatar( @@ -1337,7 +2026,7 @@ class _PostDetailScreenState extends BaseDynamicState imageUrl: _postDetailData!.post?.blogInfo!.bigAvaImg ?? "", tagPrefix: "postDetailScreen${_postDetailData!.post!.id}", ), - const SizedBox(width: 10), + SizedBox(width: design.spacing.lg), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1351,24 +2040,24 @@ class _PostDetailScreenState extends BaseDynamicState text: _postDetailData!.post?.blogInfo!.blogNickName, child: Text( _postDetailData!.post?.blogInfo!.blogNickName ?? "", - style: Theme.of(context).textTheme.titleSmall?.apply( - fontWeightDelta: 2, - ), + style: design.typography.cardTitle, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - if (hasAvatarBox) const SizedBox(height: 3), + if (hasAvatarBox) SizedBox(height: design.spacing.xs), Text( "${TimeUtil.formatTimestamp(_postDetailData!.post?.publishTime ?? 0)} · ${StringUtil.isNotEmpty(_postDetailData!.post?.ipLocation) ? _postDetailData!.post?.ipLocation : ""} · ${_postDetailData!.post?.postCount?.postHot ?? 0}${appLocalizations.hotCount}", - style: Theme.of(context).textTheme.bodySmall, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), - const SizedBox(width: 40), + SizedBox(width: design.spacing.xxl), if (_myBlogId != _postDetailData!.post!.blogId) LoftifyItemBuilder.buildFramedDoubleButton( context: context, @@ -1432,8 +2121,9 @@ class _PostDetailScreenState extends BaseDynamicState [photoLinks, previewIndex] = _getImages(); String photoCaptionJson = _postDetailData!.post!.photoCaptions; if (StringUtil.isEmpty(photoCaptionJson)) photoCaptionJson = "[]"; - List captions = - StringUtil.parseJsonList(photoCaptionJson).map((e) => e.toString()).toList(); + List captions = StringUtil.parseJsonList(photoCaptionJson) + .map((e) => e.toString()) + .toList(); double heightMinThreshold = 200; // double heightMaxThreshold = MediaQuery.sizeOf(context).height - 340; double heightMaxThreshold = 600; @@ -1447,6 +2137,7 @@ class _PostDetailScreenState extends BaseDynamicState alignment: Alignment.center, children: [ SizedBox( + key: photoLinks.length > 1 ? _imageSwiperViewportKey : null, height: preferedHeight, child: Swiper( controller: _swiperController, @@ -1479,8 +2170,11 @@ class _PostDetailScreenState extends BaseDynamicState showClose: false, fullScreen: true, useFade: true, + opaque: false, HeroPhotoViewScreen( - imageUrls: _getIllusts(), + imageUrls: _getIllusts() + .map((illust) => illust.url) + .toList(), initIndex: startIndex + index, captions: captions, tagPrefix: tagPrefix, @@ -1528,8 +2222,9 @@ class _PostDetailScreenState extends BaseDynamicState left: 6, child: ItemBuilder.buildTranslucentTag( context, - text: - _isCatutu ? appLocalizations.eraseBlur : _giftTypeString, + text: _isCatutu + ? appLocalizations.eraseBlur + : _giftTypeString, opacity: 0.5, ), ), @@ -1585,8 +2280,8 @@ class _PostDetailScreenState extends BaseDynamicState }, child: ClickableWrapper( clickable: _currentIndex != 1, - child: const Icon( - Icons.keyboard_arrow_left_rounded, + child: const ChewieIcon( + LoftifyIcons.previous, size: 30, color: Colors.white, ), @@ -1613,8 +2308,8 @@ class _PostDetailScreenState extends BaseDynamicState }, child: ClickableWrapper( clickable: _currentIndex != photoLinks.length, - child: const Icon( - Icons.keyboard_arrow_right_rounded, + child: const ChewieIcon( + LoftifyIcons.next, size: 30, color: Colors.white, ), @@ -1701,35 +2396,27 @@ class _PostDetailScreenState extends BaseDynamicState return illusts; } - _hasContent() { - String title = StringUtil.clearBlank(_postDetailData!.post!.title); - String content = StringUtil.clearBlank( - HtmlUtil.extractTextFromHtml(_postDetailData!.post!.content)); - return (title.isNotEmpty || content.isNotEmpty); + bool _hasContent() { + final title = StringUtil.clearBlank(_postDetailData!.post!.title); + try { + final content = StringUtil.clearBlank( + HtmlUtil.extractTextFromHtml(_postDetailData!.post!.content), + ); + return title.isNotEmpty || content.isNotEmpty; + } catch (error, stackTrace) { + ILogger.error('Failed to inspect post content', error, stackTrace); + return title.isNotEmpty || _postDetailData!.post!.content.isNotEmpty; + } } - _buildPostContent() { - String title = StringUtil.clearBlank(_postDetailData!.post!.title); - String content = HtmlUtil.extractTextFromHtml(_postDetailData!.post!.content); - String htmlTitle = StringUtil.isNotEmpty(title) - ? "

${_postDetailData!.post?.title}

" - : ""; - return _hasContent() || title.isNotEmpty || content.isNotEmpty - ? Container( - color: Colors.transparent, - padding: - const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 8), - child: CustomHtmlWidget( - content: "$htmlTitle${_postDetailData!.post?.content}", - illusts: _getIllusts(), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.apply(fontSizeDelta: 3, heightDelta: 0.3), - onDownloadSuccess: _handleDownloadSuccessAction, - ), - ) - : emptyWidget; + Widget _buildPostContent() { + return PostContentSection( + title: _postDetailData!.post!.title, + content: _postDetailData!.post!.content, + url: _postDetailData!.post!.permalink, + style: context.design.typography.readingBody, + onDownloadSuccess: _handleDownloadSuccessAction, + ); } _handleDownloadSuccessAction() { @@ -1740,9 +2427,18 @@ class _PostDetailScreenState extends BaseDynamicState }); } - _buildGrainItem() { - return ClickableWrapper( - child: GestureDetector( + _buildGrainItem({Key? key}) { + return Padding( + key: key, + padding: EdgeInsets.only( + left: context.design.spacing.xl, + right: context.design.spacing.xl, + top: context.design.spacing.sm, + ), + child: LoftifyContentReferenceCard( + icon: LoftifyIcons.grain, + eyebrow: appLocalizations.includedIn, + title: _postDetailData!.grainInfo!.name, onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -1752,192 +2448,120 @@ class _PostDetailScreenState extends BaseDynamicState ), ); }, - child: Container( - margin: const EdgeInsets.only(left: 16, right: 16, top: 8), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).cardColor, - width: 1, - ), - ), - child: Column( - children: [ - Row( - children: [ - Icon( - Icons.grain_rounded, - size: 16, - color: ChewieColors.getHotTagTextColor(context), - ), - const SizedBox(width: 3), - Text( - appLocalizations.includedIn, - style: Theme.of(context).textTheme.titleSmall?.apply( - fontSizeDelta: -1, - fontWeightDelta: 2, - color: ChewieColors.getHotTagTextColor(context)), - ), - const SizedBox(width: 3), - Expanded( - child: Text( - _postDetailData!.grainInfo!.name, - style: Theme.of(context) - .textTheme - .titleSmall - ?.apply(fontSizeDelta: -1), - ), - ), - Icon( - Icons.keyboard_arrow_right_rounded, - size: 16, - color: Theme.of(context).textTheme.labelSmall?.color, - ), - ], - ), - ], - ), - ), ), ); } - _buildCollectionItem() { - return Container( - margin: const EdgeInsets.only(left: 16, right: 16, top: 8), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(10), + _buildCollectionItem({Key? key}) { + return Padding( + key: key, + padding: EdgeInsets.only( + left: context.design.spacing.xl, + right: context.design.spacing.xl, + top: context.design.spacing.sm, ), - child: Column( - children: [ - Row( - children: [ - AssetUtil.loadDouble( - context, - AssetUtil.collectionLightIcon, - AssetUtil.collectionDarkIcon, - size: 12, - ), - const SizedBox(width: 3), - Expanded( - child: Text( - _postDetailData!.post!.postCollection!.name, - style: Theme.of(context) - .textTheme - .titleSmall - ?.apply(fontSizeDelta: -1), - ), - ), - GestureDetector( - onTap: () { - HapticFeedback.mediumImpact(); - CollectionApi.subscribeOrUnSubscribe( - collectionId: collectionId, - isSubscribe: - !(_postDetailData!.post!.postCollection!.subscribed), - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop( - value['meta']['desc'] ?? value['meta']['msg']); - } else { - _postDetailData!.post!.postCollection!.subscribed = - !(_postDetailData!.post!.postCollection!.subscribed); - setState(() {}); - } - }); - }, - child: ClickableWrapper( - child: Text( - _postDetailData!.post!.postCollection!.subscribed - ? appLocalizations.subscribed - : appLocalizations.subscribeCollection, - style: Theme.of(context).textTheme.titleSmall?.apply( - fontSizeDelta: -2, - fontWeightDelta: 2, - color: _postDetailData! - .post!.postCollection!.subscribed - ? Theme.of(context).textTheme.labelSmall?.color - : Theme.of(context).primaryColor, - ), - ), - ), - ), - ], + child: LoftifyContentReferenceCard( + icon: LoftifyIcons.collection, + eyebrow: appLocalizations.collection, + title: _postDetailData!.post!.postCollection!.name, + trailing: _buildCollectionSubscribeAction(), + actions: [ + LoftifyContentReferenceAction( + label: _postDetailData!.post!.pos > 1 + ? appLocalizations.prePost + : appLocalizations.atFirstPost, + enabled: _postDetailData!.post!.pos > 1, + onDisabledPressed: () { + IToast.showTop(appLocalizations.haveAtFirstPost); + }, + onPressed: () { + setState(() {}); + _fetchPreOrNextPost(isPre: true); + }, + ), + LoftifyContentReferenceAction( + label: appLocalizations.catelog, + onPressed: showCollectionBottomSheet, + ), + LoftifyContentReferenceAction( + label: _postDetailData!.post!.pos < + _postDetailData!.post!.postCollection!.postCount + ? appLocalizations.nextPost + : appLocalizations.atLastPost, + enabled: _postDetailData!.post!.pos < + _postDetailData!.post!.postCollection!.postCount, + onDisabledPressed: () { + IToast.showTop(appLocalizations.haveAtLastPost); + }, + onPressed: () { + setState(() {}); + _fetchPreOrNextPost(isPre: false); + }, ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Expanded( - child: _buildButton( - text: _postDetailData!.post!.pos > 1 - ? appLocalizations.prePost - : appLocalizations.atFirstPost, - disabled: _postDetailData!.post!.pos <= 1, - onTap: () { - if (_postDetailData!.post!.pos > 1) { - setState(() {}); - _fetchPreOrNextPost(isPre: true); - } else { - IToast.showTop(appLocalizations.haveAtFirstPost); - } - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildButton( - text: appLocalizations.catelog, - onTap: showCollectionBottomSheet, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildButton( - text: _postDetailData!.post!.pos < - _postDetailData!.post!.postCollection!.postCount - ? appLocalizations.nextPost - : appLocalizations.atLastPost, - disabled: _postDetailData!.post!.pos >= - _postDetailData!.post!.postCollection!.postCount, - onTap: () { - if (_postDetailData!.post!.pos < - _postDetailData!.post!.postCollection!.postCount) { - setState(() {}); - _fetchPreOrNextPost(isPre: false); - } else { - IToast.showTop(appLocalizations.haveAtLastPost); - } - }, - ), - ), - ], - ) ], ), ); } - _buildButton({String? text, Function()? onTap, bool disabled = false}) { - return ClickableWrapper( + Widget _buildCollectionSubscribeAction() { + final design = context.design; + final textScale = MediaQuery.textScalerOf(context).scale(1); + final subscribed = _postDetailData!.post!.postCollection!.subscribed; + final color = subscribed + ? design.colors.textSecondary + : design.colors.accentForeground; + final label = subscribed + ? appLocalizations.subscribed + : appLocalizations.subscribeCollection; + return Semantics( + button: true, + label: label, child: GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - alignment: Alignment.center, - decoration: BoxDecoration( - color: ChewieTheme.getBackground(context), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - text ?? "", - style: disabled - ? Theme.of(context).textTheme.titleSmall?.apply( - color: Theme.of(context).textTheme.labelSmall?.color) - : Theme.of(context).textTheme.titleSmall, + onTap: () { + HapticFeedback.mediumImpact(); + CollectionApi.subscribeOrUnSubscribe( + collectionId: collectionId, + isSubscribe: !subscribed, + ).then((value) { + if (!mounted) return; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } else { + _postDetailData!.post!.postCollection!.subscribed = !subscribed; + setState(() {}); + } + }); + }, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40), + child: Center( + child: Container( + height: textScale <= 1.15 ? 24 : null, + constraints: const BoxConstraints(minHeight: 24), + alignment: Alignment.center, + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: textScale <= 1.15 ? 0 : design.spacing.xs, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: subscribed + ? design.colors.outlineStrong + : color.withValues(alpha: 0.7), + width: design.borders.hairline, + ), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ), ), ), ), @@ -2009,8 +2633,11 @@ class _PostDetailScreenState extends BaseDynamicState start: true, color: color, spacing: 6, - icon: - Icon(Icons.copyright_rounded, size: 16, color: color), + icon: ChewieIcon( + LoftifyIcons.copyright, + size: 16, + color: color, + ), ), if (showCopyright && (showMark || showReBlog)) const SizedBox(height: 4), @@ -2021,8 +2648,11 @@ class _PostDetailScreenState extends BaseDynamicState start: true, color: color, spacing: 6, - icon: Icon(Icons.auto_awesome_outlined, - size: 16, color: color), + icon: ChewieIcon( + LoftifyIcons.magic, + size: 16, + color: color, + ), ), if (showMark && showReBlog) const SizedBox(height: 4), if (showReBlog) @@ -2033,7 +2663,11 @@ class _PostDetailScreenState extends BaseDynamicState spacing: 6, start: true, color: color, - icon: Icon(Icons.repeat_rounded, size: 16, color: color), + icon: ChewieIcon( + LoftifyIcons.reblog, + size: 16, + color: color, + ), ), const SizedBox(height: 4), ], @@ -2048,216 +2682,175 @@ class _PostDetailScreenState extends BaseDynamicState } Widget _buildOperationRow() { + final design = context.design; return Container( - padding: const EdgeInsets.only(left: 6, right: 16, top: 8, bottom: 8), - child: Stack( - clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisSize: MainAxisSize.max, children: [ - Row( - mainAxisSize: MainAxisSize.max, - children: [ - Stack( - children: [ - LoftifyItemBuilder.buildLikedLottieButton( - context, - showCount: true, - iconSize: 52, - animationController: _likeController, - likeCount: _postDetailData!.post!.postCount!.favoriteCount, - isLiked: _postDetailData!.liked, - onTap: () async { - _handleLike(); - }, - ), - Container( - margin: const EdgeInsets.only(left: 42), - child: LoftifyItemBuilder.buildLottieSharedButton( - context, - showCount: true, - iconSize: 52, - shareCount: _postDetailData!.post!.postCount!.shareCount, - isShared: _postDetailData!.shared, - animationController: _shareController, - onTap: () async { - _handleRecommend(); - }, - ), - ), - ], - ), - const Spacer(), - ], + DetailActionButton( + label: _postDetailData!.post!.postCount!.favoriteCount > 0 + ? StringUtil.formatCount( + _postDetailData!.post!.postCount!.favoriteCount, + ) + : appLocalizations.like, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.like, + selected: _postDetailData!.liked == true, + ), + onTap: _handleLike, ), - Positioned( - top: 12, - right: 0, - child: ItemBuilder.buildIconTextButton( - context, - text: _postDetailData!.subscribedNotNull - ? appLocalizations.favorited - : appLocalizations.favorite, - icon: _postDetailData!.subscribedNotNull - ? const Icon(Icons.star_rounded, - size: 28, color: Colors.yellow) - : const Icon(Icons.star_border_rounded, size: 28), - direction: Axis.vertical, - spacing: 0, - style: Theme.of(context).textTheme.labelSmall, - onTap: () { - BottomSheetBuilder.showBottomSheet( - context, - enableDrag: false, - (context) => SubscribePostBottomSheet( - postId: postId, - blogId: blogId, - onConfirm: (folderIds) { - _handleSubscribe(folderIds); - }, - ), - ); - }, + const SizedBox(width: 8), + DetailActionButton( + label: _postDetailData!.post!.postCount!.shareCount > 0 + ? StringUtil.formatCount( + _postDetailData!.post!.postCount!.shareCount, + ) + : appLocalizations.recommend, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.recommend, + selected: _postDetailData!.shared == true, ), + onTap: _handleRecommend, + ), + const Spacer(), + DetailActionButton( + label: _postDetailData!.subscribedNotNull + ? appLocalizations.favorited + : appLocalizations.favorite, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.bookmark, + selected: _postDetailData!.subscribedNotNull, + ), + onTap: () { + BottomSheetBuilder.showBottomSheet( + context, + enableDrag: false, + (context) => SubscribePostBottomSheet( + postId: postId, + blogId: blogId, + onConfirm: (folderIds) { + _handleSubscribe(folderIds); + }, + ), + ); + }, ), ], ), ); } - _buildFloatingButtons() { - return Column( - children: [ - ShadowIconButton( - icon: const Icon(Icons.comment_bank_outlined), - onTap: () { - jumpToComment(); - }, - ), - ], + Widget _buildFloatingOperationOverlay() { + if (_postDetailData?.post == null) return const SizedBox.shrink(); + _scheduleFloatingOperationBarVisibilitySync(); + return Positioned( + right: 0, + bottom: 0, + child: ValueListenableBuilder( + valueListenable: _floatingOperationBarVisible, + builder: (context, showFloatingBar, child) { + final visible = _isPostContentReady && showFloatingBar; + return SafeArea( + top: false, + left: false, + minimum: const EdgeInsets.only(right: 12, bottom: 10), + child: IgnorePointer( + ignoring: !visible, + child: AnimatedSlide( + offset: visible ? Offset.zero : const Offset(1.18, 0), + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: child, + ), + ), + ), + ); + }, + child: _buildFloatingOperationBar(), + ), ); } - Widget _buildFloatingOperationRow() { - return ScrollToHide.multi( - controller: _scrollToHideController, - scrollControllers: [_scrollController], - hideDirection: Axis.vertical, - child: Container( - height: 64, - decoration: BoxDecoration( - color: Theme.of(rootContext).canvasColor, - border: ChewieTheme.topBorder, - boxShadow: ChewieTheme.defaultBoxShadow, - borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), + Widget _buildFloatingOperationBar() { + return DetailFloatingActionRail( + children: [ + DetailActionButton( + label: _postDetailData!.post!.postCount!.favoriteCount > 0 + ? StringUtil.formatCount( + _postDetailData!.post!.postCount!.favoriteCount, + ) + : appLocalizations.like, + onTap: _handleLike, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.like, + selected: _postDetailData!.liked == true, + ), ), - padding: const EdgeInsets.only(left: 6, right: 16, bottom: 8), - child: Stack( - clipBehavior: Clip.none, - children: [ - Row( - mainAxisSize: MainAxisSize.max, - children: [ - Stack( - children: [ - LoftifyItemBuilder.buildLikedLottieButton( - context, - showCount: true, - iconSize: 52, - animationController: _likeController, - likeCount: - _postDetailData!.post!.postCount!.favoriteCount, - isLiked: _postDetailData!.liked, - onTap: () async { - _handleLike(); - }, - ), - Container( - margin: const EdgeInsets.only(left: 42), - child: LoftifyItemBuilder.buildLottieSharedButton( - context, - showCount: true, - iconSize: 52, - shareCount: - _postDetailData!.post!.postCount!.shareCount, - isShared: _postDetailData!.shared, - animationController: _shareController, - onTap: () async { - _handleRecommend(); - }, - ), - ), - ], - ), - const Spacer(), - ], - ), - Positioned( - top: 16, - right: 40, - child: ItemBuilder.buildIconTextButton( - context, - text: _postDetailData!.post!.postCount!.responseCount > 0 - ? "${_postDetailData!.post!.postCount!.responseCount}" - : appLocalizations.comment, - icon: const Icon(Icons.comment_bank_outlined, size: 24), - direction: Axis.vertical, - spacing: 0, - style: Theme.of(context).textTheme.labelSmall, - onTap: () { - jumpToComment(); - }, - ), - ), - Positioned( - top: 12, - right: 0, - child: ItemBuilder.buildIconTextButton( - context, - text: _postDetailData!.subscribedNotNull - ? appLocalizations.favorited - : appLocalizations.favorite, - icon: _postDetailData!.subscribedNotNull - ? const Icon(Icons.star_rounded, - size: 28, color: Colors.yellow) - : const Icon(Icons.star_border_rounded, size: 28), - direction: Axis.vertical, - spacing: 0, - style: Theme.of(context).textTheme.labelSmall, - onTap: () { - BottomSheetBuilder.showBottomSheet( - context, - enableDrag: false, - (context) => SubscribePostBottomSheet( - postId: postId, - blogId: blogId, - onConfirm: (folderIds) { - _handleSubscribe(folderIds); - }, - ), - ); - }, + DetailActionButton( + label: _postDetailData!.post!.postCount!.shareCount > 0 + ? StringUtil.formatCount( + _postDetailData!.post!.postCount!.shareCount, + ) + : appLocalizations.recommend, + onTap: _handleRecommend, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.recommend, + selected: _postDetailData!.shared == true, + ), + ), + DetailActionButton( + label: _postDetailData!.post!.postCount!.responseCount > 0 + ? StringUtil.formatCount( + _postDetailData!.post!.postCount!.responseCount, + ) + : appLocalizations.comment, + icon: const ChewieIcon(LoftifyIcons.comment), + onTap: jumpToComment, + ), + DetailActionButton( + label: _postDetailData!.subscribedNotNull + ? appLocalizations.favorited + : appLocalizations.favorite, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.bookmark, + selected: _postDetailData!.subscribedNotNull, + ), + onTap: () { + BottomSheetBuilder.showBottomSheet( + context, + enableDrag: false, + (context) => SubscribePostBottomSheet( + postId: postId, + blogId: blogId, + onConfirm: _handleSubscribe, ), - ), - ], + ); + }, ), - ), + ], ); } - Widget _buildComments(List comments) { - return ListView.builder( - shrinkWrap: true, - itemCount: comments.length, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => LoftifyItemBuilder.buildCommentRow( - context, - comments[index], - writerId: blogId, - l2Padding: const EdgeInsets.only(top: 12), - onL2CommentTap: (comment) { - HapticFeedback.mediumImpact(); - _fetchL2Comments(comment); - }, - ), + Widget _buildComments(List comments, {Key? key}) { + return Column( + key: key, + children: [ + for (final comment in comments) + LoftifyItemBuilder.buildCommentRow( + context, + comment, + writerId: blogId, + onL2CommentTap: (comment) { + HapticFeedback.mediumImpact(); + _fetchL2Comments(comment); + }, + ), + ], ); } @@ -2304,7 +2897,7 @@ class _PostDetailScreenState extends BaseDynamicState padding: const EdgeInsets.only(left: 8, right: 8), child: EasyRefresh.builder( onRefresh: _onRefresh, - onLoad: _onLoad, + onLoad: _recommendNoMore ? null : _onLoad, triggerAxis: Axis.vertical, childBuilder: (context, physics) => CustomScrollView( physics: physics, @@ -2339,98 +2932,92 @@ class _PostDetailScreenState extends BaseDynamicState showCollectionBottomSheet() { BottomSheetBuilder.showBottomSheet( context, - (context) => SingleChildScrollView( - controller: ModalScrollController.of(context), - child: CollectionBottomSheet( - postCollection: _postDetailData!.post!.postCollection!, - collectionId: collectionId, - postId: postId, - blogId: blogId, - blogName: blogName, - ), + (context) => CollectionBottomSheet( + postCollection: _postDetailData!.post!.postCollection!, + collectionId: collectionId, + postId: postId, + blogId: blogId, + blogName: blogName, ), enableDrag: false, ); } void setDownloadState(DownloadState state, {bool recover = true}) { - switch (state) { - case DownloadState.none: - downloadIcon = Icon(Icons.download_rounded, - color: Theme.of(rootContext).iconTheme.color); - break; - case DownloadState.loading: - downloadIcon = Container( - width: 20, - height: 20, - padding: const EdgeInsets.all(2), - child: CircularProgressIndicator( - color: Theme.of(context).iconTheme.color, - strokeWidth: 2, - ), - ); - break; - case DownloadState.succeed: - downloadIcon = const Icon(Icons.check_rounded, color: Colors.green); - break; - case DownloadState.failed: - downloadIcon = - const Icon(Icons.warning_amber_rounded, color: Colors.redAccent); - break; - } downloadState = state; + if (state == DownloadState.none || state == DownloadState.loading) { + _downloadProgress = 0; + } else if (state == DownloadState.succeed) { + _downloadProgress = 1; + } if (mounted) setState(() {}); if (recover) { + final recoveryPostId = postId; Future.delayed(const Duration(seconds: 2), () { + if (!mounted || postId != recoveryPostId || downloadState != state) { + return; + } setDownloadState(DownloadState.none, recover: false); }); } } + void _setDownloadProgress(int received, int total, int downloadPostId) { + if (!mounted || + postId != downloadPostId || + downloadState != DownloadState.loading || + total <= 0) { + return; + } + final next = (received / total).clamp(0.0, 1.0).toDouble(); + if (next < 1 && (next - _downloadProgress).abs() < 0.002) return; + setState(() => _downloadProgress = next); + } + + String get _downloadStateLabel => switch (downloadState) { + DownloadState.none => appLocalizations.download, + DownloadState.loading => + '${appLocalizations.downloading} ${(_downloadProgress * 100).round()}%', + DownloadState.succeed => appLocalizations.downloadComplete, + DownloadState.failed => + '${appLocalizations.downloadFailed}, ${appLocalizations.retry}', + }; + PreferredSizeWidget _buildAppBar() { + final collection = _isPostContentReady && hasCollection() + ? _postDetailData!.post!.postCollection + : null; + final collectionPosition = _postDetailData?.post?.pos ?? 0; + final collectionCount = collection?.postCount ?? 0; + final compactCollectionLabel = + collectionPosition >= 100 || collectionCount >= 100; + final collectionLabel = + '${appLocalizations.collection} $collectionPosition/$collectionCount'; return ResponsiveAppBar( showBack: true, + rightSpacing: 4, titleWidget: Text( appLocalizations.postDetail, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleLarge?.apply( fontWeightDelta: 2, ), ), - actions: [ - if (hasCollection()) - ClickableWrapper( - child: GestureDetector( - onTap: showCollectionBottomSheet, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(50), - ), - child: Row( - children: [ - AssetUtil.loadDouble( - context, - AssetUtil.collectionLightIcon, - AssetUtil.collectionDarkIcon, - size: 14, - ), - const SizedBox(width: 3), - Text( - "${appLocalizations.collection} ${_postDetailData!.post!.pos}/${_postDetailData!.post!.postCollection!.postCount}", - style: Theme.of(context) - .textTheme - .titleMedium - ?.apply(fontSizeDelta: -3), - ), - ], + actions: _isPostContentReady + ? [ + if (collection != null) + LoftifyContextPill( + icon: LoftifyIcons.collection, + label: compactCollectionLabel + ? '$collectionPosition/$collectionCount' + : collectionLabel, + semanticLabel: collectionLabel, + onPressed: showCollectionBottomSheet, ), - ), - ), - ), - ..._buildButtons(), - ], + ..._buildButtons(), + ] + : const [], ); } @@ -2442,20 +3029,30 @@ class _PostDetailScreenState extends BaseDynamicState ChewieHiveUtil.getBool(HiveUtil.showDownloadKey, defaultValue: true)); return [ - const SizedBox(width: 5), if (showDownloadButton) ...[ - CircleIconButton( - icon: downloadIcon, - onTap: () { - _handleDownloadAll(); - }, + const SizedBox(width: 8), + SizedBox.square( + dimension: 44, + child: CircleIconButton( + icon: PostDownloadActionIcon( + state: downloadState, + progress: _downloadProgress, + semanticLabel: _downloadStateLabel, + ), + padding: EdgeInsets.zero, + tooltip: _downloadStateLabel, + onTap: downloadState == DownloadState.loading || + downloadState == DownloadState.succeed + ? null + : _handleDownloadAll, + ), ), - const SizedBox(width: 5), ], - CircleIconButton( - icon: Icon(Icons.more_vert_rounded, - color: Theme.of(context).iconTheme.color), - onTap: () { + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + foregroundColor: Theme.of(context).iconTheme.color, + onPressed: () { BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()); }, ), @@ -2467,7 +3064,7 @@ class _PostDetailScreenState extends BaseDynamicState entries: [ FlutterContextMenuItem( appLocalizations.copyLink, - iconData: Icons.copy_rounded, + iconData: LoftifyIcons.copy, onPressed: () { ChewieUtils.copy( context, @@ -2480,7 +3077,7 @@ class _PostDetailScreenState extends BaseDynamicState ), FlutterContextMenuItem( appLocalizations.visitOriginalPost, - iconData: Icons.view_carousel_outlined, + iconData: LoftifyIcons.originalPost, onPressed: () { UriUtil.openInternal( context, @@ -2494,7 +3091,7 @@ class _PostDetailScreenState extends BaseDynamicState }, ), FlutterContextMenuItem(appLocalizations.openWithBrowser, - iconData: Icons.open_in_browser_rounded, onPressed: () { + iconData: LoftifyIcons.openExternal, onPressed: () { UriUtil.openExternal( LoftifyUriUtil.getPostUrlByPermalink( _postDetailData!.post!.blogInfo!.blogName, @@ -2504,7 +3101,7 @@ class _PostDetailScreenState extends BaseDynamicState }), FlutterContextMenuItem( appLocalizations.shareToOtherApps, - iconData: Icons.share_rounded, + iconData: LoftifyIcons.share, onPressed: () { UriUtil.share( LoftifyUriUtil.getPostUrlByPermalink( diff --git a/lib/Screens/Post/search_result_screen.dart b/lib/Screens/Post/search_result_screen.dart index 4c078933..9d801d75 100644 --- a/lib/Screens/Post/search_result_screen.dart +++ b/lib/Screens/Post/search_result_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:awesome_chewie/awesome_chewie.dart'; @@ -12,12 +13,34 @@ import 'package:loftify/Screens/Post/tag_detail_screen.dart'; import 'package:loftify/Widgets/PostItem/search_post_flow_item_builder.dart'; import '../../Utils/utils.dart'; +import '../../Utils/hive_util.dart'; +import '../../Utils/paged_data_controller.dart'; +import '../../Utils/tab_state_util.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; import '../../Widgets/PostItem/recommend_flow_item_builder.dart'; import '../../l10n/l10n.dart'; import 'collection_detail_screen.dart'; +class _AllSearchMetadata { + const _AllSearchMetadata({required this.tags, this.tagRank}); + + final List tags; + final TagInfo? tagRank; +} + +class _AllSearchView { + const _AllSearchView({ + required this.tags, + required this.posts, + this.tagRank, + }); + + final List tags; + final List posts; + final TagInfo? tagRank; +} + class SearchResultScreen extends StatefulWidget { const SearchResultScreen({super.key, required this.searchKey}); @@ -34,16 +57,13 @@ class _SearchResultScreenState extends BaseDynamicState @override bool get wantKeepAlive => true; List _sugList = []; - SearchAllResult? _allResult; - TagInfo? _tagRank; int _currentTabIndex = 0; - final List _collectionList = []; - final List _postList = []; - final List _grainList = []; - final List _userList = []; late TabController _tabController; - final List _tagList = []; + late final LazyTabLoadState _tabLoadState; TextEditingController? _searchController; + int _searchIntent = 0; + int _suggestIntent = 0; + String _suggestQuery = ''; final EasyRefreshController _allResultRefreshController = EasyRefreshController(); final EasyRefreshController _tagResultRefreshController = @@ -57,38 +77,165 @@ class _SearchResultScreenState extends BaseDynamicState final EasyRefreshController _userResultRefreshController = EasyRefreshController(); - final List _tabLabelList = [ - appLocalizations.comprehensive, - appLocalizations.tag, - appLocalizations.collection, - appLocalizations.grain, - appLocalizations.article, - appLocalizations.user + List get _tabLabelList { + final localizations = AppLocalizations.of(context)!; + return [ + localizations.comprehensive, + localizations.tag, + localizations.collection, + localizations.grain, + localizations.article, + localizations.user, + ]; + } + + static const List _tabIdList = [ + 'all', + 'tag', + 'collection', + 'grain', + 'article', + 'user', ]; - int _allResultOffset = 0; - int _tagResultOffset = 0; - int _collectionResultOffset = 0; - int _postResultOffset = 0; - int _grainResultOffset = 0; - int _userResultOffset = 0; - bool _allResultLoading = false; - bool _collectionResultLoading = false; - bool _tagResultLoading = false; - bool _postResultLoading = false; - bool _grainResultLoading = false; - bool _userResultLoading = false; - bool _allPostResultLoading = false; - bool _tagResultNoMore = false; - bool _collectionResultNoMore = false; - bool _postResultNoMore = false; - bool _grainResultNoMore = false; - bool _userResultNoMore = false; + late final PagedDataController + _allPagingController; + late final PagedDataController + _tagPagingController; + late final PagedDataController + _collectionPagingController; + late final PagedDataController + _postPagingController; + late final PagedDataController + _grainPagingController; + late final PagedDataController + _userPagingController; + + List> + get _pagingControllers => [ + _allPagingController, + _tagPagingController, + _collectionPagingController, + _postPagingController, + _grainPagingController, + _userPagingController, + ]; + + _AllSearchView? get _allResult { + final metadata = _allPagingController.metadata; + if (metadata == null) return null; + return _AllSearchView( + tags: metadata.tags, + tagRank: metadata.tagRank, + posts: _allPagingController.items, + ); + } + + TagInfo? get _tagRank => _tagPagingController.metadata; + List get _tagList => _tagPagingController.items; + List get _collectionList => _collectionPagingController.items; + List get _postList => _postPagingController.items; + List get _grainList => _grainPagingController.items; + List get _userList => _userPagingController.items; + + bool get _allResultNoMore => _allPagingController.noMore; + bool get _tagResultNoMore => _tagPagingController.noMore; + bool get _collectionResultNoMore => _collectionPagingController.noMore; + bool get _postResultNoMore => _postPagingController.noMore; + bool get _grainResultNoMore => _grainPagingController.noMore; + bool get _userResultNoMore => _userPagingController.noMore; @override void initState() { super.initState(); - _performSearch(widget.searchKey, init: true); + final restored = PersistentTabState.restore( + idKey: HiveUtil.searchResultTabIdKey, + legacyIndexKey: HiveUtil.searchResultTabIndexKey, + itemIds: _tabIdList, + ); + _tabLoadState = LazyTabLoadState( + itemIds: _tabIdList, + savedId: restored.id, + ); + _currentTabIndex = _tabLoadState.currentIndex; + _initPagingControllers(); initTab(); + _performSearch(widget.searchKey, init: true); + } + + @override + void dispose() { + _searchController + ?..removeListener(_bindSuggest) + ..dispose(); + _tabController.dispose(); + _allResultRefreshController.dispose(); + _tagResultRefreshController.dispose(); + _collectionResultRefreshController.dispose(); + _postResultRefreshController.dispose(); + _grainResultRefreshController.dispose(); + _userResultRefreshController.dispose(); + for (final controller in _pagingControllers) { + controller + ..removeListener(_handlePagingChanged) + ..dispose(); + } + super.dispose(); + } + + void _initPagingControllers() { + _allPagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.postData?.postView.id ?? item.itemId, + loader: _loadAllResultPage, + onError: _handlePagingError, + ); + _tagPagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.tagName, + loader: _loadTagResultPage, + onError: _handlePagingError, + ); + _collectionPagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.id, + loader: _loadCollectionResultPage, + onError: _handlePagingError, + ); + _postPagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.id, + loader: _loadPostResultPage, + onError: _handlePagingError, + ); + _grainPagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.id, + loader: _loadGrainResultPage, + onError: _handlePagingError, + ); + _userPagingController = PagedDataController( + initialCursor: 0, + keyOf: (item) => item.blogInfo.blogId, + loader: _loadUserResultPage, + onError: _handlePagingError, + ); + for (final controller in _pagingControllers) { + controller.addListener(_handlePagingChanged); + } + } + + void _handlePagingChanged() { + if (mounted) setState(() {}); + } + + void _handlePagingError(Object error, StackTrace stackTrace) { + ILogger.error('Failed to load search result', error, stackTrace); + if (!mounted) return; + IToast.showTop( + error is PagedDataException && StringUtil.isNotEmpty(error.message) + ? error.message + : appLocalizations.loadFailed, + ); } @override @@ -103,20 +250,25 @@ class _SearchResultScreenState extends BaseDynamicState bottomHeight: 56, bottomWidget: TabBarWrapper( tabController: _tabController, - tabs: _tabLabelList + tabs: _tabLabelList .asMap() .entries - .map((entry) => ItemBuilder.buildAnimatedTab(context, - selected: entry.key == _currentTabIndex, text: entry.value)) + .map( + (entry) => ItemBuilder.buildAnimatedTab( + context, + selected: entry.key == _currentTabIndex, + text: entry.value, + controller: _tabController, + tabIndex: entry.key, + ), + ) .toList(), showBorder: true, width: MediaQuery.sizeOf(context).width, + isScrollable: false, onTap: (index) { - setState(() { - _currentTabIndex = index; - }); + _setCurrentTab(index); }, - forceUnscrollable: !ResponsiveUtil.isLandscapeLayout(), ), ), body: Stack( @@ -128,349 +280,352 @@ class _SearchResultScreenState extends BaseDynamicState ); } - initTab() { - _tabController = TabController(length: _tabLabelList.length, vsync: this); - _tabController.animation?.addListener(() { - int indexChange = - _tabController.offset.abs() > 0.8 ? _tabController.offset.round() : 0; - int index = _tabController.index + indexChange; - if (index != _currentTabIndex) { - setState(() => _currentTabIndex = index); + void initTab() { + _tabController = TabController( + length: _tabIdList.length, + initialIndex: _currentTabIndex, + vsync: this, + ); + _tabController.addListener(() { + final preloadIndex = _tabController.indexIsChanging + ? _tabController.index + : _tabController.offset > 0.001 + ? _tabController.index + 1 + : _tabController.offset < -0.001 + ? _tabController.index - 1 + : _tabController.index; + if (preloadIndex >= 0 && preloadIndex < _tabLabelList.length) { + unawaited(_ensureTabLoaded(preloadIndex)); } + final index = + (_tabController.animation?.value ?? _tabController.index).round(); + if (index != _currentTabIndex) _setCurrentTab(index); }); } - _bindSuggest() { - if (_searchController!.text.isEmpty) { - _sugList.clear(); - if (mounted) setState(() {}); - } else { - _performSuggest(_searchController!.text); + void _setCurrentTab(int index, {bool persist = true}) { + final safeIndex = TabStatePreference.restoreIndex( + index, + _tabLabelList.length, + ); + if (safeIndex != _currentTabIndex) { + setState(() => _currentTabIndex = safeIndex); + } + if (persist) { + PersistentTabState.save( + idKey: HiveUtil.searchResultTabIdKey, + legacyIndexKey: HiveUtil.searchResultTabIndexKey, + itemIds: _tabIdList, + index: safeIndex, + ); } + unawaited(_ensureTabLoaded(safeIndex)); } - _fetchAllResult() async { - if (_allResultLoading) return; - _allResultLoading = true; - return await SearchApi.getAllSearchResult(key: _searchController!.text) - .then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - if (value['data'] != null) { - _allResult = SearchAllResult.fromJson(value['data']); - _allResultOffset = _allResult!.offset; - } - if (mounted) setState(() {}); - return IndicatorResult.success; - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load all result", e, t); - return IndicatorResult.fail; - } finally { - _allResultLoading = false; + Future _ensureTabLoaded(int index) async { + if (_searchController == null || + !_tabLoadState.selectAndShouldLoad(index)) { + return; + } + final started = await _refreshTabWithAnimation(index); + if (!started) { + _tabLoadState.markLoadFailed(index); + } + } + + EasyRefreshController _refreshControllerFor(int index) => switch (index) { + 0 => _allResultRefreshController, + 1 => _tagResultRefreshController, + 2 => _collectionResultRefreshController, + 3 => _grainResultRefreshController, + 4 => _postResultRefreshController, + _ => _userResultRefreshController, + }; + + Future _refreshTabWithAnimation(int index) async { + final controller = _refreshControllerFor(index); + for (var attempt = 0; attempt < 1; attempt++) { + WidgetsBinding.instance.scheduleFrame(); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return false; + if (controller.headerState == null) continue; + await controller.callRefresh( + overOffset: 8, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + ); + if (index == 0 && + _allPagingController.metadata == null && + !_allPagingController.loading && + _allPagingController.lastError == null) { + await _fetchAllResult(); } - }); + return true; + } + // A tab may have no mounted refresh header yet (notably the initial + // comprehensive search). Still fetch its first page instead of leaving + // its loading placeholder on screen indefinitely. + await switch (index) { + 0 => _fetchAllResult(), + 1 => _fetchTagResult(refresh: true), + 2 => _fetchCollectionResult(refresh: true), + 3 => _fetchGrainResult(refresh: true), + 4 => _fetchPostResult(refresh: true), + _ => _fetchUserResult(refresh: true), + }; + return true; } - _fetchAllPostResult() async { - if (_allPostResultLoading) return; - _allPostResultLoading = true; - return await SearchApi.getAllSearchPostResult( - key: _searchController!.text, - offset: _allResultOffset, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - bool noMore = false; - if (value['data'] != null) { - var tmp = SearchAllResult.fromJson(value['data']); - tmp.posts.removeWhere((element) => - _allResult!.posts.any((e) => e.itemId == element.itemId)); - _allResultOffset = tmp.offset; - _allResult?.posts.addAll(tmp.posts); - noMore = tmp.posts.isEmpty; - } - if (mounted) setState(() {}); - if (noMore) return IndicatorResult.noMore; - return IndicatorResult.success; + _bindSuggest() { + final query = _searchController!.text; + if (query == _suggestQuery) return; + _suggestQuery = query; + final intent = ++_suggestIntent; + setState(() => _sugList = []); + if (query.isNotEmpty) _performSuggest(query, intent); + } + + Future> + _loadAllResultPage(int cursor, bool refresh) async { + final value = refresh + ? await SearchApi.getAllSearchResult(key: _searchController!.text) + : await SearchApi.getAllSearchPostResult( + key: _searchController!.text, + offset: cursor, + ); + final data = _requireSearchData(value); + final rawPosts = data['posts']; + final posts = parsePagedDataItems( + rawPosts, + PostListItem.fromJson, + onMalformed: (error, stackTrace) => + _logMalformedSearchItem('comprehensive post', error, stackTrace), + ); + final nextOffset = (data['offset'] as num?)?.toInt() ?? cursor; + _AllSearchMetadata? metadata; + if (refresh) { + final tags = parsePagedDataItems( + data['tags'], + TagInfo.fromJson, + onMalformed: (error, stackTrace) => + _logMalformedSearchItem('related tag', error, stackTrace), + ); + TagInfo? tagRank; + if (data['tagRank'] is Map) { + try { + tagRank = TagInfo.fromJson( + Map.from(data['tagRank'] as Map), + ); + } catch (error, stackTrace) { + _logMalformedSearchItem('rank tag', error, stackTrace); } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load all post result", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _allPostResultLoading = false; } - }); + metadata = _AllSearchMetadata(tags: tags, tagRank: tagRank); + } + return PagedDataPage( + items: posts, + nextCursor: nextOffset, + hasMore: rawPosts is List && rawPosts.isNotEmpty, + metadata: metadata, + ); } - _fetchTagResult({bool refresh = false}) async { - if (_tagResultLoading) return; - if (refresh) _tagResultNoMore = false; - _tagResultLoading = true; - return await SearchApi.getTagSearchResult( + Future _fetchAllResult() => _allPagingController.refresh(); + + Future _fetchAllPostResult() => _allPagingController.load(); + + Future> _loadTagResultPage( + int cursor, + bool refresh, + ) async { + final value = await SearchApi.getTagSearchResult( key: _searchController!.text, - offset: refresh ? 0 : _tagResultOffset, - ).then((value) { + offset: refresh ? 0 : cursor, + ); + final data = _requireSearchData(value); + final rawTags = data['tags']; + final tags = parsePagedDataItems( + rawTags, + TagInfo.fromJson, + onMalformed: (error, stackTrace) => + _logMalformedSearchItem('tag', error, stackTrace), + ); + TagInfo? tagRank; + if (data['tagRank'] is Map) { try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - List tmp = []; - if (value['data'] != null) { - if (value['data']['offset'] != null) { - _tagResultOffset = value['data']['offset']; - } - if (value['data']['tagRank'] != null) { - _tagRank = TagInfo.fromJson(value['data']['tagRank']); - } - if (value['data']['tags'] != null) { - tmp = (value['data']['tags'] as List) - .map((e) => TagInfo.fromJson(e)) - .toList(); - if (refresh) _tagList.clear(); - for (var exist in _tagList) { - tmp.removeWhere((element) => element.tagName == exist.tagName); - } - _tagList.addAll(tmp); - } - } - if (mounted) setState(() {}); - if (tmp.isEmpty) { - _tagResultNoMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load tag result", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _tagResultLoading = false; + tagRank = TagInfo.fromJson( + Map.from(data['tagRank'] as Map), + ); + } catch (error, stackTrace) { + _logMalformedSearchItem('rank tag', error, stackTrace); } - }); + } + return PagedDataPage( + items: tags, + nextCursor: (data['offset'] as num?)?.toInt() ?? cursor, + hasMore: rawTags is List && rawTags.isNotEmpty, + metadata: tagRank, + ); } - _fetchCollectionResult({bool refresh = false}) async { - if (_collectionResultLoading) return; - if (refresh) _collectionResultNoMore = false; - _collectionResultLoading = true; - return await SearchApi.getCollectionSearchResult( + Future _fetchTagResult({bool refresh = false}) => + refresh ? _tagPagingController.refresh() : _tagPagingController.load(); + + Future> _loadCollectionResultPage( + int cursor, + bool refresh, + ) async { + final value = await SearchApi.getCollectionSearchResult( key: _searchController!.text, - offset: refresh ? 0 : _collectionResultOffset, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - List tmp = []; - if (value['data'] != null) { - if (value['data']['offset'] != null) { - _collectionResultOffset = value['data']['offset']; - } - if (value['data']['collections'] != null) { - tmp = (value['data']['collections'] as List) - .map((e) => Collection.fromJson(e)) - .toList(); - if (refresh) _collectionList.clear(); - for (var exist in _collectionList) { - tmp.removeWhere((element) => element.id == exist.id); - } - _collectionList.addAll(tmp); - } - } - if (mounted) setState(() {}); - if (tmp.isEmpty) { - _collectionResultNoMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load collection result", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _collectionResultLoading = false; - } - }); + offset: refresh ? 0 : cursor, + ); + final data = _requireSearchData(value); + final rawItems = data['collections']; + return PagedDataPage( + items: parsePagedDataItems( + rawItems, + Collection.fromJson, + onMalformed: (error, stackTrace) => + _logMalformedSearchItem('collection', error, stackTrace), + ), + nextCursor: (data['offset'] as num?)?.toInt() ?? cursor, + hasMore: rawItems is List && rawItems.isNotEmpty, + ); } - _fetchPostResult({bool refresh = false}) async { - if (_postResultLoading) return; - if (refresh) _postResultNoMore = false; - _postResultLoading = true; - return await SearchApi.getPostSearchResult( + Future _fetchCollectionResult({bool refresh = false}) => + refresh + ? _collectionPagingController.refresh() + : _collectionPagingController.load(); + + Future> _loadPostResultPage( + int cursor, + bool refresh, + ) async { + final value = await SearchApi.getPostSearchResult( key: _searchController!.text, - offset: refresh ? 0 : _postResultOffset, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - List tmp = []; - if (value['data'] != null) { - if (value['data']['offset'] != null) { - _postResultOffset = value['data']['offset']; - } - if (value['data']['posts'] != null) { - tmp = (value['data']['posts'] as List) - .map((e) => SearchPost.fromJson(e)) - .toList(); - if (refresh) _postList.clear(); - for (var exist in _postList) { - tmp.removeWhere((element) => element.id == exist.id); - } - _postList.addAll(tmp); - } - } - if (mounted) setState(() {}); - if (tmp.isEmpty) { - _postResultNoMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load post result", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _postResultLoading = false; - } - }); + offset: refresh ? 0 : cursor, + ); + final data = _requireSearchData(value); + final rawItems = data['posts']; + return PagedDataPage( + items: parsePagedDataItems( + rawItems, + SearchPost.fromJson, + onMalformed: (error, stackTrace) => + _logMalformedSearchItem('post', error, stackTrace), + ), + nextCursor: (data['offset'] as num?)?.toInt() ?? cursor, + hasMore: rawItems is List && rawItems.isNotEmpty, + ); } - _fetchGrainResult({bool refresh = false}) async { - if (_grainResultLoading) return; - if (refresh) _grainResultNoMore = false; - _grainResultLoading = true; - return await SearchApi.getGrainSearchResult( + Future _fetchPostResult({bool refresh = false}) => + refresh ? _postPagingController.refresh() : _postPagingController.load(); + + Future> _loadGrainResultPage( + int cursor, + bool refresh, + ) async { + final value = await SearchApi.getGrainSearchResult( key: _searchController!.text, - offset: refresh ? 0 : _grainResultOffset, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - List tmp = []; - if (value['data'] != null) { - if (value['data']['offset'] != null) { - _grainResultOffset = value['data']['offset']; - } - if (value['data']['grainList'] != null) { - tmp = (value['data']['grainList'] as List) - .map((e) => GrainInfo.fromJson(e)) - .toList(); - if (refresh) _grainList.clear(); - for (var exist in _grainList) { - tmp.removeWhere((element) => element.id == exist.id); - } - _grainList.addAll(tmp); - } - } - if (mounted) setState(() {}); - if (tmp.isEmpty) { - _grainResultNoMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load grain result", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _grainResultLoading = false; - } - }); + offset: refresh ? 0 : cursor, + ); + final data = _requireSearchData(value); + final rawItems = data['grainList']; + return PagedDataPage( + items: parsePagedDataItems( + rawItems, + GrainInfo.fromJson, + onMalformed: (error, stackTrace) => + _logMalformedSearchItem('grain', error, stackTrace), + ), + nextCursor: (data['offset'] as num?)?.toInt() ?? cursor, + hasMore: rawItems is List && rawItems.isNotEmpty, + ); } - _fetchUserResult({bool refresh = false}) async { - if (_userResultLoading) return; - if (refresh) _userResultNoMore = false; - _userResultLoading = true; - return await SearchApi.getUserSearchResult( + Future _fetchGrainResult({bool refresh = false}) => refresh + ? _grainPagingController.refresh() + : _grainPagingController.load(); + + Future> _loadUserResultPage( + int cursor, + bool refresh, + ) async { + final value = await SearchApi.getUserSearchResult( key: _searchController!.text, - offset: refresh ? 0 : _userResultOffset, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - List tmp = []; - if (value['data'] != null) { - if (value['data']['offset'] != null) { - _userResultOffset = value['data']['offset']; - } - if (value['data']['blogs'] != null) { - tmp = (value['data']['blogs'] as List) - .map((e) => SearchBlogData.fromJson(e)) - .toList(); - if (refresh) _userList.clear(); - for (var exist in _userList) { - tmp.removeWhere((element) => - element.blogInfo.blogId == exist.blogInfo.blogId); - } - _userList.addAll(tmp); - } - } - if (mounted) setState(() {}); - if (tmp.isEmpty) { - _userResultNoMore = true; - return IndicatorResult.noMore; - } else { - return IndicatorResult.success; - } - } - } catch (e, t) { - IToast.showTop(appLocalizations.loadFailed); - ILogger.error("Failed to load user result", e, t); - return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _userResultLoading = false; - } - }); + offset: refresh ? 0 : cursor, + ); + final data = _requireSearchData(value); + final rawItems = data['blogs']; + return PagedDataPage( + items: parsePagedDataItems( + rawItems, + SearchBlogData.fromJson, + onMalformed: (error, stackTrace) => + _logMalformedSearchItem('user', error, stackTrace), + ), + nextCursor: (data['offset'] as num?)?.toInt() ?? cursor, + hasMore: rawItems is List && rawItems.isNotEmpty, + ); + } + + Future _fetchUserResult({bool refresh = false}) => + refresh ? _userPagingController.refresh() : _userPagingController.load(); + + Map _requireSearchData(dynamic value) { + final code = value is Map ? (value['code'] as num?)?.toInt() : null; + if (code != 0) { + throw PagedDataException( + value is Map ? value['msg']?.toString() ?? '' : '', + ); + } + final data = value['data']; + return data is Map ? Map.from(data) : {}; + } + + void _logMalformedSearchItem( + String kind, + Object error, + StackTrace stackTrace, + ) { + ILogger.error('Skipped malformed search $kind', error, stackTrace); } _performSearch(String str, {bool init = false}) async { + ++_suggestIntent; + final searchIntent = ++_searchIntent; bool processed = await UriUtil.processUrl(context, str, quiet: true); + if (!mounted || searchIntent != _searchIntent) return; if (!processed) { - _searchController = TextEditingController(text: str); + _searchController?.removeListener(_bindSuggest); + _searchController ??= TextEditingController(); + _searchController!.text = str; + _suggestQuery = str; _sugList.clear(); - _userList.clear(); - _postList.clear(); - _tagList.clear(); - _collectionList.clear(); - _grainList.clear(); + final targetTab = init ? _currentTabIndex : 0; + _tabLoadState.reset(index: targetTab); + if (!init) { + _currentTabIndex = 0; + PersistentTabState.save( + idKey: HiveUtil.searchResultTabIdKey, + legacyIndexKey: HiveUtil.searchResultTabIndexKey, + itemIds: _tabIdList, + index: 0, + ); + } + for (final controller in _pagingControllers) { + controller.reset(notify: false); + } + setState(() {}); Utils.addSearchHistory(str); - _fetchAllResult(); - _fetchCollectionResult(refresh: true); - _fetchGrainResult(refresh: true); - _fetchPostResult(refresh: true); - _fetchTagResult(refresh: true); - _fetchUserResult(refresh: true); if (!init) { - FocusScope.of(context).requestFocus(FocusNode()); + FocusScope.of(context).unfocus(); _tabController.animateTo(0); } _searchController!.addListener(_bindSuggest); + unawaited(_ensureTabLoaded(targetTab)); } } @@ -478,30 +633,38 @@ class _SearchResultScreenState extends BaseDynamicState RouteUtil.pushPanelCupertinoRoute(context, TagDetailScreen(tag: tag)); } - _performSuggest(String str) { - SearchApi.getSuggestList(key: str).then((value) { + Future _performSuggest(String str, int suggestIntent) async { + bool isCurrent() => + mounted && + suggestIntent == _suggestIntent && + _searchController?.text == str; + try { + final value = await SearchApi.getSuggestList(key: str); + if (!isCurrent()) return; if (value['code'] != 0) { - IToast.showTop(value['msg']); + IToast.showTop(value['msg'] ?? appLocalizations.loadFailed); } else { - if (value['data']['items'] != null && - _searchController!.text.isNotEmpty) { - _sugList = (value['data']['items'] as List) - .map((e) => SearchSuggestItem.fromJson(e)) - .toList(); - } - if (mounted) setState(() {}); + final items = (value['data']?['items'] as List? ?? []) + .map((e) => SearchSuggestItem.fromJson(e)) + .toList(); + setState(() => _sugList = items); } - }); + } catch (_) { + if (isCurrent()) IToast.showTop(appLocalizations.loadFailed); + } } _buildSuggestList() { return Container( color: ChewieTheme.getBackground(context), - padding: const EdgeInsets.symmetric(vertical: 8), child: ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), itemCount: _sugList.length, itemBuilder: (context, index) { - return _buildSuggestItem(index, _sugList[index]); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildSuggestItem(index, _sugList[index]), + ); }, ), ); @@ -511,24 +674,41 @@ class _SearchResultScreenState extends BaseDynamicState switch (item.type) { case 0: if (index == 0) { - return LoftifyItemBuilder.buildRankTagRow(context, item.tagInfo!, - onTap: () { - Utils.addSearchHistory(_searchController!.text); - _jumpToTag(item.tagInfo!.tagName); - }); + return LoftifyItemBuilder.buildSearchSuggestionSurface( + context, + LoftifyItemBuilder.buildRankTagRow(context, item.tagInfo!, + onTap: () { + Utils.addSearchHistory(_searchController!.text); + _jumpToTag(item.tagInfo!.tagName); + }), + ); } else { - return LoftifyItemBuilder.buildTagRow(context, item.tagInfo!, + return LoftifyItemBuilder.buildSearchSuggestionSurface( + context, + LoftifyItemBuilder.buildTagRow( + context, + item.tagInfo!, + horizontalPadding: 12, onTap: () { - Utils.addSearchHistory(_searchController!.text); - _jumpToTag(item.tagInfo!.tagName); - }); + Utils.addSearchHistory(_searchController!.text); + _jumpToTag(item.tagInfo!.tagName); + }, + ), + ); } case 1: - return LoftifyItemBuilder.buildTagRow(context, item.tagInfo!, + return LoftifyItemBuilder.buildSearchSuggestionSurface( + context, + LoftifyItemBuilder.buildTagRow( + context, + item.tagInfo!, + horizontalPadding: 12, onTap: () { - Utils.addSearchHistory(_searchController!.text); - _performSearch(item.tagInfo!.tagName); - }); + Utils.addSearchHistory(_searchController!.text); + _performSearch(item.tagInfo!.tagName); + }, + ), + ); case 2: return LoftifyItemBuilder.buildUserRow(context, item.blogData!, onTap: () { @@ -561,7 +741,10 @@ class _SearchResultScreenState extends BaseDynamicState } _buildDivider() { - return Container(height: 3, color: Theme.of(context).dividerColor); + return MyDivider( + width: 1, + horizontal: 0, + ); } Widget _buildAllResultTab() { @@ -570,117 +753,136 @@ class _SearchResultScreenState extends BaseDynamicState onRefresh: () async { return await _fetchAllResult(); }, - onLoad: () async { - return await _fetchAllPostResult(); - }, + onLoad: _allResultNoMore + ? null + : () async { + return await _fetchAllPostResult(); + }, triggerAxis: Axis.vertical, childBuilder: (context, physics) => _allResult != null - ? LoadMoreNotification( - noMore: false, - onLoad: _fetchAllPostResult, - child: CustomScrollView( - physics: physics, - slivers: [ - SliverList( - delegate: SliverChildListDelegate( - [ - const SizedBox(height: 10), - if (_allResult!.tags.isEmpty && - _allResult!.tagRank == null && - _allResult!.posts.isEmpty) - Container( - height: 160, - margin: const EdgeInsets.symmetric(vertical: 16), - alignment: Alignment.center, - child: EmptyPlaceholder( - text: appLocalizations.noSearchResult, - ), - ), - if (_allResult!.tagRank != null) - LoftifyItemBuilder.buildRankTagRow( - context, - _allResult!.tagRank!, - useBackground: false, - onTap: () { - _jumpToTag(_allResult!.tagRank!.tagName); - }, - ), - if (_allResult!.tagRank != null) _buildDivider(), - if (_allResult!.tags.isNotEmpty) - ItemBuilder.buildTitle( - context, - title: appLocalizations.relatedTag, - suffixText: appLocalizations.viewAll, - topMargin: 16, - bottomMargin: 8, - onTap: () { - _tabController.animateTo(1); - }, + ? CustomScrollView( + key: const PageStorageKey('search-all-results'), + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 10), + if (_allResult!.tags.isEmpty && + _allResult!.tagRank == null && + _allResult!.posts.isEmpty) + Container( + height: 160, + margin: const EdgeInsets.symmetric(vertical: 16), + alignment: Alignment.center, + child: EmptyPlaceholder( + text: appLocalizations.noSearchResult, ), - if (_allResult!.tags.isNotEmpty) - ...List.generate( - min(_allResult!.tags.length, 2), (index) { - return LoftifyItemBuilder.buildTagRow( - context, - _allResult!.tags[index], - verticalPadding: 8, - onTap: () { - if (_allResult!.tags[index].joinCount != -1) { - _jumpToTag(_allResult!.tags[index].tagName); - } else { - _performSearch( - _allResult!.tags[index].tagName); - } - }, - ); - }), - if (_allResult!.tags.isNotEmpty) - const SizedBox(height: 8), - if (_allResult!.tags.isNotEmpty) _buildDivider(), - if (_allResult!.posts.isNotEmpty) - ItemBuilder.buildTitle( + ), + if (_allResult!.tagRank != null) + LoftifyItemBuilder.buildRankTagRow( + context, + _allResult!.tagRank!, + useBackground: false, + onTap: () { + _jumpToTag(_allResult!.tagRank!.tagName); + }, + ), + if (_allResult!.tagRank != null) _buildDivider(), + if (_allResult!.tags.isNotEmpty) + ItemBuilder.buildTitle( + context, + title: appLocalizations.relatedTag, + suffixText: appLocalizations.viewAll, + topMargin: 16, + bottomMargin: 8, + onTap: () { + _tabController.animateTo(1); + }, + ), + if (_allResult!.tags.isNotEmpty) + ...List.generate( + min(_allResult!.tags.length, 2), (index) { + return LoftifyItemBuilder.buildTagRow( context, - title: appLocalizations.relatedPost, - suffixText: appLocalizations.viewAll, - topMargin: 16, - bottomMargin: 8, + _allResult!.tags[index], + verticalPadding: 8, onTap: () { - _tabController.animateTo(4); + if (_allResult!.tags[index].joinCount != -1) { + _jumpToTag(_allResult!.tags[index].tagName); + } else { + _performSearch(_allResult!.tags[index].tagName); + } }, - ), - ], - ), - ), - if (_allResult!.posts.isNotEmpty) - SliverPadding( - padding: - const EdgeInsets.only(top: 10, left: 8, right: 8), - sliver: SliverWaterfallFlow( - gridDelegate: - const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 6, - crossAxisSpacing: 6, - maxCrossAxisExtent: 300, - ), - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return GestureDetector( - child: RecommendFlowItemBuilder - .buildWaterfallFlowPostItem( - context, - _allResult!.posts[index], - ), - ); + ); + }), + if (_allResult!.tags.isNotEmpty) + const SizedBox(height: 8), + if (_allResult!.tags.isNotEmpty) _buildDivider(), + if (_allResult!.posts.isNotEmpty) + ItemBuilder.buildTitle( + context, + title: appLocalizations.relatedPost, + suffixText: appLocalizations.viewAll, + topMargin: 16, + bottomMargin: 8, + onTap: () { + _tabController.animateTo(4); }, - childCount: _allResult!.posts.length, ), + ], + ), + ), + if (_allResult!.posts.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.only(top: 10, left: 8, right: 8), + sliver: SliverWaterfallFlow( + gridDelegate: + const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 6, + crossAxisSpacing: 6, + maxCrossAxisExtent: 300, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return GestureDetector( + child: RecommendFlowItemBuilder + .buildWaterfallFlowPostItem( + context, + _allResult!.posts[index], + ), + ); + }, + childCount: _allResult!.posts.length, ), ), - ], - ), + ), + ], ) - : LoadingWidget( - background: ChewieTheme.getBackground(context), + : CustomScrollView( + key: const PageStorageKey('search-all-initial'), + physics: physics, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: _allPagingController.lastError == null + ? LoadingWidget( + background: ChewieTheme.getBackground(context), + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(appLocalizations.loadFailed), + TextButton( + onPressed: () => unawaited(_fetchAllResult()), + child: Text(appLocalizations.retry), + ), + ], + ), + ), + ), + ], ), ); } @@ -691,59 +893,58 @@ class _SearchResultScreenState extends BaseDynamicState onRefresh: () async { return await _fetchTagResult(refresh: true); }, - onLoad: () async { - return await _fetchTagResult(); - }, + onLoad: _tagResultNoMore + ? null + : () async { + return await _fetchTagResult(); + }, triggerAxis: Axis.vertical, - childBuilder: (context, physics) => LoadMoreNotification( - noMore: _tagResultNoMore, - onLoad: _fetchTagResult, - child: CustomScrollView( - physics: physics, - slivers: [ - SliverList( - delegate: SliverChildListDelegate( - [ - const SizedBox(height: 10), - if (_tagList.isEmpty && _tagRank == null) - Container( - margin: const EdgeInsets.symmetric(vertical: 16), - height: 160, - child: EmptyPlaceholder( - text: appLocalizations.noTag, - ), + childBuilder: (context, physics) => CustomScrollView( + key: const PageStorageKey('search-tag-results'), + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 10), + if (_tagList.isEmpty && _tagRank == null) + Container( + margin: const EdgeInsets.symmetric(vertical: 16), + height: 160, + child: EmptyPlaceholder( + text: appLocalizations.noTag, ), - if (_tagRank != null) - LoftifyItemBuilder.buildRankTagRow( + ), + if (_tagRank != null) + LoftifyItemBuilder.buildRankTagRow( + context, + _tagRank!, + useBackground: false, + onTap: () { + _jumpToTag(_tagRank!.tagName); + }, + ), + if (_tagRank != null && _tagList.isNotEmpty) _buildDivider(), + if (_tagList.isNotEmpty) + ...List.generate(_tagList.length, (index) { + return LoftifyItemBuilder.buildTagRow( context, - _tagRank!, - useBackground: false, + _tagList[index], + verticalPadding: 8, onTap: () { - _jumpToTag(_tagRank!.tagName); + if (_tagList[index].joinCount != -1) { + _jumpToTag(_tagList[index].tagName); + } else { + _performSearch(_tagList[index].tagName); + } }, - ), - if (_tagRank != null && _tagList.isNotEmpty) _buildDivider(), - if (_tagList.isNotEmpty) - ...List.generate(_tagList.length, (index) { - return LoftifyItemBuilder.buildTagRow( - context, - _tagList[index], - verticalPadding: 8, - onTap: () { - if (_tagList[index].joinCount != -1) { - _jumpToTag(_tagList[index].tagName); - } else { - _performSearch(_tagList[index].tagName); - } - }, - ); - }), - if (_tagList.isNotEmpty) const SizedBox(height: 8), - ], - ), + ); + }), + if (_tagList.isNotEmpty) const SizedBox(height: 8), + ], ), - ], - ), + ), + ], ), ); } @@ -754,59 +955,58 @@ class _SearchResultScreenState extends BaseDynamicState onRefresh: () async { return await _fetchCollectionResult(refresh: true); }, - onLoad: () async { - return await _fetchCollectionResult(); - }, + onLoad: _collectionResultNoMore + ? null + : () async { + return await _fetchCollectionResult(); + }, triggerAxis: Axis.vertical, - childBuilder: (context, physics) => LoadMoreNotification( - noMore: _collectionResultNoMore, - onLoad: _fetchCollectionResult, - child: CustomScrollView( - physics: physics, - slivers: [ - SliverList( - delegate: SliverChildListDelegate( - [ - const SizedBox(height: 10), - if (_collectionList.isEmpty) - Container( - margin: const EdgeInsets.symmetric(vertical: 16), - height: 160, - child: EmptyPlaceholder( - text: appLocalizations.noCollection, - ), + childBuilder: (context, physics) => CustomScrollView( + key: const PageStorageKey('search-collection-results'), + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 10), + if (_collectionList.isEmpty) + Container( + margin: const EdgeInsets.symmetric(vertical: 16), + height: 160, + child: EmptyPlaceholder( + text: appLocalizations.noCollection, ), - ], - ), + ), + ], ), - SliverWaterfallFlow( - gridDelegate: - const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 0, - crossAxisSpacing: 6, - maxCrossAxisExtent: 600, - ), - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return LoftifyItemBuilder.buildCollectionRow( - context, _collectionList[index], verticalPadding: 8, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - CollectionDetailScreen( - collectionId: _collectionList[index].id, - blogId: _collectionList[index].blogId, - blogName: _collectionList[index].blogName, - postId: 0, - ), - ); - }); - }, - childCount: _collectionList.length, - ), + ), + SliverWaterfallFlow( + gridDelegate: + const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 0, + crossAxisSpacing: 6, + maxCrossAxisExtent: 600, ), - ], - ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return LoftifyItemBuilder.buildCollectionRow( + context, _collectionList[index], verticalPadding: 8, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + CollectionDetailScreen( + collectionId: _collectionList[index].id, + blogId: _collectionList[index].blogId, + blogName: _collectionList[index].blogName, + postId: 0, + ), + ); + }); + }, + childCount: _collectionList.length, + ), + ), + ], ), ); } @@ -817,57 +1017,56 @@ class _SearchResultScreenState extends BaseDynamicState onRefresh: () async { return await _fetchPostResult(refresh: true); }, - onLoad: () async { - return await _fetchPostResult(); - }, + onLoad: _postResultNoMore + ? null + : () async { + return await _fetchPostResult(); + }, triggerAxis: Axis.vertical, - childBuilder: (context, physics) => LoadMoreNotification( - noMore: _postResultNoMore, - onLoad: _fetchPostResult, - child: CustomScrollView( - physics: physics, - slivers: [ - SliverList( - delegate: SliverChildListDelegate( - [ - const SizedBox(height: 10), - if (_postList.isEmpty) - Container( - margin: const EdgeInsets.symmetric(vertical: 16), - height: 160, - child: EmptyPlaceholder( - text: appLocalizations.noArticle, - ), + childBuilder: (context, physics) => CustomScrollView( + key: const PageStorageKey('search-post-results'), + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 10), + if (_postList.isEmpty) + Container( + margin: const EdgeInsets.symmetric(vertical: 16), + height: 160, + child: EmptyPlaceholder( + text: appLocalizations.noArticle, ), - ], - ), - ), - if (_postList.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.only(top: 10, left: 8, right: 8), - sliver: SliverWaterfallFlow( - gridDelegate: - const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 6, - crossAxisSpacing: 6, - maxCrossAxisExtent: 300, - ), - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return GestureDetector( - child: SearchPostFlowItemBuilder - .buildWaterfallFlowPostItem( - context, - _postList[index], - ), - ); - }, - childCount: _postList.length, ), + ], + ), + ), + if (_postList.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.only(top: 10, left: 8, right: 8), + sliver: SliverWaterfallFlow( + gridDelegate: + const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 6, + crossAxisSpacing: 6, + maxCrossAxisExtent: 300, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return GestureDetector( + child: + SearchPostFlowItemBuilder.buildWaterfallFlowPostItem( + context, + _postList[index], + ), + ); + }, + childCount: _postList.length, ), ), - ], - ), + ), + ], ), ); } @@ -878,59 +1077,58 @@ class _SearchResultScreenState extends BaseDynamicState onRefresh: () async { return await _fetchGrainResult(refresh: true); }, - onLoad: () async { - return await _fetchGrainResult(); - }, + onLoad: _grainResultNoMore + ? null + : () async { + return await _fetchGrainResult(); + }, triggerAxis: Axis.vertical, - childBuilder: (context, physics) => LoadMoreNotification( - noMore: _grainResultNoMore, - onLoad: _fetchGrainResult, - child: CustomScrollView( - physics: physics, - slivers: [ - SliverList( - delegate: SliverChildListDelegate( - [ - const SizedBox(height: 10), - if (_grainList.isEmpty) - Container( - margin: const EdgeInsets.symmetric(vertical: 16), - height: 160, - child: EmptyPlaceholder( - text: appLocalizations.noGrain, - ), + childBuilder: (context, physics) => CustomScrollView( + key: const PageStorageKey('search-grain-results'), + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 10), + if (_grainList.isEmpty) + Container( + margin: const EdgeInsets.symmetric(vertical: 16), + height: 160, + child: EmptyPlaceholder( + text: appLocalizations.noGrain, ), - ], - ), + ), + ], ), - SliverWaterfallFlow( - gridDelegate: - const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 0, - crossAxisSpacing: 6, - maxCrossAxisExtent: 600, - ), - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return LoftifyItemBuilder.buildGrainRow( - context, - _grainList[index], - verticalPadding: 8, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - GrainDetailScreen( - grainId: _grainList[index].id, - blogId: _grainList[index].userId), - ); - }, - ); - }, - childCount: _grainList.length, - ), + ), + SliverWaterfallFlow( + gridDelegate: + const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 0, + crossAxisSpacing: 6, + maxCrossAxisExtent: 600, ), - ], - ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return LoftifyItemBuilder.buildGrainRow( + context, + _grainList[index], + verticalPadding: 8, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + GrainDetailScreen( + grainId: _grainList[index].id, + blogId: _grainList[index].userId), + ); + }, + ); + }, + childCount: _grainList.length, + ), + ), + ], ), ); } @@ -941,37 +1139,39 @@ class _SearchResultScreenState extends BaseDynamicState onRefresh: () async { return await _fetchUserResult(refresh: true); }, - onLoad: () async { - return await _fetchUserResult(); - }, + onLoad: _userResultNoMore + ? null + : () async { + return await _fetchUserResult(); + }, triggerAxis: Axis.vertical, - childBuilder: (context, physics) => LoadMoreNotification( - noMore: _userResultNoMore, - onLoad: _fetchUserResult, - child: CustomScrollView( - physics: physics, - slivers: [ - SliverList( - delegate: SliverChildListDelegate( - [ - const SizedBox(height: 10), - if (_userList.isEmpty) - Container( - margin: const EdgeInsets.symmetric(vertical: 16), - height: 160, - child: EmptyPlaceholder( - text: appLocalizations.noUser, - ), + childBuilder: (context, physics) => CustomScrollView( + key: const PageStorageKey('search-user-results'), + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 10), + if (_userList.isEmpty) + Container( + margin: const EdgeInsets.symmetric(vertical: 16), + height: 160, + child: EmptyPlaceholder( + text: appLocalizations.noUser, ), - ], - ), + ), + ], ), - SliverWaterfallFlow( + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 20), + sliver: SliverWaterfallFlow( gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 0, - crossAxisSpacing: 6, - maxCrossAxisExtent: 400, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + maxCrossAxisExtent: 440, ), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { @@ -992,8 +1192,8 @@ class _SearchResultScreenState extends BaseDynamicState childCount: _userList.length, ), ), - ], - ), + ), + ], ), ); } @@ -1013,7 +1213,6 @@ class _SearchResultScreenState extends BaseDynamicState hintFontSizeDelta: 1, // focusNode: _focusNode, controller: _searchController, - background: Colors.grey.withAlpha(40), hintText: appLocalizations.searchHint, onSubmitted: (text) async { _performSearch(text); diff --git a/lib/Screens/Post/tag_collection_grain_screen.dart b/lib/Screens/Post/tag_collection_grain_screen.dart index 784c2f54..d5ac3c49 100644 --- a/lib/Screens/Post/tag_collection_grain_screen.dart +++ b/lib/Screens/Post/tag_collection_grain_screen.dart @@ -3,13 +3,60 @@ import 'package:flutter/material.dart'; import 'package:loftify/Api/tag_api.dart'; import 'package:loftify/Models/tag_response.dart'; import 'package:loftify/Screens/Post/collection_detail_screen.dart'; -import 'package:loftify/Utils/asset_util.dart'; + import '../../Utils/enums.dart'; -import '../../Utils/utils.dart'; +import '../../Utils/hive_util.dart'; +import '../../Utils/tab_state_util.dart'; import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; import 'grain_detail_screen.dart'; +Widget _buildHotRankMarker(BuildContext context, int index) { + if (index > 2) { + return SizedBox( + width: 24, + child: Text( + '${index + 1}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ); + } + final hotColor = ChewieColors.getHotTagTextColor(context).withValues( + alpha: 1 - index * 0.18, + ); + return Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: hotColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(7), + ), + child: Stack( + alignment: Alignment.center, + children: [ + ChewieIcon(LoftifyIcons.hot, size: 16, color: hotColor), + Positioned( + top: 1, + right: 2, + child: Text( + '${index + 1}', + style: TextStyle( + color: hotColor, + fontSize: 7, + height: 1, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ); +} + class TagCollectionGrainScreen extends StatefulWidget { const TagCollectionGrainScreen({super.key, required this.tag}); @@ -28,16 +75,27 @@ class _TagCollectionGrainScreenState @override bool get wantKeepAlive => true; late TabController _tabController; + late final LazyTabLoadState _tabLoadState; List _tabLabelList = []; final GlobalKey _collectionKey = GlobalKey(); final GlobalKey _grainKey = GlobalKey(); int _currentTabIndex = 0; + static const List _tabIdList = ['collection', 'grain']; @override void initState() { super.initState(); initTab(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _ensureTabLoaded(_currentTabIndex); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); } @override @@ -52,7 +110,62 @@ class _TagCollectionGrainScreenState initTab() { _tabLabelList = [appLocalizations.collection, appLocalizations.grain]; - _tabController = TabController(length: _tabLabelList.length, vsync: this); + final restored = PersistentTabState.restore( + idKey: HiveUtil.tagCollectionGrainTabIdKey, + legacyIndexKey: HiveUtil.tagCollectionGrainTabIndexKey, + itemIds: _tabIdList, + ); + _tabLoadState = LazyTabLoadState( + itemIds: _tabIdList, + savedId: restored.id, + ); + _currentTabIndex = _tabLoadState.currentIndex; + _tabController = TabController( + length: _tabLabelList.length, + initialIndex: _currentTabIndex, + vsync: this, + ); + _tabController.addListener(() { + final index = + (_tabController.animation?.value ?? _tabController.index).round(); + if (index != _currentTabIndex) _setCurrentTab(index); + }); + } + + void _setCurrentTab(int index) { + final safeIndex = TabStatePreference.restoreIndex(index, _tabIdList.length); + if (safeIndex != _currentTabIndex && mounted) { + setState(() => _currentTabIndex = safeIndex); + } + PersistentTabState.save( + idKey: HiveUtil.tagCollectionGrainTabIdKey, + legacyIndexKey: HiveUtil.tagCollectionGrainTabIndexKey, + itemIds: _tabIdList, + index: safeIndex, + ); + _ensureTabLoaded(safeIndex); + } + + void _ensureTabLoaded(int index) { + if (!_tabLoadState.selectAndShouldLoad(index)) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (index == 0) { + final state = _collectionKey.currentState as CollectionTabState?; + if (state == null) { + _tabLoadState.markLoadFailed(index); + } else { + state.callRefresh(); + } + } else { + final state = _grainKey.currentState as GrainTabState?; + if (state == null) { + _tabLoadState.markLoadFailed(index); + } else { + state.callRefresh(); + } + } + }); } Widget _buildTabView() { @@ -90,14 +203,14 @@ class _TagCollectionGrainScreenState (entry) => ItemBuilder.buildAnimatedTab(context, selected: entry.key == _currentTabIndex, text: entry.value, + controller: _tabController, + tabIndex: entry.key, normalUserBold: true, sameFontSize: true), ) .toList(), onTap: (index) { - setState(() { - _currentTabIndex = index; - }); + _setCurrentTab(index); }, width: MediaQuery.sizeOf(context).width, background: ChewieTheme.getBackground(context), @@ -109,10 +222,10 @@ class _TagCollectionGrainScreenState maintainAnimation: true, maintainState: true, maintainSize: true, - child: CircleIconButton( - icon: Icon(Icons.more_vert_rounded, - color: Theme.of(context).iconTheme.color), - onTap: () {}), + child: ChewieIconButton( + icon: LoftifyIcons.moreVertical, + onPressed: () {}, + ), ), ], ); @@ -208,7 +321,7 @@ class CollectionTabState extends BaseDynamicState Widget _buildCollectionResultTab() { return EasyRefresh.builder( - refreshOnStart: true, + refreshOnStart: false, controller: _collectionRefreshController, onRefresh: () async { return await _fetchCollectionResult(refresh: true); @@ -325,9 +438,10 @@ class CollectionTabState extends BaseDynamicState child: ItemBuilder.buildTranslucentTag( context, text: "", - icon: AssetUtil.load( - AssetUtil.collectionWhiteIcon, + icon: const ChewieIcon( + LoftifyIcons.collection, size: 12, + color: Colors.white, ), isCircle: true, ), @@ -338,8 +452,8 @@ class CollectionTabState extends BaseDynamicState child: ItemBuilder.buildTranslucentTag( context, text: StringUtil.formatCount(info.viewCount), - icon: const Icon( - Icons.local_fire_department_rounded, + icon: const ChewieIcon( + LoftifyIcons.hot, color: Colors.white, size: 12, ), @@ -401,21 +515,7 @@ class CollectionTabState extends BaseDynamicState ); } - String? getIcon(int index) { - switch (index) { - case 0: - return AssetUtil.hottestIcon; - case 1: - return AssetUtil.hotIcon; - case 2: - return AssetUtil.hotlessIcon; - default: - return null; - } - } - Widget _buildHotCollectionRankItem(int index, SimpleCollectionInfo info) { - String? icon = getIcon(index); return ClickableWrapper( child: GestureDetector( onTap: () { @@ -436,23 +536,7 @@ class CollectionTabState extends BaseDynamicState child: Row( mainAxisSize: MainAxisSize.min, children: [ - Container( - height: 24, - width: 24, - alignment: Alignment.center, - decoration: icon != null - ? BoxDecoration( - image: AssetUtil.loadDecorationImage(icon), - ) - : null, - child: Text( - "${index + 1}", - style: Theme.of(context).textTheme.labelLarge?.apply( - fontWeightDelta: 3, - color: icon != null ? Colors.transparent : null, - ), - ), - ), + _buildHotRankMarker(context, index), const SizedBox(width: 24), ClipRRect( borderRadius: BorderRadius.circular(10), @@ -586,7 +670,7 @@ class GrainTabState extends BaseDynamicState Widget _buildGrainResultTab() { return EasyRefresh.builder( - refreshOnStart: true, + refreshOnStart: false, controller: _grainRefreshController, onRefresh: () async { return await _fetchGrainResult(refresh: true); @@ -709,9 +793,10 @@ class GrainTabState extends BaseDynamicState child: ItemBuilder.buildTranslucentTag( context, text: "", - icon: AssetUtil.load( - AssetUtil.grainWhiteIcon, + icon: const ChewieIcon( + LoftifyIcons.grain, size: 12, + color: Colors.white, ), isCircle: true, ), @@ -722,8 +807,8 @@ class GrainTabState extends BaseDynamicState child: ItemBuilder.buildTranslucentTag( context, text: StringUtil.formatCount(info.viewCount), - icon: const Icon( - Icons.local_fire_department_rounded, + icon: const ChewieIcon( + LoftifyIcons.hot, color: Colors.white, size: 12, ), @@ -785,21 +870,7 @@ class GrainTabState extends BaseDynamicState ); } - String? getIcon(int index) { - switch (index) { - case 0: - return AssetUtil.hottestIcon; - case 1: - return AssetUtil.hotIcon; - case 2: - return AssetUtil.hotlessIcon; - default: - return null; - } - } - Widget _buildHotGrainRankItem(int index, SimpleGrainInfo info) { - String? icon = getIcon(index); return ClickableWrapper( child: GestureDetector( onTap: () { @@ -818,23 +889,7 @@ class GrainTabState extends BaseDynamicState child: Row( mainAxisSize: MainAxisSize.min, children: [ - Container( - height: 24, - width: 24, - alignment: Alignment.center, - decoration: icon != null - ? BoxDecoration( - image: AssetUtil.loadDecorationImage(icon), - ) - : null, - child: Text( - "${index + 1}", - style: Theme.of(context).textTheme.labelLarge?.apply( - fontWeightDelta: 3, - color: icon != null ? Colors.transparent : null, - ), - ), - ), + _buildHotRankMarker(context, index), const SizedBox(width: 24), ClipRRect( borderRadius: BorderRadius.circular(10), diff --git a/lib/Screens/Post/tag_detail_screen.dart b/lib/Screens/Post/tag_detail_screen.dart index f538b347..90824ec9 100644 --- a/lib/Screens/Post/tag_detail_screen.dart +++ b/lib/Screens/Post/tag_detail_screen.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:flutter/material.dart'; @@ -9,18 +12,22 @@ import 'package:loftify/Screens/Post/tag_collection_grain_screen.dart'; import 'package:loftify/Screens/Post/tag_insearch_screen.dart'; import 'package:loftify/Screens/Post/tag_related_screen.dart'; import 'package:loftify/Screens/Suit/dress_screen.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; import 'package:loftify/Utils/asset_util.dart'; import 'package:loftify/Utils/enums.dart'; import 'package:loftify/Utils/hive_util.dart'; import '../../Models/post_detail_response.dart'; import '../../Utils/cloud_control_provider.dart'; +import '../../Utils/tab_state_util.dart'; import '../../Utils/uri_util.dart'; -import '../../Utils/utils.dart'; import '../../Widgets/BottomSheet/newest_filter_bottom_sheet.dart'; +import '../../Widgets/Design/loftify_controls.dart'; import '../../Widgets/Item/item_builder.dart'; -import '../../Widgets/Item/loftify_item_builder.dart'; +import '../../Widgets/PostItem/loftify_post_archive_grid.dart'; import '../../Widgets/PostItem/recommend_flow_item_builder.dart'; +import '../../Widgets/Tag/tag_detail_components.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class TagDetailScreen extends StatefulWidget { @@ -41,13 +48,13 @@ class _TagDetailScreenState extends BaseDynamicState TagDetailData? _tagDetailData; late TabController _tabController; + late final LazyTabLoadState _tabLoadState; final ScrollController _scrollController = ScrollController(); final GlobalKey _recommendKey = GlobalKey(); final GlobalKey _newestKey = GlobalKey(); final GlobalKey _hottestKey = GlobalKey(); - final List scrollControllers = - List.filled(3, null); - + final List _tabScrollControllers = + List.filled(3, null); PostLayoutType _postLayoutType = PostLayoutType.values[ChewieUtils.patchEnum( ChewieHiveUtil.getInt(HiveUtil.tagDetailPostLayoutTypeKey, defaultValue: 0), @@ -59,6 +66,7 @@ class _TagDetailScreenState extends BaseDynamicState appLocalizations.newest, appLocalizations.hottest ]; + static const List _tabIdList = ['explore', 'newest', 'hottest']; late GetTagPostListParams _hottestParams; int _currentHottestIndex = 0; @@ -68,49 +76,181 @@ class _TagDetailScreenState extends BaseDynamicState @override void initState() { super.initState(); + initFilter(); initTab(); _fetchTagDetail(); - initFilter(); } - initFilter() { - _hottestParams = GetTagPostListParams( - tag: widget.tag, - tagPostResultType: TagPostResultType.week, + @override + void dispose() { + _tabController.dispose(); + for (final controller in _tabScrollControllers) { + controller?.dispose(); + } + _scrollController.dispose(); + super.dispose(); + } + + void initFilter() { + _hottestParams = _restoreFilter( + HiveUtil.tagHottestFilterKey, + fallbackResultType: TagPostResultType.week, + ); + _currentHottestIndex = switch (_hottestParams.tagPostResultType) { + TagPostResultType.total => 0, + TagPostResultType.date => 1, + TagPostResultType.week => 2, + TagPostResultType.month => 3, + _ => 2, + }; + _newestParams = _restoreFilter( + HiveUtil.tagNewestFilterKey, + fallbackResultType: TagPostResultType.newPost, ); - _currentHottestIndex = _hottestParams.tagPostResultType.index - 2; - _newestParams = GetTagPostListParams( + _currentNewestIndex = switch (_newestParams.tagPostResultType) { + TagPostResultType.newComment => 1, + _ => 0, + }; + } + + GetTagPostListParams _restoreFilter( + String key, { + required TagPostResultType fallbackResultType, + }) { + dynamic savedValue; + final encoded = ChewieHiveUtil.getString(key); + if (encoded != null && encoded.isNotEmpty) { + try { + savedValue = jsonDecode(encoded); + } catch (error, stackTrace) { + ILogger.error("Failed to restore tag filter", error, stackTrace); + } + } + return GetTagPostListParams.fromPreference( tag: widget.tag, - tagPostResultType: TagPostResultType.newPost, + value: savedValue, + fallbackResultType: fallbackResultType, + ); + } + + void _persistFilter(String key, GetTagPostListParams params) { + ChewieHiveUtil.put( + key, + jsonEncode(params.toPreferenceJson()), ); - _currentNewestIndex = _newestParams.tagPostResultType.index; } @override Widget build(BuildContext context) { super.build(context); + final design = context.design; return Scaffold( appBar: _buildAppBar(), - backgroundColor: ChewieTheme.getBackground(context), + backgroundColor: design.colors.page, body: _tagDetailData != null ? _buildMainBody() - : LoadingWidget(background: ChewieTheme.getBackground(context)), + : LoadingWidget(background: design.colors.page), ); } - initTab() { - _tabController = TabController(length: _tabLabelList.length, vsync: this); - _tabController.animation?.addListener(() { - int indexChange = - _tabController.offset.abs() > 0.8 ? _tabController.offset.round() : 0; - int index = _tabController.index + indexChange; - if (index != _currentTabIndex) { - setState(() => _currentTabIndex = index); + void initTab() { + final restored = PersistentTabState.restore( + idKey: HiveUtil.tagDetailTabIdKey, + legacyIndexKey: HiveUtil.tagDetailTabIndexKey, + itemIds: _tabIdList, + ); + _tabLoadState = LazyTabLoadState( + itemIds: _tabIdList, + savedId: restored.id, + ); + _currentTabIndex = _tabLoadState.currentIndex; + _tabController = TabController( + length: _tabLabelList.length, + initialIndex: _currentTabIndex, + vsync: this, + ); + _tabController.addListener(() { + final preloadIndex = _tabController.indexIsChanging + ? _tabController.index + : _tabController.offset > 0.001 + ? _tabController.index + 1 + : _tabController.offset < -0.001 + ? _tabController.index - 1 + : _tabController.index; + if (preloadIndex >= 0 && preloadIndex < _tabLabelList.length) { + unawaited(_ensureTabLoaded(preloadIndex)); } + final index = + (_tabController.animation?.value ?? _tabController.index).round(); + if (index != _currentTabIndex) _setCurrentTab(index); }); } - _fetchTagDetail() async { + void _setCurrentTab(int index) { + final safeIndex = TabStatePreference.restoreIndex( + index, + _tabLabelList.length, + ); + if (safeIndex != _currentTabIndex) { + setState(() => _currentTabIndex = safeIndex); + } + PersistentTabState.save( + idKey: HiveUtil.tagDetailTabIdKey, + legacyIndexKey: HiveUtil.tagDetailTabIndexKey, + itemIds: _tabIdList, + index: safeIndex, + ); + _ensureTabLoaded(safeIndex); + } + + Future _ensureTabLoaded(int index) async { + if (!_tabLoadState.selectAndShouldLoad(index)) return; + final started = await _refreshTabData(index); + if (!started) { + _tabLoadState.markLoadFailed(index); + } + } + + Future _refreshTabData(int index) async { + // Do not start an incoming page's refresh while it is still mostly + // off-screen. EasyRefresh can finish a fast request before the TabBarView + // transition settles, which makes the required refresh animation appear + // to be missing. Distant pages also need more than a handful of frames to + // attach their nested scroll position. + for (var attempt = 0; attempt < 60; attempt++) { + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return false; + final tabPosition = + _tabController.animation?.value ?? _tabController.index.toDouble(); + if ((tabPosition - index).abs() > 0.01) continue; + switch (index) { + case 0: + final state = _recommendKey.currentState; + if (state != null && state.refreshReady) { + await state.callRefresh(); + return true; + } + break; + case 1: + final state = _newestKey.currentState; + if (state != null && state.refreshReady) { + await state.filterData(_newestParams); + return true; + } + break; + case 2: + final state = _hottestKey.currentState; + if (state != null && state.refreshReady) { + await state.filterData(_hottestParams); + return true; + } + break; + } + } + return false; + } + + Future _fetchTagDetail() async { TagApi.getTagDetail(tag: widget.tag).then((value) { try { if (value['meta']['status'] != 200) { @@ -119,7 +259,10 @@ class _TagDetailScreenState extends BaseDynamicState if (value['response'] != null) { _tagDetailData = TagDetailData.fromJson(value['response']); } - if (mounted) setState(() {}); + if (mounted) { + setState(() {}); + _ensureTabLoaded(_currentTabIndex); + } } } catch (e, t) { IToast.showTop(appLocalizations.loadFailed); @@ -128,123 +271,16 @@ class _TagDetailScreenState extends BaseDynamicState }); } - _buildMainBody() { - return Container( - decoration: BoxDecoration( - color: ChewieTheme.getBackground(context), - ), + Widget _buildMainBody() { + final design = context.design; + return ColoredBox( + color: design.colors.page, child: ExtendedNestedScrollView( controller: _scrollController, onlyOneScrollInBody: true, headerSliverBuilder: (_, __) => [ - SliverToBoxAdapter( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - margin: const EdgeInsets.only(top: 5), - child: AssetUtil.loadDouble( - context, - AssetUtil.tagLightIcon, - AssetUtil.tagDarkIcon, - size: 16, - ), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - _tagDetailData!.tag, - style: Theme.of(context) - .textTheme - .titleLarge - ?.apply( - fontSizeDelta: 4, - ), - ), - ), - ], - ), - ), - const SizedBox(width: 12), - LoftifyItemBuilder.buildFramedDoubleButton( - context: context, - isFollowed: _tagDetailData!.favorited, - positiveText: appLocalizations.subscribed, - negtiveText: appLocalizations.subscribe, - onTap: () { - HapticFeedback.mediumImpact(); - TagApi.subscribeOrUnSubscribe( - tag: widget.tag, - isSubscribe: !_tagDetailData!.favorited, - id: NumberUtil.parseToInt( - _tagDetailData!.favoritedTagId), - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? - value['meta']['msg']); - } else { - _tagDetailData!.favorited = - !_tagDetailData!.favorited; - setState(() {}); - } - }); - }, - ), - if (ResponsiveUtil.isLandscapeLayout()) - ..._buildButtons(true), - ], - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 5, - alignment: WrapAlignment.start, - children: [ - if (_tagDetailData!.tagRanksNew.isNotEmpty) - ItemBuilder.buildTagItem( - context, - _tagDetailData!.tagRanksNew[0].name ?? "", - TagType.hot, - showIcon: false, - jumpToTag: false, - ), - ItemBuilder.buildTagItem( - context, - "${StringUtil.formatCount(_tagDetailData!.tagViewCount)}${appLocalizations.viewCount}", - TagType.normal, - showTagLabel: false, - showIcon: false, - jumpToTag: false, - ), - ItemBuilder.buildTagItem( - context, - showTagLabel: false, - "${StringUtil.formatCount(_tagDetailData!.postAllCount)}${appLocalizations.participateCount}", - TagType.normal, - showIcon: false, - jumpToTag: false, - ), - ], - ), - const SizedBox(height: 12), - MyDivider(horizontal: 0, vertical: 0), - ], - ), - ), - ), - SliverToBoxAdapter( - child: _buildEntries(), - ), + SliverToBoxAdapter(child: _buildTagHero()), + SliverToBoxAdapter(child: _buildEntries()), if (_tabLabelList.isNotEmpty) _buildTabBar(), if (_currentTabIndex == 1) _buildNewestFilterBar(), if (_currentTabIndex == 2) _buildHottestFilterBar(), @@ -254,148 +290,251 @@ class _TagDetailScreenState extends BaseDynamicState ); } + Widget _buildTagHero() { + final data = _tagDetailData!; + return _buildContentFrame( + LoftifyTagHero( + tag: data.tag, + subscribed: data.favorited, + subscribeLabel: appLocalizations.subscribe, + subscribedLabel: appLocalizations.subscribed, + metrics: [ + if (data.tagRanksNew.isNotEmpty && + StringUtil.isNotEmpty(data.tagRanksNew.first.name)) + LoftifyTagMetric( + data.tagRanksNew.first.name ?? '', + emphasized: true, + ), + LoftifyTagMetric( + '${StringUtil.formatCount(data.tagViewCount)}${appLocalizations.viewCount}', + ), + LoftifyTagMetric( + '${StringUtil.formatCount(data.postAllCount)}${appLocalizations.participateCount}', + ), + ], + onSubscriptionPressed: _toggleSubscription, + trailing: + ResponsiveUtil.isLandscapeLayout() ? _buildButtons(true) : const [], + ), + ); + } + + void _toggleSubscription() { + HapticFeedback.mediumImpact(); + TagApi.subscribeOrUnSubscribe( + tag: widget.tag, + isSubscribe: !_tagDetailData!.favorited, + id: NumberUtil.parseToInt(_tagDetailData!.favoritedTagId), + ).then((value) { + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } else { + _tagDetailData!.favorited = !_tagDetailData!.favorited; + if (mounted) setState(() {}); + } + }); + } + + Widget _buildContentFrame( + Widget child, { + double top = 0, + double bottom = 0, + }) { + final design = context.design; + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final centeredInset = ((width - design.grid.maximumContentWidth) / 2) + .clamp(0.0, double.infinity); + final pageInset = design.grid.pagePaddingFor(width); + return Padding( + padding: EdgeInsets.only( + left: centeredInset + pageInset, + right: centeredInset + pageInset, + top: top, + bottom: bottom, + ), + child: child, + ); + }, + ); + } + + double _contentInsetFor(double width) { + final design = context.design; + final centeredInset = ((width - design.grid.maximumContentWidth) / 2) + .clamp(0.0, double.infinity); + return centeredInset + design.grid.pagePaddingFor(width); + } + Widget _buildTabBar() { + final design = context.design; + final tabBar = TabBarWrapper( + tabController: _tabController, + tabBarPadding: EdgeInsets.zero, + labelPadding: EdgeInsets.zero, + background: design.colors.page, + tabs: _tabLabelList + .asMap() + .entries + .map((entry) => ItemBuilder.buildAnimatedTab( + context, + selected: entry.key == _currentTabIndex, + text: entry.value, + controller: _tabController, + tabIndex: entry.key, + sameFontSize: true, + fontSizeDelta: -1, + )) + .toList(), + onTap: (index) { + if (_currentTabIndex == index) { + _refreshTabData(index); + } + _setCurrentTab(index); + }, + ); return SliverPersistentHeader( pinned: true, - key: ValueKey(StringUtil.getRandomString()), + key: ValueKey('tag-detail-tabs-${widget.tag}'), delegate: SliverAppBarDelegate( radius: 0, - background: ChewieTheme.getBackground(context), - tabBar: TabBar( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), - overlayColor: WidgetStateProperty.all(Colors.transparent), - controller: _tabController, - tabs: _tabLabelList - .asMap() - .entries - .map((entry) => ItemBuilder.buildAnimatedTab(context, - selected: entry.key == _currentTabIndex, text: entry.value)) - .toList(), - labelPadding: const EdgeInsets.symmetric(horizontal: 0), - enableFeedback: true, - dividerHeight: 0, - physics: const BouncingScrollPhysics(), - labelStyle: Theme.of(context).textTheme.titleLarge, - unselectedLabelStyle: - Theme.of(context).textTheme.titleLarge?.apply(color: Colors.grey), - indicator: UnderlinedTabIndicator( - borderColor: Theme.of(context).primaryColor, - ), - onTap: (index) { - if (_currentTabIndex == index) { - switch (index) { - case 0: - _recommendKey.currentState?.callRefresh(); - break; - case 1: - _newestKey.currentState?.filterData(_newestParams); - break; - case 2: - _hottestKey.currentState?.filterData(_hottestParams); - break; - } - } - setState(() { - _currentTabIndex = index; - }); - }, + background: design.colors.page, + tabBar: PreferredSize( + preferredSize: tabBar.preferredSize, + child: _buildContentFrame(tabBar), ), ), ); } - scrollToTop() { + void scrollToTop() { _scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); } Widget _buildTabView() { - List children = []; - children.add(RecommendTab( - key: _recommendKey, - tag: widget.tag, - postLayoutType: _postLayoutType, - )); - children.add(Builder(builder: (BuildContext context) { - final parentController = PrimaryScrollController.of(context); - if (scrollControllers[0]?.parent != parentController) { - scrollControllers[0]?.dispose(); - scrollControllers[0] = SubordinateScrollController(parentController); - } - return NewestTab( - key: _newestKey, - tag: widget.tag, - scrollController: scrollControllers[0], - postLayoutType: _postLayoutType, - ); - })); - children.add(Builder(builder: (BuildContext context) { - final parentController = PrimaryScrollController.of(context); - if (scrollControllers[1]?.parent != parentController) { - scrollControllers[1]?.dispose(); - scrollControllers[1] = SubordinateScrollController(parentController); - } - return HottestTab( - key: _hottestKey, - tag: widget.tag, - scrollController: scrollControllers[1], - postLayoutType: _postLayoutType, - ); - })); return TabBarView( controller: _tabController, - children: children, + children: [ + Builder(builder: (context) { + final parentController = PrimaryScrollController.of(context); + if (_tabScrollControllers[0]?.parent != parentController) { + _tabScrollControllers[0]?.dispose(); + _tabScrollControllers[0] = + SubordinateScrollController(parentController); + } + return RecommendTab( + key: _recommendKey, + tag: widget.tag, + scrollController: _tabScrollControllers[0], + postLayoutType: _postLayoutType, + ); + }), + Builder(builder: (context) { + final parentController = PrimaryScrollController.of(context); + if (_tabScrollControllers[1]?.parent != parentController) { + _tabScrollControllers[1]?.dispose(); + _tabScrollControllers[1] = + SubordinateScrollController(parentController); + } + return NewestTab( + key: _newestKey, + tag: widget.tag, + scrollController: _tabScrollControllers[1], + postLayoutType: _postLayoutType, + initialParams: _newestParams, + ); + }), + Builder(builder: (context) { + final parentController = PrimaryScrollController.of(context); + if (_tabScrollControllers[2]?.parent != parentController) { + _tabScrollControllers[2]?.dispose(); + _tabScrollControllers[2] = + SubordinateScrollController(parentController); + } + return HottestTab( + key: _hottestKey, + tag: widget.tag, + scrollController: _tabScrollControllers[2], + postLayoutType: _postLayoutType, + initialParams: _hottestParams, + ); + }), + ], ); } Widget _buildEntries() { + final design = context.design; + final entryHeight = LoftifyTagDiscoveryCard.preferredHeight(context); bool showTagDress = controlProvider.globalControl.showTagDress; bool showEntries = _tagDetailData!.collectionRank != null || (_tagDetailData!.propGiftTagConfig != null && showTagDress) || StringUtil.isNotEmpty(_tagDetailData!.relatedTags); return showEntries - ? Container( - height: 70, + ? SizedBox( + height: entryHeight + design.spacing.lg, width: MediaQuery.sizeOf(context).width, - margin: const EdgeInsets.only(top: 10), - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - children: [ - if (_tagDetailData!.collectionRank != null) - _buildEntryItem( - darkBg: AssetUtil.collectionDarkIllust, - lightBg: AssetUtil.collectionLightIllust, - title: appLocalizations.collectionGrain, - desc: appLocalizations.collectionGrainDetail( - _tagDetailData!.collectionRank!.title), - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, TagCollectionGrainScreen(tag: widget.tag)); - }), - if (StringUtil.isNotEmpty(_tagDetailData!.relatedTags)) - _buildEntryItem( - darkBg: AssetUtil.tagDarkIllust, - lightBg: AssetUtil.tagLightIllust, - title: appLocalizations.relatedTag, - desc: _tagDetailData!.relatedTags, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, TagRelatedScreen(tag: widget.tag)); - }), - if (_tagDetailData!.propGiftTagConfig != null && showTagDress) - _buildEntryItem( - darkBg: AssetUtil.dressDarkIllust, - lightBg: AssetUtil.dressLightIllust, - title: appLocalizations.relatedDressShort, - desc: appLocalizations.relatedDressShortDetail( - _tagDetailData!.propGiftTagConfig!.slotCount), - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, DressScreen(tag: widget.tag)); - }, + child: LayoutBuilder( + builder: (context, constraints) { + final inset = _contentInsetFor(constraints.maxWidth); + return ListView( + padding: EdgeInsets.only( + left: inset, + right: inset, + bottom: design.spacing.lg, ), - ], + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + children: [ + if (_tagDetailData!.collectionRank != null) + _buildEntryItem( + darkBg: AssetUtil.collectionDarkIllust, + lightBg: AssetUtil.collectionLightIllust, + title: appLocalizations.collectionGrain, + desc: appLocalizations.collectionGrainDetail( + _tagDetailData!.collectionRank!.title, + ), + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + TagCollectionGrainScreen(tag: widget.tag), + ); + }, + ), + if (StringUtil.isNotEmpty(_tagDetailData!.relatedTags)) + _buildEntryItem( + darkBg: AssetUtil.tagDarkIllust, + lightBg: AssetUtil.tagLightIllust, + title: appLocalizations.relatedTag, + desc: _tagDetailData!.relatedTags, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + TagRelatedScreen(tag: widget.tag), + ); + }, + ), + if (_tagDetailData!.propGiftTagConfig != null && + showTagDress) + _buildEntryItem( + darkBg: AssetUtil.dressDarkIllust, + lightBg: AssetUtil.dressLightIllust, + title: appLocalizations.relatedDressShort, + desc: appLocalizations.relatedDressShortDetail( + _tagDetailData!.propGiftTagConfig!.slotCount, + ), + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, + DressScreen(tag: widget.tag), + ); + }, + ), + ], + ); + }, ), ) : emptyWidget; @@ -406,134 +545,76 @@ class _TagDetailScreenState extends BaseDynamicState required String darkBg, required String title, required String desc, - Function()? onTap, + required VoidCallback onTap, }) { - return ClickableWrapper( - child: GestureDetector( + final design = context.design; + final entryHeight = LoftifyTagDiscoveryCard.preferredHeight(context); + final entryWidth = LoftifyTagDiscoveryCard.preferredWidth(context); + return Padding( + padding: EdgeInsets.only(right: design.spacing.md), + child: LoftifyTagDiscoveryCard( + title: title, + description: desc, onTap: onTap, - child: Container( - margin: const EdgeInsets.only(right: 12), - width: 170, - height: 65, - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: AssetUtil.loadDouble( - context, - lightBg, - darkBg, - width: 170, - height: 65, - fit: BoxFit.cover, - ), - ), - Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .labelMedium - ?.apply(fontSizeDelta: -1), - ), - const SizedBox(height: 8), - Text( - desc, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - ], - ), - ), - ], - ), + illustration: AssetUtil.loadDouble( + context, + lightBg, + darkBg, + width: entryWidth, + height: entryHeight, + fit: BoxFit.cover, ), ), ); } Widget _buildNewestFilterBar() { + final design = context.design; return SliverPersistentHeader( - key: ValueKey("$_currentTabIndex"), + key: ValueKey('tag-detail-newest-filter-$_currentTabIndex'), pinned: true, delegate: SliverHeaderDelegate.fixedHeight( - height: 50, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: ChewieTheme.getBackground(context), - ), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: CustomSlidingSegmentedControl( - isStretch: true, - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(50), - ), - thumbDecoration: BoxDecoration( - color: Theme.of(context).canvasColor, - borderRadius: BorderRadius.circular(50), - ), - height: 50, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - children: { - 0: Text(appLocalizations.releaseRecently), - 1: Text(appLocalizations.commentRecently), - }, - initialValue: _currentNewestIndex, - onValueChanged: (index) { - setState(() { - _currentNewestIndex = index; - switch (_currentNewestIndex) { - case 0: - _newestParams = _newestParams.copyWith( - tagPostResultType: TagPostResultType.newPost, - ); - break; - case 1: - _newestParams = _newestParams.copyWith( - tagPostResultType: TagPostResultType.newComment, - ); - break; - } - _newestKey.currentState?.filterData(_newestParams); - }); - }, - ), - ), - const SizedBox(width: 12), - ItemBuilder.buildIconTextButton( - context, - icon: const Icon( - Icons.filter_alt_rounded, - size: 16, - ), - text: appLocalizations.filter, - onTap: () { - BottomSheetBuilder.showBottomSheet( - context, - (context) => NewestFilterBottomSheet( - params: _newestParams.clone(), - onConfirm: (params) { - _newestParams = params; - _newestKey.currentState?.filterData(_newestParams); - }, - ), + height: 64, + child: ColoredBox( + color: design.colors.page, + child: _buildContentFrame( + _buildFilterStrip( + selectedIndex: _currentNewestIndex, + labels: [ + appLocalizations.releaseRecently, + appLocalizations.commentRecently, + ], + onSelected: (index) { + setState(() { + _currentNewestIndex = index; + _newestParams = _newestParams.copyWith( + tagPostResultType: index == 0 + ? TagPostResultType.newPost + : TagPostResultType.newComment, ); - }, - ), - ], + }); + _persistFilter(HiveUtil.tagNewestFilterKey, _newestParams); + _refreshTabData(1); + }, + onFilter: () { + BottomSheetBuilder.showBottomSheet( + context, + (context) => NewestFilterBottomSheet( + params: _newestParams.clone(), + onConfirm: (params) { + _newestParams = params; + _persistFilter( + HiveUtil.tagNewestFilterKey, + _newestParams, + ); + _refreshTabData(1); + }, + ), + ); + }, + ), + top: design.spacing.md, + bottom: design.spacing.md, ), ), ), @@ -541,95 +622,118 @@ class _TagDetailScreenState extends BaseDynamicState } Widget _buildHottestFilterBar() { + final design = context.design; return SliverPersistentHeader( - key: ValueKey("$_currentTabIndex"), + key: ValueKey('tag-detail-hottest-filter-$_currentTabIndex'), pinned: true, delegate: SliverHeaderDelegate.fixedHeight( - height: 50, - child: Container( - height: 50, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: ChewieTheme.getBackground(context), - ), - child: Row( - children: [ - Expanded( - child: CustomSlidingSegmentedControl( - isStretch: true, - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(50), - ), - height: 50, - thumbDecoration: BoxDecoration( - color: Theme.of(context).canvasColor, - borderRadius: BorderRadius.circular(50), + height: 64, + child: ColoredBox( + color: design.colors.page, + child: _buildContentFrame( + _buildFilterStrip( + selectedIndex: _currentHottestIndex, + labels: [ + appLocalizations.all, + appLocalizations.dayRank, + appLocalizations.weekRank, + appLocalizations.monthRank, + ], + onSelected: (index) { + setState(() { + _currentHottestIndex = index; + _hottestParams = _hottestParams.copyWith( + tagPostResultType: switch (index) { + 0 => TagPostResultType.total, + 1 => TagPostResultType.date, + 2 => TagPostResultType.week, + _ => TagPostResultType.month, + }, + ); + }); + _persistFilter(HiveUtil.tagHottestFilterKey, _hottestParams); + _refreshTabData(2); + }, + onFilter: () { + BottomSheetBuilder.showBottomSheet( + context, + (context) => NewestFilterBottomSheet( + params: _hottestParams.clone(), + onConfirm: (params) { + _hottestParams = params; + _persistFilter( + HiveUtil.tagHottestFilterKey, + _hottestParams, + ); + _refreshTabData(2); + }, ), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - children: { - 0: Text(appLocalizations.all), - 1: Text(appLocalizations.dayRank), - 2: Text(appLocalizations.weekRank), - 3: Text(appLocalizations.monthRank), - }, - initialValue: _currentHottestIndex, - onValueChanged: (index) { - setState(() { - _currentHottestIndex = index; - switch (_currentHottestIndex) { - case 0: - _hottestParams = _hottestParams.copyWith( - tagPostResultType: TagPostResultType.total, - ); - break; - case 1: - _hottestParams = _hottestParams.copyWith( - tagPostResultType: TagPostResultType.date, - ); - break; - case 2: - _hottestParams = _hottestParams.copyWith( - tagPostResultType: TagPostResultType.week, - ); - break; - case 3: - _hottestParams = _hottestParams.copyWith( - tagPostResultType: TagPostResultType.month, - ); - break; - } - _hottestKey.currentState?.filterData(_hottestParams); - }); - }, - ), + ); + }, + ), + top: design.spacing.md, + bottom: design.spacing.md, + ), + ), + ), + ); + } + + Widget _buildFilterStrip({ + required int selectedIndex, + required List labels, + required ValueChanged onSelected, + required VoidCallback onFilter, + }) { + final design = context.design; + final segmentTextStyle = design.typography.label.copyWith( + color: design.colors.textPrimary, + ); + return Row( + children: [ + Expanded( + child: CustomSlidingSegmentedControl( + isStretch: true, + innerPadding: EdgeInsets.all(design.spacing.xxs), + padding: design.spacing.md, + height: 40, + decoration: BoxDecoration( + color: design.colors.surfaceMuted, + borderRadius: BorderRadius.circular(design.radii.full), + ), + thumbDecoration: BoxDecoration( + color: design.colors.surfaceRaised, + borderRadius: BorderRadius.circular(design.radii.full), + border: Border.all( + color: design.colors.outline, + width: design.borders.hairline, ), - const SizedBox(width: 12), - ItemBuilder.buildIconTextButton( - context, - icon: const Icon( - Icons.filter_alt_rounded, - size: 16, + ), + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + children: { + for (var index = 0; index < labels.length; index++) + index: Text( + labels[index], + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: segmentTextStyle, ), - text: appLocalizations.filter, - onTap: () { - BottomSheetBuilder.showBottomSheet( - context, - (context) => NewestFilterBottomSheet( - params: _hottestParams.clone(), - onConfirm: (params) { - _hottestParams = params; - _hottestKey.currentState?.filterData(_hottestParams); - }, - ), - ); - }, - ), - ], + }, + initialValue: selectedIndex, + onValueChanged: onSelected, ), ), - ), + SizedBox(width: design.spacing.md), + LoftifyButton( + label: appLocalizations.filter, + icon: LoftifyIcons.filter, + variant: LoftifyButtonVariant.ghost, + size: LoftifyButtonSize.compact, + onPressed: onFilter, + ), + ], ); } @@ -649,30 +753,23 @@ class _TagDetailScreenState extends BaseDynamicState List _buildButtons([bool small = false]) { return [ const SizedBox(width: 5), - CircleIconButton( - icon: AssetUtil.loadDouble( - context, - AssetUtil.searchLightIcon, - AssetUtil.searchDarkIcon, - size: small ? 20 : 24, - ), - padding: small ? const EdgeInsets.all(4) : null, - onTap: () { + ChewieIconButton( + icon: LoftifyIcons.search, + tooltip: appLocalizations.search, + iconSize: small ? 20 : 24, + onPressed: () { RouteUtil.pushPanelCupertinoRoute( context, TagInsearchScreen(tag: widget.tag)); }, ), const SizedBox(width: 5), - CircleIconButton( - icon: Icon( - _postLayoutType == PostLayoutType.waterfallflow - ? Icons.view_agenda_outlined - : Icons.view_module_outlined, - color: Theme.of(context).iconTheme.color, - size: small ? 20 : 24, - ), - padding: small ? const EdgeInsets.all(4) : null, - onTap: () { + ChewieIconButton( + icon: _postLayoutType == PostLayoutType.waterfallflow + ? LoftifyIcons.listLayout + : LoftifyIcons.gridLayout, + tooltip: appLocalizations.layoutSetting, + iconSize: small ? 20 : 24, + onPressed: () { if (_postLayoutType == PostLayoutType.waterfallflow) { _postLayoutType = PostLayoutType.grid; } else { @@ -684,37 +781,34 @@ class _TagDetailScreenState extends BaseDynamicState }, ), const SizedBox(width: 5), - CircleIconButton( - icon: Icon( - Icons.more_vert_rounded, - color: Theme.of(context).iconTheme.color, - size: small ? 20 : 24, - ), - padding: small ? const EdgeInsets.all(4) : null, - onTap: () { + ChewieIconButton( + icon: LoftifyIcons.moreVertical, + tooltip: appLocalizations.moreInfo, + iconSize: small ? 20 : 24, + onPressed: () { BottomSheetBuilder.showContextMenu(context, _buildMoreButtons()); }, ), ]; } - _buildMoreButtons() { + FlutterContextMenu _buildMoreButtons() { String url = LoftifyUriUtil.getTagUrlByTagName(widget.tag); return FlutterContextMenu( entries: [ FlutterContextMenuItem( appLocalizations.copyLink, - iconData: Icons.copy_rounded, + iconData: LoftifyIcons.copy, onPressed: () { ChewieUtils.copy(context, url); }, ), FlutterContextMenuItem(appLocalizations.openWithBrowser, - iconData: Icons.open_in_browser_rounded, onPressed: () { + iconData: LoftifyIcons.openExternal, onPressed: () { UriUtil.openExternal(url); }), FlutterContextMenuItem(appLocalizations.shareToOtherApps, - iconData: Icons.share_rounded, onPressed: () { + iconData: LoftifyIcons.share, onPressed: () { UriUtil.share(url); }), ], @@ -722,14 +816,62 @@ class _TagDetailScreenState extends BaseDynamicState } } +double _tagResultContentInset(BuildContext context, double viewportWidth) { + final design = context.design; + final centeredInset = ((viewportWidth - design.grid.maximumContentWidth) / 2) + .clamp(0.0, double.infinity); + return centeredInset + design.grid.pagePaddingFor(viewportWidth); +} + +Widget _buildTagPostGrid( + BuildContext context, { + required ScrollController? controller, + required ScrollPhysics physics, + required List posts, +}) { + final design = context.design; + return LayoutBuilder( + builder: (context, constraints) { + final horizontalInset = + _tagResultContentInset(context, constraints.maxWidth); + final geometry = LoftifyArchiveGridGeometry.calculate( + constraints.maxWidth - horizontalInset * 2, + ); + return GridView.builder( + controller: controller, + physics: physics, + padding: EdgeInsets.only( + top: design.spacing.sectionTop, + left: horizontalInset, + right: horizontalInset, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: geometry.columnCount, + mainAxisSpacing: geometry.spacing, + crossAxisSpacing: geometry.spacing, + ), + itemCount: posts.length, + itemBuilder: (context, index) => + RecommendFlowItemBuilder.buildNineGridPostItem( + context, + posts[index], + wh: geometry.tileExtent, + ), + ); + }, + ); +} + class RecommendTab extends StatefulWidget { const RecommendTab({ super.key, required this.tag, + this.scrollController, required this.postLayoutType, }); final String tag; + final ScrollController? scrollController; final PostLayoutType postLayoutType; @override @@ -750,25 +892,34 @@ class RecommendTabState extends BaseDynamicState bool get isWaterfallFlow => widget.postLayoutType == PostLayoutType.waterfallflow; - @override - void initState() { - super.initState(); - callRefresh(); - } + bool get refreshReady => + (widget.scrollController?.hasClients ?? false) || + _recommendResultRefreshController.headerState != null; + + Future callRefresh() => _recommendResultRefreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + scrollController: widget.scrollController, + jumpToEdge: false, + ); - callRefresh() { - _fetchRecommendResult(refresh: true); - _recommendResultRefreshController.callRefresh(); + @override + void dispose() { + _recommendResultRefreshController.dispose(); + super.dispose(); } - _fetchRecommendResult({bool refresh = false}) async { - if (_recommendResultLoading) return; + Future _fetchRecommendResult({bool refresh = false}) async { + if (_recommendResultLoading || (!refresh && _recommendNoMore)) { + return IndicatorResult.none; + } if (refresh) _recommendNoMore = false; _recommendResultLoading = true; return await TagApi.getRecommendList( tag: widget.tag, offset: refresh ? 0 : _recommendResultOffset, - ).then((value) { + ).then((value) { try { if (value['code'] != 0) { IToast.showTop(value['msg']); @@ -788,8 +939,8 @@ class RecommendTabState extends BaseDynamicState .removeWhere((e) => RecommendFlowItemBuilder.isInvalid(e)); } if (mounted) setState(() {}); - if (newPosts.isEmpty) { - _recommendNoMore = true; + _recommendNoMore = newPosts.isEmpty; + if (_recommendNoMore && !refresh) { return IndicatorResult.noMore; } else { return IndicatorResult.success; @@ -808,55 +959,48 @@ class RecommendTabState extends BaseDynamicState @override Widget build(BuildContext context) { super.build(context); + final design = context.design; + final viewportWidth = MediaQuery.sizeOf(context).width; + final horizontalInset = _tagResultContentInset(context, viewportWidth); + final gutter = design.grid.gutterFor(viewportWidth); return EasyRefresh.builder( controller: _recommendResultRefreshController, - refreshOnStart: true, + refreshOnStart: false, onRefresh: () async { return await _fetchRecommendResult(refresh: true); }, - onLoad: () async { - return await _fetchRecommendResult(); - }, + onLoad: _recommendNoMore ? null : _fetchRecommendResult, triggerAxis: Axis.vertical, - childBuilder: (context, physics) => LoadMoreNotification( - onLoad: _fetchRecommendResult, - noMore: _recommendNoMore, - child: isWaterfallFlow - ? WaterfallFlow.builder( - cacheExtent: 9999, - physics: physics, - padding: const EdgeInsets.only(top: 10, left: 8, right: 8), - gridDelegate: - const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 6, - crossAxisSpacing: 6, - maxCrossAxisExtent: 300, - ), - itemBuilder: (BuildContext context, int index) { - return RecommendFlowItemBuilder.buildWaterfallFlowPostItem( - context, - _recommendList[index], - excludeTag: widget.tag, - ); - }, - itemCount: _recommendList.length, - ) - : GridView.extent( - shrinkWrap: true, - padding: const EdgeInsets.only(top: 10, left: 8, right: 8), - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: physics, - children: List.generate(_recommendList.length, (index) { - return RecommendFlowItemBuilder.buildNineGridPostItem( - context, - _recommendList[index], - wh: 160, - ); - }), + childBuilder: (context, physics) => isWaterfallFlow + ? WaterfallFlow.builder( + controller: widget.scrollController, + cacheExtent: MediaQuery.sizeOf(context).height, + physics: physics, + padding: EdgeInsets.only( + top: design.spacing.sectionTop, + left: horizontalInset, + right: horizontalInset, ), - ), + gridDelegate: SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: gutter, + crossAxisSpacing: gutter, + maxCrossAxisExtent: design.grid.maximumDenseCardExtent, + ), + itemBuilder: (BuildContext context, int index) { + return RecommendFlowItemBuilder.buildWaterfallFlowPostItem( + context, + _recommendList[index], + excludeTag: widget.tag, + ); + }, + itemCount: _recommendList.length, + ) + : _buildTagPostGrid( + context, + controller: widget.scrollController, + physics: physics, + posts: _recommendList, + ), ); } } @@ -867,11 +1011,13 @@ class HottestTab extends StatefulWidget { required this.tag, this.scrollController, required this.postLayoutType, + required this.initialParams, }); final String tag; final PostLayoutType postLayoutType; final ScrollController? scrollController; + final GetTagPostListParams initialParams; @override State createState() => HottestTabState(); @@ -893,28 +1039,42 @@ class HottestTabState extends BaseDynamicState bool get isWaterfallFlow => widget.postLayoutType == PostLayoutType.waterfallflow; + bool get refreshReady => + (widget.scrollController?.hasClients ?? false) || + _hottestResultRefreshController.headerState != null; + @override void initState() { super.initState(); - filterData(GetTagPostListParams( - tag: widget.tag, - tagPostResultType: TagPostResultType.week, - )); + _hottestParams = widget.initialParams.clone(); } - filterData(GetTagPostListParams newParam) { - _hottestParams = newParam; - _fetchHottestResult(refresh: true); - _hottestResultRefreshController.callRefresh(); + Future filterData(GetTagPostListParams newParam) { + _hottestParams = newParam.clone(); + return _hottestResultRefreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + scrollController: widget.scrollController, + jumpToEdge: false, + ); } - _fetchHottestResult({bool refresh = false}) async { - if (_hottestResultLoading) return; + @override + void dispose() { + _hottestResultRefreshController.dispose(); + super.dispose(); + } + + Future _fetchHottestResult({bool refresh = false}) async { + if (_hottestResultLoading || (!refresh && _hottestNoMore)) { + return IndicatorResult.none; + } if (refresh) _hottestNoMore = false; _hottestResultLoading = true; return await TagApi.getPostList( _hottestParams!.copyWith(offset: refresh ? 0 : _hottestResultOffset), - ).then((value) { + ).then((value) { try { if (value['code'] != 0) { IToast.showTop(value['msg']); @@ -934,8 +1094,8 @@ class HottestTabState extends BaseDynamicState .removeWhere((e) => RecommendFlowItemBuilder.isInvalid(e)); } if (mounted) setState(() {}); - if (newPosts.isEmpty) { - _hottestNoMore = false; + _hottestNoMore = newPosts.isEmpty; + if (_hottestNoMore && !refresh) { return IndicatorResult.noMore; } else { return IndicatorResult.success; @@ -954,55 +1114,48 @@ class HottestTabState extends BaseDynamicState @override Widget build(BuildContext context) { super.build(context); + final design = context.design; + final viewportWidth = MediaQuery.sizeOf(context).width; + final horizontalInset = _tagResultContentInset(context, viewportWidth); + final gutter = design.grid.gutterFor(viewportWidth); return EasyRefresh.builder( controller: _hottestResultRefreshController, - refreshOnStart: true, + refreshOnStart: false, onRefresh: () async { return await _fetchHottestResult(refresh: true); }, - onLoad: () async { - return await _fetchHottestResult(); - }, + onLoad: _hottestNoMore ? null : _fetchHottestResult, triggerAxis: Axis.vertical, - childBuilder: (context, physics) => LoadMoreNotification( - onLoad: _fetchHottestResult, - noMore: _hottestNoMore, - child: isWaterfallFlow - ? WaterfallFlow.builder( - cacheExtent: 9999, - physics: physics, - padding: const EdgeInsets.only(top: 10, left: 8, right: 8), - gridDelegate: - const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 6, - crossAxisSpacing: 6, - maxCrossAxisExtent: 300, - ), - itemBuilder: (BuildContext context, int index) { - return RecommendFlowItemBuilder.buildWaterfallFlowPostItem( - context, - _hottestList[index], - excludeTag: widget.tag, - ); - }, - itemCount: _hottestList.length, - ) - : GridView.extent( - shrinkWrap: true, - padding: const EdgeInsets.only(top: 10, left: 8, right: 8), - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: physics, - children: List.generate(_hottestList.length, (index) { - return RecommendFlowItemBuilder.buildNineGridPostItem( - context, - _hottestList[index], - wh: 160, - ); - }), + childBuilder: (context, physics) => isWaterfallFlow + ? WaterfallFlow.builder( + controller: widget.scrollController, + cacheExtent: MediaQuery.sizeOf(context).height, + physics: physics, + padding: EdgeInsets.only( + top: design.spacing.sectionTop, + left: horizontalInset, + right: horizontalInset, ), - ), + gridDelegate: SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: gutter, + crossAxisSpacing: gutter, + maxCrossAxisExtent: design.grid.maximumDenseCardExtent, + ), + itemBuilder: (BuildContext context, int index) { + return RecommendFlowItemBuilder.buildWaterfallFlowPostItem( + context, + _hottestList[index], + excludeTag: widget.tag, + ); + }, + itemCount: _hottestList.length, + ) + : _buildTagPostGrid( + context, + controller: widget.scrollController, + physics: physics, + posts: _hottestList, + ), ); } } @@ -1013,11 +1166,13 @@ class NewestTab extends StatefulWidget { required this.tag, this.scrollController, required this.postLayoutType, + required this.initialParams, }); final String tag; final PostLayoutType postLayoutType; final ScrollController? scrollController; + final GetTagPostListParams initialParams; @override State createState() => NewestTabState(); @@ -1038,28 +1193,42 @@ class NewestTabState extends BaseDynamicState bool get isWaterfallFlow => widget.postLayoutType == PostLayoutType.waterfallflow; + bool get refreshReady => + (widget.scrollController?.hasClients ?? false) || + _newestResultRefreshController.headerState != null; + @override void initState() { super.initState(); - filterData(GetTagPostListParams( - tag: widget.tag, - tagPostResultType: TagPostResultType.newPost, - )); + _newestParams = widget.initialParams.clone(); } - filterData(GetTagPostListParams newParam) { - _newestParams = newParam; - _fetchNewestResult(refresh: true); - _newestResultRefreshController.callRefresh(); + Future filterData(GetTagPostListParams newParam) { + _newestParams = newParam.clone(); + return _newestResultRefreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + scrollController: widget.scrollController, + jumpToEdge: false, + ); + } + + @override + void dispose() { + _newestResultRefreshController.dispose(); + super.dispose(); } - _fetchNewestResult({bool refresh = false}) async { - if (_newestResultLoading) return; + Future _fetchNewestResult({bool refresh = false}) async { + if (_newestResultLoading || (!refresh && _newestNoMore)) { + return IndicatorResult.none; + } if (refresh) _newestNoMore = false; _newestResultLoading = true; return await TagApi.getPostList( _newestParams!.copyWith(offset: refresh ? 0 : _newestResultOffset), - ).then((value) { + ).then((value) { try { if (value['code'] != 0) { IToast.showTop(value['msg']); @@ -1080,8 +1249,8 @@ class NewestTabState extends BaseDynamicState .removeWhere((e) => RecommendFlowItemBuilder.isInvalid(e)); } if (mounted) setState(() {}); - if (newPosts.isEmpty) { - _newestNoMore = false; + _newestNoMore = newPosts.isEmpty; + if (_newestNoMore && !refresh) { return IndicatorResult.noMore; } else { return IndicatorResult.success; @@ -1101,55 +1270,48 @@ class NewestTabState extends BaseDynamicState @override Widget build(BuildContext context) { super.build(context); + final design = context.design; + final viewportWidth = MediaQuery.sizeOf(context).width; + final horizontalInset = _tagResultContentInset(context, viewportWidth); + final gutter = design.grid.gutterFor(viewportWidth); return EasyRefresh.builder( controller: _newestResultRefreshController, - refreshOnStart: true, + refreshOnStart: false, onRefresh: () async { return await _fetchNewestResult(refresh: true); }, - onLoad: () async { - return await _fetchNewestResult(); - }, + onLoad: _newestNoMore ? null : _fetchNewestResult, triggerAxis: Axis.vertical, - childBuilder: (context, physics) => LoadMoreNotification( - onLoad: _fetchNewestResult, - noMore: _newestNoMore, - child: isWaterfallFlow - ? WaterfallFlow.builder( - cacheExtent: 9999, - physics: physics, - padding: const EdgeInsets.only(top: 10, left: 8, right: 8), - gridDelegate: - const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 6, - crossAxisSpacing: 6, - maxCrossAxisExtent: 300, - ), - itemBuilder: (BuildContext context, int index) { - return RecommendFlowItemBuilder.buildWaterfallFlowPostItem( - context, - _newestList[index], - excludeTag: widget.tag, - ); - }, - itemCount: _newestList.length, - ) - : GridView.extent( - padding: const EdgeInsets.only(top: 10, left: 8, right: 8), - shrinkWrap: true, - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: physics, - children: List.generate(_newestList.length, (index) { - return RecommendFlowItemBuilder.buildNineGridPostItem( - context, - _newestList[index], - wh: 160, - ); - }), + childBuilder: (context, physics) => isWaterfallFlow + ? WaterfallFlow.builder( + controller: widget.scrollController, + cacheExtent: MediaQuery.sizeOf(context).height, + physics: physics, + padding: EdgeInsets.only( + top: design.spacing.sectionTop, + left: horizontalInset, + right: horizontalInset, ), - ), + gridDelegate: SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: gutter, + crossAxisSpacing: gutter, + maxCrossAxisExtent: design.grid.maximumDenseCardExtent, + ), + itemBuilder: (BuildContext context, int index) { + return RecommendFlowItemBuilder.buildWaterfallFlowPostItem( + context, + _newestList[index], + excludeTag: widget.tag, + ); + }, + itemCount: _newestList.length, + ) + : _buildTagPostGrid( + context, + controller: widget.scrollController, + physics: physics, + posts: _newestList, + ), ); } } diff --git a/lib/Screens/Post/tag_insearch_screen.dart b/lib/Screens/Post/tag_insearch_screen.dart index 10f05c12..917fca32 100644 --- a/lib/Screens/Post/tag_insearch_screen.dart +++ b/lib/Screens/Post/tag_insearch_screen.dart @@ -43,7 +43,7 @@ class _TagInsearchScreenState extends BaseDynamicState }); _fetchRelatedTag(); Future.delayed(const Duration(milliseconds: 200), () { - FocusScope.of(context).requestFocus(_focusNode); + if (mounted) _focusNode.requestFocus(); }); } @@ -64,7 +64,8 @@ class _TagInsearchScreenState extends BaseDynamicState children: [ if (_relatedTagList.isNotEmpty && _postList.isEmpty) ItemBuilder.buildTitle(context, - title: appLocalizations.allAreSearching, bottomMargin: 8), + title: appLocalizations.allAreSearching, + bottomMargin: 8), if (_relatedTagList.isNotEmpty && _postList.isEmpty) ItemBuilder.buildWrapTagList( context, @@ -183,7 +184,7 @@ class _TagInsearchScreenState extends BaseDynamicState }, child: WaterfallFlow.builder( physics: physics, - cacheExtent: 9999, + cacheExtent: MediaQuery.sizeOf(context).height, padding: const EdgeInsets.only(top: 10, left: 8, right: 8), gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( mainAxisSpacing: 6, @@ -207,7 +208,7 @@ class _TagInsearchScreenState extends BaseDynamicState Widget _buildSearchBar() { return Container( - height: 35, + height: 48, margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: ItemBuilder.buildSearchBar( focusNode: _focusNode, @@ -215,7 +216,6 @@ class _TagInsearchScreenState extends BaseDynamicState bottomMargin: 18, hintFontSizeDelta: 1, context: context, - background: Colors.grey.withAlpha(40), hintText: appLocalizations.multipleSearchKeySeparatedBySpaces, onSubmitted: (value) { _performSearch(value); @@ -229,8 +229,8 @@ class _TagInsearchScreenState extends BaseDynamicState return ResponsiveAppBar( showBack: true, centerTitle: true, - titleWidget: ClickableWrapper(child: - ItemBuilder.buildTagItem( + titleWidget: ClickableWrapper( + child: ItemBuilder.buildTagItem( context, widget.tag, TagType.normal, diff --git a/lib/Screens/Post/tag_related_screen.dart b/lib/Screens/Post/tag_related_screen.dart index abab53c1..5e493a91 100644 --- a/lib/Screens/Post/tag_related_screen.dart +++ b/lib/Screens/Post/tag_related_screen.dart @@ -106,7 +106,7 @@ class _TagRelatedScreenState extends BaseDynamicState return await _fetchResult(); }, child: WaterfallFlow.builder( - cacheExtent: 9999, + cacheExtent: MediaQuery.sizeOf(context).height, physics: physics, padding: const EdgeInsets.only(top: 10, left: 8, right: 8), gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( @@ -133,8 +133,8 @@ class _TagRelatedScreenState extends BaseDynamicState return ResponsiveAppBar( backgroundColor: ChewieTheme.getBackground(context), showBack: true, - titleWidget: ClickableWrapper(child: - ItemBuilder.buildTagItem( + titleWidget: ClickableWrapper( + child: ItemBuilder.buildTagItem( context, widget.tag, TagType.normal, diff --git a/lib/Screens/Post/video_detail_screen.dart b/lib/Screens/Post/video_detail_screen.dart index b3ea8442..c8fece75 100644 --- a/lib/Screens/Post/video_detail_screen.dart +++ b/lib/Screens/Post/video_detail_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:awesome_chewie/awesome_chewie.dart'; @@ -12,35 +13,22 @@ import 'package:loftify/Models/recommend_response.dart'; import 'package:loftify/Models/search_response.dart'; import 'package:loftify/Screens/Info/user_detail_screen.dart'; import 'package:loftify/Screens/Post/video_list_controller.dart'; -import 'package:loftify/Utils/app_provider.dart'; import 'package:loftify/Utils/enums.dart'; import 'package:loftify/Utils/hive_util.dart'; -import 'package:loftify/Widgets/PostItem/general_post_item_builder.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:loftify/Utils/uri_util.dart'; +import 'package:loftify/Utils/utils.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item.dart'; import 'package:video_player/video_player.dart'; import '../../Models/illust.dart'; import '../../Utils/cloud_control_provider.dart'; +import '../../Utils/loftify_file_util.dart'; import '../../Widgets/BottomSheet/comment_bottom_sheet.dart'; import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/loftify_icons.dart'; +import '../../Widgets/loftify_reaction_icon.dart'; import '../../l10n/l10n.dart'; -class QuickerScrollPhysics extends BouncingScrollPhysics { - const QuickerScrollPhysics({super.parent}); - - @override - QuickerScrollPhysics applyTo(ScrollPhysics? ancestor) { - return QuickerScrollPhysics(parent: buildParent(ancestor)); - } - - @override - SpringDescription get spring => SpringDescription.withDampingRatio( - mass: 0.2, - stiffness: 300.0, - ratio: 1.1, - ); -} - class VideoDetailScreen extends StatefulWidget { const VideoDetailScreen({ super.key, @@ -66,6 +54,21 @@ class VideoDetailScreen extends StatefulWidget { State createState() => _VideoDetailScreenState(); } +@visibleForTesting +enum VideoBackTarget { wait, exitFullScreen, closeAuthor, popPage } + +@visibleForTesting +VideoBackTarget resolveVideoBackTarget({ + required bool fullScreenTransitionInProgress, + required bool fullScreen, + required bool authorVisible, +}) { + if (fullScreenTransitionInProgress) return VideoBackTarget.wait; + if (fullScreen) return VideoBackTarget.exitFullScreen; + if (authorVisible) return VideoBackTarget.closeAuthor; + return VideoBackTarget.popPage; +} + class _VideoDetailScreenState extends BaseDynamicState with TickerProviderStateMixin, WidgetsBindingObserver, RouteAware { PostListItem? _currentPostItem; @@ -75,67 +78,221 @@ class _VideoDetailScreenState extends BaseDynamicState int blogId = 0; int page = 0; int offset = 0; - double? downloadProgress; - late ScrollController _scrollController; final PageController _pageController = PageController(); final VideoListController _videoListController = VideoListController(); + final GlobalKey _authorSwipeKey = + GlobalKey(); + final Map _downloadProgress = {}; + final Map> _danmakuMessages = {}; + final Set _loadingDanmaku = {}; + Timer? _initialFetchTimer; + PageRoute? _subscribedRoute; + Object? _initialLoadError; + Size? _sizeBeforeFullscreen; + bool _initialLoading = true; + bool _fetching = false; + bool _hasMoreVideo = true; + bool _routeVisible = true; + bool _resumeAfterInterruption = false; + bool _isFullScreen = false; + bool _fullScreenTransitionInProgress = false; + bool _authorPaneOpen = false; + bool _authorNavigationPending = false; + bool _continuousPlayback = false; + bool _danmakuEnabled = false; + bool _autoAdvanceInProgress = false; + double _playbackSpeed = 1; + int _requestGeneration = 0; @override void dispose() { - _scrollController.dispose(); - routeObserver.unsubscribe(this); + _initialFetchTimer?.cancel(); + _requestGeneration++; + if (_subscribedRoute != null) { + chewieProvider.routeObserver.unsubscribe(this); + } WidgetsBinding.instance.removeObserver(this); + _videoListController.removeListener(_handleVideoControllerChanged); _videoListController.dispose(); + _pageController.dispose(); + // Leaving the video page must also release the portrait override used + // after exiting full screen (not only an active full-screen lock). + if (_sizeBeforeFullscreen != null) { + unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); + unawaited(ResponsiveUtil.restoreOrientationPolicy( + logicalSize: _sizeBeforeFullscreen, + )); + } super.dispose(); } @override void didChangeDependencies() { - routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute); super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute && !identical(route, _subscribedRoute)) { + if (_subscribedRoute != null) { + chewieProvider.routeObserver.unsubscribe(this); + } + _subscribedRoute = route; + chewieProvider.routeObserver.subscribe(this, route); + } + } + + @override + void didPushNext() { + _routeVisible = false; + _pauseForInterruption(); + } + + @override + void didPopNext() { + _routeVisible = true; + _resumeFromInterruption(); } @override - void didPushNext() {} + void didPop() { + _routeVisible = false; + unawaited(_videoListController.pauseCurrent()); + } @override void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.inactive: - _videoListController.currentPlayer.pause(); - break; - case AppLifecycleState.resumed: - break; - case AppLifecycleState.paused: - _videoListController.currentPlayer.pause(); - break; - case AppLifecycleState.detached: - break; - case AppLifecycleState.hidden: - break; - } - if (state != AppLifecycleState.resumed) { - _videoListController.currentPlayer.pause(); + if (state == AppLifecycleState.resumed) { + if (_routeVisible) _resumeFromInterruption(); + return; } + _pauseForInterruption(); } @override void initState() { super.initState(); + _continuousPlayback = ChewieHiveUtil.getBool( + HiveUtil.videoContinuousPlaybackKey, + defaultValue: false, + ); + _danmakuEnabled = ChewieHiveUtil.getBool( + HiveUtil.videoDanmakuEnabledKey, + defaultValue: false, + ); WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollController = ScrollController(); - _videoListController.addListener(() { - if (mounted) setState(() {}); - }); - _initParams(); - Future.delayed(const Duration(milliseconds: 500), () { - _fetchData(); - }); - }); + _videoListController.addListener(_handleVideoControllerChanged); + _initParams(); + _initialFetchTimer = Timer(const Duration(milliseconds: 250), _fetchData); + } + + void _handleVideoControllerChanged() { + final value = + _videoListController.currentPlayerOrNull?.controllerOrNull?.value; + if (_continuousPlayback && + !_autoAdvanceInProgress && + (value?.isCompleted ?? false)) { + unawaited(_advanceToNextVideo()); + } + if (mounted) setState(() {}); + } + + void _pauseForInterruption() { + final player = _videoListController.currentPlayerOrNull; + _resumeAfterInterruption = + _resumeAfterInterruption || (player?.isPlaying ?? false); + unawaited(player?.pause() ?? Future.value()); + } + + void _resumeFromInterruption() { + if (!_resumeAfterInterruption) return; + _resumeAfterInterruption = false; + unawaited(_videoListController.resumeCurrent()); + } + + Future _advanceToNextVideo() async { + if (_autoAdvanceInProgress || !_continuousPlayback || !mounted) return; + final nextIndex = _videoListController.index.value + 1; + if (nextIndex >= _videoListController.videoCount || + !_pageController.hasClients) { + return; + } + _autoAdvanceInProgress = true; + try { + await _pageController.nextPage( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } finally { + _autoAdvanceInProgress = false; + } + } + + Future _setContinuousPlayback(bool enabled) async { + if (_continuousPlayback == enabled) return; + if (mounted) setState(() => _continuousPlayback = enabled); + await Future.wait([ + ChewieHiveUtil.put(HiveUtil.videoContinuousPlaybackKey, enabled), + ..._videoListController.playerList + .map((player) => player.setLooping(!enabled)), + ]); + } + + Future _setPlaybackSpeed(double speed) async { + if (_playbackSpeed == speed) return; + if (mounted) setState(() => _playbackSpeed = speed); + await Future.wait( + _videoListController.playerList + .map((player) => player.setPlaybackSpeed(speed)), + ); + } + + Future _setDanmakuEnabled( + bool enabled, + PostListItem item, + ) async { + if (mounted) setState(() => _danmakuEnabled = enabled); + await ChewieHiveUtil.put(HiveUtil.videoDanmakuEnabledKey, enabled); + if (enabled) await _loadDanmaku(item); + } + + Future _loadDanmaku(PostListItem item) async { + final itemId = item.itemId; + if (_danmakuMessages.containsKey(itemId) || !_loadingDanmaku.add(itemId)) { + return; + } + try { + final response = await PostApi.getL1Comments( + postId: item.postData!.postView.id, + blogId: item.postData!.postView.blogId, + ); + final rawList = response is Map && response['data'] is Map + ? (response['data'] as Map)['list'] + : null; + final messages = rawList is List + ? rawList + .whereType() + .map((raw) { + try { + return HtmlUtil.extractTextFromHtml( + Comment.fromJson(Map.from(raw)).content, + ).trim(); + } catch (_) { + return ''; + } + }) + .where((message) => message.isNotEmpty) + .take(12) + .toList() + : []; + _danmakuMessages[itemId] = messages; + } catch (error, stackTrace) { + ILogger.error('Failed to load video danmaku', error, stackTrace); + _danmakuMessages[itemId] = const []; + } finally { + _loadingDanmaku.remove(itemId); + if (mounted) setState(() {}); + } } - _initParams() { + void _initParams() { if (widget.postItem != null) { permalink = widget.postItem!.postData!.postView.permalink; postId = widget.postItem!.postData!.postView.id; @@ -167,15 +324,16 @@ class _VideoDetailScreenState extends BaseDynamicState } } - _uploadHistory() async { + Future _uploadHistory() async { if (_currentPostItem == null) return; int userId = await HiveUtil.getUserId(); + final current = _currentPostItem!; PostApi.uploadHistory( - postId: _currentPostItem!.itemId, - blogId: _currentPostItem!.blogInfo!.blogId, + postId: current.itemId, + blogId: current.blogInfo!.blogId, userId: userId, - postType: _currentPostItem!.itemType, - collectionId: _currentPostItem!.postCollection?.grainId, + postType: current.itemType, + collectionId: current.postCollection?.grainId, ).then((value) { if (value['code'] != 200) { IToast.showTop(value['msg']); @@ -183,134 +341,308 @@ class _VideoDetailScreenState extends BaseDynamicState }); } - _fetchData({bool init = true}) async { - List tmp = []; - await PostApi.getVideoDetail( - permalink: permalink, - offset: offset, - count: page, - ).then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - page++; - offset = value['data']['offset']; - List t = value['data']['list']; - for (var e in t) { - tmp.add(PostListItem.fromJson(e)); - } - _postItemList.addAll(tmp); - if (init) { - _initPlayer(); - _uploadHistory(); - _currentPostItem = _postItemList[0]; + Future> _fetchData({bool init = true}) async { + if (_fetching || !_hasMoreVideo) return const []; + _fetching = true; + final generation = _requestGeneration; + if (init && mounted) { + setState(() { + _initialLoading = true; + _initialLoadError = null; + }); + } + + try { + final value = await PostApi.getVideoDetail( + permalink: permalink, + offset: offset, + count: page, + ); + if (!mounted || generation != _requestGeneration) return const []; + if (value['code'] != 0) { + _showSlideCaptchaIfNeeded(value); + throw StateError( + value['msg']?.toString() ?? appLocalizations.loadFailed); + } + + final data = value['data']; + final rawList = data is Map ? data['list'] : null; + final parsed = []; + if (rawList is List) { + for (final rawItem in rawList) { + try { + final item = PostListItem.fromJson(rawItem); + final url = + item.postData?.postView.videoPostView?.videoInfo.originUrl; + final uri = url == null ? null : Uri.tryParse(url); + if (uri != null && uri.hasScheme) parsed.add(item); + } catch (error, stackTrace) { + ILogger.error('Skipped malformed video item', error, stackTrace); } - setState(() {}); } - } catch (e, t) { - ILogger.error("Failed to load video detail", e, t); + } + + page++; + if (data is Map && data['offset'] is int) offset = data['offset'] as int; + if (parsed.isEmpty) _hasMoreVideo = false; + _postItemList.addAll(parsed); + + if (init && parsed.isNotEmpty) { + _currentPostItem = parsed.first; + _initPlayer(); + unawaited(_uploadHistory()); + if (_danmakuEnabled) unawaited(_loadDanmaku(parsed.first)); + } + return parsed; + } catch (error, stackTrace) { + ILogger.error('Failed to load video detail', error, stackTrace); + if (init) _initialLoadError = error; + if (!init) { if (mounted) IToast.showTop(appLocalizations.loadFailed); + rethrow; } - if (mounted) setState(() {}); - }); - return tmp; + return const []; + } finally { + _fetching = false; + if (init) _initialLoading = false; + if (mounted && generation == _requestGeneration) setState(() {}); + } } void _initPlayer() { - _videoListController.init( - context: context, - pageController: _pageController, - initialList: _postItemList - .map( - (e) => CustomVideoController( - videoInfo: e, - builder: () => VideoPlayerController.networkUrl( - Uri.parse( - e.postData!.postView.videoPostView!.videoInfo.originUrl), - ), - ), - ) - .toList(), + unawaited(_videoListController.init( + initialList: _postItemList.map(_buildVideoController).toList(), videoProvider: (int index, List list) async { - List tmp = await _fetchData(init: false); - return tmp - .map( - (e) => CustomVideoController( - videoInfo: e, - builder: () => VideoPlayerController.networkUrl( - Uri.parse( - e.postData!.postView.videoPostView!.videoInfo.originUrl), - ), - ), - ) - .toList(); + final additions = await _fetchData(init: false); + return additions.map(_buildVideoController).toList(); }, + )); + } + + CustomVideoController _buildVideoController(PostListItem item) { + return CustomVideoController( + videoInfo: item, + looping: !_continuousPlayback, + playbackSpeed: _playbackSpeed, + builder: () => VideoPlayerController.networkUrl( + Uri.parse( + item.postData!.postView.videoPostView!.videoInfo.originUrl, + ), + ), ); } @override Widget build(BuildContext context) { - return AnnotatedRegion( - value: SystemUiOverlayStyle.light, - child: Container( - color: Colors.black, - child: Stack( - children: [ - _buildBody(), - if (!ResponsiveUtil.isLandscapeLayout()) _buildTopWidget(), - ], + return PopScope( + canPop: !_isFullScreen && !_fullScreenTransitionInProgress, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _handleBack(); + }, + child: AnnotatedRegion( + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.black, + systemNavigationBarIconBrightness: Brightness.light, + ), + child: ColoredBox( + color: Colors.black, + child: _buildInteractiveVideoExperience(), ), ), ); } + Widget _buildInteractiveVideoExperience() { + final content = Stack( + children: [ + _buildBody(), + _buildTopWidget(), + ], + ); + final item = _currentPostItem; + if (item?.blogInfo == null) return content; + return InteractiveAuthorSwipe( + key: _authorSwipeKey, + authorId: item!.itemId, + authorBuilder: (_) => UserDetailScreen( + blogId: item.blogInfo!.blogId, + blogName: item.blogInfo!.blogName, + onBack: () => + unawaited(_authorSwipeKey.currentState?.close() ?? Future.value()), + ), + onOpen: () { + if (mounted) setState(() => _authorPaneOpen = true); + _pauseForInterruption(); + }, + onClose: () { + if (mounted) setState(() => _authorPaneOpen = false); + _resumeFromInterruption(); + }, + onBackRequested: _handleBack, + child: content, + ); + } + Widget _buildBody() { + if (_videoListController.videoCount == 0) { + if (_initialLoadError != null) return _buildInitialLoadError(); + if (_initialLoading) { + return const LoadingWidget( + forceDark: true, + showText: false, + bottomPadding: 0, + ); + } + return _buildInitialLoadError(); + } + return PageView.builder( physics: const ClampingScrollPhysics(), controller: _pageController, scrollDirection: Axis.vertical, itemCount: _videoListController.videoCount, onPageChanged: (index) { - _currentPostItem = _postItemList[index]; - _uploadHistory(); + final player = _videoListController.playerOfIndex(index); + _currentPostItem = player?.videoInfo; + unawaited(_uploadHistory()); + unawaited(_videoListController.loadIndex(index)); + if (_danmakuEnabled && _currentPostItem != null) { + unawaited(_loadDanmaku(_currentPostItem!)); + } }, itemBuilder: (context, index) { - var player = _videoListController.playerOfIndex(index)!; + final player = _videoListController.playerOfIndex(index)!; + final item = player.videoInfo!; return _buildVideoPage( - player.videoInfo!, - hidePauseIcon: !player.showPauseIcon.value, - aspectRatio: 1, - bottomPadding: 16.0, + item, + hidePauseIcon: !player.showPauseIcon, onSingleTap: () async { - if (player.controller.value.isPlaying) { - await player.pause(); + if (player.loadState == VideoLoadState.failed) { + await player.retry( + autoplay: index == _videoListController.index.value, + ); + } else if (player.isPlaying) { + await player.pause(showPauseIcon: true); } else { await player.play(); } - setState(() {}); }, - onAddFavorite: () {}, - video: Center( - child: AspectRatio( - aspectRatio: player.controller.value.aspectRatio, - child: VideoPlayer(player.controller), - ), - ), + video: _buildVideoSurface(player, index), + progressPlayer: player, + canResumeAfterScrub: () => + mounted && + _routeVisible && + index == _videoListController.index.value, ); }, ); } - _buildVideoPage( + Widget _buildInitialLoadError() { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ChewieIcon(LoftifyIcons.invalidContent, + size: 42, color: Colors.white70), + const SizedBox(height: 12), + Text( + appLocalizations.loadFailed, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () { + _hasMoreVideo = true; + unawaited(_fetchData()); + }, + icon: const ChewieIcon(LoftifyIcons.retry), + label: Text(appLocalizations.retry), + ), + ], + ), + ), + ); + } + + Widget _buildVideoSurface(CustomVideoController player, int index) { + final controller = player.controllerOrNull; + if (player.loadState == VideoLoadState.failed) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ChewieIcon(LoftifyIcons.videoUnavailable, + size: 42, color: Colors.white70), + const SizedBox(height: 12), + Text( + appLocalizations.loadFailed, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () => unawaited(player.retry( + autoplay: index == _videoListController.index.value, + )), + icon: const ChewieIcon(LoftifyIcons.retry), + label: Text(appLocalizations.retry), + ), + ], + ), + ), + ); + } + if (player.loadState != VideoLoadState.ready || controller == null) { + return const LoadingWidget( + forceDark: true, + showText: false, + bottomPadding: 0, + ); + } + + return Stack( + alignment: Alignment.center, + children: [ + Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio > 0 + ? controller.value.aspectRatio + : 9 / 16, + child: VideoPlayer(controller), + ), + ), + if (controller.value.isBuffering) + const IgnorePointer( + child: LoadingWidget( + size: 42, + forceDark: true, + showText: false, + bottomPadding: 0, + ), + ), + ], + ); + } + + Widget _buildVideoPage( PostListItem postListItem, { - double bottomPadding = 16, - double aspectRatio = 9 / 16.0, bool hidePauseIcon = false, required Widget video, Function()? onSingleTap, - Function()? onAddFavorite, + required CustomVideoController progressPlayer, + required bool Function() canResumeAfterScrub, }) { + final bottomSafeInset = + _isFullScreen ? 0.0 : MediaQuery.viewPaddingOf(context).bottom; Widget videoContainer = Stack( children: [ Container( @@ -318,22 +650,17 @@ class _VideoDetailScreenState extends BaseDynamicState width: double.infinity, color: Colors.black, alignment: Alignment.center, - child: aspectRatio == 1 - ? video - : AspectRatio( - aspectRatio: aspectRatio, - child: video, - ), + child: video, ), if (!hidePauseIcon) Container( height: double.infinity, width: double.infinity, alignment: Alignment.center, - child: Icon( - Icons.play_arrow_rounded, + child: ChewieIcon( + LoftifyIcons.play, size: 90, - color: Colors.white.withOpacity(0.6), + color: Colors.white.withValues(alpha: 0.6), ), ), ], @@ -341,21 +668,115 @@ class _VideoDetailScreenState extends BaseDynamicState Widget body = Stack( children: [ videoContainer, + if (_danmakuEnabled) + Positioned.fill( + child: IgnorePointer( + child: DanmakuOverlay( + messages: _danmakuMessages[postListItem.itemId] ?? const [], + player: progressPlayer, + ), + ), + ), + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: SizedBox( + height: 260, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Color(0x33000000), + Color(0xB3000000), + ], + ), + ), + ), + ), + ), + ), Positioned( + left: 0, + right: 0, + bottom: bottomSafeInset + 14, child: _buildVideoMeta(postListItem), ), + Positioned( + left: 0, + right: 0, + bottom: bottomSafeInset, + child: ImmersiveVideoProgressBar( + player: progressPlayer, + canResume: canResumeAfterScrub, + semanticLabel: appLocalizations.video, + ), + ), ], ); - return GestureDetector( + return VideoLongPressGesture( + player: progressPlayer, onTap: onSingleTap, + onOpenMenu: () => unawaited(_showVideoActions(postListItem)), child: body, ); } - _buildVideoMeta(PostListItem postListItem) { + Widget _buildVideoMeta(PostListItem postListItem) { + final useLandscapeLayout = + MediaQuery.orientationOf(context) == Orientation.landscape; + if (useLandscapeLayout) { + return SafeArea( + top: false, + minimum: const EdgeInsets.only(left: 18, right: 18, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Align( + alignment: Alignment.bottomLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: min(MediaQuery.sizeOf(context).width * 0.48, 520), + ), + child: _buildPostContent(postListItem, landscape: true), + ), + ), + ), + const SizedBox(width: 20), + VideoListButtonColumn( + isLandscape: true, + bottomPadding: 0, + blogInfo: postListItem.blogInfo!, + likeCount: postListItem.postData!.postCount!.favoriteCount, + shareCount: postListItem.postData!.postCount!.shareCount, + commentCount: postListItem.postData!.postCount!.responseCount, + isShared: postListItem.share ?? false, + isLiked: postListItem.favorite, + isFollowing: postListItem.following, + showDownloadButton: + controlProvider.globalControl.showVideoDownloadButton, + onLike: () => _handleLike(postListItem), + onComment: () => _showComments(postListItem), + onTapAvatar: () => unawaited(_openAuthor(postListItem)), + onFollow: () => _handleFollow(postListItem), + onShare: () => _handleShare(postListItem), + downloadProgress: _downloadProgress[postListItem.itemId], + onDownload: () => _handleDownload(postListItem), + ), + ], + ), + ); + } + return Container( margin: const EdgeInsets.only(bottom: 10), child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: _buildPostContent(postListItem), @@ -371,101 +792,177 @@ class _VideoDetailScreenState extends BaseDynamicState isFollowing: postListItem.following, showDownloadButton: controlProvider.globalControl.showVideoDownloadButton, - onLike: () { - HapticFeedback.mediumImpact(); - PostApi.likeOrUnLike( - isLike: !(postListItem.favorite == true), - postId: postListItem.itemId, - blogId: postListItem.blogInfo!.blogId) - .then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } else { - postListItem.favorite = !(postListItem.favorite == true); - postListItem.postData!.postCount!.favoriteCount += - postListItem.favorite == true ? 1 : -1; - if (mounted) setState(() {}); - } - }); - }, - onComment: () { - BottomSheetBuilder.showBottomSheet( - context, - (context) => SingleChildScrollView( - controller: ModalScrollController.of(context), - child: CommentBottomSheet( - postId: postListItem.postData!.postView.id, - blogId: postListItem.postData!.postView.blogId, - publishTime: postListItem.postData!.postView.publishTime, - ), - ), - enableDrag: false, - backgroundColor: ChewieTheme.getBackground(context), - ); - }, - onTapAvatar: () { - RouteUtil.pushPanelCupertinoRoute( - context, - UserDetailScreen( - blogId: postListItem.blogInfo!.blogId, - blogName: postListItem.blogInfo!.blogName, - ), - ); - }, - onFollow: () { - HapticFeedback.mediumImpact(); - UserApi.followOrUnfollow( - isFollow: !postListItem.following, - blogId: postListItem.blogInfo!.blogId, - blogName: postListItem.blogInfo!.blogName, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } - postListItem.following = !postListItem.following; - setState(() {}); - }); - }, - onShare: () { - HapticFeedback.mediumImpact(); - PostApi.shareOrUnShare( - isShare: !(postListItem.share == true), - postId: postListItem.itemId, - blogId: postListItem.blogInfo!.blogId) - .then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } else { - postListItem.share = !(postListItem.share == true); - postListItem.postData!.postCount!.shareCount += - postListItem.share == true ? 1 : -1; - if (mounted) setState(() {}); - } - }); - }, - downloadProgress: downloadProgress, - onDownload: () { - if (downloadProgress == null) { - FileUtil.saveVideoByIllust( - context, - getIllust(postListItem), - onReceiveProgress: (count, total) { - downloadProgress = count / total; - setState(() {}); - }, - ).then((res) { - downloadProgress = null; - setState(() {}); - }); - } - }, + onLike: () => _handleLike(postListItem), + onComment: () => _showComments(postListItem), + onTapAvatar: () => unawaited(_openAuthor(postListItem)), + onFollow: () => _handleFollow(postListItem), + onShare: () => _handleShare(postListItem), + downloadProgress: _downloadProgress[postListItem.itemId], + onDownload: () => _handleDownload(postListItem), ), ], ), ); } - getIllust(PostListItem postListItem) { + void _handleLike(PostListItem postListItem) { + HapticFeedback.mediumImpact(); + PostApi.likeOrUnLike( + isLike: !(postListItem.favorite == true), + postId: postListItem.itemId, + blogId: postListItem.blogInfo!.blogId, + ).then((value) { + if (value['meta']['status'] != 200) { + _showSlideCaptchaIfNeeded(value); + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return; + } + postListItem.favorite = !(postListItem.favorite == true); + postListItem.postData!.postCount!.favoriteCount += + postListItem.favorite == true ? 1 : -1; + if (mounted) setState(() {}); + }); + } + + void _showComments(PostListItem postListItem) { + BottomSheetBuilder.showBottomSheet( + context, + (context) => CommentBottomSheet( + postId: postListItem.postData!.postView.id, + blogId: postListItem.postData!.postView.blogId, + publishTime: postListItem.postData!.postView.publishTime, + ), + enableDrag: false, + backgroundColor: ChewieTheme.getBackground(context), + topRadius: const Radius.circular(24), + ); + } + + Future _showVideoActions(PostListItem postListItem) async { + HapticFeedback.mediumImpact(); + final player = _videoListController.currentPlayerOrNull; + final shouldResume = player?.isPlaying ?? false; + if (shouldResume) await player?.pause(); + if (!mounted) return; + + await BottomSheetBuilder.showBottomSheet( + context, + (sheetContext) => VideoPlaybackActionsSheet( + continuousPlayback: _continuousPlayback, + danmakuEnabled: _danmakuEnabled, + playbackSpeed: _playbackSpeed, + downloading: _downloadProgress.containsKey(postListItem.itemId), + onContinuousPlaybackChanged: (enabled) { + unawaited(_setContinuousPlayback(enabled)); + }, + onDanmakuChanged: (enabled) { + unawaited(_setDanmakuEnabled(enabled, postListItem)); + }, + onPlaybackSpeedChanged: (speed) { + unawaited(_setPlaybackSpeed(speed)); + }, + onShare: () { + Navigator.pop(sheetContext); + UriUtil.share( + LoftifyUriUtil.getPostUrlByPermalink( + postListItem.blogInfo!.blogName, + postListItem.postData!.postView.permalink, + ), + ); + }, + onDownload: () { + Navigator.pop(sheetContext); + unawaited(_handleDownload(postListItem)); + }, + ), + responsive: true, + backgroundColor: Colors.transparent, + ); + + if (shouldResume && + mounted && + _routeVisible && + identical(player, _videoListController.currentPlayerOrNull)) { + await player?.play(); + } + } + + Future _openAuthor(PostListItem postListItem) async { + if (_authorNavigationPending || !mounted) return; + _authorNavigationPending = true; + if (_isFullScreen) await _toggleFullScreen(); + if (!mounted) return; + RouteUtil.pushPanelCupertinoRoute( + context, + UserDetailScreen( + blogId: postListItem.blogInfo!.blogId, + blogName: postListItem.blogInfo!.blogName, + ), + ); + await Future.delayed(const Duration(milliseconds: 600)); + _authorNavigationPending = false; + } + + void _handleFollow(PostListItem postListItem) { + HapticFeedback.mediumImpact(); + UserApi.followOrUnfollow( + isFollow: !postListItem.following, + blogId: postListItem.blogInfo!.blogId, + blogName: postListItem.blogInfo!.blogName, + ).then((value) { + if (value['meta']['status'] != 200) { + _showSlideCaptchaIfNeeded(value); + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return; + } + postListItem.following = !postListItem.following; + if (mounted) setState(() {}); + }); + } + + void _handleShare(PostListItem postListItem) { + HapticFeedback.mediumImpact(); + PostApi.shareOrUnShare( + isShare: !(postListItem.share == true), + postId: postListItem.itemId, + blogId: postListItem.blogInfo!.blogId, + ).then((value) { + if (value['meta']['status'] != 200) { + _showSlideCaptchaIfNeeded(value); + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + return; + } + postListItem.share = !(postListItem.share == true); + postListItem.postData!.postCount!.shareCount += + postListItem.share == true ? 1 : -1; + if (mounted) setState(() {}); + }); + } + + Future _handleDownload(PostListItem postListItem) async { + final itemId = postListItem.itemId; + if (_downloadProgress.containsKey(itemId)) return; + if (mounted) setState(() => _downloadProgress[itemId] = 0); + try { + await LoftifyFileUtil.saveVideoByIllust( + context, + getIllust(postListItem), + onReceiveProgress: (count, total) { + if (!mounted || total <= 0) return; + setState(() { + _downloadProgress[itemId] = (count / total).clamp(0, 1); + }); + }, + ); + } catch (error, stackTrace) { + ILogger.error('Failed to download video', error, stackTrace); + if (mounted) IToast.showTop(appLocalizations.downloadFailed); + } finally { + if (mounted) setState(() => _downloadProgress.remove(itemId)); + } + } + + Illust getIllust(PostListItem postListItem) { String rawUrl = postListItem.postData!.postView.videoPostView!.videoInfo.originUrl; return Illust( @@ -484,28 +981,41 @@ class _VideoDetailScreenState extends BaseDynamicState ); } - _hasContent(PostListItem postListItem) { + bool _hasContent(PostListItem postListItem) { String title = StringUtil.clearBlank(postListItem.postData!.postView.title); String content = StringUtil.clearBlank( HtmlUtil.extractTextFromHtml(postListItem.postData!.postView.digest)); return (title.isNotEmpty || content.isNotEmpty); } - _buildPostContent(PostListItem postListItem) { + Widget _buildPostContent( + PostListItem postListItem, { + bool landscape = false, + }) { String title = StringUtil.clearBlank(postListItem.postData!.postView.title); String digest = StringUtil.limitString( HtmlUtil.extractTextFromHtml(postListItem.postData!.postView.digest)); + final html = [ + if (title.isNotEmpty) '

$title

', + if (digest.isNotEmpty) digest, + ].join(); return _hasContent(postListItem) ? Container( - padding: - const EdgeInsets.only(left: 16, top: 16, bottom: 16, right: 0), + padding: landscape + ? const EdgeInsets.only(left: 6, bottom: 4) + : const EdgeInsets.only( + left: 16, + top: 16, + bottom: 16, + ), child: Column( + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (digest.isNotEmpty) + if (html.isNotEmpty) HtmlWidget( - "

$title

$digest", + html, textStyle: Theme.of(context).textTheme.titleSmall?.apply( fontSizeDelta: 1, color: Colors.grey[300], @@ -515,7 +1025,7 @@ class _VideoDetailScreenState extends BaseDynamicState final color = Theme.of(context).primaryColor; return { 'color': - '#${color.value.toRadixString(16).substring(2, 8)}' + '#${color.toARGB32().toRadixString(16).substring(2, 8)}' }; } return null; @@ -525,7 +1035,7 @@ class _VideoDetailScreenState extends BaseDynamicState return true; }, ), - if (digest.isNotEmpty) const SizedBox(height: 8), + if (html.isNotEmpty) const SizedBox(height: 8), if (postListItem.postData!.postView.tagList.isNotEmpty) _buildTagList(postListItem), ], @@ -534,7 +1044,7 @@ class _VideoDetailScreenState extends BaseDynamicState : emptyWidget; } - _buildTagList(PostListItem postListItem) { + Widget _buildTagList(PostListItem postListItem) { return Container( padding: EdgeInsets.only( right: 16, @@ -565,44 +1075,1308 @@ class _VideoDetailScreenState extends BaseDynamicState padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), child: Row( children: [ - CircleIconButton( - onTap: () { - Navigator.pop(context); + ChewieIconButton( + icon: LoftifyIcons.back, + foregroundColor: Colors.white, + onPressed: _handleBack, + ), + const Spacer(), + ChewieIconButton( + icon: (_videoListController.currentPlayerOrNull?.isMuted ?? false) + ? LoftifyIcons.mute + : LoftifyIcons.sound, + foregroundColor: Colors.white, + onPressed: () { + unawaited( + _videoListController.currentPlayerOrNull?.toggleMute() ?? + Future.value(), + ); }, - icon: const Icon( - Icons.arrow_back_rounded, - color: Colors.white, - ), + ), + ChewieIconButton( + icon: _isFullScreen + ? LoftifyIcons.exitFullscreen + : LoftifyIcons.enterFullscreen, + foregroundColor: Colors.white, + onPressed: () => unawaited(_toggleFullScreen()), ), ], ), ), ); } -} -class VideoListButtonColumn extends StatelessWidget { - final double? bottomPadding; - final bool isLiked; - final bool isShared; - final bool isFollowing; - final Function()? onLike; - final Function()? onComment; - final Function()? onDownload; - final Function()? onShare; - final Function()? onFollow; - final Function()? onTapAvatar; - final int likeCount; - final int shareCount; - final int commentCount; - final SimpleBlogInfo blogInfo; - final double? downloadProgress; - final bool showDownloadButton; + Future _toggleFullScreen() async { + if (!ResponsiveUtil.isMobile() || _fullScreenTransitionInProgress) return; + _fullScreenTransitionInProgress = true; + try { + if (!_isFullScreen) { + _sizeBeforeFullscreen = MediaQuery.sizeOf(context); + if (mounted) setState(() => _isFullScreen = true); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + return; + } - const VideoListButtonColumn({ + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + // Restore a definite portrait orientation before clearing the full-screen + // state. The usual tablet policy may allow landscape and leave the video + // page sideways even though the controls already say it is not full screen. + await SystemChrome.setPreferredOrientations( + const [DeviceOrientation.portraitUp], + ); + if (mounted) setState(() => _isFullScreen = false); + } finally { + _fullScreenTransitionInProgress = false; + } + } + + bool _showSlideCaptchaIfNeeded(dynamic response) { + if (!mounted || response is! Map) return false; + final meta = response['meta']; + final status = response['code'] ?? (meta is Map ? meta['status'] : null); + if (status != 4071) return false; + Utils.validSlideCaptcha(context); + return true; + } + + void _handleBack() { + final authorSwipe = _authorSwipeKey.currentState; + switch (resolveVideoBackTarget( + fullScreenTransitionInProgress: _fullScreenTransitionInProgress, + fullScreen: _isFullScreen, + authorVisible: _authorPaneOpen || (authorSwipe?.isAuthorVisible ?? false), + )) { + case VideoBackTarget.wait: + return; + case VideoBackTarget.exitFullScreen: + unawaited(_toggleFullScreen()); + return; + case VideoBackTarget.closeAuthor: + unawaited(authorSwipe?.close() ?? Future.value()); + return; + case VideoBackTarget.popPage: + Navigator.of(context).pop(); + } + } +} + +class InteractiveAuthorSwipe extends StatefulWidget { + const InteractiveAuthorSwipe({ super.key, - this.bottomPadding, - this.showDownloadButton = true, + required this.authorId, + required this.child, + required this.authorBuilder, + this.onOpen, + this.onClose, + this.onBackRequested, + }); + + final Object authorId; + final Widget child; + final WidgetBuilder authorBuilder; + final VoidCallback? onOpen; + final VoidCallback? onClose; + final VoidCallback? onBackRequested; + + @override + State createState() => InteractiveAuthorSwipeState(); +} + +class InteractiveAuthorSwipeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + final GlobalKey _authorNavigatorKey = + GlobalKey(); + bool _authorBuilt = false; + bool _open = false; + bool _closing = false; + bool _removingHistoryEntry = false; + LocalHistoryEntry? _historyEntry; + double _width = 1; + + bool get isAuthorVisible => + _authorBuilt && + (_open || _controller.isAnimating || _controller.value > 0.001); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + } + + @override + void didUpdateWidget(covariant InteractiveAuthorSwipe oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.authorId == widget.authorId) return; + _controller.stop(); + _removeHistoryEntry(); + _controller.value = 0; + _authorBuilt = false; + _open = false; + _closing = false; + } + + @override + void dispose() { + _closing = true; + _removeHistoryEntry(); + _controller.dispose(); + super.dispose(); + } + + void _handleDragStart(DragStartDetails details) { + _controller.stop(); + _ensureHistoryEntry(); + if (!_authorBuilt) setState(() => _authorBuilt = true); + } + + void _handleDragUpdate(DragUpdateDetails details) { + _controller.value = + (_controller.value - details.delta.dx / _width).clamp(0, 1); + } + + void _handleDragEnd(DragEndDetails details) { + final velocity = details.primaryVelocity ?? 0; + final settleThreshold = _open ? 0.76 : 0.24; + final shouldOpen = velocity <= -600 || + (velocity < 600 && _controller.value >= settleThreshold); + if (shouldOpen) { + _animateOpen(); + } else { + _animateClosed(); + } + } + + void _handleDragCancel() { + if (_open) { + _animateOpen(); + } else { + _animateClosed(); + } + } + + Future _animateOpen() async { + _ensureHistoryEntry(); + if (!_authorBuilt && mounted) setState(() => _authorBuilt = true); + if (!_open) { + setState(() => _open = true); + widget.onOpen?.call(); + } + await _controller.animateTo(1, curve: Curves.easeOutCubic); + } + + Future _animateClosed({bool removeHistoryEntry = true}) async { + if (_closing) return; + _closing = true; + if (removeHistoryEntry) _removeHistoryEntry(); + final wasOpen = _open; + try { + await _controller.animateTo(0, curve: Curves.easeOutCubic); + if (!mounted) return; + if (wasOpen) widget.onClose?.call(); + setState(() => _open = false); + } finally { + _closing = false; + } + } + + Future close() => _animateClosed(); + + void _ensureHistoryEntry() { + if (_historyEntry != null) return; + final route = ModalRoute.of(context); + if (route == null) return; + late final LocalHistoryEntry entry; + entry = LocalHistoryEntry( + onRemove: () { + if (identical(_historyEntry, entry)) _historyEntry = null; + if (_removingHistoryEntry || !mounted) return; + if (widget.onBackRequested != null) { + widget.onBackRequested!(); + } else { + unawaited(_animateClosed(removeHistoryEntry: false)); + } + }, + ); + _historyEntry = entry; + route.addLocalHistoryEntry(entry); + } + + void _removeHistoryEntry() { + final entry = _historyEntry; + if (entry == null) return; + _historyEntry = null; + _removingHistoryEntry = true; + entry.remove(); + _removingHistoryEntry = false; + } + + Widget _buildAuthorNavigator() { + return Navigator( + key: _authorNavigatorKey, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (context) => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + if (widget.onBackRequested != null) { + widget.onBackRequested!(); + } else { + unawaited(_animateClosed()); + } + } + }, + child: widget.authorBuilder(context), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: !_open, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && _open) { + if (widget.onBackRequested != null) { + widget.onBackRequested!(); + } else { + unawaited(_animateClosed()); + } + } + }, + child: LayoutBuilder( + builder: (context, constraints) { + _width = max(1, constraints.maxWidth); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + onHorizontalDragCancel: _handleDragCancel, + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final progress = _controller.value; + return ClipRect( + child: Stack( + children: [ + Positioned.fill( + child: Transform.translate( + key: const ValueKey('interactive-video-pane'), + offset: Offset(-_width * 0.16 * progress, 0), + child: widget.child, + ), + ), + if (progress > 0) + Positioned.fill( + child: IgnorePointer( + child: ColoredBox( + color: Colors.black.withValues( + alpha: 0.2 * progress, + ), + ), + ), + ), + if (_authorBuilt) + Positioned.fill( + child: IgnorePointer( + ignoring: progress < 0.999, + child: Transform.translate( + key: const ValueKey('interactive-author-pane'), + offset: Offset(_width * (1 - progress), 0), + child: DecoratedBox( + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black38, + blurRadius: 18, + offset: Offset(-5, 0), + ), + ], + ), + child: _buildAuthorNavigator(), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class VideoLongPressGesture extends StatefulWidget { + const VideoLongPressGesture({ + super.key, + required this.player, + required this.child, + required this.onOpenMenu, + this.onTap, + this.edgeFraction = 0.16, + this.minimumEdgeWidth = 52, + this.maximumEdgeWidth = 92, + this.temporarySpeed = 2, + }); + + final CustomVideoController player; + final Widget child; + final VoidCallback onOpenMenu; + final VoidCallback? onTap; + final double edgeFraction; + final double minimumEdgeWidth; + final double maximumEdgeWidth; + final double temporarySpeed; + + @override + State createState() => _VideoLongPressGestureState(); +} + +class _VideoLongPressGestureState extends State { + bool _speeding = false; + bool _leftEdge = false; + double _restoreSpeed = 1; + + @override + void didUpdateWidget(covariant VideoLongPressGesture oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.player, widget.player) && _speeding) { + unawaited(oldWidget.player.setPlaybackSpeed(_restoreSpeed)); + _speeding = false; + } + } + + @override + void dispose() { + if (_speeding) { + unawaited(widget.player.setPlaybackSpeed(_restoreSpeed)); + } + super.dispose(); + } + + void _handleLongPressStart(LongPressStartDetails details, double width) { + final edgeWidth = (width * widget.edgeFraction) + .clamp(widget.minimumEdgeWidth, widget.maximumEdgeWidth) + .toDouble(); + final onLeft = details.localPosition.dx <= edgeWidth; + final onRight = details.localPosition.dx >= width - edgeWidth; + if (!onLeft && !onRight) { + widget.onOpenMenu(); + return; + } + if (!widget.player.prepared || _speeding) return; + _restoreSpeed = widget.player.playbackSpeed; + _leftEdge = onLeft; + HapticFeedback.selectionClick(); + setState(() => _speeding = true); + unawaited(widget.player.setPlaybackSpeed(widget.temporarySpeed)); + } + + void _stopTemporarySpeed() { + if (!_speeding) return; + final restoreSpeed = _restoreSpeed; + setState(() => _speeding = false); + unawaited(widget.player.setPlaybackSpeed(restoreSpeed)); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + onLongPressStart: (details) => + _handleLongPressStart(details, constraints.maxWidth), + onLongPressEnd: (_) => _stopTemporarySpeed(), + onLongPressCancel: _stopTemporarySpeed, + child: Stack( + children: [ + Positioned.fill(child: widget.child), + if (_speeding) + Positioned( + top: MediaQuery.paddingOf(context).top + 68, + left: _leftEdge ? 20 : null, + right: _leftEdge ? null : 20, + child: IgnorePointer( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.68), + borderRadius: BorderRadius.circular(99), + border: Border.all( + color: Colors.white.withValues(alpha: 0.16), + ), + ), + child: Text( + '${widget.temporarySpeed.toStringAsFixed(0)}x', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class DanmakuOverlay extends StatefulWidget { + const DanmakuOverlay({ + super.key, + required this.messages, + required this.player, + }); + + final List messages; + final CustomVideoController player; + + @override + State createState() => _DanmakuOverlayState(); +} + +class _DanmakuOverlayState extends State + with SingleTickerProviderStateMixin { + static const double _slotSeconds = 4; + static const double _travelSeconds = 8; + static const List _lanePhaseFractions = [ + 0, + 0.42, + 0.76, + 0.2, + 0.58, + ]; + static const List _laneVerticalJitter = [0, 6, -3, 5, -5]; + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 12), + ); + widget.player.revision.addListener(_syncPlayback); + _syncPlayback(); + } + + @override + void didUpdateWidget(covariant DanmakuOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.player, widget.player)) { + oldWidget.player.revision.removeListener(_syncPlayback); + widget.player.revision.addListener(_syncPlayback); + } + _syncPlayback(); + } + + @override + void dispose() { + widget.player.revision.removeListener(_syncPlayback); + _controller.dispose(); + super.dispose(); + } + + void _syncPlayback() { + final shouldAnimate = widget.messages.isNotEmpty && widget.player.isPlaying; + if (shouldAnimate && !_controller.isAnimating) { + _controller.repeat(); + } else if (!shouldAnimate && _controller.isAnimating) { + _controller.stop(canceled: false); + } + } + + @override + Widget build(BuildContext context) { + if (widget.messages.isEmpty) return const SizedBox.shrink(); + return SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final laneCount = width > 700 ? 5 : 4; + final itemsPerLane = + max(1, (widget.messages.length / laneCount).ceil()); + final cycleSeconds = (itemsPerLane + 1) * _slotSeconds + + _lanePhaseFractions.take(laneCount).reduce( + (current, next) => current > next ? current : next) * + _slotSeconds; + final cycleDuration = Duration( + milliseconds: (cycleSeconds * 1000).round(), + ); + if (_controller.duration != cycleDuration) { + _controller.duration = cycleDuration; + } + final maxMessageWidth = min(width * 0.72, 560.0); + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final elapsed = _controller.value * cycleSeconds; + return Stack( + children: [ + for (var index = 0; index < widget.messages.length; index++) + if (_localDanmakuTimeForIndex( + elapsed, + index, + laneCount, + cycleSeconds, + ) < + _travelSeconds) + Positioned( + top: 82 + + (index % laneCount) * 38 + + _laneVerticalJitter[index % laneCount], + left: width - + (width + maxMessageWidth) * + (_localDanmakuTimeForIndex( + elapsed, + index, + laneCount, + cycleSeconds, + ) / + _travelSeconds), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxMessageWidth, + ), + child: Text( + widget.messages[index], + key: ValueKey('danmaku-$index'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 3), + Shadow( + color: Colors.black54, + offset: Offset(1, 1)), + ], + ), + ), + ), + ), + ], + ); + }, + ); + }, + ), + ); + } + + double _localDanmakuTime( + double elapsed, + int sequence, + int lane, + double cycleSeconds, + ) { + final lanePhase = _lanePhaseFractions[lane] * _slotSeconds; + return (elapsed - sequence * _slotSeconds - lanePhase + cycleSeconds) % + cycleSeconds; + } + + double _localDanmakuTimeForIndex( + double elapsed, + int index, + int laneCount, + double cycleSeconds, + ) => + _localDanmakuTime( + elapsed, + index ~/ laneCount, + index % laneCount, + cycleSeconds, + ); +} + +class ImmersiveVideoProgressBar extends StatefulWidget { + const ImmersiveVideoProgressBar({ + super.key, + required this.player, + required this.canResume, + required this.semanticLabel, + }); + + final CustomVideoController player; + final bool Function() canResume; + final String semanticLabel; + + @override + State createState() => + _ImmersiveVideoProgressBarState(); +} + +class _ImmersiveVideoProgressBarState extends State { + Timer? _seekThrottle; + Future _seekChain = Future.value(); + Duration? _pendingPosition; + double? _dragFraction; + bool _dragging = false; + bool _wasPlaying = false; + int _seekTicket = 0; + + @override + void initState() { + super.initState(); + widget.player.revision.addListener(_handlePlayerChanged); + } + + @override + void didUpdateWidget(covariant ImmersiveVideoProgressBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.player, widget.player)) { + oldWidget.player.revision.removeListener(_handlePlayerChanged); + widget.player.revision.addListener(_handlePlayerChanged); + _cancelScrub(); + } + } + + @override + void dispose() { + widget.player.revision.removeListener(_handlePlayerChanged); + _seekThrottle?.cancel(); + super.dispose(); + } + + void _handlePlayerChanged() { + if (mounted) setState(() {}); + } + + void _cancelScrub() { + _seekThrottle?.cancel(); + _seekThrottle = null; + _pendingPosition = null; + _dragFraction = null; + _dragging = false; + _seekTicket++; + } + + void _beginScrub(LongPressStartDetails details, double width) { + final controller = widget.player.controllerOrNull; + if (controller == null || !controller.value.isInitialized) return; + _wasPlaying = widget.player.isPlaying; + HapticFeedback.selectionClick(); + setState(() => _dragging = true); + if (_wasPlaying) unawaited(widget.player.pause()); + _updateScrub(details.localPosition.dx, width); + } + + void _updateScrub(double dx, double width) { + final controller = widget.player.controllerOrNull; + if (!_dragging || controller == null || width <= 0) return; + final fraction = (dx / width).clamp(0.0, 1.0); + final duration = controller.value.duration; + setState(() => _dragFraction = fraction); + _scheduleSeek(Duration( + milliseconds: (duration.inMilliseconds * fraction).round(), + )); + } + + void _scheduleSeek(Duration position) { + _pendingPosition = position; + if (_seekThrottle != null) return; + _commitPendingSeek(); + _seekThrottle = Timer(const Duration(milliseconds: 45), () { + _seekThrottle = null; + final pending = _pendingPosition; + if (pending != null) _scheduleSeek(pending); + }); + } + + void _commitPendingSeek() { + final position = _pendingPosition; + final controller = widget.player.controllerOrNull; + if (position == null || controller == null) return; + _pendingPosition = null; + final ticket = ++_seekTicket; + _seekChain = _seekChain.then((_) async { + if (!mounted || ticket != _seekTicket) return; + if (!identical(widget.player.controllerOrNull, controller)) return; + try { + await controller.seekTo(position); + } catch (error) { + debugPrint('Failed to seek video: $error'); + } + }); + } + + Future _finishScrub() async { + if (!_dragging) return; + final controller = widget.player.controllerOrNull; + final fraction = _dragFraction; + _seekThrottle?.cancel(); + _seekThrottle = null; + _pendingPosition = null; + final ticket = ++_seekTicket; + if (controller != null && fraction != null) { + final position = Duration( + milliseconds: + (controller.value.duration.inMilliseconds * fraction).round(), + ); + _seekChain = _seekChain.then((_) async { + if (!mounted || ticket != _seekTicket) return; + if (!identical(widget.player.controllerOrNull, controller)) return; + try { + await controller.seekTo(position); + } catch (error) { + debugPrint('Failed to finish video seek: $error'); + } + }); + await _seekChain; + } + if (!mounted) return; + setState(() { + _dragging = false; + _dragFraction = null; + }); + if (_wasPlaying && widget.canResume()) await widget.player.play(); + } + + String _formatDuration(Duration duration) { + final totalSeconds = max(0, duration.inSeconds); + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final controller = widget.player.controllerOrNull; + if (controller == null || !controller.value.isInitialized) { + return const SizedBox(height: 30); + } + final value = controller.value; + final durationMs = max(1, value.duration.inMilliseconds); + final playedFraction = _dragFraction ?? + (value.position.inMilliseconds / durationMs).clamp(0.0, 1.0); + final bufferedFraction = value.buffered.isEmpty + ? 0.0 + : (value.buffered.last.end.inMilliseconds / durationMs).clamp(0.0, 1.0); + final shownPosition = Duration( + milliseconds: (durationMs * playedFraction).round(), + ); + + return Semantics( + label: widget.semanticLabel, + value: + '${_formatDuration(shownPosition)} / ${_formatDuration(value.duration)}', + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final bubbleLeft = (width * playedFraction - 38) + .clamp(8.0, max(8.0, width - 84)) + .toDouble(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + onLongPressStart: (details) => _beginScrub(details, width), + onLongPressMoveUpdate: (details) { + _updateScrub(details.localPosition.dx, width); + }, + onLongPressEnd: (_) => unawaited(_finishScrub()), + onLongPressCancel: () => unawaited(_finishScrub()), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + height: _dragging ? 58 : 30, + child: Stack( + clipBehavior: Clip.none, + children: [ + if (_dragging) + Positioned( + left: bubbleLeft, + bottom: 19, + child: Container( + width: 76, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 5, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.76), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.white.withValues(alpha: 0.14), + ), + ), + alignment: Alignment.center, + child: Text( + '${_formatDuration(shownPosition)} / ${_formatDuration(value.duration)}', + maxLines: 1, + style: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _dragging ? 7 : 3, + child: Stack( + children: [ + const Positioned.fill( + child: ColoredBox(color: Colors.white24), + ), + Positioned.fill( + child: FractionallySizedBox( + widthFactor: bufferedFraction, + alignment: Alignment.centerLeft, + child: const ColoredBox(color: Colors.white38), + ), + ), + Positioned.fill( + child: FractionallySizedBox( + widthFactor: playedFraction, + alignment: Alignment.centerLeft, + child: const ColoredBox(color: Colors.white), + ), + ), + ], + ), + ), + if (_dragging) + Positioned( + left: (width * playedFraction - 7) + .clamp(0.0, max(0.0, width - 14)), + bottom: -3.5, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(color: Colors.black54, width: 1.5), + boxShadow: const [ + BoxShadow(color: Colors.black45, blurRadius: 5), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +class VideoPlaybackActionsSheet extends StatefulWidget { + const VideoPlaybackActionsSheet({ + super.key, + required this.continuousPlayback, + required this.danmakuEnabled, + required this.playbackSpeed, + required this.downloading, + required this.onContinuousPlaybackChanged, + required this.onDanmakuChanged, + required this.onPlaybackSpeedChanged, + required this.onShare, + required this.onDownload, + }); + + final bool continuousPlayback; + final bool danmakuEnabled; + final double playbackSpeed; + final bool downloading; + final ValueChanged onContinuousPlaybackChanged; + final ValueChanged onDanmakuChanged; + final ValueChanged onPlaybackSpeedChanged; + final VoidCallback onShare; + final VoidCallback onDownload; + + @override + State createState() => + _VideoPlaybackActionsSheetState(); +} + +class _VideoPlaybackActionsSheetState extends State { + static const _speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; + late bool _continuousPlayback; + late bool _danmakuEnabled; + late double _playbackSpeed; + + @override + void initState() { + super.initState(); + _continuousPlayback = widget.continuousPlayback; + _danmakuEnabled = widget.danmakuEnabled; + _playbackSpeed = widget.playbackSpeed; + } + + String _speedLabel(double speed) { + return speed == speed.roundToDouble() ? '${speed.toInt()}x' : '${speed}x'; + } + + @override + Widget build(BuildContext context) { + final radius = ChewieDimens.defaultRadius; + return Wrap( + runAlignment: WrapAlignment.center, + children: [ + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical( + top: radius, + bottom: ResponsiveUtil.isWideDevice() ? radius : Radius.zero, + ), + color: ChewieTheme.scaffoldBackgroundColor, + border: ChewieTheme.responsiveBorder, + boxShadow: ChewieTheme.defaultBoxShadow, + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 34, + height: 4, + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: ChewieTheme.dividerColor, + borderRadius: BorderRadius.circular(99), + ), + ), + _buildHeader(), + const SizedBox(height: 6), + _buildActionGrid(), + CaptionItem( + title: appLocalizations.playbackSpeed, + initiallyExpanded: true, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final speed in _speeds) + _VideoSpeedChip( + label: _speedLabel(speed), + selected: _playbackSpeed == speed, + onTap: () { + setState(() => _playbackSpeed = speed); + widget.onPlaybackSpeedChanged(speed); + }, + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: ChewieTheme.primaryColor.withAlpha(30), + borderRadius: BorderRadius.circular(9), + ), + child: ChewieIcon( + LoftifyIcons.videoSettings, + color: ChewieTheme.primaryColor, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + appLocalizations.videoPlaybackOptions, + style: ChewieTheme.titleMedium.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: ChewieTheme.primaryColor.withAlpha(24), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + _speedLabel(_playbackSpeed), + style: ChewieTheme.labelMedium.copyWith( + color: ChewieTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + + Widget _buildActionGrid() { + final actions = [ + _VideoActionTile( + icon: LoftifyIcons.continuousPlayback, + label: appLocalizations.continuousPlayback, + selected: _continuousPlayback, + onTap: () { + setState(() => _continuousPlayback = !_continuousPlayback); + widget.onContinuousPlaybackChanged(_continuousPlayback); + }, + ), + _VideoActionTile( + icon: LoftifyIcons.share, + label: appLocalizations.share, + onTap: widget.onShare, + ), + _VideoActionTile( + icon: LoftifyIcons.danmaku, + label: appLocalizations.danmaku, + selected: _danmakuEnabled, + onTap: () { + setState(() => _danmakuEnabled = !_danmakuEnabled); + widget.onDanmakuChanged(_danmakuEnabled); + }, + ), + _VideoActionTile( + icon: LoftifyIcons.download, + iconWidget: widget.downloading + ? SizedBox.square( + dimension: 17, + child: CircularProgressIndicator( + color: ChewieTheme.primaryColor, + strokeWidth: 2, + ), + ) + : null, + label: widget.downloading + ? appLocalizations.downloading + : appLocalizations.download, + onTap: widget.downloading ? null : widget.onDownload, + ), + ]; + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 480 ? 4 : 2; + return GridView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemCount: actions.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisExtent: 54, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemBuilder: (_, index) => actions[index], + ); + }, + ); + } +} + +class _VideoActionTile extends StatelessWidget { + const _VideoActionTile({ + required this.icon, + required this.label, + this.iconWidget, + this.selected = false, + this.onTap, + }); + + final IconData icon; + final Widget? iconWidget; + final String label; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final foreground = + selected ? ChewieTheme.primaryColor : ChewieTheme.bodyMedium.color; + return Semantics( + button: true, + selected: selected, + label: label, + child: PressableAnimation( + child: Material( + color: selected + ? ChewieTheme.primaryColor.withAlpha(22) + : ChewieTheme.canvasColor, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + child: Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: ChewieTheme.primaryColor.withAlpha( + selected ? 42 : 24, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: iconWidget ?? + ChewieIcon(icon, color: foreground, size: 17), + ), + ), + const SizedBox(width: 9), + Expanded( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.bodyMedium.copyWith( + color: foreground, + fontWeight: + selected ? FontWeight.bold : FontWeight.w500, + ), + ), + ), + if (selected) + ChewieIcon( + LoftifyIcons.check, + size: 16, + color: ChewieTheme.primaryColor, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _VideoSpeedChip extends StatelessWidget { + const _VideoSpeedChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + child: PressableAnimation( + child: Material( + color: selected + ? ChewieTheme.primaryColor.withAlpha(28) + : ChewieTheme.cardColor, + borderRadius: BorderRadius.circular(99), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selected) ...[ + ChewieIcon( + LoftifyIcons.check, + size: 15, + color: ChewieTheme.primaryColor, + ), + const SizedBox(width: 4), + ], + Text( + label, + style: ChewieTheme.bodyMedium.copyWith( + color: selected + ? ChewieTheme.primaryColor + : ChewieTheme.bodyMedium.color, + fontWeight: selected ? FontWeight.bold : FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class VideoListButtonColumn extends StatelessWidget { + final double? bottomPadding; + final bool isLiked; + final bool isShared; + final bool isFollowing; + final Function()? onLike; + final Function()? onComment; + final Function()? onDownload; + final Function()? onShare; + final Function()? onFollow; + final Function()? onTapAvatar; + final int likeCount; + final int shareCount; + final int commentCount; + final SimpleBlogInfo blogInfo; + final double? downloadProgress; + final bool showDownloadButton; + final bool isLandscape; + + const VideoListButtonColumn({ + super.key, + this.bottomPadding, + this.showDownloadButton = true, this.onLike, this.onComment, this.onShare, @@ -617,12 +2391,101 @@ class VideoListButtonColumn extends StatelessWidget { this.isFollowing = false, this.onTapAvatar, this.downloadProgress, + this.isLandscape = false, }); @override Widget build(BuildContext context) { + final iconSize = isLandscape ? 24.0 : 29.0; + final actions = [ + _IconButton( + compact: isLandscape, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.like, + selected: isLiked, + size: iconSize, + color: isLiked ? LoftifyReactionColors.like : Colors.white, + ), + text: _formatVideoCount(likeCount), + onTap: onLike, + ), + _IconButton( + compact: isLandscape, + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.recommend, + selected: isShared, + size: iconSize, + color: isShared ? LoftifyReactionColors.recommend : Colors.white, + ), + text: _formatVideoCount(shareCount), + onTap: onShare, + ), + _IconButton( + compact: isLandscape, + icon: ChewieIcon( + LoftifyIcons.comment, + size: iconSize, + color: Colors.white, + ), + text: _formatVideoCount(commentCount), + onTap: onComment, + ), + if (showDownloadButton) + downloadProgress != null + ? _IconButton( + compact: isLandscape, + icon: SizedBox( + width: iconSize, + height: iconSize, + child: CircularProgressIndicator( + value: downloadProgress!.clamp(0.0, 1.0).toDouble(), + color: Colors.white, + backgroundColor: Colors.white24, + strokeWidth: isLandscape ? 2 : 2.5, + ), + ), + text: '${(downloadProgress! * 100).toStringAsFixed(0)}%', + onTap: onDownload, + ) + : _IconButton( + compact: isLandscape, + icon: ChewieIcon( + LoftifyIcons.download, + size: iconSize, + color: Colors.white, + ), + text: appLocalizations.download, + onTap: onDownload, + ), + ]; + + if (isLandscape) { + return Container( + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.24), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildAvatar(context, compact: true), + const SizedBox(width: 7), + for (final action in actions) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: action, + ), + ], + ), + ); + } + return Container( - width: 40, + width: 48, margin: EdgeInsets.only( bottom: bottomPadding ?? 50, right: 12, @@ -631,96 +2494,72 @@ class VideoListButtonColumn extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end, children: [ - GestureDetector( - onTap: onTapAvatar, - child: Stack( - children: [ - const SizedBox(width: 45, height: 45), - ItemBuilder.buildAvatar( - context: context, - imageUrl: blogInfo.bigAvaImg, - showLoading: false, - size: 40, - showBorder: false, - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Center( - child: ClickableWrapper(child: - GestureDetector( - onTap: onFollow, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(50), - border: Border.all( - color: - Theme.of(context).primaryColor.withAlpha(127), - width: 0.5, - ), - ), - child: Icon( - isFollowing - ? Icons.check_rounded - : Icons.add_rounded, - size: 12, - color: Theme.of(context).primaryColor, - ), - ), - ), - ), - ), - ), - ], - ), - ), + _buildAvatar(context), const SizedBox(height: 10), - _IconButton( - icon: Icon(Icons.favorite_rounded, - size: 35, - color: isLiked ? ChewieColors.likeButtonColor : Colors.white), - text: '$likeCount', - onTap: onLike, - ), - _IconButton( - icon: Icon(Icons.thumb_up_rounded, - size: 35, - color: isShared ? ChewieColors.shareButtonColor : Colors.white), - text: '$shareCount', - onTap: onShare, + ...actions, + const SizedBox(height: 20), + ], + ), + ); + } + + static String _formatVideoCount(int count) { + if (count < 10000) return '$count'; + if (count < 1000000) { + final truncated = (count / 1000).floor() / 10; + return '${truncated.toStringAsFixed(1)}w'; + } + if (count < 100000000) return '${(count / 10000).floor()}w'; + final truncated = (count / 10000000).floor() / 10; + return '${truncated.toStringAsFixed(1)}y'; + } + + Widget _buildAvatar(BuildContext context, {bool compact = false}) { + final avatarSize = compact ? 34.0 : 40.0; + return GestureDetector( + onTap: onTapAvatar, + child: Stack( + children: [ + SizedBox( + width: compact ? 39 : 45, + height: compact ? 39 : 45, ), - _IconButton( - icon: const Icon(Icons.mode_comment_rounded, - size: 35, color: Colors.white), - text: '$commentCount', - onTap: onComment, + ItemBuilder.buildAvatar( + context: context, + imageUrl: blogInfo.bigAvaImg, + showLoading: false, + size: avatarSize, + showBorder: false, ), - if (showDownloadButton) - downloadProgress != null - ? _IconButton( - icon: Container( - width: 32, - height: 32, - margin: const EdgeInsets.only(bottom: 3), - child: CircularProgressIndicator( - value: downloadProgress, + if (!isFollowing) + Positioned( + bottom: 0, + left: 0, + right: compact ? 5 : 0, + child: Center( + child: ClickableWrapper( + child: GestureDetector( + onTap: onFollow, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( color: Colors.white, - strokeWidth: 3, + borderRadius: BorderRadius.circular(50), + border: Border.all( + color: Theme.of(context).primaryColor.withAlpha(127), + width: 0.5, + ), + ), + child: ChewieIcon( + LoftifyIcons.add, + size: compact ? 10 : 12, + color: Theme.of(context).primaryColor, ), ), - text: '${(downloadProgress! * 100).toStringAsFixed(0)}%', - onTap: onDownload, - ) - : _IconButton( - icon: const Icon(Icons.download_rounded, - size: 35, color: Colors.white), - text: appLocalizations.download, - onTap: onDownload, ), - const SizedBox(height: 20), + ), + ), + ), ], ), ); @@ -731,37 +2570,46 @@ class _IconButton extends StatelessWidget { final Widget? icon; final String? text; final Function? onTap; + final bool compact; const _IconButton({ this.icon, this.text, this.onTap, + this.compact = false, }); @override Widget build(BuildContext context) { - Widget body = Column( + final body = Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - GestureDetector( - child: icon ?? emptyWidget, - onTap: () { - onTap?.call(); - }, - ), - Container(height: 2), - Text( - text ?? '??', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + icon ?? emptyWidget, + const SizedBox(height: 3), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 46), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + text ?? '??', + maxLines: 1, + softWrap: false, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: compact ? 11 : 12, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), ), ], ); - return ClickableWrapper(child: - Container( - padding: const EdgeInsets.symmetric(vertical: 10), + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap?.call(), + child: SizedBox( + width: 48, + height: compact ? 52 : 62, child: body, ), ); diff --git a/lib/Screens/Post/video_list_controller.dart b/lib/Screens/Post/video_list_controller.dart index a1839420..81088e47 100644 --- a/lib/Screens/Post/video_list_controller.dart +++ b/lib/Screens/Post/video_list_controller.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:loftify/Models/recommend_response.dart'; import 'package:video_player/video_player.dart'; @@ -10,123 +10,163 @@ typedef LoadMoreVideo = Future> Function( List list, ); -/// TikTokVideoListController是一系列视频的控制器,内部管理了视频控制器数组 -/// 提供了预加载/释放/加载更多功能 +enum VideoLoadState { idle, initializing, ready, failed } + +/// Coordinates the short-video feed. Only the visible item is allowed to play; +/// nearby items are prepared and distant items release their native decoders. class VideoListController extends ChangeNotifier { VideoListController({ this.loadMoreCount = 1, - this.preloadCount = 2, - - /// 设置为0后,任何不在画面内的视频都会被释放 - /// 若不设置为0,安卓将会无法加载第三个开始的视频 + this.preloadCount = 1, this.disposeCount = 0, }); - /// 到第几个触发预加载,例如:1:最后一个,2:倒数第二个 final int loadMoreCount; - - /// 预加载多少个视频 final int preloadCount; - - /// 超出多少个,就释放视频 final int disposeCount; - /// 提供视频的builder + final ValueNotifier index = ValueNotifier(0); + final List playerList = []; + LoadMoreVideo? _videoProvider; + CustomVideoController? _observedPlayer; + bool _loadingMore = false; + bool _canLoadMore = true; + bool _closed = false; + int _loadGeneration = 0; - loadIndex(BuildContext context, int target, {bool reload = false}) { - if (!reload) { - if (index.value == target) return; - } - var oldIndex = index.value; - var newIndex = target; - - if (!(oldIndex == 0 && newIndex == 0)) { - playerOfIndex(oldIndex)?.controller.seekTo(Duration.zero); - // playerOfIndex(oldIndex)?.controller.addListener(_didUpdateValue); - // playerOfIndex(oldIndex)?.showPauseIcon.addListener(_didUpdateValue); - playerOfIndex(oldIndex)?.pause(); - } - playerOfIndex(newIndex)?.controller.addListener(_didUpdateValue); - playerOfIndex(newIndex)?.showPauseIcon.addListener(_didUpdateValue); - playerOfIndex(newIndex)?.play(); - for (var i = 0; i < playerList.length; i++) { - /// 需要释放[disposeCount]之前的视频 - /// i < newIndex - disposeCount 向下滑动时释放视频 - /// i > newIndex + disposeCount 向上滑动,同时避免disposeCount设置为0时失去视频预加载功能 - if (i < newIndex - disposeCount || i > newIndex + max(disposeCount, 2)) { - playerOfIndex(i)?.controller.removeListener(_didUpdateValue); - playerOfIndex(i)?.showPauseIcon.removeListener(_didUpdateValue); - playerOfIndex(i)?.dispose(); - continue; - } - if (i > newIndex && i < newIndex + preloadCount) { - playerOfIndex(i)?.init(); - continue; - } - } - if (playerList.length - newIndex <= loadMoreCount + 1) { - _videoProvider?.call(newIndex, playerList).then( - (list) async { - playerList.addAll(list); - notifyListeners(); - }, - ); - } + int get videoCount => playerList.length; + + CustomVideoController? get currentPlayerOrNull => playerOfIndex(index.value); - index.value = target; + CustomVideoController get currentPlayer => currentPlayerOrNull!; + + CustomVideoController? playerOfIndex(int target) { + if (target < 0 || target >= playerList.length) return null; + return playerList[target]; } - _didUpdateValue() { + Future init({ + required List initialList, + required LoadMoreVideo videoProvider, + }) async { + if (_closed) return; + _videoProvider = videoProvider; + _canLoadMore = true; + playerList.addAll(initialList); notifyListeners(); + if (playerList.isNotEmpty) { + await loadIndex(0, reload: true); + } } - CustomVideoController? playerOfIndex(int index) { - if (index < 0 || index > playerList.length - 1) { - return null; + Future loadIndex(int target, {bool reload = false}) async { + if (_closed || playerList.isEmpty) return; + final safeTarget = target.clamp(0, playerList.length - 1); + if (!reload && index.value == safeTarget) return; + + final generation = ++_loadGeneration; + final oldPlayer = currentPlayerOrNull; + final newPlayer = playerOfIndex(safeTarget); + index.value = safeTarget; + _observe(newPlayer); + notifyListeners(); + + if (!identical(oldPlayer, newPlayer)) { + await oldPlayer?.pause(); + await oldPlayer?.seekToStart(); } - return playerList[index]; + if (_closed || generation != _loadGeneration || newPlayer == null) return; + + await newPlayer.play(); + if (_closed || generation != _loadGeneration) { + await newPlayer.pause(); + return; + } + + unawaited(_releaseAndPreloadAround(safeTarget, generation)); + _loadMoreIfNeeded(safeTarget); } - int get videoCount => playerList.length; + Future _releaseAndPreloadAround(int active, int generation) async { + for (var i = 0; i < playerList.length; i++) { + if (_closed || generation != _loadGeneration) return; + final distance = (i - active).abs(); + if (distance > max(disposeCount, preloadCount)) { + await playerList[i].release(); + } else if (i != active && distance <= preloadCount) { + await playerList[i].init(); + } + } + } - init({ - required BuildContext context, - required PageController pageController, - required List initialList, - required LoadMoreVideo videoProvider, - bool loop = false, - }) async { - playerList.addAll(initialList); - _videoProvider = videoProvider; - pageController.addListener(() { - var p = pageController.page!; - if (p % 1 == 0) { - loadIndex(context, p ~/ 1); + void _loadMoreIfNeeded(int active) { + if (_closed || _loadingMore || !_canLoadMore || _videoProvider == null) { + return; + } + if (playerList.length - active > loadMoreCount + 1) return; + + _loadingMore = true; + unawaited(() async { + try { + final additions = await _videoProvider!(active, playerList); + if (_closed) return; + final existingIds = playerList + .map((player) => player.videoInfo?.itemId) + .whereType() + .toSet(); + final unique = additions.where((player) { + final itemId = player.videoInfo?.itemId; + return itemId == null || existingIds.add(itemId); + }).toList(); + if (unique.isEmpty) { + _canLoadMore = false; + for (final player in additions) { + await player.close(); + } + } else { + playerList.addAll(unique); + notifyListeners(); + } + } catch (error) { + debugPrint('Failed to load more videos: $error'); + } finally { + _loadingMore = false; } - }); - loadIndex(context, 0, reload: true); - notifyListeners(); + }()); } - /// 目前的视频序号 - ValueNotifier index = ValueNotifier(0); + void _observe(CustomVideoController? player) { + if (identical(_observedPlayer, player)) return; + _observedPlayer?.revision.removeListener(_didUpdateValue); + _observedPlayer = player; + _observedPlayer?.revision.addListener(_didUpdateValue); + } - /// 视频列表 - List playerList = []; + void _didUpdateValue() { + if (!_closed) notifyListeners(); + } + + Future pauseCurrent({bool showPauseIcon = false}) async { + await currentPlayerOrNull?.pause(showPauseIcon: showPauseIcon); + } - /// - CustomVideoController get currentPlayer => playerList[index.value]; + Future resumeCurrent() async { + await currentPlayerOrNull?.play(); + } @override - Future dispose() async { - for (var player in playerList) { - player.controller.removeListener(_didUpdateValue); - player.showPauseIcon.removeListener(_didUpdateValue); - player.showPauseIcon.dispose(); - await player.dispose(); + void dispose() { + if (_closed) return; + _closed = true; + _loadGeneration++; + _observedPlayer?.revision.removeListener(_didUpdateValue); + _observedPlayer = null; + for (final player in playerList) { + unawaited(player.close()); } - playerList = []; + playerList.clear(); + index.dispose(); super.dispose(); } } @@ -134,122 +174,238 @@ class VideoListController extends ChangeNotifier { typedef ControllerSetter = Future Function(T controller); typedef ControllerBuilder = T Function(); -/// 抽象类,作为视频控制器必须实现这些方法 abstract class BaseVideoController { - /// 获取当前的控制器实例 - T? get controller; + T? get controllerOrNull; + ValueNotifier get revision; + VideoLoadState get loadState; + bool get prepared; + Object? get lastError; - /// 是否显示暂停按钮 - ValueNotifier get showPauseIcon; - - /// 加载视频,在init后,应当开始下载视频内容 Future init({ControllerSetter? afterInit}); - - /// 视频销毁,在dispose后,应当释放任何内存资源 - Future dispose(); - - /// 播放 + Future retry({bool autoplay = false}); + Future release(); + Future close(); Future play(); - - /// 暂停 Future pause({bool showPauseIcon = false}); } -/// 异步方法并发锁 -Completer? _syncLock; - class CustomVideoController extends BaseVideoController { - VideoPlayerController? _controller; - final ValueNotifier _showPauseIcon = ValueNotifier(false); - - final PostListItem? videoInfo; - - final ControllerBuilder _builder; - final ControllerSetter? _afterInit; - CustomVideoController({ this.videoInfo, required ControllerBuilder builder, ControllerSetter? afterInit, + bool looping = true, + double playbackSpeed = 1, }) : _builder = builder, - _afterInit = afterInit; + _afterInit = afterInit, + _looping = looping, + _playbackSpeed = playbackSpeed; + + final PostListItem? videoInfo; + final ControllerBuilder _builder; + final ControllerSetter? _afterInit; + + final ValueNotifier _revision = ValueNotifier(0); + Future _operationLock = Future.value(); + VideoPlayerController? _controller; + VideoLoadState _loadState = VideoLoadState.idle; + Object? _lastError; + bool _prepared = false; + bool _showPauseIcon = false; + bool _closed = false; + bool _looping; + double _playbackSpeed; + int _generation = 0; @override - VideoPlayerController get controller { - _controller ??= _builder.call(); - return _controller!; - } + VideoPlayerController? get controllerOrNull => _controller; - bool get isDispose => _disposeLock != null; + @override + ValueNotifier get revision => _revision; + @override + VideoLoadState get loadState => _loadState; + + @override bool get prepared => _prepared; - bool _prepared = false; - Completer? _disposeLock; + @override + Object? get lastError => _lastError; - /// 防止异步方法并发 - Future _syncCall(Future Function()? fn) async { - // 设置同步等待 - var lastCompleter = _syncLock; - var completer = Completer(); - _syncLock = completer; - // 等待其他同步任务完成 - await lastCompleter?.future; - // 主任务 - await fn?.call(); - // 结束 - completer.complete(); - } + bool get showPauseIcon => _showPauseIcon; - @override - Future dispose() async { - if (!prepared) return; - _prepared = false; - await controller.dispose(); - _controller = null; - _disposeLock = Completer(); + bool get isPlaying => _controller?.value.isPlaying ?? false; + + bool get isMuted => (_controller?.value.volume ?? 1) == 0; + + bool get looping => _looping; + + double get playbackSpeed => _playbackSpeed; + + Future _runExclusive(Future Function() operation) { + final result = _operationLock.then((_) => operation()); + _operationLock = result.catchError((Object _) {}); + return result; } @override Future init({ ControllerSetter? afterInit, - }) async { - if (prepared) return; - await _syncCall(() async { - await controller.initialize(); - await controller.setLooping(true); - afterInit ??= _afterInit; - await afterInit?.call(controller); - _prepared = true; + }) { + return _runExclusive(() async { + if (_closed || _prepared || _loadState == VideoLoadState.initializing) { + return; + } + final generation = ++_generation; + _setLoadState(VideoLoadState.initializing); + _lastError = null; + final controller = _builder(); + _controller = controller; + controller.addListener(_handleControllerValue); + try { + await controller.initialize(); + if (_closed || generation != _generation) { + await _disposeController(controller); + return; + } + await controller.setLooping(_looping); + await controller.setPlaybackSpeed(_playbackSpeed); + await (afterInit ?? _afterInit)?.call(controller); + if (_closed || generation != _generation) { + await _disposeController(controller); + return; + } + _prepared = true; + _setLoadState(VideoLoadState.ready); + } catch (error) { + _lastError = error; + _prepared = false; + _setLoadState(VideoLoadState.failed); + debugPrint('Failed to initialize video: $error'); + await _disposeController(controller); + } }); - if (_disposeLock != null) { - _disposeLock?.complete(); - _disposeLock = null; - } } @override - Future pause({bool showPauseIcon = false}) async { + Future retry({bool autoplay = false}) async { + await release(); await init(); - if (!prepared) return; - if (_disposeLock != null) { - await _disposeLock?.future; - } - await controller.pause(); - _showPauseIcon.value = true; + if (autoplay && prepared) await play(); } @override Future play() async { await init(); - if (!prepared) return; - if (_disposeLock != null) { - await _disposeLock?.future; + return _runExclusive(() async { + final controller = _controller; + if (_closed || !_prepared || controller == null) return; + await controller.play(); + _showPauseIcon = false; + _notifyListeners(); + }); + } + + @override + Future pause({bool showPauseIcon = false}) { + return _runExclusive(() async { + final controller = _controller; + if (_closed || !_prepared || controller == null) return; + await controller.pause(); + _showPauseIcon = showPauseIcon; + _notifyListeners(); + }); + } + + Future seekToStart() async { + return _runExclusive(() async { + final controller = _controller; + if (_closed || !_prepared || controller == null) return; + await controller.seekTo(Duration.zero); + }); + } + + Future toggleMute() async { + return _runExclusive(() async { + final controller = _controller; + if (_closed || !_prepared || controller == null) return; + await controller.setVolume(isMuted ? 1 : 0); + _notifyListeners(); + }); + } + + Future setLooping(bool looping) { + _looping = looping; + return _runExclusive(() async { + final controller = _controller; + if (_closed || !_prepared || controller == null) return; + await controller.setLooping(looping); + _notifyListeners(); + }); + } + + Future setPlaybackSpeed(double speed) { + _playbackSpeed = speed.clamp(0.5, 2).toDouble(); + return _runExclusive(() async { + final controller = _controller; + if (_closed || !_prepared || controller == null) return; + await controller.setPlaybackSpeed(_playbackSpeed); + _notifyListeners(); + }); + } + + @override + Future release() { + return _runExclusive(() async { + _generation++; + final controller = _controller; + _controller = null; + _prepared = false; + _showPauseIcon = false; + _lastError = null; + if (!_closed) _setLoadState(VideoLoadState.idle); + if (controller != null) await _disposeController(controller); + }); + } + + Future _disposeController(VideoPlayerController controller) async { + controller.removeListener(_handleControllerValue); + if (identical(_controller, controller)) _controller = null; + try { + await controller.dispose(); + } catch (error) { + debugPrint('Failed to dispose video controller: $error'); + } + } + + void _handleControllerValue() { + final controller = _controller; + if (_closed || controller == null) return; + if (controller.value.hasError) { + _lastError = controller.value.errorDescription; + _prepared = false; + _setLoadState(VideoLoadState.failed); + return; } - await controller.play(); - _showPauseIcon.value = false; + _notifyListeners(); + } + + void _setLoadState(VideoLoadState state) { + if (_loadState == state) return; + _loadState = state; + _notifyListeners(); + } + + void _notifyListeners() { + if (!_closed) _revision.value++; } @override - ValueNotifier get showPauseIcon => _showPauseIcon; + Future close() async { + if (_closed) return; + await release(); + _closed = true; + _generation++; + _revision.dispose(); + } } diff --git a/lib/Screens/Setting/about_setting_screen.dart b/lib/Screens/Setting/about_setting_screen.dart index d3825cc9..080b9ffc 100644 --- a/lib/Screens/Setting/about_setting_screen.dart +++ b/lib/Screens/Setting/about_setting_screen.dart @@ -10,7 +10,9 @@ import 'package:share_plus/share_plus.dart'; import '../../Utils/cloud_control_provider.dart'; import '../../Utils/hive_util.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; +import 'base_setting_screen.dart'; const countThreholdLevel1 = 3; const countThreholdLevel2 = 6; @@ -18,8 +20,14 @@ const countThreholdLevel3 = 12; const countThreholdLevel4 = 18; const countThreholdLevel5 = 24; -class AboutSettingScreen extends StatefulWidget { - const AboutSettingScreen({super.key}); +class AboutSettingScreen extends BaseSettingScreen { + const AboutSettingScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/about"; @@ -52,13 +60,13 @@ class _AboutSettingScreenState extends BaseDynamicState }); } - diaplayCelebrate() { + void diaplayCelebrate() { restore(); RouteUtil.pushFadeRoute(context, const EggScreen()); setState(() {}); } - restore() { + void restore() { count = 0; if (_timer != null) _timer!.cancel(); if (_hapticTimer != null) _hapticTimer!.cancel(); @@ -68,31 +76,32 @@ class _AboutSettingScreenState extends BaseDynamicState setState(() {}); } - startShake() { + void startShake() { _shakeAnimationController.start(shakeCount: 0); } - setHapticTimer(Function() callback) { + void setHapticTimer(VoidCallback callback) { if (_hapticTimer != null) _hapticTimer!.cancel(); _hapticTimer = - Timer.periodic(const Duration(milliseconds: 10), (_) => callback()); + Timer.periodic(const Duration(milliseconds: 80), (_) => callback()); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: ResponsiveAppBar( - showBack: true, - title: appLocalizations.about, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ), - body: EasyRefresh( + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.about, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + overrideBody: EasyRefresh( child: ListView( + padding: widget.padding, children: [ - const SizedBox(height: 20), + const SizedBox(height: 10), Center( - child: ClickableWrapper(child: - GestureDetector( + child: ClickableWrapper( + child: GestureDetector( onLongPressStart: (details) { if (controlProvider.globalControl.enableEasterEggs) { if (_timer != null) _timer!.cancel(); @@ -156,7 +165,7 @@ class _AboutSettingScreenState extends BaseDynamicState ), ), Container( - margin: const EdgeInsets.all(10), + margin: EdgeInsets.zero, child: ScrollConfiguration( behavior: NoShadowScrollBehavior(), child: Consumer( @@ -165,113 +174,126 @@ class _AboutSettingScreenState extends BaseDynamicState shrinkWrap: true, padding: EdgeInsets.zero, children: [ - const SizedBox(height: 10), - EntryItem( - title: appLocalizations.changelog, - roundTop: true, - showLeading: true, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, const UpdateLogScreen()); - }, - leading: Icons.merge_type_outlined, - ), - EntryItem( - title: appLocalizations.bugReport, - onTap: () { - UriUtil.launchUrlUri(context, - cloudControlProvider.globalControl.issueUrl); - }, - showLeading: true, - leading: Icons.bug_report_outlined, - ), - EntryItem( - title: appLocalizations.githubRepo, - onTap: () { - UriUtil.launchUrlUri(context, - cloudControlProvider.globalControl.repoUrl); - }, - showLeading: true, - roundBottom: true, - leading: Icons.commit_outlined, - ), - const SizedBox(height: 10), - EntryItem( - roundTop: true, - title: appLocalizations.rate, - showLeading: true, - onTap: () { - BottomSheetBuilder.showBottomSheet( - context, - (context) => const StarBottomSheet(), - responsive: true, - ); - }, - leading: Icons.rate_review_outlined, + CaptionItem( + context: context, + title: appName, + children: [ + EntryItem( + title: appLocalizations.changelog, + showLeading: true, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const UpdateLogScreen()); + }, + leading: LoftifyIcons.merge, + ), + EntryItem( + title: appLocalizations.bugReport, + onTap: () { + UriUtil.launchUrlUri( + context, + cloudControlProvider.globalControl.issueUrl, + ); + }, + showLeading: true, + leading: LoftifyIcons.bug, + trailing: LoftifyIcons.openExternal, + ), + EntryItem( + title: appLocalizations.githubRepo, + onTap: () { + UriUtil.launchUrlUri( + context, + cloudControlProvider.globalControl.repoUrl, + ); + }, + showLeading: true, + leading: LoftifyIcons.commit, + trailing: LoftifyIcons.openExternal, + ), + ], ), - EntryItem( - title: appLocalizations.shareApp, - showLeading: true, - onTap: () { - Share.share( - cloudControlProvider.globalControl.shareText); - }, - leading: Icons.share_rounded, - ), - EntryItem( + CaptionItem( + context: context, title: appLocalizations.contact, - onTap: () { - UriUtil.launchEmailUri( - context, - cloudControlProvider.globalControl.feedbackEmail, - subject: cloudControlProvider - .globalControl.feedbackSubject, - body: cloudControlProvider - .globalControl.feedbackBody, - ); - }, - showLeading: true, - leading: Icons.contact_support_outlined, - ), - EntryItem( - title: appLocalizations.officialWebsite, - roundBottom: !(cloudControlProvider - .globalControl.showTelegramGroup) && - !(cloudControlProvider.globalControl.showQQGroup), - onTap: () { - UriUtil.launchUrlUri( - context, - cloudControlProvider - .globalControl.officialWebsite); - }, - showLeading: true, - leading: Icons.language_outlined, + children: [ + EntryItem( + title: appLocalizations.rate, + showLeading: true, + onTap: () { + BottomSheetBuilder.showBottomSheet( + context, + (context) => const StarBottomSheet(), + responsive: true, + ); + }, + leading: LoftifyIcons.review, + ), + EntryItem( + title: appLocalizations.shareApp, + showLeading: true, + onTap: () { + Share.share( + cloudControlProvider.globalControl.shareText, + ); + }, + leading: LoftifyIcons.share, + ), + EntryItem( + title: appLocalizations.contact, + onTap: () { + UriUtil.launchEmailUri( + context, + cloudControlProvider + .globalControl.feedbackEmail, + subject: cloudControlProvider + .globalControl.feedbackSubject, + body: cloudControlProvider + .globalControl.feedbackBody, + ); + }, + showLeading: true, + leading: LoftifyIcons.support, + trailing: LoftifyIcons.contact, + ), + EntryItem( + title: appLocalizations.officialWebsite, + onTap: () { + UriUtil.launchUrlUri( + context, + cloudControlProvider + .globalControl.officialWebsite, + ); + }, + showLeading: true, + leading: LoftifyIcons.language, + trailing: LoftifyIcons.openExternal, + ), + if (cloudControlProvider.globalControl.showQQGroup) + EntryItem( + title: appLocalizations.qqGroup, + onTap: () { + UriUtil.openExternal(cloudControlProvider + .globalControl.qqGroupUrl); + }, + showLeading: true, + leading: LoftifyIcons.group, + trailing: LoftifyIcons.openExternal, + ), + if (cloudControlProvider + .globalControl.showTelegramGroup) + EntryItem( + title: appLocalizations.telegramGroup, + onTap: () { + UriUtil.openExternal(cloudControlProvider + .globalControl.telegramGroupUrl); + }, + showLeading: true, + leading: LoftifyIcons.send, + trailing: LoftifyIcons.openExternal, + ), + ], ), - if (cloudControlProvider.globalControl.showQQGroup) - EntryItem( - title: appLocalizations.qqGroup, - roundBottom: !cloudControlProvider - .globalControl.showTelegramGroup, - onTap: () { - UriUtil.openExternal(cloudControlProvider - .globalControl.qqGroupUrl); - }, - showLeading: true, - leading: Icons.group_outlined, - ), - if (cloudControlProvider - .globalControl.showTelegramGroup) - EntryItem( - title: appLocalizations.telegramGroup, - onTap: () { - UriUtil.openExternal(cloudControlProvider - .globalControl.telegramGroupUrl); - }, - roundBottom: true, - showLeading: true, - leading: Icons.telegram_outlined, - ), - const SizedBox(height: 10) ], ), )), diff --git a/lib/Screens/Setting/apperance_setting_screen.dart b/lib/Screens/Setting/apperance_setting_screen.dart index a77ce29a..cca0e5a5 100644 --- a/lib/Screens/Setting/apperance_setting_screen.dart +++ b/lib/Screens/Setting/apperance_setting_screen.dart @@ -6,6 +6,7 @@ import 'package:loftify/Utils/app_provider.dart'; import 'package:loftify/Utils/hive_util.dart'; import 'package:provider/provider.dart'; +import '../../Utils/enums.dart'; import '../../l10n/l10n.dart'; import 'base_setting_screen.dart'; @@ -133,11 +134,64 @@ class _AppearanceSettingScreenState }); }, ), + Selector( + selector: (context, appProvider) => + appProvider.navigationBarDisplayStyle, + builder: (context, displayStyle, child) => InlineSelectionItem< + SelectionItemModel>( + title: appLocalizations.navigationBarDisplayStyle, + description: + appLocalizations.navigationBarDisplayStyleDescription, + hint: appLocalizations.chooseNavigationBarDisplayStyle, + items: NavigationBarDisplayStyle.values + .map((style) => SelectionItemModel(style.label, style)) + .toList(), + initItem: SelectionItemModel( + displayStyle.label, + displayStyle, + ), + onChanged: (item) { + if (item != null) { + appProvider.navigationBarDisplayStyle = item.value; + } + }, + ), + ), + Selector( + selector: (context, appProvider) => + appProvider.reduceTransparency, + builder: (context, reduceTransparency, child) => CheckboxItem( + value: reduceTransparency, + title: appLocalizations.reduceTransparency, + description: appLocalizations.reduceTransparencyDescription, + roundBottom: true, + onTap: () { + appProvider.reduceTransparency = !reduceTransparency; + }, + ), + ), ], ), CaptionItem( title: appLocalizations.home, children: [ + Selector( + selector: (context, provider) => provider.hideHomeAppBarOnScroll, + builder: (context, value, child) => CheckboxItem( + value: value, + title: appLocalizations.hideHomeAppBarOnScroll, + onTap: () => appProvider.hideHomeAppBarOnScroll = !value, + ), + ), + Selector( + selector: (context, provider) => provider.hideSearchNavigation, + builder: (context, value, child) => CheckboxItem( + value: value, + title: appLocalizations.hideSearchNavigation, + description: appLocalizations.hideSearchNavigationDescription, + onTap: () => appProvider.hideSearchNavigation = !value, + ), + ), CheckboxItem( value: _showRecommendArticle, title: appLocalizations.showArticleInRecommendFlow, diff --git a/lib/Screens/Setting/base_setting_screen.dart b/lib/Screens/Setting/base_setting_screen.dart index cef8c508..b4c31c7c 100644 --- a/lib/Screens/Setting/base_setting_screen.dart +++ b/lib/Screens/Setting/base_setting_screen.dart @@ -2,10 +2,14 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; abstract class BaseSettingScreen extends StatefulWidget { + static const double sectionTopMargin = 10; + static const EdgeInsets defaultPagePadding = + EdgeInsets.symmetric(horizontal: 10); + const BaseSettingScreen({ super.key, this.showTitleBar = true, - this.padding = const EdgeInsets.symmetric(horizontal: 6), + this.padding = defaultPagePadding, this.searchText = "", this.searchConfig, }); diff --git a/lib/Screens/Setting/blacklist_setting_screen.dart b/lib/Screens/Setting/blacklist_setting_screen.dart index 49000fc5..99f5f5ec 100644 --- a/lib/Screens/Setting/blacklist_setting_screen.dart +++ b/lib/Screens/Setting/blacklist_setting_screen.dart @@ -6,7 +6,7 @@ import 'package:loftify/Screens/Info/user_detail_screen.dart'; import '../../Api/setting_api.dart'; import '../../Widgets/Item/item_builder.dart'; -import '../../Widgets/Item/loftify_item_builder.dart'; +import '../../Widgets/Item/setting_management_item.dart'; import '../../l10n/l10n.dart'; import 'base_setting_screen.dart'; @@ -31,53 +31,55 @@ class _BlacklistSettingScreenState bool loading = false; final EasyRefreshController _refreshController = EasyRefreshController(); List blacklist = []; - bool _noMore = false; - _fetchBlacklist({bool refresh = false}) async { - if (loading) return; - if (refresh) _noMore = false; + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchBlacklist({bool refresh = false}) async { + if (loading || !mounted) return IndicatorResult.none; loading = true; - return await SettingApi.getBlacklist(offset: refresh ? 0 : blacklist.length) - .then((value) { - try { - if (value == null) return IndicatorResult.fail; - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - var tmp = (value['response']['blogs'] as List) - .map((e) => BlacklistItem.fromJson(e)) - .toList(); - if (refresh) blacklist.clear(); - blacklist.addAll(tmp); - if (tmp.isEmpty && !refresh) { - _noMore = true; - return IndicatorResult.noMore; - } - return IndicatorResult.success; - } - } catch (e, t) { - ILogger.error("Failed to load blacklist", e, t); - IToast.showTop(appLocalizations.loadBlacklistFailed); + try { + final value = + await SettingApi.getBlacklist(offset: refresh ? 0 : blacklist.length); + if (!mounted) return IndicatorResult.none; + if (value == null) return IndicatorResult.fail; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); return IndicatorResult.fail; - } finally { - loading = false; - if (mounted) setState(() {}); + } else { + var tmp = (value['response']['blogs'] as List) + .map((e) => BlacklistItem.fromJson(e)) + .toList(); + if (refresh) blacklist.clear(); + blacklist.addAll(tmp); + if (tmp.isEmpty && !refresh) { + return IndicatorResult.noMore; + } + return IndicatorResult.success; } - }); + } catch (e, t) { + if (!mounted) return IndicatorResult.none; + ILogger.error("Failed to load blacklist", e, t); + IToast.showTop(appLocalizations.loadBlacklistFailed); + return IndicatorResult.fail; + } finally { + loading = false; + if (mounted) setState(() {}); + } } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: ChewieTheme.getBackground(context), - appBar: widget.showTitleBar - ? ResponsiveAppBar( - showBack: !ResponsiveUtil.isLandscapeLayout(), - title: appLocalizations.blacklistSetting, - ) - : null, - body: EasyRefresh( + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.blacklistSetting, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + overrideBody: EasyRefresh( controller: _refreshController, refreshOnStart: true, onRefresh: () async { @@ -87,22 +89,36 @@ class _BlacklistSettingScreenState return await _fetchBlacklist(); }, triggerAxis: Axis.vertical, - child: LoadMoreNotification( - noMore: _noMore, - onLoad: _fetchBlacklist, - child: ListView.builder( - itemCount: blacklist.length, - padding: EdgeInsets.zero, - itemBuilder: (context, index) => - _buildBlacklistRow(blacklist[index]), - ), + child: ListView( + padding: widget.padding, + children: [ + CaptionItem( + title: + '${appLocalizations.blacklistSetting} (${blacklist.length})', + children: blacklist.isEmpty + ? [ + SettingManagementEmptyState( + text: appLocalizations.noContent), + ] + : blacklist.map(_buildBlacklistRow).toList(), + ), + ], ), ), ); } - _buildBlacklistRow(BlacklistItem blacklistItem) { - return GestureDetector( + Widget _buildBlacklistRow(BlacklistItem blacklistItem) { + return SettingManagementItem( + title: blacklistItem.blogInfo.blogNickName, + description: blacklistItem.blogInfo.blogName, + leading: ItemBuilder.buildAvatar( + context: context, + imageUrl: blacklistItem.blogInfo.bigAvaImg, + showLoading: false, + showBorder: true, + size: 40, + ), onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -111,63 +127,31 @@ class _BlacklistSettingScreenState blogName: blacklistItem.blogInfo.blogName), ); }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - width: 0.5, - ), - ), - ), - child: Row( - children: [ - ItemBuilder.buildAvatar( - context: context, - imageUrl: blacklistItem.blogInfo.bigAvaImg, - showLoading: false, - showBorder: true, - size: 40, - ), - const SizedBox(width: 10), - Expanded( - child: Text(blacklistItem.blogInfo.blogNickName), - ), - LoftifyItemBuilder.buildFramedDoubleButton( - context: context, - isFollowed: false, - positiveText: appLocalizations.unlockBlacklist, - negtiveText: appLocalizations.unlockBlacklist, - onTap: () { - DialogBuilder.showConfirmDialog( - context, - title: appLocalizations.unlockBlacklist, - message: appLocalizations.unlockBlacklistMessage( - blacklistItem.blogInfo.blogNickName), - confirmButtonText: appLocalizations.unlock, - onTapConfirm: () { - UserApi.blockOrUnBlock( - blogId: blacklistItem.blogInfo.blogId, - isBlock: false, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop( - value['meta']['desc'] ?? value['meta']['msg']); - } else { - blacklist.remove(blacklistItem); - setState(() {}); - } - }); - }, - onTapCancel: () {}, - customDialogType: CustomDialogType.normal, - ); - }, - ), - ], - ), - ), + actionLabel: appLocalizations.unlockBlacklist, + onAction: () { + DialogBuilder.showConfirmDialog( + context, + title: appLocalizations.unlockBlacklist, + message: appLocalizations + .unlockBlacklistMessage(blacklistItem.blogInfo.blogNickName), + confirmButtonText: appLocalizations.unlock, + onTapConfirm: () { + UserApi.blockOrUnBlock( + blogId: blacklistItem.blogInfo.blogId, + isBlock: false, + ).then((value) { + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } else { + blacklist.remove(blacklistItem); + setState(() {}); + } + }); + }, + onTapCancel: () {}, + customDialogType: CustomDialogType.normal, + ); + }, ); } } diff --git a/lib/Screens/Setting/egg_screen.dart b/lib/Screens/Setting/egg_screen.dart index d729d81a..b51bc07e 100644 --- a/lib/Screens/Setting/egg_screen.dart +++ b/lib/Screens/Setting/egg_screen.dart @@ -31,7 +31,7 @@ class _EggScreenState extends BaseDynamicState WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { _celebrateController = AnimationController( duration: const Duration(seconds: 5), vsync: this); - celebrateWidget = LottieUtil.load( + celebrateWidget = LottieFiles.buildAnimation( LottieFiles.celebrate, size: MediaQuery.sizeOf(context).width * 2, controller: _celebrateController, diff --git a/lib/Screens/Setting/experiment_setting_screen.dart b/lib/Screens/Setting/experiment_setting_screen.dart index b067b805..3a6db7fa 100644 --- a/lib/Screens/Setting/experiment_setting_screen.dart +++ b/lib/Screens/Setting/experiment_setting_screen.dart @@ -4,11 +4,10 @@ import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_windowmanager/flutter_windowmanager.dart'; import 'package:local_auth/local_auth.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; import '../../Utils/app_provider.dart'; +import '../../Utils/display_mode_util.dart'; import '../../Utils/hive_util.dart'; -import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; import '../Lock/pin_change_screen.dart'; import '../Lock/pin_verify_screen.dart'; @@ -40,14 +39,11 @@ class _ExperimentSettingScreenState ChewieHiveUtil.getBool(HiveUtil.enableSafeModeKey, defaultValue: false); bool _enableBiometric = ChewieHiveUtil.getBool(HiveUtil.enableBiometricKey); bool _biometricAvailable = false; - int _refreshRate = ChewieHiveUtil.getInt(HiveUtil.refreshRateKey); List _modes = []; + DisplayMode? _selectedMode; DisplayMode? _activeMode; DisplayMode? _preferredMode; - List> get _supportedModeTuples => - _modes.map((e) => Tuple2(e.toString(), e)).toList(); - @override void initState() { super.initState(); @@ -55,13 +51,37 @@ class _ExperimentSettingScreenState if (ResponsiveUtil.isAndroid()) getRefreshRate(); } - getRefreshRate() async { - _modes = await FlutterDisplayMode.supported; - _activeMode = await FlutterDisplayMode.active; - _preferredMode = await FlutterDisplayMode.preferred; - ILogger.info( - "Current active display mode: $_activeMode\nCurrent preferred display mode: $_preferredMode"); - setState(() {}); + Future getRefreshRate() async { + try { + final supportedModes = await FlutterDisplayMode.supported; + final activeMode = await FlutterDisplayMode.active; + final preferredMode = await FlutterDisplayMode.preferred; + final selectedMode = DisplayModePreference.resolve( + modes: supportedModes, + activeMode: activeMode, + encodedMode: ChewieHiveUtil.getString(HiveUtil.refreshRateModeKey), + legacyIndex: ChewieHiveUtil.getInt( + HiveUtil.refreshRateKey, + defaultValue: -1, + ), + ); + ILogger.info( + "Current active display mode: $activeMode\nCurrent preferred display mode: $preferredMode"); + if (!mounted) return; + setState(() { + _modes = DisplayModePreference.ordered(supportedModes); + _activeMode = activeMode; + _preferredMode = preferredMode; + _selectedMode = selectedMode; + }); + } catch (error, stackTrace) { + ILogger.error("Failed to load display modes", error, stackTrace); + if (mounted) { + IToast.showTop( + appLocalizations.setRefreshRateFailedWithError(error.toString()), + ); + } + } } @override @@ -77,73 +97,79 @@ class _ExperimentSettingScreenState selector: (context, globalProvider) => globalProvider.pinSettled, builder: (context, pinSettled, child) => _privacySettings(pinSettled), ), - if (ResponsiveUtil.isAndroid()) _fpsSettings(), + if (ResponsiveUtil.isAndroid()) ...[ + _fpsSettings(), + ], ], ); } - _fpsSettings() { - return [ - const SizedBox(height: 10), - EntryItem( - title: appLocalizations.refreshRate, - description: appLocalizations.refreshRateDescription( - _modes.isNotEmpty - ? _modes[_refreshRate.clamp(0, _modes.length - 1)].toString() - : "", - _preferredMode?.toString() ?? "Unknown", - _activeMode?.toString() ?? "Unknown", - ), - roundTop: true, - roundBottom: true, - onTap: () { - getRefreshRate(); - BottomSheetBuilder.showListBottomSheet( - context, - (context) => TileList.fromOptions( - _supportedModeTuples, - (item2) async { - try { - ILogger.info("Try to set display mode: ${item2.toString()}"); - ILogger.info( - "Active display mode before set: ${_activeMode.toString()}\nPreferred display mode before set: ${_preferredMode.toString()}"); - await FlutterDisplayMode.setPreferredMode(item2); - _activeMode = await FlutterDisplayMode.active; - _preferredMode = await FlutterDisplayMode.preferred; - ILogger.info( - "Active display mode after set: ${_activeMode.toString()}\nPreferred display mode after set: ${_preferredMode.toString()}"); - if (_preferredMode?.toString() != item2.toString()) { - IToast.showTop(appLocalizations.setRefreshRateFailed); - } else { - if (_activeMode?.toString() != item2.toString()) { - IToast.showTop(S.current - .setRefreshRateSuccessWithDisplayModeNotChanged); - } else { - IToast.showTop(appLocalizations.setRefreshRateSuccess); - } - } - } catch (e, t) { - IToast.showTop(appLocalizations - .setRefreshRateFailedWithError(e.toString())); - ILogger.error("Failed to set display mode", e, t); - } - _refreshRate = _modes.indexOf(item2); - getRefreshRate(); - ChewieHiveUtil.put(HiveUtil.refreshRateKey, _refreshRate); - Navigator.pop(context); - }, - selected: _modes[_refreshRate.clamp(0, _modes.length - 1)], - context: context, - title: appLocalizations.chooseRefreshRate, - onCloseTap: () => Navigator.pop(context), + Widget _fpsSettings() { + String modeLabel(DisplayMode mode) => DisplayModePreference.label( + mode, + automaticLabel: appLocalizations.followSystem, + ); + final selectedMode = _selectedMode; + return CaptionItem( + title: appLocalizations.refreshRate, + children: [ + InlineSelectionItem>( + title: appLocalizations.refreshRate, + description: appLocalizations.refreshRateDescription( + selectedMode == null ? "" : modeLabel(selectedMode), + _preferredMode == null ? "Unknown" : modeLabel(_preferredMode!), + _activeMode == null ? "Unknown" : modeLabel(_activeMode!), ), - ); - }, - ), - ]; + items: _modes + .map((mode) => SelectionItemModel(modeLabel(mode), mode)) + .toList(), + initItem: selectedMode == null + ? null + : SelectionItemModel(modeLabel(selectedMode), selectedMode), + hint: appLocalizations.chooseRefreshRate, + onChanged: (item) async { + if (item == null) return; + final mode = item.value; + try { + ILogger.info("Try to set display mode: $mode"); + await DisplayModeController.setPreferredMode(mode); + await ChewieHiveUtil.put( + HiveUtil.refreshRateModeKey, + DisplayModePreference.encode(mode), + ); + await ChewieHiveUtil.delete(HiveUtil.refreshRateKey); + final activeMode = await FlutterDisplayMode.active; + final preferredMode = await FlutterDisplayMode.preferred; + if (!mounted) return; + setState(() { + _selectedMode = mode; + _activeMode = activeMode; + _preferredMode = preferredMode; + }); + if (preferredMode != mode) { + IToast.showTop(appLocalizations.setRefreshRateFailed); + } else if (activeMode != mode) { + IToast.showTop( + appLocalizations + .setRefreshRateSuccessWithDisplayModeNotChanged, + ); + } else { + IToast.showTop(appLocalizations.setRefreshRateSuccess); + } + } catch (error, stackTrace) { + IToast.showTop( + appLocalizations + .setRefreshRateFailedWithError(error.toString()), + ); + ILogger.error("Failed to set display mode", error, stackTrace); + } + await getRefreshRate(); + }), + ], + ); } - _privacySettings(bool pinSettled) { + Widget _privacySettings(bool pinSettled) { return CaptionItem( title: appLocalizations.privacySetting, children: [ @@ -152,9 +178,8 @@ class _ExperimentSettingScreenState title: appLocalizations.enableGestureLock, onTap: onEnablePinTapped, ), - Visibility( - visible: _enableGuesturePasswd, - child: EntryItem( + if (_enableGuesturePasswd) + EntryItem( title: pinSettled ? appLocalizations.changeGestureLock : appLocalizations.setGestureLock, @@ -162,53 +187,42 @@ class _ExperimentSettingScreenState pinSettled ? "" : appLocalizations.haveToSetGestureLockTip, onTap: onChangePinTapped, ), - ), - Visibility( - visible: _enableGuesturePasswd && pinSettled && _biometricAvailable, - child: CheckboxItem( + if (_enableGuesturePasswd && pinSettled && _biometricAvailable) + CheckboxItem( value: _enableBiometric, disabled: ResponsiveUtil.isMacOS() || ResponsiveUtil.isLinux(), title: appLocalizations.biometric, description: appLocalizations.biometricUnlockTip, onTap: onBiometricTapped, ), - ), - Visibility( - visible: _enableGuesturePasswd && pinSettled, - child: CheckboxItem( + if (_enableGuesturePasswd && pinSettled) + CheckboxItem( value: _autoLock, title: appLocalizations.autoLock, description: appLocalizations.autoLockTip, onTap: onEnableAutoLockTapped, ), - ), - Visibility( - visible: _enableGuesturePasswd && pinSettled && _autoLock, - child: Selector( + if (_enableGuesturePasswd && pinSettled && _autoLock) + Selector( selector: (context, globalProvider) => globalProvider.autoLockSeconds, - builder: (context, autoLockTime, child) => InlineSelectionItem + InlineSelectionItem>( title: appLocalizations.autoLockDelay, - tip: AppProvider.getAutoLockOptionLabel(autoLockTime), - onTap: () { - BottomSheetBuilder.showListBottomSheet( - context, - (context) => TileList.fromOptions( - AppProvider.getAutoLockOptions(), - (item2) { - appProvider.autoLockSeconds = item2; - Navigator.pop(context); - }, - selected: autoLockTime, - context: context, - title: appLocalizations.chooseAutoLockDelay, - onCloseTap: () => Navigator.pop(context), - ), - ); + items: AppProvider.getAutoLockOptions() + .map((option) => + SelectionItemModel(option.item1, option.item2)) + .toList(), + initItem: SelectionItemModel( + AppProvider.getAutoLockOptionLabel(autoLockTime), + autoLockTime, + ), + hint: appLocalizations.chooseAutoLockDelay, + onChanged: (item) { + if (item != null) appProvider.autoLockSeconds = item.value; }, ), ), - ), CheckboxItem( value: _enableSafeMode, title: appLocalizations.safeMode, @@ -221,15 +235,16 @@ class _ExperimentSettingScreenState ); } - initBiometricAuthentication() async { - LocalAuthentication localAuth = LocalAuthentication(); - bool available = await localAuth.canCheckBiometrics; + Future initBiometricAuthentication() async { + final localAuth = LocalAuthentication(); + final available = await localAuth.canCheckBiometrics; + if (!mounted) return; setState(() { _biometricAvailable = available; }); } - onEnablePinTapped() { + void onEnablePinTapped() { setState(() { RouteUtil.pushPanelCupertinoRoute( context, @@ -250,7 +265,7 @@ class _ExperimentSettingScreenState }); } - onBiometricTapped() { + void onBiometricTapped() { if (!_enableBiometric) { RouteUtil.pushPanelCupertinoRoute( context, @@ -273,7 +288,7 @@ class _ExperimentSettingScreenState } } - onChangePinTapped() { + void onChangePinTapped() { setState(() { RouteUtil.pushPanelCupertinoRoute(context, const PinChangeScreen()); // .then((value) { @@ -286,14 +301,14 @@ class _ExperimentSettingScreenState }); } - onEnableAutoLockTapped() { + void onEnableAutoLockTapped() { setState(() { _autoLock = !_autoLock; ChewieHiveUtil.put(HiveUtil.autoLockKey, _autoLock); }); } - onSafeModeTapped() { + void onSafeModeTapped() { setState(() { _enableSafeMode = !_enableSafeMode; if (ResponsiveUtil.isMobile()) { diff --git a/lib/Screens/Setting/filename_setting_screen.dart b/lib/Screens/Setting/filename_setting_screen.dart index 29417d01..b410fc8b 100644 --- a/lib/Screens/Setting/filename_setting_screen.dart +++ b/lib/Screens/Setting/filename_setting_screen.dart @@ -1,16 +1,22 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Utils/hive_util.dart'; -import 'package:loftify/Utils/itoast.dart'; -import 'package:loftify/Widgets/Custom/no_shadow_scroll_behavior.dart'; import '../../Utils/constant.dart'; import '../../Utils/enums.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; -import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; +import 'base_setting_screen.dart'; -class FilenameSettingScreen extends StatefulWidget { - const FilenameSettingScreen({super.key, this.onSaved}); +class FilenameSettingScreen extends BaseSettingScreen { + const FilenameSettingScreen({ + super.key, + this.onSaved, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); final Function(String)? onSaved; @@ -20,7 +26,8 @@ class FilenameSettingScreen extends StatefulWidget { State createState() => _FilenameSettingScreenState(); } -class _FilenameSettingScreenState extends BaseDynamicState +class _FilenameSettingScreenState + extends BaseDynamicState with TickerProviderStateMixin { final TextEditingController _controller = TextEditingController(); final FocusNode _focusNode = FocusNode(); @@ -35,7 +42,14 @@ class _FilenameSettingScreenState extends BaseDynamicState 1.35; + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.filenameFormat, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + children: [ + CaptionItem( + title: appLocalizations.filenameFormat, + children: [ + InputItem( + focusNode: _focusNode, + hint: appLocalizations.inputFilenameFormat, + textInputAction: TextInputAction.done, + controller: _controller, + onSubmit: (text) { + _save(); + }, + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.widget, + widget: Row( children: [ const SizedBox(width: 5), CircleIconButton( context: context, - icon: const Icon(Icons.refresh_rounded), + icon: const ChewieIcon(LoftifyIcons.refresh), onTap: _reset, ), CircleIconButton( context: context, - icon: const Icon(Icons.save_rounded), + icon: const ChewieIcon(LoftifyIcons.save), onTap: _save, ), ], ), ), - const SizedBox(height: 10), - Wrap( - alignment: WrapAlignment.start, - runAlignment: WrapAlignment.start, - crossAxisAlignment: WrapCrossAlignment.start, - spacing: 10, - runSpacing: 10, - children: [ - RoundIconTextButton( - context, - text: appLocalizations.availableFields, - textStyle: Theme.of(context) - .textTheme - .titleSmall - ?.apply(fontWeightDelta: 2), + ), + Padding( + padding: const EdgeInsets.all(10), + child: Wrap( + alignment: WrapAlignment.start, + runAlignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 10, + runSpacing: 10, + children: [ + RoundIconTextButton( + text: appLocalizations.availableFields, + textStyle: Theme.of(context) + .textTheme + .titleSmall + ?.apply(fontWeightDelta: 2), + radius: 10, + padding: const EdgeInsets.only(top: 8, bottom: 8, left: 8), + background: Colors.transparent, + ), + ...FilenameField.values.map((field) { + return RoundIconTextButton( + text: field.label, + textStyle: Theme.of(context).textTheme.titleSmall, radius: 10, - padding: - const EdgeInsets.only(top: 8, bottom: 8, left: 8), - background: Colors.transparent, - ), - ...FilenameField.values.map((field) { - return RoundIconTextButton( - context, - text: field.label, - textStyle: Theme.of(context).textTheme.titleSmall, - radius: 10, - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - background: Theme.of(context).canvasColor, - onTap: () { - _focusNode.requestFocus(); - final text = _controller.text; - int cursor = _controller.selection.baseOffset; - if (cursor < 0) cursor = text.length; - cursor = cursor.clamp(0, text.length); - final newText = text.substring(0, cursor) + - field.format + - text.substring(cursor); - _controller.text = newText; - _controller.selection = TextSelection.fromPosition( - TextPosition( - offset: cursor + field.format.length)); - }, - ); - }), - ]), - const SizedBox(height: 10), - Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + background: Theme.of(context).canvasColor, + onTap: () { + _focusNode.requestFocus(); + final text = _controller.text; + int cursor = _controller.selection.baseOffset; + if (cursor < 0) cursor = text.length; + cursor = cursor.clamp(0, text.length); + final newText = text.substring(0, cursor) + + field.format + + text.substring(cursor); + _controller.text = newText; + _controller.selection = TextSelection.fromPosition( + TextPosition(offset: cursor + field.format.length)); + }, + ); + }), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), color: Theme.of(context).canvasColor, ), - child: Table( - // defaultColumnWidth: const IntrinsicColumnWidth(), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ - _buildRow([ - appLocalizations.field, - appLocalizations.description, - appLocalizations.example, - ], fontWeightDelta: 2), - ...List.generate( - FilenameField.values.length, - (index) { - return _buildRow([ - FilenameField.values[index].label, - FilenameField.values[index].description, - FilenameField.values[index].example, - ], useBorder: index != FilenameField.values.length - 1); - }, - ), - ], - ), + child: compact + ? Column( + children: List.generate( + FilenameField.values.length, + (index) => _buildCompactField( + FilenameField.values[index], + useBorder: index != FilenameField.values.length - 1, + ), + ), + ) + : Table( + defaultVerticalAlignment: + TableCellVerticalAlignment.middle, + children: [ + _buildRow([ + appLocalizations.field, + appLocalizations.description, + appLocalizations.example, + ], fontWeightDelta: 2), + ...List.generate( + FilenameField.values.length, + (index) { + return _buildRow([ + FilenameField.values[index].label, + FilenameField.values[index].description, + FilenameField.values[index].example, + ], + useBorder: + index != FilenameField.values.length - 1); + }, + ), + ], + ), ), - const SizedBox(height: 16), - ], - ), + ), + ], ), + ], + ); + } + + Widget _buildCompactField( + FilenameField field, { + required bool useBorder, + }) { + final scheme = Theme.of(context).colorScheme; + return Container( + key: ValueKey('filename-field-${field.name}'), + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + border: useBorder + ? Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 0.5, + ), + ) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + field.label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + field.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: 8), + Text( + '${appLocalizations.example}: ${field.example}', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: scheme.primary, + height: 1.35, + ), + ), + ], ), ); } diff --git a/lib/Screens/Setting/general_setting_screen.dart b/lib/Screens/Setting/general_setting_screen.dart index 85a96893..c2a75f05 100644 --- a/lib/Screens/Setting/general_setting_screen.dart +++ b/lib/Screens/Setting/general_setting_screen.dart @@ -1,31 +1,26 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; -import 'package:loftify/Models/github_response.dart'; -import 'package:loftify/Utils/cache_util.dart'; -import 'package:loftify/Utils/itoast.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; import 'package:window_manager/window_manager.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../Utils/app_provider.dart'; import '../../Utils/enums.dart'; -import '../../Utils/file_util.dart'; import '../../Utils/hive_util.dart'; -import '../../Utils/ilogger.dart'; -import '../../Utils/locale_util.dart'; -import '../../Utils/responsive_util.dart'; import '../../Utils/utils.dart'; -import '../../Widgets/BottomSheet/bottom_sheet_builder.dart'; -import '../../Widgets/BottomSheet/list_bottom_sheet.dart'; -import '../../Widgets/Dialog/custom_dialog.dart'; -import '../../Widgets/Dialog/dialog_builder.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; -import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; +import 'base_setting_screen.dart'; -class GeneralSettingScreen extends StatefulWidget { - const GeneralSettingScreen({super.key}); +class GeneralSettingScreen extends BaseSettingScreen { + const GeneralSettingScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/general"; @@ -36,19 +31,21 @@ class GeneralSettingScreen extends StatefulWidget { class GeneralSettingScreenState extends BaseDynamicState with TickerProviderStateMixin { String _cacheSize = ""; - List> _supportedLocaleTuples = []; bool inAppBrowser = ChewieHiveUtil.getBool(HiveUtil.inappWebviewKey); String currentVersion = ""; String latestVersion = ""; ReleaseItem? latestReleaseItem; bool autoCheckUpdate = ChewieHiveUtil.getBool(HiveUtil.autoCheckUpdateKey); - bool enableMinimizeToTray = ChewieHiveUtil.getBool(HiveUtil.enableCloseToTrayKey); - bool recordWindowState = ChewieHiveUtil.getBool(HiveUtil.recordWindowStateKey); - bool enableCloseNotice = ChewieHiveUtil.getBool(HiveUtil.enableCloseNoticeKey); - int doubleTapAction = Utils.patchEnum( + bool enableMinimizeToTray = + ChewieHiveUtil.getBool(HiveUtil.enableCloseToTrayKey); + bool recordWindowState = + ChewieHiveUtil.getBool(HiveUtil.recordWindowStateKey); + bool enableCloseNotice = + ChewieHiveUtil.getBool(HiveUtil.enableCloseNoticeKey); + int doubleTapAction = ChewieUtils.patchEnum( ChewieHiveUtil.getInt(HiveUtil.doubleTapActionKey, defaultValue: 1), DoubleTapAction.values.length); - int downloadSuccessAction = Utils.patchEnum( + int downloadSuccessAction = ChewieUtils.patchEnum( ChewieHiveUtil.getInt(HiveUtil.downloadSuccessActionKey), DownloadSuccessAction.values.length); String _logSize = ""; @@ -57,12 +54,14 @@ class GeneralSettingScreenState extends BaseDynamicState Future getLogSize() async { double size = await FileOutput.getLogsSize(); + if (!mounted) return; setState(() { _logSize = CacheUtil.renderSize(size); }); } - refreshLauchAtStartup() { + void refreshLauchAtStartup() { + if (!mounted) return; setState(() { launchAtStartup = ChewieHiveUtil.getBool(HiveUtil.launchAtStartupKey); }); @@ -72,50 +71,46 @@ class GeneralSettingScreenState extends BaseDynamicState void initState() { super.initState(); getLogSize(); - filterLocale(); if (ResponsiveUtil.isMobile()) getCacheSize(); fetchReleases(false); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - } - void getCacheSize() { CacheUtil.loadCache().then((value) { + if (!mounted) return; setState(() { _cacheSize = value; }); }); } - void filterLocale() { - _supportedLocaleTuples = []; - List locales = S.delegate.supportedLocales; - _supportedLocaleTuples.add(Tuple2(appLocalizations.followSystem, null)); - for (Locale locale in locales) { - dynamic tuple = LocaleUtil.getTuple(locale); - if (tuple != null) { - _supportedLocaleTuples.add(tuple); - } - } + List> get _supportedLocaleItems { + return AppLocalizations.supportedLocales + .map(LocaleUtil.getSelectionItemModel) + .whereType>() + .toList() + ..insert( + 0, + SelectionItemModel(appLocalizations.followSystem, null), + ); } Future fetchReleases(bool showTip) async { setState(() {}); - Utils.getReleases( + ChewieUtils.getReleases( context: context, showLoading: showTip, showUpdateDialog: showTip, showFailedToast: showTip, showLatestToast: showTip, onGetCurrentVersion: (currentVersion) { + if (!mounted) return; setState(() { this.currentVersion = currentVersion; }); }, onGetLatestRelease: (latestVersion, latestReleaseItem) { + if (!mounted) return; setState(() { this.latestVersion = latestVersion; this.latestReleaseItem = latestReleaseItem; @@ -126,321 +121,297 @@ class GeneralSettingScreenState extends BaseDynamicState @override Widget build(BuildContext context) { - return Container( - color: Colors.transparent, - child: Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: ResponsiveAppBar( - showBack: true, - title: appLocalizations.generalSetting, + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.generalSetting, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + children: [ + CaptionItem( context: context, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ), - body: EasyRefresh( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 10), - children: [ - if (ResponsiveUtil.isLandscapeLayout()) const SizedBox(height: 10), - Selector( - selector: (context, globalProvider) => globalProvider.locale, - builder: (context, locale, child) => EntryItem( - context: context, - title: appLocalizations.language, - tip: LocaleUtil.getLabel(locale)!, - roundTop: true, - roundBottom: true, - onTap: () { - filterLocale(); - BottomSheetBuilder.showListBottomSheet( - context, - (context) => TileList.fromOptions( - _supportedLocaleTuples, - (item2) { - appProvider.locale = item2; - Navigator.pop(context); - }, - selected: locale, - context: context, - title: appLocalizations.chooseLanguage, - onCloseTap: () => Navigator.pop(context), - ), - ); - }, + title: appLocalizations.language, + children: [ + Selector( + selector: (context, globalProvider) => globalProvider.locale, + builder: (context, locale, child) => + InlineSelectionItem>( + title: appLocalizations.language, + items: _supportedLocaleItems, + initItem: SelectionItemModel( + locale == null + ? appLocalizations.followSystem + : LocaleUtil.getLabel(locale) ?? locale.toLanguageTag(), + locale, ), - ), - const SizedBox(height: 10), - CaptionItem( - context: context, title: appLocalizations.operationSetting), - EntryItem( - context: context, - title: appLocalizations.doubleTapInDetailPage, - tip: DoubleTapAction.values[doubleTapAction].label, - onTap: () { - BottomSheetBuilder.showListBottomSheet( - context, - (sheetContext) => TileList.fromOptions( - DoubleTapAction.like.tuples, - (newAction) { - Navigator.pop(sheetContext); - setState(() { - doubleTapAction = newAction.index; - ChewieHiveUtil.put( - HiveUtil.doubleTapActionKey, doubleTapAction); - }); - }, - selected: DoubleTapAction.values[doubleTapAction], - title: appLocalizations.chooseDoubleTapInDetailPage, - context: context, - onCloseTap: () => Navigator.pop(sheetContext), - crossAxisAlignment: CrossAxisAlignment.start, - ), - ); - }, - ), - EntryItem( - context: context, - title: appLocalizations.afterDownloadSuccess, - description: appLocalizations.afterDownloadSuccessDescription, - roundBottom: true, - tip: DownloadSuccessAction.values[downloadSuccessAction].label, - onTap: () { - BottomSheetBuilder.showListBottomSheet( - context, - (sheetContext) => TileList.fromOptions( - DownloadSuccessAction.unlike.tuples, - (newAction) { - Navigator.pop(sheetContext); - setState(() { - downloadSuccessAction = newAction.index; - ChewieHiveUtil.put(HiveUtil.downloadSuccessActionKey, - downloadSuccessAction); - }); - }, - selected: - DownloadSuccessAction.values[downloadSuccessAction], - title: appLocalizations.chooseAfterDownloadSuccess, - context: context, - onCloseTap: () => Navigator.pop(sheetContext), - crossAxisAlignment: CrossAxisAlignment.start, - ), - ); + hint: appLocalizations.chooseLanguage, + onChanged: (item) { + if (item != null) appProvider.locale = item.value; }, ), - if (ResponsiveUtil.isDesktop()) ..._desktopSetting(), - if (ResponsiveUtil.isMobile()) ..._mobileSetting(), - const SizedBox(height: 10), - CheckboxItem( - value: autoCheckUpdate, - roundTop: true, - context: context, - title: appLocalizations.autoCheckUpdates, - onTap: () { - setState(() { - autoCheckUpdate = !autoCheckUpdate; - ChewieHiveUtil.put(HiveUtil.autoCheckUpdateKey, autoCheckUpdate); - }); - }, + ), + ], + ), + CaptionItem( + context: context, + title: appLocalizations.operationSetting, + children: [ + InlineSelectionItem>( + title: appLocalizations.doubleTapInDetailPage, + items: DoubleTapAction.values + .map((action) => SelectionItemModel(action.label, action)) + .toList(), + initItem: SelectionItemModel( + DoubleTapAction.values[doubleTapAction].label, + DoubleTapAction.values[doubleTapAction], ), - EntryItem( - context: context, - title: appLocalizations.checkUpdates, - roundBottom: true, - description: - Utils.compareVersion(latestVersion, currentVersion) > 0 - ? appLocalizations.newVersion(latestVersion) - : appLocalizations.alreadyLatestVersion, - descriptionColor: - Utils.compareVersion(latestVersion, currentVersion) > 0 - ? Colors.redAccent - : null, - tip: currentVersion, - onTap: () { - fetchReleases(true); - }, + hint: appLocalizations.chooseDoubleTapInDetailPage, + onChanged: (item) { + if (item == null) return; + setState(() { + doubleTapAction = item.value.index; + ChewieHiveUtil.put( + HiveUtil.doubleTapActionKey, doubleTapAction); + }); + }, + ), + InlineSelectionItem>( + title: appLocalizations.afterDownloadSuccess, + description: appLocalizations.afterDownloadSuccessDescription, + items: DownloadSuccessAction.values + .map((action) => SelectionItemModel(action.label, action)) + .toList(), + initItem: SelectionItemModel( + DownloadSuccessAction.values[downloadSuccessAction].label, + DownloadSuccessAction.values[downloadSuccessAction], ), - ..._logSetting(), - const SizedBox(height: 10), - ], - ), + hint: appLocalizations.chooseAfterDownloadSuccess, + onChanged: (item) { + if (item == null) return; + setState(() { + downloadSuccessAction = item.value.index; + ChewieHiveUtil.put( + HiveUtil.downloadSuccessActionKey, downloadSuccessAction); + }); + }, + ), + ], ), - ), + if (ResponsiveUtil.isDesktop()) ..._desktopSetting(), + if (ResponsiveUtil.isMobile()) ..._mobileSetting(), + CaptionItem( + context: context, + title: appLocalizations.checkUpdates, + children: [ + CheckboxItem( + value: autoCheckUpdate, + context: context, + title: appLocalizations.autoCheckUpdates, + onTap: () { + setState(() { + autoCheckUpdate = !autoCheckUpdate; + ChewieHiveUtil.put( + HiveUtil.autoCheckUpdateKey, autoCheckUpdate); + }); + }, + ), + EntryItem( + context: context, + title: appLocalizations.checkUpdates, + description: + ChewieUtils.compareVersion(latestVersion, currentVersion) > 0 + ? appLocalizations.newVersion(latestVersion) + : appLocalizations.alreadyLatestVersion, + descriptionColor: + ChewieUtils.compareVersion(latestVersion, currentVersion) > 0 + ? context.design.colors.warning + : null, + tip: currentVersion, + onTap: () { + fetchReleases(true); + }, + ), + ], + ), + ..._logSetting(), + ], ); } - _mobileSetting() { + List _mobileSetting() { return [ - const SizedBox(height: 10), - CheckboxItem( - value: inAppBrowser, - context: context, - title: appLocalizations.inAppBrowser, - roundTop: true, - onTap: () { - setState(() { - inAppBrowser = !inAppBrowser; - ChewieHiveUtil.put(HiveUtil.inappWebviewKey, inAppBrowser); - }); - }, - ), - EntryItem( + CaptionItem( context: context, - title: appLocalizations.clearCache, - roundBottom: true, - tip: _cacheSize, - onTap: () { - CustomLoadingDialog.showLoading(title: appLocalizations.clearingCache); - getTemporaryDirectory().then((tempDir) { - CacheUtil.delDir(tempDir).then((value) { - CacheUtil.loadCache().then((value) { - setState(() { - _cacheSize = value; - CustomLoadingDialog.dismissLoading(); - IToast.showTop(appLocalizations.clearCacheSuccess); + title: appLocalizations.mobileSetting, + children: [ + CheckboxItem( + value: inAppBrowser, + context: context, + title: appLocalizations.inAppBrowser, + onTap: () { + setState(() { + inAppBrowser = !inAppBrowser; + ChewieHiveUtil.put(HiveUtil.inappWebviewKey, inAppBrowser); + }); + }, + ), + EntryItem( + context: context, + title: appLocalizations.clearCache, + tip: _cacheSize, + onTap: () { + CustomLoadingDialog.showLoading( + title: appLocalizations.clearingCache); + getTemporaryDirectory().then((tempDir) { + CacheUtil.delDir(tempDir).then((value) { + CacheUtil.loadCache().then((value) { + if (!mounted) { + CustomLoadingDialog.dismissLoading(); + return; + } + setState(() { + _cacheSize = value; + CustomLoadingDialog.dismissLoading(); + IToast.showTop(appLocalizations.clearCacheSuccess); + }); + }); }); }); - }); - }); - }, + }, + ), + ], ), ]; } - _logSetting() { + List _logSetting() { return [ - const SizedBox(height: 10), - EntryItem( - context: context, - title: appLocalizations.exportLog, - description: appLocalizations.exportLogHint, - roundTop: true, - onTap: () { - FileUtil.exportLogs(); - }, - ), - EntryItem( + CaptionItem( context: context, - title: appLocalizations.clearLog, - roundBottom: true, - tip: _logSize, - onTap: () async { - DialogBuilder.showConfirmDialog( - context, - title: appLocalizations.clearLogTitle, - message: appLocalizations.clearLogHint, - onTapConfirm: () async { - CustomLoadingDialog.showLoading(title: appLocalizations.clearingLog); - try { - await FileOutput.clearLogs(); - await getLogSize(); - IToast.showTop(appLocalizations.clearLogSuccess); - } catch (e, t) { - ILogger.error("Failed to clear logs", e, t); - IToast.showTop(appLocalizations.clearLogFailed); - } finally { - CustomLoadingDialog.dismissLoading(); - } + title: appLocalizations.other, + children: [ + EntryItem( + context: context, + title: appLocalizations.exportLog, + description: appLocalizations.exportLogHint, + onTap: () { + FileUtil.exportLogs(); }, - ); - }, + ), + EntryItem( + context: context, + title: appLocalizations.clearLog, + tip: _logSize, + onTap: () async { + DialogBuilder.showConfirmDialog( + context, + title: appLocalizations.clearLogTitle, + message: appLocalizations.clearLogHint, + onTapConfirm: () async { + CustomLoadingDialog.showLoading( + title: appLocalizations.clearingLog); + try { + await FileOutput.clearLogs(); + await getLogSize(); + IToast.showTop(appLocalizations.clearLogSuccess); + } catch (e, t) { + ILogger.error("Failed to clear logs", e, t); + IToast.showTop(appLocalizations.clearLogFailed); + } finally { + CustomLoadingDialog.dismissLoading(); + } + }, + ); + }, + ), + ], ), ]; } - _desktopSetting() { + List _desktopSetting() { return [ - const SizedBox(height: 10), CaptionItem( - context: context, title: appLocalizations.desktopSetting), - CheckboxItem( context: context, - title: appLocalizations.launchAtStartup, - value: launchAtStartup, - onTap: () async { - setState(() { - launchAtStartup = !launchAtStartup; - ChewieHiveUtil.put(HiveUtil.launchAtStartupKey, launchAtStartup); - }); - if (launchAtStartup) { - await LaunchAtStartup.instance.enable(); - } else { - await LaunchAtStartup.instance.disable(); - } - Utils.initTray(); - }, - ), - CheckboxItem( - context: context, - title: appLocalizations.showTray, - value: showTray, - onTap: () async { - setState(() { - showTray = !showTray; - ChewieHiveUtil.put(HiveUtil.showTrayKey, showTray); - if (showTray) { + title: appLocalizations.desktopSetting, + children: [ + CheckboxItem( + context: context, + title: appLocalizations.launchAtStartup, + value: launchAtStartup, + onTap: () async { + setState(() { + launchAtStartup = !launchAtStartup; + ChewieHiveUtil.put( + HiveUtil.launchAtStartupKey, launchAtStartup); + }); + if (launchAtStartup) { + await LaunchAtStartup.instance.enable(); + } else { + await LaunchAtStartup.instance.disable(); + } Utils.initTray(); - } else { - Utils.removeTray(); - } - }); - }, - ), - Visibility( - visible: showTray, - child: EntryItem( - context: context, - title: appLocalizations.closeWindowOption, - tip: enableMinimizeToTray - ? appLocalizations.minimizeToTray - : appLocalizations.exitApp, - onTap: () { - List> options = [ - Tuple2(appLocalizations.minimizeToTray, 0), - Tuple2(appLocalizations.exitApp, 1), - ]; - BottomSheetBuilder.showListBottomSheet( - context, - (sheetContext) => TileList.fromOptions( - options, - (idx) { - Navigator.pop(sheetContext); - if (idx == 0) { - setState(() { - enableMinimizeToTray = true; - ChewieHiveUtil.put( - HiveUtil.enableCloseToTrayKey, enableMinimizeToTray); - }); - } else if (idx == 1) { - setState(() { - enableMinimizeToTray = false; - ChewieHiveUtil.put( - HiveUtil.enableCloseToTrayKey, enableMinimizeToTray); - }); - } - }, - selected: enableMinimizeToTray ? 0 : 1, - title: appLocalizations.chooseCloseWindowOption, - context: context, - onCloseTap: () => Navigator.pop(sheetContext), - crossAxisAlignment: CrossAxisAlignment.start, + }, + ), + CheckboxItem( + context: context, + title: appLocalizations.showTray, + value: showTray, + onTap: () async { + setState(() { + showTray = !showTray; + ChewieHiveUtil.put(HiveUtil.showTrayKey, showTray); + if (showTray) { + Utils.initTray(); + } else { + Utils.removeTray(); + } + }); + }, + ), + Visibility( + visible: showTray, + child: InlineSelectionItem>( + title: appLocalizations.closeWindowOption, + items: [ + SelectionItemModel(appLocalizations.minimizeToTray, true), + SelectionItemModel(appLocalizations.exitApp, false), + ], + initItem: SelectionItemModel( + enableMinimizeToTray + ? appLocalizations.minimizeToTray + : appLocalizations.exitApp, + enableMinimizeToTray, ), - ); - }, - ), - ), - CheckboxItem( - context: context, - title: appLocalizations.autoMemoryWindowPositionAndSize, - value: recordWindowState, - description: appLocalizations.autoMemoryWindowPositionAndSizeTip, - roundBottom: true, - onTap: () async { - setState(() { - recordWindowState = !recordWindowState; - ChewieHiveUtil.put(HiveUtil.recordWindowStateKey, recordWindowState); - }); - HiveUtil.setWindowSize(await windowManager.getSize()); - HiveUtil.setWindowPosition(await windowManager.getPosition()); - }, + hint: appLocalizations.chooseCloseWindowOption, + onChanged: (item) { + if (item == null) return; + setState(() { + enableMinimizeToTray = item.value; + ChewieHiveUtil.put( + HiveUtil.enableCloseToTrayKey, + enableMinimizeToTray, + ); + }); + }, + ), + ), + CheckboxItem( + context: context, + title: appLocalizations.autoMemoryWindowPositionAndSize, + value: recordWindowState, + description: appLocalizations.autoMemoryWindowPositionAndSizeTip, + onTap: () async { + setState(() { + recordWindowState = !recordWindowState; + ChewieHiveUtil.put( + HiveUtil.recordWindowStateKey, recordWindowState); + }); + HiveUtil.setWindowSize(await windowManager.getSize()); + HiveUtil.setWindowPosition(await windowManager.getPosition()); + }, + ), + ], ), ]; } diff --git a/lib/Screens/Setting/image_setting_screen.dart b/lib/Screens/Setting/image_setting_screen.dart index 00b56850..b2f53725 100644 --- a/lib/Screens/Setting/image_setting_screen.dart +++ b/lib/Screens/Setting/image_setting_screen.dart @@ -1,21 +1,23 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Screens/Setting/filename_setting_screen.dart'; import 'package:loftify/Utils/enums.dart'; -import 'package:loftify/Utils/route_util.dart'; import '../../Utils/cloud_control_provider.dart'; import '../../Utils/constant.dart'; import '../../Utils/hive_util.dart'; -import '../../Utils/responsive_util.dart'; -import '../../Widgets/BottomSheet/bottom_sheet_builder.dart'; -import '../../Widgets/BottomSheet/list_bottom_sheet.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; -import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; +import 'base_setting_screen.dart'; -class ImageSettingScreen extends StatefulWidget { - const ImageSettingScreen({super.key}); +class ImageSettingScreen extends BaseSettingScreen { + const ImageSettingScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/image"; @@ -41,227 +43,187 @@ class _ImageSettingScreenState extends BaseDynamicState defaultValue: defaultFilenameFormat) ?? defaultFilenameFormat; - @override - void initState() { - super.initState(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - } - - showImageQualitySelect({ - Function(ImageQuality)? onSelected, - dynamic selected, - required String title, - }) { - BottomSheetBuilder.showListBottomSheet( - context, - (context) => TileList.fromOptions( - EnumsLabelGetter.getImageQualityLabels(), - (item2) { - onSelected?.call(item2); - Navigator.pop(context); - }, - selected: selected, - context: context, - title: title, - onCloseTap: () => Navigator.pop(context), - ), - ); - } - @override Widget build(BuildContext context) { bool showImageQualitySettings = controlProvider.globalControl.showImageQualitySettings; bool showBigImageSettings = controlProvider.globalControl.showBigImageSettings; - return Container( - color: Colors.transparent, - child: Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: ResponsiveAppBar( - showBack: true, - title: appLocalizations.imageSetting, + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.imageSetting, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + children: [ + if (showImageQualitySettings) ..._imageQualitySettings(), + if (showBigImageSettings) ..._bigImageSettings(), + CaptionItem( context: context, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ), - body: EasyRefresh( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 10), - children: [ - if (ResponsiveUtil.isLandscapeLayout()) const SizedBox(height: 10), - if (showImageQualitySettings) ..._imageQualitySettings(), - if (showImageQualitySettings) const SizedBox(height: 10), - if (showBigImageSettings) ..._bigImageSettings(), - if (showImageQualitySettings || showBigImageSettings) - const SizedBox(height: 10), - CaptionItem( - context: context, title: appLocalizations.downloadImageSetting), - EntryItem( - context: context, - title: appLocalizations.downloadImagePath, - description: savePath ?? "", - tip: appLocalizations.edit, - onTap: () async { - String? selectedDirectory = - await FilePicker.platform.getDirectoryPath( - dialogTitle: appLocalizations.chooseDownloadImagePath, - lockParentWindow: true, - ); - if (selectedDirectory != null) { + title: appLocalizations.downloadImageSetting, + children: [ + EntryItem( + context: context, + title: appLocalizations.downloadImagePath, + description: savePath ?? "", + tip: appLocalizations.edit, + onTap: () async { + String? selectedDirectory = + await FilePicker.platform.getDirectoryPath( + dialogTitle: appLocalizations.chooseDownloadImagePath, + lockParentWindow: true, + ); + if (selectedDirectory != null) { + setState(() { + savePath = selectedDirectory; + ChewieHiveUtil.put(HiveUtil.savePathKey, selectedDirectory); + }); + } + }, + ), + EntryItem( + context: context, + title: appLocalizations.filenameFormat, + description: _filenameFormat, + tip: appLocalizations.edit, + onTap: () { + var page = FilenameSettingScreen( + onSaved: (newFormat) { setState(() { - savePath = selectedDirectory; - ChewieHiveUtil.put(HiveUtil.savePathKey, selectedDirectory); + _filenameFormat = newFormat; }); - } - }, - ), - EntryItem( - context: context, - title: appLocalizations.filenameFormat, - description: _filenameFormat, - tip: appLocalizations.edit, - roundBottom: true, - onTap: () { - var page = FilenameSettingScreen( - onSaved: (newFormat) { - setState(() { - _filenameFormat = newFormat; - }); - }, - ); - RouteUtil.pushPanelCupertinoRoute(context, page); - }, - ), - const SizedBox(height: 30), - ], - ), + }, + ); + RouteUtil.pushPanelCupertinoRoute(context, page); + }, + ), + ], ), + ], + ); + } + + List> get _imageQualityOptions => + EnumsLabelGetter.getImageQualityLabels() + .map((option) => SelectionItemModel(option.item1, option.item2)) + .toList(); + + Widget _buildImageQualitySelection({ + required String title, + String description = "", + required String hint, + required ImageQuality selected, + required ValueChanged onChanged, + }) { + return InlineSelectionItem>( + title: title, + description: description, + items: _imageQualityOptions, + initItem: SelectionItemModel( + EnumsLabelGetter.getImageQualityLabel(selected), + selected, ), + hint: hint, + onChanged: (item) { + if (item != null) onChanged(item.value); + }, ); } - _imageQualitySettings() { + List _imageQualitySettings() { return [ CaptionItem( - context: context, title: appLocalizations.imageQuality), - EntryItem( context: context, - title: appLocalizations.waterfallFlowImageQuality, - tip: EnumsLabelGetter.getImageQualityLabel(waterfallFlowImageQuality), - onTap: () { - showImageQualitySelect( - onSelected: (quality) { + title: appLocalizations.imageQuality, + children: [ + _buildImageQualitySelection( + title: appLocalizations.waterfallFlowImageQuality, + hint: appLocalizations.chooseWaterfallFlowImageQuality, + selected: waterfallFlowImageQuality, + onChanged: (quality) { setState(() { waterfallFlowImageQuality = quality; ChewieHiveUtil.put( HiveUtil.waterfallFlowImageQualityKey, quality.index); }); }, - selected: waterfallFlowImageQuality, - title: appLocalizations.chooseWaterfallFlowImageQuality, - ); - }, - ), - EntryItem( - context: context, - title: appLocalizations.postDetailImageQuality, - tip: EnumsLabelGetter.getImageQualityLabel(postDetailImageQuality), - onTap: () { - showImageQualitySelect( - onSelected: (quality) { + ), + _buildImageQualitySelection( + title: appLocalizations.postDetailImageQuality, + hint: appLocalizations.choosePostDetailImageQuality, + selected: postDetailImageQuality, + onChanged: (quality) { setState(() { postDetailImageQuality = quality; - ChewieHiveUtil.put(HiveUtil.postDetailImageQualityKey, quality.index); + ChewieHiveUtil.put( + HiveUtil.postDetailImageQualityKey, quality.index); }); }, - selected: postDetailImageQuality, - title: appLocalizations.choosePostDetailImageQuality, - ); - }, - ), - EntryItem( - context: context, - title: appLocalizations.bigImageQuality, - tip: EnumsLabelGetter.getImageQualityLabel(imageDetailImageQuality), - roundBottom: true, - onTap: () { - showImageQualitySelect( - onSelected: (quality) { + ), + _buildImageQualitySelection( + title: appLocalizations.bigImageQuality, + hint: appLocalizations.chooseBigImageQuality, + selected: imageDetailImageQuality, + onChanged: (quality) { setState(() { imageDetailImageQuality = quality; ChewieHiveUtil.put( HiveUtil.imageDetailImageQualityKey, quality.index); }); }, - selected: imageDetailImageQuality, - title: appLocalizations.chooseBigImageQuality, - ); - }, + ), + ], ), ]; } - _bigImageSettings() { + List _bigImageSettings() { return [ CaptionItem( - context: context, title: appLocalizations.bigImageSetting), - CheckboxItem( - value: followMainColor, context: context, - title: appLocalizations.backgroundColorFollowMainColor, - onTap: () { - setState(() { - followMainColor = !followMainColor; - ChewieHiveUtil.put( - HiveUtil.followMainColorKey, - followMainColor, - ); - }); - }, - ), - EntryItem( - context: context, - title: appLocalizations.tapLinkButton, - tip: EnumsLabelGetter.getImageQualityLabel(tapLinkButtonImageQuality), - description: appLocalizations.tapLinkButtonDescription, - onTap: () { - showImageQualitySelect( - onSelected: (quality) { + title: appLocalizations.bigImageSetting, + children: [ + CheckboxItem( + value: followMainColor, + context: context, + title: appLocalizations.backgroundColorFollowMainColor, + onTap: () { + setState(() { + followMainColor = !followMainColor; + ChewieHiveUtil.put( + HiveUtil.followMainColorKey, + followMainColor, + ); + }); + }, + ), + _buildImageQualitySelection( + title: appLocalizations.tapLinkButton, + description: appLocalizations.tapLinkButtonDescription, + hint: appLocalizations.chooseTapLinkButton, + selected: tapLinkButtonImageQuality, + onChanged: (quality) { setState(() { tapLinkButtonImageQuality = quality; ChewieHiveUtil.put( HiveUtil.tapLinkButtonImageQualityKey, quality.index); }); }, - selected: tapLinkButtonImageQuality, - title: appLocalizations.chooseTapLinkButton, - ); - }, - ), - EntryItem( - context: context, - title: appLocalizations.longPressLinkButton, - tip: EnumsLabelGetter.getImageQualityLabel( - longPressLinkButtonImageQuality), - description: appLocalizations.longPressLinkButtonDescription, - roundBottom: true, - onTap: () { - showImageQualitySelect( - onSelected: (quality) { + ), + _buildImageQualitySelection( + title: appLocalizations.longPressLinkButton, + description: appLocalizations.longPressLinkButtonDescription, + hint: appLocalizations.chooseLongPressLinkButton, + selected: longPressLinkButtonImageQuality, + onChanged: (quality) { setState(() { longPressLinkButtonImageQuality = quality; ChewieHiveUtil.put( HiveUtil.longPressLinkButtonImageQualityKey, quality.index); }); }, - selected: longPressLinkButtonImageQuality, - title: appLocalizations.chooseLongPressLinkButton, - ); - }, + ), + ], ), ]; } diff --git a/lib/Screens/Setting/lofter_basic_setting_screen.dart b/lib/Screens/Setting/lofter_basic_setting_screen.dart index 11d7b6bb..41c43127 100644 --- a/lib/Screens/Setting/lofter_basic_setting_screen.dart +++ b/lib/Screens/Setting/lofter_basic_setting_screen.dart @@ -1,22 +1,22 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; -import 'package:loftify/Utils/uri_util.dart'; -import 'package:loftify/Widgets/Dialog/custom_dialog.dart'; import '../../Api/setting_api.dart'; import '../../Api/user_api.dart'; import '../../Models/account_response.dart'; import '../../Utils/app_provider.dart'; import '../../Utils/hive_util.dart'; -import '../../Utils/ilogger.dart'; -import '../../Utils/itoast.dart'; -import '../../Utils/responsive_util.dart'; -import '../../Widgets/Dialog/dialog_builder.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; -import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; +import 'base_setting_screen.dart'; -class LofterBasicSettingScreen extends StatefulWidget { - const LofterBasicSettingScreen({super.key}); +class LofterBasicSettingScreen extends BaseSettingScreen { + const LofterBasicSettingScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/lofterBasic"; @@ -25,7 +25,8 @@ class LofterBasicSettingScreen extends StatefulWidget { _LofterBasicSettingScreenState(); } -class _LofterBasicSettingScreenState extends BaseDynamicState +class _LofterBasicSettingScreenState + extends BaseDynamicState with TickerProviderStateMixin { bool acceptGiftFlag = true; bool showReturnGiftPreviewImg = true; @@ -148,14 +149,13 @@ class _LofterBasicSettingScreenState extends BaseDynamicState ListView( physics: physics, - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: widget.padding, children: [ - if (ResponsiveUtil.isLandscapeLayout()) const SizedBox(height: 10), - CheckboxItem( - value: personalRecommend, - context: context, - title: appLocalizations.personalizedService, - roundTop: true, - roundBottom: true, - description: appLocalizations.personalizedServiceDescription, - onTap: () { - SettingApi.updatePersonalRecommendSetting( - isEnable: !personalRecommend, - ).then((value) { - if (value['meta']['status'] == 200) { - personalRecommend = !personalRecommend; - setState(() {}); - } else { - IToast.showTop( - value['meta']['desc'] ?? value['meta']['msg']); - } - }); - }, - ), - const SizedBox(height: 10), CaptionItem( - context: context, title: appLocalizations.copyrightProtection), - CheckboxItem( - value: appimagestamp, context: context, - title: appLocalizations.copyrightWatermark, - description: appLocalizations.copyrightWatermarkDescription, - onTap: () { - _updateCopyRightSetting( - copyRightType: CopyRightType.appimagestamp, - isClose: appimagestamp, - onSuccess: () { - appimagestamp = !appimagestamp; + title: appLocalizations.basicSetting, + children: [ + CheckboxItem( + value: personalRecommend, + context: context, + title: appLocalizations.personalizedService, + description: appLocalizations.personalizedServiceDescription, + onTap: () { + SettingApi.updatePersonalRecommendSetting( + isEnable: !personalRecommend, + ).then((value) { + if (value['meta']['status'] == 200) { + personalRecommend = !personalRecommend; + setState(() {}); + } else { + IToast.showTop( + value['meta']['desc'] ?? value['meta']['msg']); + } + }); }, - ); - }, + ), + ], ), - CheckboxItem( - value: imageprotection, + CaptionItem( context: context, - title: appLocalizations.workProtection, - description: appLocalizations.workProtectionDescription, - onTap: () { - _updateCopyRightSetting( - copyRightType: CopyRightType.imageprotection, - isClose: imageprotection, - onSuccess: () { - imageprotection = !imageprotection; + title: appLocalizations.copyrightProtection, + children: [ + CheckboxItem( + value: appimagestamp, + context: context, + title: appLocalizations.copyrightWatermark, + description: appLocalizations.copyrightWatermarkDescription, + onTap: () { + _updateCopyRightSetting( + copyRightType: CopyRightType.appimagestamp, + isClose: appimagestamp, + onSuccess: () { + appimagestamp = !appimagestamp; + }, + ); }, - ); - }, - ), - CheckboxItem( - value: videoprotection, - context: context, - roundBottom: true, - title: appLocalizations.shareVideoProtection, - description: appLocalizations.shareVideoProtectionDescription, - onTap: () { - _updateCopyRightSetting( - copyRightType: CopyRightType.videoprotection, - isClose: videoprotection, - onSuccess: () { - videoprotection = !videoprotection; + ), + CheckboxItem( + value: imageprotection, + context: context, + title: appLocalizations.workProtection, + description: appLocalizations.workProtectionDescription, + onTap: () { + _updateCopyRightSetting( + copyRightType: CopyRightType.imageprotection, + isClose: imageprotection, + onSuccess: () { + imageprotection = !imageprotection; + }, + ); }, - ); - }, + ), + CheckboxItem( + value: videoprotection, + context: context, + title: appLocalizations.shareVideoProtection, + description: appLocalizations.shareVideoProtectionDescription, + onTap: () { + _updateCopyRightSetting( + copyRightType: CopyRightType.videoprotection, + isClose: videoprotection, + onSuccess: () { + videoprotection = !videoprotection; + }, + ); + }, + ), + ], ), - const SizedBox(height: 10), CaptionItem( - context: context, title: appLocalizations.giftSetting), - CheckboxItem( - value: acceptGiftFlag, - context: context, - title: appLocalizations.acceptGift, - onTap: () { - SettingApi.updateGiftSetting( - acceptGiftFlag: !acceptGiftFlag, - showReturnGiftPreviewImg: showReturnGiftPreviewImg, - ).then((value) { - if (value['code'] == 200) { - acceptGiftFlag = !acceptGiftFlag; - setState(() {}); - } else { - IToast.showTop(value['msg']); - } - }); - }, - ), - CheckboxItem( - value: showReturnGiftPreviewImg, - roundBottom: true, context: context, - title: appLocalizations.imageBlurPreview, - onTap: () { - SettingApi.updateGiftSetting( - acceptGiftFlag: acceptGiftFlag, - showReturnGiftPreviewImg: !showReturnGiftPreviewImg, - ).then((value) { - if (value['code'] == 200) { - showReturnGiftPreviewImg = !showReturnGiftPreviewImg; - setState(() {}); - } else { - IToast.showTop(value['msg']); - } - }); - }, + title: appLocalizations.giftSetting, + children: [ + CheckboxItem( + value: acceptGiftFlag, + context: context, + title: appLocalizations.acceptGift, + onTap: () { + SettingApi.updateGiftSetting( + acceptGiftFlag: !acceptGiftFlag, + showReturnGiftPreviewImg: showReturnGiftPreviewImg, + ).then((value) { + if (value['code'] == 200) { + acceptGiftFlag = !acceptGiftFlag; + setState(() {}); + } else { + IToast.showTop(value['msg']); + } + }); + }, + ), + CheckboxItem( + value: showReturnGiftPreviewImg, + context: context, + title: appLocalizations.imageBlurPreview, + onTap: () { + SettingApi.updateGiftSetting( + acceptGiftFlag: acceptGiftFlag, + showReturnGiftPreviewImg: !showReturnGiftPreviewImg, + ).then((value) { + if (value['code'] == 200) { + showReturnGiftPreviewImg = !showReturnGiftPreviewImg; + setState(() {}); + } else { + IToast.showTop(value['msg']); + } + }); + }, + ), + ], ), const SizedBox(height: 30), ], diff --git a/lib/Screens/Setting/mobile_setting_navigation_screen.dart b/lib/Screens/Setting/mobile_setting_navigation_screen.dart index d6d38b6e..1c0766c4 100644 --- a/lib/Screens/Setting/mobile_setting_navigation_screen.dart +++ b/lib/Screens/Setting/mobile_setting_navigation_screen.dart @@ -14,19 +14,26 @@ */ import 'package:awesome_chewie/awesome_chewie.dart'; -import 'package:cloudotp/Screens/Setting/setting_appearance_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_backup_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_general_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_operation_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_safe_screen.dart'; import 'package:flutter/material.dart'; -import 'package:lucide_icons/lucide_icons.dart'; import '../../Utils/app_provider.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; +import 'apperance_setting_screen.dart'; +import 'experiment_setting_screen.dart'; +import 'general_setting_screen.dart'; +import 'image_setting_screen.dart'; +import 'lofter_basic_setting_screen.dart'; +import 'base_setting_screen.dart'; -class MobileSettingNavigationScreen extends StatefulWidget { - const MobileSettingNavigationScreen({super.key}); +class MobileSettingNavigationScreen extends BaseSettingScreen { + const MobileSettingNavigationScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/navigation"; @@ -36,85 +43,79 @@ class MobileSettingNavigationScreen extends StatefulWidget { } class _MobileSettingNavigationScreenState - extends BaseDynamicState - with TickerProviderStateMixin { - @override - void initState() { - super.initState(); - } - - @override + extends BaseDynamicState { @override Widget build(BuildContext context) { - return Container( - color: Colors.transparent, - child: Scaffold( - appBar: ResponsiveAppBar( + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.setting, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + children: [ + CaptionItem( + context: context, title: appLocalizations.setting, - showBack: true, - showBorder: true, - actions: const [ - BlankIconButton(), - SizedBox(width: 5), + children: [ + EntryItem( + title: appLocalizations.generalSetting, + leading: LoftifyIcons.generalSettings, + showLeading: true, + onTap: () { + RouteUtil.pushCupertinoRoute( + context, + GeneralSettingScreen(key: generalSettingScreenKey), + ); + }, + ), + EntryItem( + title: appLocalizations.appearanceSetting, + leading: LoftifyIcons.appearance, + showLeading: true, + onTap: () { + RouteUtil.pushCupertinoRoute( + context, + const AppearanceSettingScreen(), + ); + }, + ), + EntryItem( + title: appLocalizations.imageSetting, + leading: LoftifyIcons.image, + showLeading: true, + onTap: () { + RouteUtil.pushCupertinoRoute( + context, + const ImageSettingScreen(), + ); + }, + ), + EntryItem( + title: appLocalizations.lofterBasicSetting, + leading: LoftifyIcons.basicSettings, + showLeading: true, + onTap: () { + RouteUtil.pushCupertinoRoute( + context, + const LofterBasicSettingScreen(), + ); + }, + ), + EntryItem( + title: appLocalizations.experimentSetting, + leading: LoftifyIcons.experiment, + showLeading: true, + onTap: () { + RouteUtil.pushCupertinoRoute( + context, + const ExperimentSettingScreen(), + ); + }, + ), ], ), - body: EasyRefresh( - child: ListView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 10), - children: [ - EntryItem( - title: appLocalizations.generalSetting, - leading: LucideIcons.settings2, - showLeading: true, - onTap: () { - RouteUtil.pushCupertinoRoute( - context, - GeneralSettingScreen(key: generalSettingScreenKey), - ); - }, - ), - EntryItem( - title: appLocalizations.appearanceSetting, - leading: LucideIcons.paintbrushVertical, - showLeading: true, - onTap: () { - RouteUtil.pushCupertinoRoute( - context, const AppearanceSettingScreen()); - }, - ), - EntryItem( - title: appLocalizations.operationSetting, - leading: LucideIcons.pointer, - showLeading: true, - onTap: () { - RouteUtil.pushCupertinoRoute( - context, const OperationSettingScreen()); - }, - ), - EntryItem( - title: appLocalizations.backupSetting, - leading: LucideIcons.cloudUpload, - showLeading: true, - onTap: () { - RouteUtil.pushCupertinoRoute( - context, const BackupSettingScreen()); - }, - ), - EntryItem( - title: appLocalizations.safeSetting, - leading: LucideIcons.shieldCheck, - showLeading: true, - onTap: () { - RouteUtil.pushCupertinoRoute( - context, const SafeSettingScreen()); - }, - ), - const SizedBox(height: 30), - ], - ), - ), - ), + const SizedBox(height: 30), + ], ); } } diff --git a/lib/Screens/Setting/select_font_screen.dart b/lib/Screens/Setting/select_font_screen.dart index 059abfed..4707f9d6 100644 --- a/lib/Screens/Setting/select_font_screen.dart +++ b/lib/Screens/Setting/select_font_screen.dart @@ -1,19 +1,20 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Utils/app_provider.dart'; -import 'package:loftify/Utils/file_util.dart'; -import 'package:loftify/Utils/hive_util.dart'; -import 'package:loftify/Utils/itoast.dart'; -import 'package:loftify/Widgets/Dialog/dialog_builder.dart'; -import '../../Resources/fonts.dart'; -import '../../Utils/responsive_util.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; +import 'base_setting_screen.dart'; -class SelectFontScreen extends StatefulWidget { - const SelectFontScreen({super.key}); +class SelectFontScreen extends BaseSettingScreen { + const SelectFontScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/font"; @@ -24,61 +25,46 @@ class SelectFontScreen extends StatefulWidget { class _SelectFontScreenState extends BaseDynamicState with TickerProviderStateMixin { CustomFont _currentFont = CustomFont.getCurrentFont(); - List customFonts = HiveUtil.getCustomFonts(); + List customFonts = ChewieHiveUtil.getCustomFonts(); @override Widget build(BuildContext context) { - return Container( - color: Colors.transparent, - child: Scaffold( - appBar: ResponsiveAppBar( - showBack: true, - title: appLocalizations.chooseFontFamily, + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.chooseFontFamily, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + children: [ + CaptionItem( context: context, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ), - body: EasyRefresh( - child: ListView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 10), - children: [ - if (ResponsiveUtil.isLandscapeLayout()) const SizedBox(height: 10), - CaptionItem( - context: context, title: appLocalizations.defaultFontFamily), - ItemBuilder.buildContainerItem( - context: context, - child: Container( - margin: - const EdgeInsets.symmetric(vertical: 10, horizontal: 10), - child: Wrap( - runSpacing: 10, - spacing: 10, - children: _buildDefaultFontList(), - ), - ), - bottomRadius: true, + title: appLocalizations.defaultFontFamily, + children: [ + Container( + margin: const EdgeInsets.all(10), + child: Wrap( + runSpacing: 10, + spacing: 10, + children: _buildDefaultFontList(), ), - const SizedBox(height: 10), - CaptionItem( - context: context, title: appLocalizations.customFontFamily), - ItemBuilder.buildContainerItem( - context: context, - child: Container( - margin: - const EdgeInsets.symmetric(vertical: 10, horizontal: 10), - child: Wrap( - runSpacing: 10, - spacing: 10, - children: _buildCustomFontList(), - ), - ), - bottomRadius: true, + ), + ], + ), + CaptionItem( + context: context, + title: appLocalizations.customFontFamily, + children: [ + Container( + margin: const EdgeInsets.all(10), + child: Wrap( + runSpacing: 10, + spacing: 10, + children: _buildCustomFontList(), ), - const SizedBox(height: 30), - ], - ), + ), + ], ), - ), + ], ); } @@ -89,12 +75,7 @@ class _SelectFontScreenState extends BaseDynamicState currentFont: _currentFont, font: CustomFont.defaultFonts[index], context: context, - onChanged: (_) { - _currentFont = CustomFont.defaultFonts[index]; - appProvider.currentFont = _currentFont; - setState(() {}); - CustomFont.loadFont(context, _currentFont, autoRestartApp: false); - }, + onChanged: (_) => _applyFont(CustomFont.defaultFonts[index]), ), ); return list; @@ -108,30 +89,21 @@ class _SelectFontScreenState extends BaseDynamicState showDelete: true, font: customFonts[index], context: context, - onChanged: (_) { - _currentFont = customFonts[index]; - appProvider.currentFont = _currentFont; - setState(() {}); - CustomFont.loadFont(context, customFonts[index], - autoRestartApp: false); - }, + onChanged: (_) => _applyFont(customFonts[index]), onDelete: (_) { DialogBuilder.showConfirmDialog( context, title: appLocalizations.deleteFont(customFonts[index].intlFontName), - message: - appLocalizations.deleteFontMessage(customFonts[index].intlFontName), + message: appLocalizations + .deleteFontMessage(customFonts[index].intlFontName), onTapConfirm: () async { if (customFonts[index] == _currentFont) { - _currentFont = CustomFont.Default; - appProvider.currentFont = _currentFont; - setState(() {}); - CustomFont.loadFont(context, _currentFont, - autoRestartApp: false); + final applied = await _applyFont(CustomFont.Default); + if (!applied) return; } await CustomFont.deleteFont(customFonts[index]); customFonts.removeAt(index); - HiveUtil.setCustomFonts(customFonts); + ChewieHiveUtil.setCustomFonts(customFonts); setState(() {}); }, ); @@ -153,11 +125,8 @@ class _SelectFontScreenState extends BaseDynamicState await CustomFont.copyFont(filePath: result.files.single.path!); if (customFont != null) { customFonts.add(customFont); - HiveUtil.setCustomFonts(customFonts); - _currentFont = customFont; - appProvider.currentFont = _currentFont; - CustomFont.loadFont(context, _currentFont, autoRestartApp: false); - setState(() {}); + ChewieHiveUtil.setCustomFonts(customFonts); + await _applyFont(customFont); } else { IToast.showTop(appLocalizations.fontFamlyLoadFailed); } @@ -167,4 +136,16 @@ class _SelectFontScreenState extends BaseDynamicState ); return list; } + + Future _applyFont(CustomFont font) async { + final applied = await CustomFont.loadFont( + context, + font, + autoRestartApp: false, + ); + if (!mounted || !applied) return false; + setState(() => _currentFont = font); + appProvider.currentFont = font; + return true; + } } diff --git a/lib/Screens/Setting/select_theme_screen.dart b/lib/Screens/Setting/select_theme_screen.dart index 19c2e61c..39cb8593 100644 --- a/lib/Screens/Setting/select_theme_screen.dart +++ b/lib/Screens/Setting/select_theme_screen.dart @@ -1,15 +1,20 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; -import 'package:loftify/Resources/theme_color_data.dart'; import 'package:loftify/Utils/app_provider.dart'; -import 'package:loftify/Utils/hive_util.dart'; -import 'package:loftify/Utils/responsive_util.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; +import 'base_setting_screen.dart'; -class SelectThemeScreen extends StatefulWidget { - const SelectThemeScreen({super.key}); +class SelectThemeScreen extends BaseSettingScreen { + const SelectThemeScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/theme"; @@ -19,103 +24,209 @@ class SelectThemeScreen extends StatefulWidget { class _SelectThemeScreenState extends BaseDynamicState with TickerProviderStateMixin { - int _selectedLightIndex = HiveUtil.getLightThemeIndex(); - int _selectedDarkIndex = HiveUtil.getDarkThemeIndex(); + static const List _presetAccentColors = [ + Color(0xFF14C2BB), + Color(0xFF2196F3), + Color(0xFF009688), + Color(0xFFE91E63), + Color(0xFF9C27B0), + Color(0xFFFF5722), + Color(0xFF795548), + Color(0xFF607D8B), + Color(0xFFFF9800), + Color(0xFF4CAF50), + Color(0xFF3F51B5), + ]; + + int _selectedLightIndex = ChewieHiveUtil.getLightThemeIndex(); + int _selectedDarkIndex = ChewieHiveUtil.getDarkThemeIndex(); + int _lightPrimaryColorIndex = ChewieHiveUtil.getLightThemePrimaryColorIndex(); + int _darkPrimaryColorIndex = ChewieHiveUtil.getDarkThemePrimaryColorIndex(); @override Widget build(BuildContext context) { - return Container( - color: Colors.transparent, - child: Scaffold( - appBar: ResponsiveAppBar( - showBack: true, - title: appLocalizations.selectTheme, + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.selectTheme, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + children: [ + CaptionItem( context: context, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, + title: appLocalizations.lightTheme, + children: [ + _buildThemeScroller(_buildLightThemeList()), + _buildAccentColorPalette(isDark: false), + ], ), - body: EasyRefresh( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 10), - children: [ - if (ResponsiveUtil.isLandscapeLayout()) const SizedBox(height: 10), - CaptionItem( - context: context, title: appLocalizations.lightTheme), - ItemBuilder.buildContainerItem( - context: context, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 10), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: IntrinsicHeight( - child: Row( - children: _buildLightThemeList(), - ), - ), - ), - ), - bottomRadius: true, - ), - const SizedBox(height: 10), - CaptionItem( - context: context, title: appLocalizations.darkTheme), - ItemBuilder.buildContainerItem( - context: context, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 10), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: IntrinsicHeight( - child: Row( - children: _buildDarkThemeList(), - ), - ), - ), - ), - bottomRadius: true, - ), - const SizedBox(height: 10), - ], - ), + CaptionItem( + context: context, + title: appLocalizations.darkTheme, + children: [ + _buildThemeScroller(_buildDarkThemeList()), + _buildAccentColorPalette(isDark: true), + ], + ), + ], + ); + } + + Widget _buildThemeScroller(List children) { + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 10), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + scrollDirection: Axis.horizontal, + child: IntrinsicHeight(child: Row(children: children)), ), ), ); } List _buildLightThemeList() { - var list = List.generate( - ChewieThemeColorData.defaultLightThemes.length, + final themes = [ + ...ChewieThemeColorData.defaultLightThemes, + ...appProvider.customLightThemes, + ]; + return List.generate( + themes.length, (index) => ItemBuilder.buildThemeItem( - index: index, - groupIndex: _selectedLightIndex, - themeColorData: ChewieThemeColorData.defaultLightThemes[index], - context: context, - onChanged: (index) { - setState(() { - _selectedLightIndex = index ?? 0; - appProvider.setLightTheme(index ?? 0); - }); - }), + index: index, + groupIndex: _selectedLightIndex, + themeColorData: themes[index], + context: context, + onChanged: (index) { + setState(() { + _selectedLightIndex = index ?? 0; + appProvider.setLightTheme(_selectedLightIndex); + }); + }, + ), ); - // list.add(ItemBuilder.buildEmptyThemeItem(context: context, onTap: null)); - return list; } List _buildDarkThemeList() { - var list = List.generate( - ChewieThemeColorData.defaultDarkThemes.length, + final themes = [ + ...ChewieThemeColorData.defaultDarkThemes, + ...appProvider.customDarkThemes, + ]; + return List.generate( + themes.length, (index) => ItemBuilder.buildThemeItem( - index: index, - groupIndex: _selectedDarkIndex, - themeColorData: ChewieThemeColorData.defaultDarkThemes[index], - context: context, - onChanged: (index) { - setState(() { - _selectedDarkIndex = index ?? 0; - appProvider.setDarkTheme(index ?? 0); - }); - }), + index: index, + groupIndex: _selectedDarkIndex, + themeColorData: themes[index], + context: context, + onChanged: (index) { + setState(() { + _selectedDarkIndex = index ?? 0; + appProvider.setDarkTheme(_selectedDarkIndex); + }); + }, + ), + ); + } + + Widget _buildAccentColorPalette({required bool isDark}) { + final selectedIndex = + isDark ? _darkPrimaryColorIndex : _lightPrimaryColorIndex; + final palette = Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _buildAccentCircle( + key: ValueKey( + 'theme-accent-${isDark ? 'dark' : 'light'}-reset', + ), + color: null, + isSelected: selectedIndex == 0, + onTap: () => _setAccentColor(isDark, null, 0), + ), + for (int index = 0; index < _presetAccentColors.length; index++) + _buildAccentCircle( + key: ValueKey( + 'theme-accent-${isDark ? 'dark' : 'light'}-$index', + ), + color: _presetAccentColors[index], + isSelected: selectedIndex == index + 1, + onTap: () => _setAccentColor( + isDark, + _presetAccentColors[index], + index + 1, + ), + ), + ], + ); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(appLocalizations.primaryColor, style: ChewieTheme.bodyMedium), + const SizedBox(height: 8), + palette, + ], + ), + ); + } + + void _setAccentColor(bool isDark, Color? color, int paletteIndex) { + setState(() { + if (isDark) { + _darkPrimaryColorIndex = paletteIndex; + appProvider.setDarkPrimaryColorOverride(color, paletteIndex); + } else { + _lightPrimaryColorIndex = paletteIndex; + appProvider.setLightPrimaryColorOverride(color, paletteIndex); + } + }); + } + + Widget _buildAccentCircle({ + required Key key, + required Color? color, + required bool isSelected, + required VoidCallback onTap, + }) { + return InkAnimation( + key: key, + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: SizedBox.square( + dimension: 44, + child: Center( + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? ChewieTheme.primaryColor + : ChewieTheme.dividerColor, + width: isSelected ? 2 : 1, + ), + ), + child: color == null + ? Icon( + LoftifyIcons.reset, + size: 16, + color: ChewieTheme.iconColor, + ) + : isSelected + ? ChewieIcon( + LoftifyIcons.check, + size: 16, + color: ColorUtil.getContrastColor(color), + ) + : null, + ), + ), + ), ); - // list.add(ItemBuilder.buildEmptyThemeItem(context: context, onTap: null)); - return list; } } diff --git a/lib/Screens/Setting/setting_navigation_screen.dart b/lib/Screens/Setting/setting_navigation_screen.dart index 92f9305a..5dd8d814 100644 --- a/lib/Screens/Setting/setting_navigation_screen.dart +++ b/lib/Screens/Setting/setting_navigation_screen.dart @@ -1,21 +1,27 @@ -import 'dart:async'; +/* + * Copyright (c) 2025 Robert-Stackflow. + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + */ import 'package:awesome_chewie/awesome_chewie.dart'; -import 'package:cloudotp/Screens/Setting/about_setting_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_appearance_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_backup_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_general_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_operation_screen.dart'; -import 'package:cloudotp/Screens/Setting/setting_safe_screen.dart'; -import 'package:cloudotp/l10n/l10n.dart'; import 'package:flutter/material.dart'; -import 'package:lucide_icons/lucide_icons.dart'; -import 'package:provider/provider.dart'; - import '../../Utils/app_provider.dart'; -import '../../Widgets/cloudotp/navigation_bar.dart'; - +import '../../Widgets/loftify_icons.dart'; +import '../../l10n/l10n.dart'; +import 'about_setting_screen.dart'; +import 'apperance_setting_screen.dart'; +import 'experiment_setting_screen.dart'; +import 'general_setting_screen.dart'; +import 'image_setting_screen.dart'; +import 'lofter_basic_setting_screen.dart'; + +/// Wide-screen settings navigation using the same pages and design foundations +/// as the mobile settings route. class SettingNavigationScreen extends StatefulWidget { final int initPageIndex; @@ -30,243 +36,89 @@ class SettingNavigationScreen extends StatefulWidget { } class _SettingNavigationScreenState - extends BaseDynamicState - with AutomaticKeepAliveClientMixin { - int _selectedIndex = 0; - String _searchText = ""; - final TextEditingController _searchController = TextEditingController(); - final SearchConfig _searchConfig = SearchConfig(); - Timer? _debounceTimer; - - final List _navigationItems = [ - SettingNavigationItem( - title: () => appLocalizations.generalSetting, - icon: LucideIcons.settings2), - SettingNavigationItem( - title: () => appLocalizations.appearanceSetting, - icon: LucideIcons.paintbrushVertical), - SettingNavigationItem( - title: () => appLocalizations.operationSetting, - icon: LucideIcons.pointer), - SettingNavigationItem( - title: () => appLocalizations.backupSetting, - icon: LucideIcons.cloudUpload), - SettingNavigationItem( - title: () => appLocalizations.safeSetting, - icon: LucideIcons.shieldCheck), - SettingNavigationItem( - title: () => appLocalizations.about, icon: LucideIcons.info), + extends BaseDynamicState { + late int _selectedIndex; + + late final List _pages = [ + GeneralSettingScreen(key: generalSettingScreenKey), + const AppearanceSettingScreen(), + const ImageSettingScreen(), + const LofterBasicSettingScreen(), + const ExperimentSettingScreen(), + const AboutSettingScreen(), ]; - final List _pageCache = List.filled(8, null); - @override void initState() { super.initState(); - _searchController.addListener(_onSearchTextChanged); - _selectedIndex = widget.initPageIndex; - } - - @override - void dispose() { - _searchController.removeListener(_onSearchTextChanged); - _searchController.dispose(); - _debounceTimer?.cancel(); - super.dispose(); - } - - void _onSearchTextChanged() { - if (_debounceTimer?.isActive ?? false) _debounceTimer?.cancel(); - _debounceTimer = Timer(const Duration(milliseconds: 200), () { - search(_searchController.text); - }); - } - - search(String query) { - setState(() { - _searchText = query; - }); - } - - Widget _buildCurrentPage(int index) { - // if (_pageCache[index] != null) return _pageCache[index]!; - - late Widget page; - switch (index) { - case 0: - page = GeneralSettingScreen( - key: generalSettingScreenKey, - showTitleBar: false, - searchText: _searchText, - searchConfig: _searchConfig, - ); - break; - case 1: - page = AppearanceSettingScreen( - showTitleBar: false, - searchText: _searchText, - searchConfig: _searchConfig, - ); - break; - case 2: - page = OperationSettingScreen( - showTitleBar: false, - searchText: _searchText, - searchConfig: _searchConfig, - ); - break; - case 3: - page = BackupSettingScreen( - showTitleBar: false, - searchText: _searchText, - searchConfig: _searchConfig, - ); - break; - case 4: - page = SafeSettingScreen( - showTitleBar: false, - searchText: _searchText, - searchConfig: _searchConfig, - ); - break; - case 5: - page = AboutSettingScreen( - showTitleBar: false, - searchText: _searchText, - searchConfig: _searchConfig, - ); - break; - default: - page = const SizedBox.shrink(); - } - - _pageCache[index] = _KeepAliveWrapper(child: page); - return _pageCache[index]!; + _selectedIndex = widget.initPageIndex.clamp(0, _pages.length - 1); } @override Widget build(BuildContext context) { - super.build(context); - return Consumer( - builder: (context, provider, child) { - return Scaffold( - appBar: ResponsiveAppBar( - showBack: false, - titleLeftMargin: 15, - title: appLocalizations.setting, - ), - body: Row( - children: [ - MyNavigationBar( - items: _navigationItems, - selectedIndex: _selectedIndex, - onSelected: (index) { - setState(() { - _selectedIndex = index; - }); - }, + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: ChewieTheme.scaffoldBackgroundColor, + body: Row( + children: [ + NavigationRail( + extended: true, + minExtendedWidth: 196, + backgroundColor: ChewieTheme.scaffoldBackgroundColor, + indicatorColor: colorScheme.primary.withValues(alpha: 0.12), + selectedIconTheme: IconThemeData(color: colorScheme.primary), + selectedLabelTextStyle: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + labelType: NavigationRailLabelType.none, + selectedIndex: _selectedIndex, + onDestinationSelected: (index) { + setState(() => _selectedIndex = index); + }, + destinations: [ + _destination( + LoftifyIcons.generalSettings, + appLocalizations.generalSetting, + ), + _destination( + LoftifyIcons.appearance, + appLocalizations.appearanceSetting, + ), + _destination(LoftifyIcons.image, appLocalizations.imageSetting), + _destination( + LoftifyIcons.basicSettings, + appLocalizations.lofterBasicSetting, ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // _buildSearchRow(), - Expanded(child: _buildCurrentPage(_selectedIndex)), - ], - ), + _destination( + LoftifyIcons.experiment, + appLocalizations.experimentSetting, ), + _destination(LoftifyIcons.about, appLocalizations.about), ], ), - ); - }, - ); - } - - @override - bool get wantKeepAlive => true; - - _buildSearchRow() { - return Container( - margin: const EdgeInsets.only(left: 20, top: 11, bottom: 10), - constraints: const BoxConstraints(maxWidth: 500), - child: Row( - children: [ + VerticalDivider( + width: 1, + thickness: 1, + color: ChewieTheme.dividerColor, + ), Expanded( - child: MySearchBar( - controller: _searchController, - hintText: "搜索设置项(空格分隔多个关键词、支持拼音匹配、模糊匹配)", - onSubmitted: (text) { - search(text); - }, - showSearchButton: false, + child: IndexedStack( + index: _selectedIndex, + children: _pages, ), ), - const SizedBox(width: 8), - RoundIconTextButton( - background: ChewieTheme.canvasColor, - border: ChewieTheme.borderWithWidth(1), - padding: const EdgeInsets.symmetric(horizontal: 10), - tooltip: "高级选项", - color: ChewieTheme.titleMedium.color, - icon: Icon( - LucideIcons.listFilter, - color: ChewieTheme.iconColor, - size: 20, - ), - onPressed: () { - BottomSheetBuilder.showContextMenu( - context, _buildFilterMoreButtons()); - }, - ), ], ), ); } - _buildFilterMoreButtons() { - return FlutterContextMenu( - entries: [ - FlutterContextMenuItem.checkbox( - "拼音匹配", - checked: _searchConfig.enablePinyin, - onPressed: () { - setState(() { - _searchConfig.enablePinyin = !_searchConfig.enablePinyin; - }); - }, - ), - FlutterContextMenuItem.checkbox( - "模糊匹配", - checked: _searchConfig.enableShortPinyin, - onPressed: () { - setState(() { - _searchConfig.enableShortPinyin = - !_searchConfig.enableShortPinyin; - }); - }, - ) - ], + NavigationRailDestination _destination(IconData icon, String label) { + return NavigationRailDestination( + icon: ChewieIcon(icon), + selectedIcon: ChewieIcon(icon), + label: Text(label), ); } } - -class _KeepAliveWrapper extends StatefulWidget { - final Widget child; - - const _KeepAliveWrapper({required this.child}); - - @override - State<_KeepAliveWrapper> createState() => _KeepAliveWrapperState(); -} - -class _KeepAliveWrapperState extends BaseDynamicState<_KeepAliveWrapper> - with AutomaticKeepAliveClientMixin { - @override - Widget build(BuildContext context) { - super.build(context); - return widget.child; - } - - @override - bool get wantKeepAlive => true; -} diff --git a/lib/Screens/Setting/setting_screen.dart b/lib/Screens/Setting/setting_screen.dart index 7969bffe..74616ab7 100644 --- a/lib/Screens/Setting/setting_screen.dart +++ b/lib/Screens/Setting/setting_screen.dart @@ -1,3 +1,4 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Screens/Setting/apperance_setting_screen.dart'; import 'package:loftify/Screens/Setting/blacklist_setting_screen.dart'; @@ -7,17 +8,21 @@ import 'package:loftify/Screens/Setting/lofter_basic_setting_screen.dart'; import 'package:loftify/Screens/Setting/tagshield_setting_screen.dart'; import 'package:loftify/Screens/Setting/userdynamicshield_setting_screen.dart'; import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; -import '../../Utils/responsive_util.dart'; -import '../../Utils/route_util.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; -import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; import 'about_setting_screen.dart'; +import 'base_setting_screen.dart'; import 'experiment_setting_screen.dart'; -class SettingScreen extends StatefulWidget { - const SettingScreen({super.key}); +class SettingScreen extends BaseSettingScreen { + const SettingScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting"; @@ -34,145 +39,121 @@ class _SettingScreenState extends BaseDynamicState @override Widget build(BuildContext context) { - return Container( - color: Colors.transparent, - child: Scaffold( - appBar: ResponsiveAppBar( - showBack: true, - title: appLocalizations.setting, + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.setting, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + children: [ + CaptionItem( context: context, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ), - body: EasyRefresh( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 10), - children: [ - if (ResponsiveUtil.isLandscapeLayout()) const SizedBox(height: 10), - CaptionItem( - context: context, title: appLocalizations.basicSetting), - EntryItem( - context: context, - title: appLocalizations.generalSetting, - showLeading: true, - onTap: () { - RouteUtil.pushPanelCupertinoRoute(context, - GeneralSettingScreen(key: generalSettingScreenKey)); - }, - leading: Icons.settings_outlined, - ), - EntryItem( - context: context, - title: appLocalizations.appearanceSetting, - showLeading: true, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, const AppearanceSettingScreen()); - }, - leading: Icons.color_lens_outlined, - ), - EntryItem( - context: context, - title: appLocalizations.imageSetting, - showLeading: true, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, const ImageSettingScreen()); - }, - leading: Icons.image_outlined, - ), - // EntryItem( - // context: context, - // title: appLocalizations.operationSetting, - // showLeading: true, - // onTap: () { - // RouteUtil.pushPanelCupertinoRoute( - // context, const OperationSettingScreen()); - // }, - // leading: Icons.touch_app_outlined, - // ), - EntryItem( - context: context, - title: appLocalizations.experimentSetting, - showLeading: true, - roundBottom: true, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, const ExperimentSettingScreen()); - }, - leading: Icons.flag_outlined, - ), - if (appProvider.token.isNotEmpty) ..._buildLofter(), - const SizedBox(height: 10), - _buildAbout(), - const SizedBox(height: 20), - ], - ), + title: appLocalizations.basicSetting, + children: [ + EntryItem( + title: appLocalizations.generalSetting, + showLeading: true, + onTap: () { + RouteUtil.pushPanelCupertinoRoute(context, + GeneralSettingScreen(key: generalSettingScreenKey)); + }, + leading: LoftifyIcons.generalSettings, + ), + EntryItem( + title: appLocalizations.appearanceSetting, + showLeading: true, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const AppearanceSettingScreen()); + }, + leading: LoftifyIcons.appearance, + ), + EntryItem( + title: appLocalizations.imageSetting, + showLeading: true, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const ImageSettingScreen()); + }, + leading: LoftifyIcons.image, + ), + EntryItem( + title: appLocalizations.experimentSetting, + showLeading: true, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const ExperimentSettingScreen()); + }, + leading: LoftifyIcons.flag, + ), + ], ), - ), + if (appProvider.token.isNotEmpty) _buildLofter(), + _buildAbout(), + ], ); } - _buildAbout() { - return EntryItem( + Widget _buildAbout() { + return CaptionItem( context: context, - title: appLocalizations.about, - roundBottom: true, - roundTop: true, - showLeading: true, - padding: 15, - onTap: () { - RouteUtil.pushPanelCupertinoRoute(context, const AboutSettingScreen()); - }, - leading: Icons.info_outline_rounded, + title: appLocalizations.other, + children: [ + EntryItem( + title: appLocalizations.about, + showLeading: true, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const AboutSettingScreen()); + }, + leading: LoftifyIcons.info, + ), + ], ); } - List _buildLofter() { - return [ - const SizedBox(height: 10), - CaptionItem( - context: context, title: appLocalizations.lofterSetting), - EntryItem( - context: context, - showLeading: true, - title: appLocalizations.lofterBasicSetting, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, const LofterBasicSettingScreen()); - }, - leading: Icons.copyright_rounded, - ), - EntryItem( - context: context, - showLeading: true, - title: appLocalizations.blacklistSetting, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, const BlacklistSettingScreen()); - }, - leading: Icons.block_rounded, - ), - EntryItem( - context: context, - showLeading: true, - title: appLocalizations.tagShieldSetting, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, const TagShieldSettingScreen()); - }, - leading: Icons.tag_rounded, - ), - EntryItem( - context: context, - showLeading: true, - roundBottom: true, - title: appLocalizations.userDynamicShieldSetting, - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, const UserDynamicShieldSettingScreen()); - }, - leading: Icons.shield_outlined, - ), - ]; + Widget _buildLofter() { + return CaptionItem( + context: context, + title: appLocalizations.lofterSetting, + children: [ + EntryItem( + showLeading: true, + title: appLocalizations.lofterBasicSetting, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const LofterBasicSettingScreen()); + }, + leading: LoftifyIcons.copyright, + ), + EntryItem( + showLeading: true, + title: appLocalizations.blacklistSetting, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const BlacklistSettingScreen()); + }, + leading: LoftifyIcons.block, + ), + EntryItem( + showLeading: true, + title: appLocalizations.tagShieldSetting, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const TagShieldSettingScreen()); + }, + leading: LoftifyIcons.tag, + ), + EntryItem( + showLeading: true, + title: appLocalizations.userDynamicShieldSetting, + onTap: () { + RouteUtil.pushPanelCupertinoRoute( + context, const UserDynamicShieldSettingScreen()); + }, + leading: LoftifyIcons.shield, + ), + ], + ); } } diff --git a/lib/Screens/Setting/tagshield_setting_screen.dart b/lib/Screens/Setting/tagshield_setting_screen.dart index 267da0de..b65175bb 100644 --- a/lib/Screens/Setting/tagshield_setting_screen.dart +++ b/lib/Screens/Setting/tagshield_setting_screen.dart @@ -1,20 +1,20 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/setting_api.dart'; -import 'package:loftify/Widgets/BottomSheet/input_bottom_sheet.dart'; -import 'package:loftify/Widgets/Dialog/custom_dialog.dart'; -import '../../Resources/theme.dart'; -import '../../Utils/ilogger.dart'; -import '../../Utils/itoast.dart'; -import '../../Widgets/BottomSheet/bottom_sheet_builder.dart'; -import '../../Widgets/Dialog/dialog_builder.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; -import '../../Widgets/Item/item_builder.dart'; -import '../../Widgets/Item/loftify_item_builder.dart'; +import '../../Widgets/Item/setting_management_item.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; +import 'base_setting_screen.dart'; -class TagShieldSettingScreen extends StatefulWidget { - const TagShieldSettingScreen({super.key}); +class TagShieldSettingScreen extends BaseSettingScreen { + const TagShieldSettingScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/tagShield"; @@ -22,139 +22,136 @@ class TagShieldSettingScreen extends StatefulWidget { State createState() => _TagShieldSettingScreenState(); } -class _TagShieldSettingScreenState extends BaseDynamicState +class _TagShieldSettingScreenState + extends BaseDynamicState with TickerProviderStateMixin { bool loading = false; final EasyRefreshController _refreshController = EasyRefreshController(); List tags = []; - _fetchTags() async { - if (loading) return; + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchTags() async { + if (loading || !mounted) return IndicatorResult.none; loading = true; - return await SettingApi.getShieldTagList().then((value) { - try { - if (value == null) return IndicatorResult.fail; - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - return IndicatorResult.fail; - } else { - tags = (value['response']['list'] as List) - .map((e) => e.toString()) - .toList(); - return IndicatorResult.success; - } - } catch (e, t) { - ILogger.error("Failed to load tag shield list", e, t); - IToast.showTop(appLocalizations.loadFailed); + try { + final value = await SettingApi.getShieldTagList(); + if (!mounted) return IndicatorResult.none; + if (value == null) return IndicatorResult.fail; + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); return IndicatorResult.fail; - } finally { - loading = false; - if (mounted) setState(() {}); + } else { + tags = (value['response']['list'] as List) + .map((e) => e.toString()) + .toList(); + return IndicatorResult.success; } - }); + } catch (e, t) { + if (!mounted) return IndicatorResult.none; + ILogger.error("Failed to load tag shield list", e, t); + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + loading = false; + if (mounted) setState(() {}); + } } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: ChewieTheme.getBackground(context), - appBar: ResponsiveAppBar( - showBack: true, - showBorder: true, - title: "${appLocalizations.tagShieldSetting}(${tags.length})", - context: context, - actions: [ - CircleIconButton( - context: context, - icon: Icon(Icons.add_rounded, - color: Theme.of(context).iconTheme.color), - onTap: () { - BottomSheetBuilder.showBottomSheet( - context, - (sheetContext) => InputBottomSheet( - buttonText: appLocalizations.confirm, - title: appLocalizations.addShieldTag, - text: "", - onConfirm: (text) { - SettingApi.shieldOrUnshieldTag(tag: text, isShield: true) - .then((value) { - IToast.showTop( - value['meta']['desc'] ?? value['meta']['msg']); - if (value['meta']['status'] == 200) { - tags.insert(0, text); - setState(() {}); - } - }); - }, - ), - preferMinWidth: 400, - responsive: true, - ); - }, - ), - ], - ), - body: EasyRefresh( - controller: _refreshController, - refreshOnStart: true, - onRefresh: () { - _fetchTags(); - }, - triggerAxis: Axis.vertical, - child: ListView.builder( - itemCount: tags.length, - padding: EdgeInsets.zero, - itemBuilder: (context, index) => _buildTagRow(tags[index]), - ), - ), - ); - } - - _buildTagRow(String tag) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - width: 0.5, + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.tagShieldSetting, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + actions: [ + CircleIconButton( + context: context, + icon: ChewieIcon( + LoftifyIcons.add, + color: Theme.of(context).iconTheme.color, ), - ), - ), - child: Row( - children: [ - Expanded( - child: Text(tag), - ), - LoftifyItemBuilder.buildFramedDoubleButton( - context: context, - isFollowed: false, - positiveText: appLocalizations.unblockShieldTag, - negtiveText: appLocalizations.unblockShieldTag, - onTap: () { - DialogBuilder.showConfirmDialog( - context, - title: appLocalizations.unblockShieldTag, - message: appLocalizations.unblockShieldTagMessage(tag), - confirmButtonText: appLocalizations.unlock, - onTapConfirm: () { - SettingApi.shieldOrUnshieldTag(tag: tag, isShield: false) + onTap: () { + BottomSheetBuilder.showBottomSheet( + context, + (sheetContext) => InputBottomSheet( + buttonText: appLocalizations.confirm, + title: appLocalizations.addShieldTag, + text: "", + onConfirm: (text) { + SettingApi.shieldOrUnshieldTag(tag: text, isShield: true) .then((value) { IToast.showTop( value['meta']['desc'] ?? value['meta']['msg']); if (value['meta']['status'] == 200) { - tags.remove(tag); + tags.insert(0, text); setState(() {}); } }); }, - onTapCancel: () {}, - customDialogType: CustomDialogType.normal, - ); - }, - ), - ], + ), + preferMinWidth: 400, + responsive: true, + ); + }, + ), + ], + overrideBody: EasyRefresh( + controller: _refreshController, + refreshOnStart: true, + onRefresh: () async { + return await _fetchTags(); + }, + triggerAxis: Axis.vertical, + child: ListView( + padding: widget.padding, + children: [ + CaptionItem( + title: '${appLocalizations.tagShieldSetting} (${tags.length})', + children: tags.isEmpty + ? [ + SettingManagementEmptyState( + text: appLocalizations.noContent), + ] + : tags.map(_buildTagRow).toList(), + ), + ], + ), ), ); } + + Widget _buildTagRow(String tag) { + return SettingManagementItem( + title: tag, + leadingIcon: LoftifyIcons.tag, + actionLabel: appLocalizations.unblockShieldTag, + onAction: () { + DialogBuilder.showConfirmDialog( + context, + title: appLocalizations.unblockShieldTag, + message: appLocalizations.unblockShieldTagMessage(tag), + confirmButtonText: appLocalizations.unlock, + onTapConfirm: () { + SettingApi.shieldOrUnshieldTag(tag: tag, isShield: false) + .then((value) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + if (value['meta']['status'] == 200) { + tags.remove(tag); + setState(() {}); + } + }); + }, + onTapCancel: () {}, + customDialogType: CustomDialogType.normal, + ); + }, + ); + } } diff --git a/lib/Screens/Setting/userdynamicshield_setting_screen.dart b/lib/Screens/Setting/userdynamicshield_setting_screen.dart index 6ed2f3cb..1454584a 100644 --- a/lib/Screens/Setting/userdynamicshield_setting_screen.dart +++ b/lib/Screens/Setting/userdynamicshield_setting_screen.dart @@ -1,21 +1,23 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Models/recommend_response.dart'; import '../../Api/setting_api.dart'; import '../../Api/user_api.dart'; -import '../../Resources/theme.dart'; -import '../../Utils/ilogger.dart'; -import '../../Utils/itoast.dart'; -import '../../Utils/route_util.dart'; -import '../../Widgets/Dialog/dialog_builder.dart'; -import '../../Widgets/General/EasyRefresh/easy_refresh.dart'; import '../../Widgets/Item/item_builder.dart'; -import '../../Widgets/Item/loftify_item_builder.dart'; +import '../../Widgets/Item/setting_management_item.dart'; import '../../l10n/l10n.dart'; import '../Info/user_detail_screen.dart'; +import 'base_setting_screen.dart'; -class UserDynamicShieldSettingScreen extends StatefulWidget { - const UserDynamicShieldSettingScreen({super.key}); +class UserDynamicShieldSettingScreen extends BaseSettingScreen { + const UserDynamicShieldSettingScreen({ + super.key, + super.padding, + super.showTitleBar, + super.searchConfig, + super.searchText, + }); static const String routeName = "/setting/userDynamicShield"; @@ -31,63 +33,85 @@ class _UserDynamicShieldSettingScreenState final EasyRefreshController _refreshController = EasyRefreshController(); List shieldList = []; - _fetchShieldList() async { - if (loading) return; + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchShieldList() async { + if (loading || !mounted) return IndicatorResult.none; loading = true; - return await SettingApi.getShieldBloglist().then((value) { - try { - if (value == null) return IndicatorResult.fail; - if (value['code'] != 0) { - IToast.showTop(value['desc'] ?? value['msg']); - return IndicatorResult.fail; - } else { - shieldList.clear(); - var tmp = (value['data']['blogInfos'] as List) - .map((e) => SimpleBlogInfo.fromJson(e)) - .toList(); - shieldList.addAll(tmp); - return IndicatorResult.success; - } - } catch (e, t) { - ILogger.error("Failed to load user dynamic shield list", e, t); - IToast.showTop(appLocalizations.loadFailed); + try { + final value = await SettingApi.getShieldBloglist(); + if (!mounted) return IndicatorResult.none; + if (value == null) return IndicatorResult.fail; + if (value['code'] != 0) { + IToast.showTop(value['desc'] ?? value['msg']); return IndicatorResult.fail; - } finally { - loading = false; - if (mounted) setState(() {}); + } else { + var tmp = (value['data']['blogInfos'] as List) + .map((e) => SimpleBlogInfo.fromJson(e)) + .toList(); + shieldList = tmp; + return IndicatorResult.success; } - }); + } catch (e, t) { + if (!mounted) return IndicatorResult.none; + ILogger.error("Failed to load user dynamic shield list", e, t); + IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + loading = false; + if (mounted) setState(() {}); + } } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: ChewieTheme.getBackground(context), - appBar: ResponsiveAppBar( - showBack: true, - showBorder: true, - title: appLocalizations.userDynamicShieldSetting, - context: context, - ), - body: EasyRefresh( + return ChewieItemBuilder.buildSettingScreen( + context: context, + title: appLocalizations.userDynamicShieldSetting, + showTitleBar: widget.showTitleBar, + showBack: !ResponsiveUtil.isLandscapeLayout(), + padding: widget.padding, + overrideBody: EasyRefresh( controller: _refreshController, refreshOnStart: true, onRefresh: () async { return await _fetchShieldList(); }, triggerAxis: Axis.vertical, - child: ListView.builder( - itemCount: shieldList.length, - padding: EdgeInsets.zero, - itemBuilder: (context, index) => - _buildShieldlistRow(shieldList[index]), + child: ListView( + padding: widget.padding, + children: [ + CaptionItem( + title: + '${appLocalizations.userDynamicShieldSetting} (${shieldList.length})', + children: shieldList.isEmpty + ? [ + SettingManagementEmptyState( + text: appLocalizations.noContent), + ] + : shieldList.map(_buildShieldlistRow).toList(), + ), + ], ), ), ); } - _buildShieldlistRow(SimpleBlogInfo blogInfo) { - return GestureDetector( + Widget _buildShieldlistRow(SimpleBlogInfo blogInfo) { + return SettingManagementItem( + title: blogInfo.blogNickName, + description: blogInfo.blogName, + leading: ItemBuilder.buildAvatar( + context: context, + imageUrl: blogInfo.bigAvaImg, + showLoading: false, + showBorder: true, + size: 40, + ), onTap: () { RouteUtil.pushPanelCupertinoRoute( context, @@ -95,59 +119,28 @@ class _UserDynamicShieldSettingScreenState blogId: blogInfo.blogId, blogName: blogInfo.blogName), ); }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - width: 0.5, - ), - ), - ), - child: Row( - children: [ - ItemBuilder.buildAvatar( - context: context, - imageUrl: blogInfo.bigAvaImg, - showLoading: false, - showBorder: true, - size: 40, - ), - const SizedBox(width: 10), - Expanded( - child: Text(blogInfo.blogNickName), - ), - LoftifyItemBuilder.buildFramedDoubleButton( - context: context, - isFollowed: false, - positiveText: appLocalizations.resumeView, - negtiveText: appLocalizations.resumeView, - onTap: () { - DialogBuilder.showConfirmDialog( - context, - title: appLocalizations.resumeViewDynamic, - message: - appLocalizations.resumeViewDynamicMessage(blogInfo.blogNickName), - onTapConfirm: () { - UserApi.shieldBlogOrUnShield( - blogId: blogInfo.blogId, - isShield: false, - ).then((value) { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - shieldList.remove(blogInfo); - setState(() {}); - } - }); - }, - ); - }, - ), - ], - ), - ), + actionLabel: appLocalizations.resumeView, + onAction: () { + DialogBuilder.showConfirmDialog( + context, + title: appLocalizations.resumeViewDynamic, + message: + appLocalizations.resumeViewDynamicMessage(blogInfo.blogNickName), + onTapConfirm: () { + UserApi.shieldBlogOrUnShield( + blogId: blogInfo.blogId, + isShield: false, + ).then((value) { + if (value['code'] != 0) { + IToast.showTop(value['msg']); + } else { + shieldList.remove(blogInfo); + setState(() {}); + } + }); + }, + ); + }, ); } } diff --git a/lib/Screens/Suit/custom_bg_avatar_list_screen.dart b/lib/Screens/Suit/custom_bg_avatar_list_screen.dart index 9e7a6f83..9d499845 100644 --- a/lib/Screens/Suit/custom_bg_avatar_list_screen.dart +++ b/lib/Screens/Suit/custom_bg_avatar_list_screen.dart @@ -1,12 +1,13 @@ - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:blur/blur.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/gift_api.dart'; import 'package:loftify/Models/suit_response.dart'; import 'package:loftify/Widgets/BottomSheet/custom_bg_avatar_detail_bottom_sheet.dart'; +import 'package:loftify/Widgets/Design/loftify_media_overlays.dart'; import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/Suit/dress_preview_card.dart'; import '../../l10n/l10n.dart'; class CustomBgAvatarListScreen extends StatefulWidget { @@ -14,11 +15,13 @@ class CustomBgAvatarListScreen extends StatefulWidget { super.key, this.tags = const [], this.blogId, + this.refreshOnStart = true, }); final List tags; final int? blogId; + final bool refreshOnStart; static const String routeName = "/info/customBgAvatarList"; @@ -27,7 +30,8 @@ class CustomBgAvatarListScreen extends StatefulWidget { CustomBgAvatarListScreenState(); } -class CustomBgAvatarListScreenState extends BaseDynamicState +class CustomBgAvatarListScreenState + extends BaseDynamicState with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @@ -38,6 +42,8 @@ class CustomBgAvatarListScreenState extends BaseDynamicState _refreshController.callRefresh(); + static buildTagBar(BuildContext context, List tags, String? selected, Function(String? tag) onSelectedTag) { if (ResponsiveUtil.isDesktop()) { @@ -62,9 +68,8 @@ class CustomBgAvatarListScreenState extends BaseDynamicState CustomBgAvatarDetailBottomSheet(item: item), - responsive: true, - ); - }, - child: Container( - decoration: BoxDecoration( - color: ChewieTheme.canvasColor, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 15), - _buildProductBg(item), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - item.type == 0 ? item.product!.name : item.lootBox!.name, - style: Theme.of(context) - .textTheme - .titleMedium - ?.apply(fontWeightDelta: 2, fontSizeDelta: -1), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(height: 5), - SizedBox( - height: 16, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: item.tags.length, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemBuilder: (context, index) => Container( - margin: const EdgeInsets.only(right: 5), - child: RoundIconTextButton( - text: "#${item.tags[index].tag}", - padding: const EdgeInsets.symmetric( - horizontal: 4, vertical: 0), - radius: 5, - textStyle: Theme.of(context).textTheme.labelSmall, - ), - ), - ), - ), - const SizedBox(height: 12), - ], - ), - ), - ), + final title = item.type == 0 ? item.product!.name : item.lootBox!.name; + final tags = item.tags.take(3).map((e) => '#${e.tag}').join(' '); + return DressPreviewCard( + title: title, + subtitle: tags, + badge: item.type == 0 + ? appLocalizations.singleSuit + : appLocalizations.cardPool, + previewPadding: EdgeInsets.zero, + preview: _buildProductBg(item, height: 210), + onTap: () { + BottomSheetBuilder.showBottomSheet( + context, + (_) => CustomBgAvatarDetailBottomSheet(item: item), + responsive: true, + ); + }, ); } - _buildProductBg(ProductItem item) { + _buildProductBg(ProductItem item, {double height = 240}) { String bgUrl = ""; bool isAvatar = false; if (item.type == 0) { @@ -331,7 +299,10 @@ class CustomBgAvatarListScreenState extends BaseDynamicState tags; final int? blogId; + final bool refreshOnStart; static const String routeName = "/suit/customDress"; @override - State createState() => _CustomDressListScreenState(); + State createState() => CustomDressListScreenState(); } -class _CustomDressListScreenState extends BaseDynamicState +class CustomDressListScreenState extends BaseDynamicState with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @@ -42,6 +45,8 @@ class _CustomDressListScreenState extends BaseDynamicState _refreshController.callRefresh(); + _fetchList({bool refresh = false}) async { if (_loading) return; if (refresh) { @@ -152,7 +157,7 @@ class _CustomDressListScreenState extends BaseDynamicState= 4 - ? GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: 4, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 1, - ), - itemBuilder: (context, index) { - return ChewieItemBuilder.buildCachedImage( - imageUrl: item.partList[index].partUrl, - context: context, - showLoading: false, - placeholderBackground: Colors.transparent, - width: 80, - height: 80, - ); - }, - ) - : ChewieItemBuilder.buildCachedImage( - imageUrl: item.coverImg, - context: context, - showLoading: false, - placeholderBackground: Colors.transparent, - width: 200, - height: 200, - ), - ), - const SizedBox(height: 10), - Text( - item.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 5), - Text( - appLocalizations.pendantCount(item.partCount), - style: Theme.of(context).textTheme.labelMedium, - ), - const SizedBox(height: 10), - RoundIconTextButton( - text: appLocalizations.viewDetail, - background: Theme.of(context).primaryColor, - onPressed: () { - RouteUtil.pushPanelCupertinoRoute( - context, - DressDetailScreen( - returnGiftDressId: item.returnGiftDressId, - ), - ); - }, - ), - const SizedBox(height: 4), - ], - ), + return DressPreviewCard( + title: item.name, + subtitle: StringUtil.isNotEmpty(item.creatorNickName) + ? item.creatorNickName + : appLocalizations.pendantCount(item.partCount), + badge: appLocalizations.pendantCount(item.partCount), + onTap: () => _openDressDetail(item), + preview: _buildDressPreview(item), + ); + } + + void _openDressDetail(GiftDress item) { + RouteUtil.pushPanelCupertinoRoute( + context, + DressDetailScreen(returnGiftDressId: item.returnGiftDressId), + ); + } + + Widget _buildDressPreview(GiftDress item) { + if (item.partCount < 4 || item.partList.length < 4) { + return ChewieItemBuilder.buildCachedImage( + imageUrl: item.coverImg, + context: context, + showLoading: false, + placeholderBackground: Colors.transparent, + width: double.infinity, + height: double.infinity, + fit: BoxFit.contain, + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: 4, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) => ChewieItemBuilder.buildCachedImage( + imageUrl: item.partList[index].partUrl, + context: context, + showLoading: false, + placeholderBackground: Colors.transparent, + fit: BoxFit.contain, ), ); } _buildGiftEmoteItem(GiftEmote item) { - return GestureDetector( + return DressPreviewCard( + title: item.name, + subtitle: appLocalizations.emoteCount(item.emoteCount), + badge: appLocalizations.emoteCount(item.emoteCount), onTap: () { RouteUtil.pushPanelCupertinoRoute( context, - EmoteDetailScreen( - emotePackId: item.packageId, - ), + EmoteDetailScreen(emotePackId: item.packageId), ); }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - decoration: BoxDecoration( - color: ChewieTheme.canvasColor, - borderRadius: BorderRadius.circular(10), + preview: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: item.emoteList.length.clamp(0, 4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, ), - child: Column( - children: [ - const SizedBox(width: 15), - SizedBox( - width: 200, - height: 200, - child: item.emoteCount >= 4 - ? GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: 4, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 1, - ), - itemBuilder: (context, index) { - return ChewieItemBuilder.buildCachedImage( - imageUrl: item.emoteList[index].url, - context: context, - showLoading: false, - placeholderBackground: Colors.transparent, - width: 80, - height: 80, - ); - }, - ) - : ChewieItemBuilder.buildCachedImage( - imageUrl: item.name, - context: context, - showLoading: false, - placeholderBackground: Colors.transparent, - width: 200, - height: 200, - ), - ), - const SizedBox(height: 10), - Text( - item.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 5), - Text( - appLocalizations.emoteCount(item.emoteCount), - style: Theme.of(context).textTheme.labelMedium, - ), - const SizedBox(height: 10), - RoundIconTextButton( - text: appLocalizations.viewDetail, - background: Theme.of(context).primaryColor, - onPressed: () { - RouteUtil.pushPanelCupertinoRoute( - context, - EmoteDetailScreen( - emotePackId: item.packageId, - ), - ); - }, - ), - const SizedBox(height: 4), - ], + itemBuilder: (context, index) => ChewieItemBuilder.buildCachedImage( + imageUrl: item.emoteList[index].url, + context: context, + showLoading: false, + placeholderBackground: Colors.transparent, + fit: BoxFit.contain, ), ), ); diff --git a/lib/Screens/Suit/dress_detail_screen.dart b/lib/Screens/Suit/dress_detail_screen.dart index 285ef3eb..130ed44c 100644 --- a/lib/Screens/Suit/dress_detail_screen.dart +++ b/lib/Screens/Suit/dress_detail_screen.dart @@ -1,4 +1,3 @@ - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,8 +6,12 @@ import 'package:loftify/Models/gift_response.dart'; import 'package:loftify/Screens/Info/nested_mixin.dart'; import 'package:loftify/Utils/hive_util.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../Utils/enums.dart'; +import '../../Utils/loftify_file_util.dart'; +import '../../Widgets/Design/loftify_download_progress_button.dart'; import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class DressDetailScreen extends StatefulWidgetForNested { @@ -35,46 +38,42 @@ class _DressDetailScreenState extends BaseDynamicState String? userAvatarImg; String? currentAvatarImg; final EasyRefreshController _refreshController = EasyRefreshController(); + final Map _downloadProgress = {}; @override void initState() { super.initState(); - currentAvatarImg = - ChewieHiveUtil.getString(HiveUtil.customAvatarBoxKey, defaultValue: null); + currentAvatarImg = ChewieHiveUtil.getString(HiveUtil.customAvatarBoxKey, + defaultValue: null); setState(() {}); } - _fetchDetail({bool refresh = false}) async { - if (_loading) return; + Future _fetchDetail({bool refresh = false}) async { + if (_loading) return IndicatorResult.none; _loading = true; - userAvatarImg = (await HiveUtil.getUserInfo())?.bigAvaImg; - await DressApi.getDressDetail( - returnGiftDressId: widget.returnGiftDressId, - ).then((value) { - try { - if (value['code'] != 200) { - IToast.showTop(value['msg']); - return IndicatorResult.fail; - } else { - _giftDress = GiftDress.fromJson(value['data']['returnGiftDress']); - _giftDress!.partList.sort((a, b) => a.partType.compareTo(b.partType)); - if (mounted) setState(() {}); - return IndicatorResult.success; - } - } catch (e, t) { - ILogger.error("Failed to load dress detail", e, t); - if (mounted) IToast.showTop(appLocalizations.loadFailed); + try { + userAvatarImg = (await HiveUtil.getUserInfo())?.bigAvaImg; + final value = await DressApi.getDressDetail( + returnGiftDressId: widget.returnGiftDressId, + ); + if (value['code'] != 200) { + IToast.showTop(value['msg']); return IndicatorResult.fail; - } finally { - if (mounted) setState(() {}); - _loading = false; } - }); + _giftDress = GiftDress.fromJson(value['data']['returnGiftDress']); + _giftDress!.partList.sort((a, b) => a.partType.compareTo(b.partType)); + return IndicatorResult.success; + } catch (e, t) { + ILogger.error("Failed to load dress detail", e, t); + if (mounted) IToast.showTop(appLocalizations.loadFailed); + return IndicatorResult.fail; + } finally { + _loading = false; + if (mounted) setState(() {}); + } } - _onRefresh() async { - return await _fetchDetail(refresh: true); - } + Future _onRefresh() => _fetchDetail(refresh: true); @override Widget build(BuildContext context) { @@ -94,23 +93,112 @@ class _DressDetailScreenState extends BaseDynamicState } Widget _buildBody(ScrollPhysics physics) { - return WaterfallFlow.builder( + return CustomScrollView( physics: physics, - cacheExtent: 9999, - padding: const EdgeInsets.all(10), - itemCount: _giftDress?.partList.length ?? 0, - gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 10, - crossAxisSpacing: 10, - maxCrossAxisExtent: 300, + cacheExtent: MediaQuery.sizeOf(context).height, + slivers: [ + if (_giftDress != null) + SliverToBoxAdapter(child: _buildDressHeader(_giftDress!)), + SliverPadding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 20), + sliver: SliverWaterfallFlow( + gridDelegate: + const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 10, + crossAxisSpacing: 10, + maxCrossAxisExtent: 300, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildItem(_giftDress!.partList[index]), + childCount: _giftDress?.partList.length ?? 0, + ), + ), + ), + ], + ); + } + + Widget _buildDressHeader(GiftDress dress) { + final colors = Theme.of(context).colorScheme; + return Container( + margin: const EdgeInsets.fromLTRB(10, 10, 10, 14), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: ChewieTheme.canvasColor, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.65), + width: 0.6, + ), + ), + child: Row( + children: [ + Container( + width: 92, + height: 92, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(14), + ), + child: ChewieItemBuilder.buildCachedImage( + imageUrl: dress.coverImg, + context: context, + showLoading: false, + placeholderBackground: Colors.transparent, + fit: BoxFit.contain, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dress.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + StringUtil.isNotEmpty(dress.creatorNickName) + ? dress.creatorNickName + : appLocalizations.dressDetail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + appLocalizations.pendantCount(dress.partCount), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], ), - itemBuilder: (context, index) { - return _buildItem(_giftDress!.partList[index]); - }, ); } - _dressOrUnDress(GiftPartItem item) async { + Future _dressOrUnDress(GiftPartItem item) async { HapticFeedback.mediumImpact(); if (currentAvatarImg == item.partUrl) { await ChewieHiveUtil.put(HiveUtil.customAvatarBoxKey, ""); @@ -125,21 +213,33 @@ class _DressDetailScreenState extends BaseDynamicState } } - _buildItem(GiftPartItem item) { + Widget _buildItem(GiftPartItem item) { + final isAvatarBox = item.partType == 1; + final isDressing = currentAvatarImg == item.partUrl; return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: ChewieTheme.canvasColor, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.65), + width: 0.6, + ), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: item.partType == 1 + Container( + height: 176, + width: double.infinity, + alignment: Alignment.center, + padding: const EdgeInsets.all(18), + color: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.07), + child: isAvatarBox ? ItemBuilder.buildAvatar( context: context, - size: 60, + size: 92, showLoading: false, imageUrl: userAvatarImg ?? "", avatarBoxImageUrl: item.partUrl, @@ -153,6 +253,7 @@ class _DressDetailScreenState extends BaseDynamicState showClose: false, fullScreen: true, useFade: true, + opaque: false, HeroPhotoViewScreen( imageUrls: [item.partUrl], useMainColor: false, @@ -164,8 +265,9 @@ class _DressDetailScreenState extends BaseDynamicState tag: item.partUrl, child: ChewieItemBuilder.buildCachedImage( context: context, - width: 90, - height: 90, + width: double.infinity, + height: double.infinity, + fit: BoxFit.contain, showLoading: false, placeholderBackground: Colors.transparent, imageUrl: item.img, @@ -173,68 +275,107 @@ class _DressDetailScreenState extends BaseDynamicState ), ), ), - const SizedBox(height: 10), - Text( - item.partName, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 5), - Text( - item.partType == 1 ? appLocalizations.avatarBox : appLocalizations.commentBubble, - style: Theme.of(context).textTheme.labelMedium, - ), - const SizedBox(height: 10), - Row( - children: [ - if (item.partType == 1) const SizedBox(width: 0), - if (item.partType == 1) - Center( - child: CircleIconButton( - icon: const Icon(Icons.download_done_rounded, size: 24), - onTap: () async { - CustomLoadingDialog.showLoading( - title: appLocalizations.downloading); - String url = item.partUrl; - await FileUtil.saveImage(context, url); - CustomLoadingDialog.dismissLoading(); - }, - ), - ), - if (item.partType != 1) + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Row( + children: [ Expanded( - flex: 2, - child: RoundIconTextButton( - text: appLocalizations.download, onPressed: () async { - CustomLoadingDialog.showLoading( - title: appLocalizations.downloading); - String url = item.partUrl; - await FileUtil.saveImage(context, url); - CustomLoadingDialog.dismissLoading(); - }), - ), - if (item.partType == 1) const SizedBox(width: 5), - if (item.partType == 1) - Expanded( - flex: 3, - child: RoundIconTextButton( - text: currentAvatarImg == item.partUrl - ? appLocalizations.dressingCurrently - : appLocalizations.dressImmediately, - background: currentAvatarImg == item.partUrl - ? null - : Theme.of(context).primaryColor, - onPressed: () { - _dressOrUnDress(item); - }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.partName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + isAvatarBox + ? appLocalizations.avatarBox + : appLocalizations.commentBubble, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], ), ), - ], + LoftifyDownloadProgressIconButton( + semanticLabel: appLocalizations.download, + icon: LoftifyIcons.download, + progress: _downloadProgress[item.partUrl], + onPressed: _downloadProgress.containsKey(item.partUrl) + ? null + : () => _downloadPart(item), + ), + ], + ), ), + if (isAvatarBox) + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + child: Row( + children: [ + Expanded( + child: RoundIconTextButton( + height: 42, + background: CustomDialogColors.readableActionFill( + ChewieTheme.primaryColor.withAlpha(200), + ), + color: Colors.white, + text: isDressing + ? appLocalizations.dressingCurrently + : appLocalizations.dressImmediately, + onPressed: () => _dressOrUnDress(item), + ), + ), + ], + ), + ), ], ), ); } + Future _downloadPart(GiftPartItem item) async { + final key = item.partUrl; + if (_downloadProgress.containsKey(key)) return; + setState(() => _downloadProgress[key] = 0); + try { + final success = await LoftifyFileUtil.saveImage( + context, + key, + onReceiveProgress: (received, total) { + _updateDownloadProgress(key, received, total); + }, + ); + await _showDownloadCompletion(key, success); + } finally { + if (mounted) setState(() => _downloadProgress.remove(key)); + } + } + + void _updateDownloadProgress(String key, int received, int total) { + if (!mounted || !_downloadProgress.containsKey(key) || total <= 0) return; + final next = (received / total).clamp(0.0, 1.0).toDouble(); + final current = _downloadProgress[key] ?? 0; + if (next < 1 && (next - current).abs() < 0.002) return; + setState(() => _downloadProgress[key] = next); + } + + Future _showDownloadCompletion(String key, bool success) async { + if (!mounted || !_downloadProgress.containsKey(key) || !success) return; + setState(() => _downloadProgress[key] = 1); + final motion = context.design.motion; + await Future.delayed(motion.effective(context, motion.state)); + } + PreferredSizeWidget _buildAppBar() { return ResponsiveAppBar( showBack: true, diff --git a/lib/Screens/Suit/dress_screen.dart b/lib/Screens/Suit/dress_screen.dart index eec15179..c45fc0f7 100644 --- a/lib/Screens/Suit/dress_screen.dart +++ b/lib/Screens/Suit/dress_screen.dart @@ -1,4 +1,3 @@ - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Models/gift_response.dart'; @@ -7,6 +6,7 @@ import 'package:loftify/Screens/Suit/dress_detail_screen.dart'; import '../../Api/dress_api.dart'; import '../../Utils/enums.dart'; import '../../Widgets/Item/item_builder.dart'; +import '../../Widgets/Suit/dress_preview_card.dart'; import '../../l10n/l10n.dart'; class DressScreen extends StatefulWidget { @@ -125,7 +125,7 @@ class _DressScreenState extends BaseDynamicState return LoadMoreNotification( child: WaterfallFlow.builder( physics: physics, - cacheExtent: 9999, + cacheExtent: MediaQuery.sizeOf(context).height, padding: const EdgeInsets.all(10), itemCount: _giftDressList.length, gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( @@ -143,57 +143,28 @@ class _DressScreenState extends BaseDynamicState } _buildGiftDressItem(GiftDress item) { - return GestureDetector( - onTap: () { - RouteUtil.pushPanelCupertinoRoute( - context, - DressDetailScreen( - returnGiftDressId: item.returnGiftDressId, - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - decoration: BoxDecoration( - color: ChewieTheme.canvasColor, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - children: [ - const SizedBox(width: 15), - ChewieItemBuilder.buildCachedImage( - imageUrl: item.coverImg, - context: context, - showLoading: false, - placeholderBackground: Colors.transparent, - width: 200, - height: 200, - ), - const SizedBox(height: 10), - Text( - item.name, - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - appLocalizations.pendantCount(item.partCount), - style: Theme.of(context).textTheme.labelMedium, - ), - const SizedBox(height: 10), - RoundIconTextButton( - text: appLocalizations.viewDetail, - background: Theme.of(context).primaryColor, - onPressed: () { - RouteUtil.pushPanelCupertinoRoute( - context, - DressDetailScreen( - returnGiftDressId: item.returnGiftDressId, - ), - ); - }, - ), - const SizedBox(height: 4), - ], - ), + void openDetail() { + RouteUtil.pushPanelCupertinoRoute( + context, + DressDetailScreen(returnGiftDressId: item.returnGiftDressId), + ); + } + + return DressPreviewCard( + title: item.name, + subtitle: StringUtil.isNotEmpty(item.creatorNickName) + ? item.creatorNickName + : appLocalizations.pendantCount(item.partCount), + badge: appLocalizations.pendantCount(item.partCount), + onTap: openDetail, + preview: ChewieItemBuilder.buildCachedImage( + imageUrl: item.coverImg, + context: context, + showLoading: false, + placeholderBackground: Colors.transparent, + width: double.infinity, + height: double.infinity, + fit: BoxFit.contain, ), ); } @@ -203,8 +174,8 @@ class _DressScreenState extends BaseDynamicState showBack: true, centerTitle: StringUtil.isNotEmpty(widget.tag), titleWidget: StringUtil.isNotEmpty(widget.tag) - ? ClickableWrapper(child: - ItemBuilder.buildTagItem( + ? ClickableWrapper( + child: ItemBuilder.buildTagItem( context, widget.tag!, TagType.normal, diff --git a/lib/Screens/Suit/dress_suit_list_screen.dart b/lib/Screens/Suit/dress_suit_list_screen.dart index f27c0bb4..6d306108 100644 --- a/lib/Screens/Suit/dress_suit_list_screen.dart +++ b/lib/Screens/Suit/dress_suit_list_screen.dart @@ -1,13 +1,18 @@ - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/gift_api.dart'; import '../../Models/dress_response.dart'; +import '../../Widgets/Suit/dress_preview_card.dart'; import '../../l10n/l10n.dart'; class DressSuitListScreen extends StatefulWidget { - const DressSuitListScreen({super.key}); + const DressSuitListScreen({ + super.key, + this.refreshOnStart = true, + }); + + final bool refreshOnStart; static const String routeName = "/info/dressSuit"; @@ -25,6 +30,8 @@ class DressSuitListScreenState extends BaseDynamicState final EasyRefreshController _refreshController = EasyRefreshController(); bool _noMore = false; + void callRefresh() => _refreshController.callRefresh(); + _fetchList({bool refresh = false}) async { if (_loading) return; if (refresh) { @@ -79,7 +86,7 @@ class DressSuitListScreenState extends BaseDynamicState Widget build(BuildContext context) { super.build(context); return EasyRefresh.builder( - refreshOnStart: true, + refreshOnStart: widget.refreshOnStart, controller: _refreshController, onRefresh: _onRefresh, onLoad: _onLoad, @@ -94,7 +101,7 @@ class DressSuitListScreenState extends BaseDynamicState return LoadMoreNotification( child: WaterfallFlow.builder( physics: physics, - cacheExtent: 9999, + cacheExtent: MediaQuery.sizeOf(context).height, padding: const EdgeInsets.all(10), itemCount: _dressSuitList.length, gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( @@ -112,63 +119,24 @@ class DressSuitListScreenState extends BaseDynamicState } _buildDressItem(DressingItem item) { - return ClickableWrapper(child: - GestureDetector( - onTap: () {}, - child: Stack( - alignment: Alignment.bottomLeft, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(20), - child: ChewieItemBuilder.buildCachedImage( - imageUrl: item.img, - context: context, - height: 400, - fit: BoxFit.cover, - width: double.infinity, - showLoading: false, - ), - ), - Container( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 6), - Text( - item.intro, - style: const TextStyle(color: Colors.white54, fontSize: 16), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - // const SizedBox(height: 10), - // Row( - // children: [ - // Expanded( - // child: RoundIconTextButton( - // context, - // background: Colors.white24, - // color: Colors.white, - // text: appLocalizations.viewDetail, - // onTap: () {}, - // ), - // ), - // ], - // ), - const SizedBox(height: 4), - ], - ), - ), - ], - ), + return DressPreviewCard( + title: item.name, + subtitle: item.intro, + badge: item.specialLabel.isNotEmpty + ? item.specialLabel + : item.showPrice.isNotEmpty + ? item.showPrice + : null, + previewHeight: 260, + previewPadding: EdgeInsets.zero, + onTap: null, + preview: ChewieItemBuilder.buildCachedImage( + imageUrl: item.img, + context: context, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + showLoading: false, ), ); } diff --git a/lib/Screens/Suit/emote_detail_screen.dart b/lib/Screens/Suit/emote_detail_screen.dart index 89d88566..05e09c18 100644 --- a/lib/Screens/Suit/emote_detail_screen.dart +++ b/lib/Screens/Suit/emote_detail_screen.dart @@ -1,4 +1,3 @@ - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/dress_api.dart'; @@ -6,6 +5,10 @@ import 'package:loftify/Models/gift_response.dart'; import 'package:loftify/Screens/Info/nested_mixin.dart'; import 'package:loftify/Utils/hive_util.dart'; +import '../../Theme/loftify_design_theme.dart'; +import '../../Utils/loftify_file_util.dart'; +import '../../Widgets/Design/loftify_download_progress_button.dart'; +import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; class EmoteDetailScreen extends StatefulWidgetForNested { @@ -32,20 +35,21 @@ class _EmoteDetailScreenState extends BaseDynamicState String? userAvatarImg; String? currentAvatarImg; final EasyRefreshController _refreshController = EasyRefreshController(); + final Map _downloadProgress = {}; @override void initState() { super.initState(); - currentAvatarImg = - ChewieHiveUtil.getString(HiveUtil.customAvatarBoxKey, defaultValue: null); + currentAvatarImg = ChewieHiveUtil.getString(HiveUtil.customAvatarBoxKey, + defaultValue: null); setState(() {}); } - _fetchDetail({bool refresh = false}) async { - if (_loading) return; + Future _fetchDetail({bool refresh = false}) async { + if (_loading) return IndicatorResult.none; _loading = true; userAvatarImg = (await HiveUtil.getUserInfo())?.bigAvaImg; - await DressApi.getEmoteDetail( + return await DressApi.getEmoteDetail( emotePackId: widget.emotePackId, ).then((value) { try { @@ -70,7 +74,7 @@ class _EmoteDetailScreenState extends BaseDynamicState }); } - _onRefresh() async { + Future _onRefresh() async { return await _fetchDetail(refresh: true); } @@ -94,7 +98,7 @@ class _EmoteDetailScreenState extends BaseDynamicState Widget _buildBody(ScrollPhysics physics) { return WaterfallFlow.builder( physics: physics, - cacheExtent: 9999, + cacheExtent: MediaQuery.sizeOf(context).height, padding: const EdgeInsets.all(10), itemCount: _giftEmote?.emoteList.length ?? 0, gridDelegate: const SliverWaterfallFlowDelegateWithMaxCrossAxisExtent( @@ -108,7 +112,7 @@ class _EmoteDetailScreenState extends BaseDynamicState ); } - _buildItem(EmoteItem item) { + Widget _buildItem(EmoteItem item) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), decoration: BoxDecoration( @@ -140,13 +144,14 @@ class _EmoteDetailScreenState extends BaseDynamicState children: [ Expanded( flex: 2, - child: RoundIconTextButton( - text: appLocalizations.download, onPressed: () async { - CustomLoadingDialog.showLoading(title: appLocalizations.downloading); - String url = item.url; - await FileUtil.saveImage(context, url); - CustomLoadingDialog.dismissLoading(); - }), + child: LoftifyDownloadProgressButton( + label: appLocalizations.download, + icon: LoftifyIcons.download, + progress: _downloadProgress[item.url], + onPressed: _downloadProgress.containsKey(item.url) + ? null + : () => _downloadEmote(item), + ), ), ], ), @@ -155,6 +160,39 @@ class _EmoteDetailScreenState extends BaseDynamicState ); } + Future _downloadEmote(EmoteItem item) async { + final key = item.url; + if (_downloadProgress.containsKey(key)) return; + setState(() => _downloadProgress[key] = 0); + try { + final success = await LoftifyFileUtil.saveImage( + context, + key, + onReceiveProgress: (received, total) { + _updateDownloadProgress(key, received, total); + }, + ); + await _showDownloadCompletion(key, success); + } finally { + if (mounted) setState(() => _downloadProgress.remove(key)); + } + } + + void _updateDownloadProgress(String key, int received, int total) { + if (!mounted || !_downloadProgress.containsKey(key) || total <= 0) return; + final next = (received / total).clamp(0.0, 1.0).toDouble(); + final current = _downloadProgress[key] ?? 0; + if (next < 1 && (next - current).abs() < 0.002) return; + setState(() => _downloadProgress[key] = next); + } + + Future _showDownloadCompletion(String key, bool success) async { + if (!mounted || !_downloadProgress.containsKey(key) || !success) return; + setState(() => _downloadProgress[key] = 1); + final motion = context.design.motion; + await Future.delayed(motion.effective(context, motion.state)); + } + PreferredSizeWidget _buildAppBar() { return ResponsiveAppBar( showBack: true, diff --git a/lib/Screens/Suit/suit_screen.dart b/lib/Screens/Suit/suit_screen.dart index 754dd323..2b7b976c 100644 --- a/lib/Screens/Suit/suit_screen.dart +++ b/lib/Screens/Suit/suit_screen.dart @@ -1,10 +1,11 @@ - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Screens/Suit/custom_bg_avatar_list_screen.dart'; import 'package:loftify/Screens/Suit/dress_suit_list_screen.dart'; import '../../Api/gift_api.dart'; +import '../../Utils/hive_util.dart'; +import '../../Utils/tab_state_util.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../l10n/l10n.dart'; import 'custom_dress_list_screen.dart'; @@ -24,6 +25,12 @@ class _SuitScreenState extends BaseDynamicState bool get wantKeepAlive => true; final List _tabLabelList = ["官方", "定制"]; + static const List _tabIdList = ['official', 'custom']; + static const List _customPageIdList = [ + 'background', + 'dress', + 'emote', + ]; late TabController _tabController; int _currentTabIndex = 0; int _currentOfficialBottomBarIndex = 0; @@ -32,15 +39,69 @@ class _SuitScreenState extends BaseDynamicState List _officialPageList = []; List _customPageList = []; final PageController _officialPageController = PageController(); - final PageController _customPageController = PageController(); + late final PageController _customPageController; + final GlobalKey _officialPageKey = GlobalKey(); + final GlobalKey _backgroundPageKey = + GlobalKey(); + final GlobalKey _dressPageKey = GlobalKey(); + final GlobalKey _emotePageKey = GlobalKey(); + final Set _loadedPageIds = {}; List tags = []; @override void initState() { super.initState(); + _currentCustomBottomBarIndex = PersistentTabState.restore( + idKey: HiveUtil.suitCustomPageIdKey, + legacyIndexKey: HiveUtil.suitCustomPageIndexKey, + itemIds: _customPageIdList, + ).index; + _customPageController = PageController( + initialPage: _currentCustomBottomBarIndex, + ); + _customPageController.addListener(_handleCustomPageChanged); + _officialPageController.addListener(_handleOfficialPageChanged); initTab(); fetchTag(); initPage(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _ensureCurrentPageLoaded(); + }); + } + + @override + void dispose() { + _tabController.dispose(); + _customPageController + ..removeListener(_handleCustomPageChanged) + ..dispose(); + _officialPageController + ..removeListener(_handleOfficialPageChanged) + ..dispose(); + super.dispose(); + } + + void _handleCustomPageChanged() { + final page = _customPageController.page?.round(); + if (page == null || page == _currentCustomBottomBarIndex || !mounted) { + return; + } + setState(() => _currentCustomBottomBarIndex = page); + PersistentTabState.save( + idKey: HiveUtil.suitCustomPageIdKey, + legacyIndexKey: HiveUtil.suitCustomPageIndexKey, + itemIds: _customPageIdList, + index: page, + ); + _ensureCurrentPageLoaded(); + } + + void _handleOfficialPageChanged() { + final page = _officialPageController.page?.round(); + if (page == null || page == _currentOfficialBottomBarIndex || !mounted) { + return; + } + setState(() => _currentOfficialBottomBarIndex = page); } fetchTag() async { @@ -51,9 +112,9 @@ class _SuitScreenState extends BaseDynamicState tag: "", ); String t = res['data']["tags"]; + if (!mounted) return; tags = t.split(","); initPage(); - setState(() {}); } catch (e, t) { ILogger.error("Failed to fetch Tag", e, t); } @@ -61,12 +122,28 @@ class _SuitScreenState extends BaseDynamicState initPage() { _officialPageList = [ - const DressSuitListScreen(), + DressSuitListScreen( + key: _officialPageKey, + refreshOnStart: false, + ), ]; _customPageList = [ - CustomBgAvatarListScreen(tags: tags), - CustomDressListScreen(tags: tags), - CustomDressListScreen(tags: tags, propType: 3), + CustomBgAvatarListScreen( + key: _backgroundPageKey, + tags: tags, + refreshOnStart: false, + ), + CustomDressListScreen( + key: _dressPageKey, + tags: tags, + refreshOnStart: false, + ), + CustomDressListScreen( + key: _emotePageKey, + tags: tags, + propType: 3, + refreshOnStart: false, + ), ]; _pageList = [ PageView( @@ -79,33 +156,80 @@ class _SuitScreenState extends BaseDynamicState children: _customPageList, ), ]; - _customPageController.addListener(() { - if (_customPageController.page != _currentCustomBottomBarIndex) { - setState(() { - _currentCustomBottomBarIndex = _customPageController.page!.round(); - }); - } - }); - _officialPageController.addListener(() { - if (_officialPageController.page != _currentOfficialBottomBarIndex) { - setState(() { - _currentOfficialBottomBarIndex = - _officialPageController.page!.round(); - }); - } - }); setState(() {}); } initTab() { - _tabController = TabController(length: _tabLabelList.length, vsync: this); - _tabController.animation?.addListener(() { - int indexChange = - _tabController.offset.abs() > 0.8 ? _tabController.offset.round() : 0; - int index = _tabController.index + indexChange; - if (index != _currentTabIndex) { - setState(() => _currentTabIndex = index); + _currentTabIndex = PersistentTabState.restore( + idKey: HiveUtil.suitTabIdKey, + legacyIndexKey: HiveUtil.suitTabIndexKey, + itemIds: _tabIdList, + ).index; + _tabController = TabController( + length: _tabLabelList.length, + initialIndex: _currentTabIndex, + vsync: this, + ); + _tabController.addListener(() { + final index = + (_tabController.animation?.value ?? _tabController.index).round(); + if (index != _currentTabIndex) _setCurrentTab(index); + }); + } + + void _setCurrentTab(int index) { + final safeIndex = TabStatePreference.restoreIndex(index, _tabIdList.length); + if (safeIndex != _currentTabIndex && mounted) { + setState(() => _currentTabIndex = safeIndex); + } + PersistentTabState.save( + idKey: HiveUtil.suitTabIdKey, + legacyIndexKey: HiveUtil.suitTabIndexKey, + itemIds: _tabIdList, + index: safeIndex, + ); + _ensureCurrentPageLoaded(); + } + + void _ensureCurrentPageLoaded() { + final pageId = _currentTabIndex == 0 + ? 'official' + : 'custom:${_customPageIdList[_currentCustomBottomBarIndex]}'; + if (!_loadedPageIds.add(pageId)) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + var loaded = false; + switch (pageId) { + case 'official': + final state = _officialPageKey.currentState; + if (state != null) { + state.callRefresh(); + loaded = true; + } + break; + case 'custom:background': + final state = _backgroundPageKey.currentState; + if (state != null) { + state.callRefresh(); + loaded = true; + } + break; + case 'custom:dress': + final state = _dressPageKey.currentState; + if (state != null) { + state.callRefresh(); + loaded = true; + } + break; + case 'custom:emote': + final state = _emotePageKey.currentState; + if (state != null) { + state.callRefresh(); + loaded = true; + } + break; } + if (!loaded) _loadedPageIds.remove(pageId); }); } @@ -135,21 +259,21 @@ class _SuitScreenState extends BaseDynamicState _buildTabBar() { return TabBarWrapper( tabController: _tabController, - tabs: _tabLabelList + tabs: _tabLabelList .asMap() .entries .map( (entry) => ItemBuilder.buildAnimatedTab(context, selected: entry.key == _currentTabIndex, text: entry.value, + controller: _tabController, + tabIndex: entry.key, normalUserBold: true, sameFontSize: true), ) .toList(), onTap: (index) { - setState(() { - _currentTabIndex = index; - }); + _setCurrentTab(index); }, background: ResponsiveUtil.isLandscapeLayout() ? Colors.transparent diff --git a/lib/Screens/main_screen.dart b/lib/Screens/main_screen.dart index 73784092..1428f1da 100644 --- a/lib/Screens/main_screen.dart +++ b/lib/Screens/main_screen.dart @@ -8,15 +8,16 @@ import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:loftify/Api/server_api.dart'; import 'package:loftify/Screens/Login/login_by_captcha_screen.dart'; import 'package:loftify/Screens/panel_screen.dart'; -import 'package:loftify/Utils/asset_util.dart'; import 'package:loftify/Utils/cloud_control_provider.dart'; import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; import 'package:loftify/Widgets/Item/item_builder.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; import 'package:provider/provider.dart'; import 'package:tray_manager/tray_manager.dart'; import 'package:window_manager/window_manager.dart'; -import '../../l10n/l10n.dart'; +import '../l10n/l10n.dart'; import '../Api/login_api.dart'; import '../Api/user_api.dart'; import '../Models/account_response.dart'; @@ -50,6 +51,7 @@ class MainScreenState extends BaseWindowState Widget? darkModeWidget; FullBlogInfo? blogInfo; bool _hasJumpedToPinVerify = false; + bool _orientationPolicyUpdateScheduled = false; Orientation? _oldOrientation; @override @@ -139,7 +141,7 @@ class MainScreenState extends BaseWindowState WidgetsBinding.instance.addPostFrameCallback((_) async { showQQGroupDialog(); jumpToLogin(); - darkModeWidget = LottieUtil.load( + darkModeWidget = LottieFiles.buildAnimation( LottieFiles.sunLight, size: 25, autoForward: !ColorUtil.isDark(context), @@ -148,7 +150,7 @@ class MainScreenState extends BaseWindowState ResponsiveUtil.runByPlatform(desktop: () async { await Utils.initTray(); trayManager.addListener(this); - keyboardHandlerState?.focus(); + appProvider.shortcutFocusNode.requestFocus(); }); }); initConfig(); @@ -178,7 +180,7 @@ class MainScreenState extends BaseWindowState } initConfig() { - ResponsiveUtil.checkSizeCondition(); + unawaited(ResponsiveUtil.checkSizeCondition()); ResponsiveUtil.runByPlatform( desktop: () { initHotKey(); @@ -190,8 +192,9 @@ class MainScreenState extends BaseWindowState .then((value) => setState(() => isMaximized = value)); }, mobile: () { - ChewieUtils.setSafeMode( - ChewieHiveUtil.getBool(HiveUtil.enableSafeModeKey, defaultValue: false)); + ChewieUtils.setSafeMode(ChewieHiveUtil.getBool( + HiveUtil.enableSafeModeKey, + defaultValue: false)); }, ); initDeepLinks(); @@ -212,20 +215,25 @@ class MainScreenState extends BaseWindowState ); } - initEasyRefresh() { - // EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( - // backgroundColor: Theme.of(context).canvasColor, - // indicator: LottieUtil.load(LottieUtil.getLoadingPath(context)), - // hapticFeedback: true, - // triggerOffset: 40, - // ); - EasyRefresh.defaultHeaderBuilder = () => MaterialHeader( - backgroundColor: Theme.of(context).canvasColor, - color: Theme.of(context).primaryColor, + void initEasyRefresh() { + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + hapticFeedback: true, + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, ); EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( - indicator: LottieUtil.load(LottieFiles.getLoadingPath(context)), + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, ); + chewieProvider.loadingWidgetBuilder = LottieFiles.buildLoadingAnimation; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; } showQQGroupDialog() { @@ -250,7 +258,8 @@ class MainScreenState extends BaseWindowState void jumpToLogin() { if (ChewieHiveUtil.isFirstLogin() && - ChewieHiveUtil.getString(HiveUtil.tokenKey, defaultValue: null) == null) { + ChewieHiveUtil.getString(HiveUtil.tokenKey, defaultValue: null) == + null) { HiveUtil.initConfig(); ChewieHiveUtil.setFirstLogin(); if (ResponsiveUtil.isLandscapeLayout()) { @@ -289,7 +298,7 @@ class MainScreenState extends BaseWindowState return ResponsiveUtil.selectByResponsive( landscape: Scaffold( resizeToAvoidBottomInset: false, - backgroundColor: ChewieTheme.canvasColor, + backgroundColor: ChewieTheme.scaffoldBackgroundColor, body: SafeArea(child: _buildDesktopBody()), ), desktop: _buildDesktopBody(), @@ -322,7 +331,7 @@ class MainScreenState extends BaseWindowState entries: [ FlutterContextMenuItem( appLocalizations.viewPersonalHomepage, - iconData: Icons.person_outline_rounded, + iconData: LoftifyIcons.profile, onPressed: () async { panelScreenState?.pushPage(UserDetailScreen( blogId: blogInfo!.blogId, @@ -334,7 +343,7 @@ class MainScreenState extends BaseWindowState FlutterContextMenuItem( appLocalizations.logout, status: MenuItemStatus.warning, - iconData: Icons.logout_rounded, + iconData: LoftifyIcons.logout, onPressed: () async { HiveUtil.confirmLogout(context); }, @@ -378,7 +387,7 @@ class MainScreenState extends BaseWindowState width: 42 + leftPadding + rightPadding, alignment: Alignment.center, decoration: BoxDecoration( - color: Theme.of(context).canvasColor, + color: Theme.of(context).scaffoldBackgroundColor, border: Border( right: BorderSide( color: Theme.of(context).dividerColor, @@ -391,10 +400,13 @@ class MainScreenState extends BaseWindowState children: [ ResponsiveUtil.selectByPlatform(desktop: const WindowMoveHandle()), Consumer( - builder: (_, cloudControlProvider, __) => - Selector( - selector: (context, appProvider) => appProvider.sidebarChoice, - builder: (context, sidebarChoice, child) => + builder: (_, cloudControlProvider, __) => Selector( + selector: (context, appProvider) => ( + sidebarChoice: appProvider.sidebarChoice, + hideSearch: appProvider.hideSearchNavigation, + ), + builder: (context, preferences, child) => Selector( selector: (context, appProvider) => !appProvider.showPanelNavigator, @@ -408,35 +420,37 @@ class MainScreenState extends BaseWindowState const SizedBox(height: 8), ToolButton( context: context, - selected: - hideNavigator && sidebarChoice == SideBarChoice.Home, - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, + selected: hideNavigator && + preferences.sidebarChoice == SideBarChoice.Home, + icon: LoftifyIcons.home, + selectedIcon: LoftifyIcons.home, onPressed: () async { appProvider.sidebarChoice = SideBarChoice.Home; panelScreenState?.popAll(false); }, iconSize: 24, ), + if (!preferences.hideSearch) ...[ + const SizedBox(height: 8), + ToolButton( + context: context, + selected: hideNavigator && + preferences.sidebarChoice == SideBarChoice.Search, + icon: LoftifyIcons.search, + selectedIcon: LoftifyIcons.search, + onPressed: () async { + appProvider.sidebarChoice = SideBarChoice.Search; + panelScreenState?.popAll(false); + }, + ), + ], const SizedBox(height: 8), ToolButton( context: context, selected: hideNavigator && - sidebarChoice == SideBarChoice.Search, - icon: Icons.search_rounded, - selectedIcon: Icons.manage_search_rounded, - onPressed: () async { - appProvider.sidebarChoice = SideBarChoice.Search; - panelScreenState?.popAll(false); - }, - ), - const SizedBox(height: 8), - ToolButton( - context: context, - selected: hideNavigator && - sidebarChoice == SideBarChoice.Dynamic, - icon: Icons.favorite_border_rounded, - selectedIcon: Icons.favorite_rounded, + preferences.sidebarChoice == SideBarChoice.Dynamic, + icon: LoftifyIcons.activity, + selectedIcon: LoftifyIcons.activity, onPressed: () async { appProvider.sidebarChoice = SideBarChoice.Dynamic; panelScreenState?.popAll(false); @@ -445,10 +459,10 @@ class MainScreenState extends BaseWindowState const SizedBox(height: 8), ToolButton( context: context, - selected: - hideNavigator && sidebarChoice == SideBarChoice.Mine, - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, + selected: hideNavigator && + preferences.sidebarChoice == SideBarChoice.Mine, + icon: LoftifyIcons.profile, + selectedIcon: LoftifyIcons.profile, onPressed: () async { appProvider.sidebarChoice = SideBarChoice.Mine; panelScreenState?.popAll(false); @@ -499,12 +513,7 @@ class MainScreenState extends BaseWindowState if (cloudControlProvider.globalControl.showDress) ...[ ToolButton( context: context, - iconBuilder: (_) => AssetUtil.loadDouble( - context, - AssetUtil.dressLightIcon, - AssetUtil.dressDarkIcon, - ), - padding: const EdgeInsets.all(8), + icon: LoftifyIcons.dress, onPressed: () { RouteUtil.pushPanelCupertinoRoute( context, const SuitScreen()); @@ -514,11 +523,7 @@ class MainScreenState extends BaseWindowState ], ToolButton( context: context, - iconBuilder: (_) => Icon( - Icons.notifications_on_outlined, - color: Theme.of(context).iconTheme.color, - size: 20, - ), + icon: LoftifyIcons.notifications, onPressed: () { RouteUtil.pushPanelCupertinoRoute( context, const SystemNoticeScreen()); @@ -527,12 +532,7 @@ class MainScreenState extends BaseWindowState const SizedBox(height: 2), ToolButton( context: context, - iconBuilder: (_) => AssetUtil.loadDouble( - context, - AssetUtil.settingLightIcon, - AssetUtil.settingDarkIcon, - ), - padding: const EdgeInsets.all(8), + icon: LoftifyIcons.settings, onPressed: () { RouteUtil.pushPanelCupertinoRoute( context, const SettingScreen()); @@ -587,6 +587,21 @@ class MainScreenState extends BaseWindowState } } + @override + void didChangeMetrics() { + super.didChangeMetrics(); + if (!ResponsiveUtil.isMobile() || _orientationPolicyUpdateScheduled) { + return; + } + _orientationPolicyUpdateScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _orientationPolicyUpdateScheduled = false; + if (mounted) { + unawaited(ResponsiveUtil.checkSizeCondition()); + } + }); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { diff --git a/lib/Screens/panel_screen.dart b/lib/Screens/panel_screen.dart index a9382076..0cba51a7 100644 --- a/lib/Screens/panel_screen.dart +++ b/lib/Screens/panel_screen.dart @@ -13,6 +13,8 @@ * If not, see . */ +import 'dart:async'; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -24,10 +26,49 @@ import 'package:provider/provider.dart'; import '../Utils/app_provider.dart'; import '../Utils/enums.dart'; import '../Utils/lottie_files.dart'; +import '../Widgets/Navigation/loftify_glass_navigation_bar.dart'; +import '../Widgets/loftify_icons.dart'; import '../l10n/l10n.dart'; import 'Navigation/home_screen.dart'; import 'Navigation/search_screen.dart'; +class PanelBackScope extends StatelessWidget { + const PanelBackScope({ + super.key, + required this.canRootPop, + required this.onNestedPop, + required this.child, + }); + + final bool canRootPop; + final FutureOr Function() onNestedPop; + final Widget child; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: canRootPop, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) onNestedPop(); + }, + child: child, + ); + } +} + +List visiblePanelChoices({required bool hideSearch}) => [ + SideBarChoice.Home, + if (!hideSearch) SideBarChoice.Search, + SideBarChoice.Dynamic, + SideBarChoice.Mine, + ]; + +int visiblePanelPageIndex( + SideBarChoice choice, { + required bool hideSearch, +}) => + visiblePanelChoices(hideSearch: hideSearch).indexOf(choice); + class PanelScreen extends StatefulWidget { const PanelScreen({ super.key, @@ -39,7 +80,7 @@ class PanelScreen extends StatefulWidget { State createState() => PanelScreenState(); } -class PanelScreenState extends BaseDynamicState +class PanelScreenState extends BasePanelScreenState with TickerProviderStateMixin, AutomaticKeepAliveClientMixin, @@ -49,6 +90,50 @@ class PanelScreenState extends BaseDynamicState List _keyList = []; bool unlogin = false; int _currentIndex = 0; + List get _visibleChoices => + visiblePanelChoices(hideSearch: appProvider.hideSearchNavigation); + + int _visibleIndexFor(int logicalIndex) { + final index = visiblePanelPageIndex( + SideBarChoice.fromInt(logicalIndex), + hideSearch: appProvider.hideSearchNavigation, + ); + return index < 0 ? 0 : index; + } + + List _buildVisiblePages() => _visibleChoices.map((choice) { + return switch (choice) { + SideBarChoice.Home => HomeScreen(key: _keyList[choice.index]), + SideBarChoice.Search => SearchScreen(key: _keyList[choice.index]), + SideBarChoice.Dynamic => DynamicScreen(key: _keyList[choice.index]), + SideBarChoice.Mine => MineScreen(key: _keyList[choice.index]), + }; + }).toList(); + + void _replacePageController(int logicalIndex) { + final previous = _pageController; + _pageController = PageController( + initialPage: _visibleIndexFor(logicalIndex), + keepPage: false, + ); + WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose()); + } + + void _configurePages() { + _keyList = [ + homeScreenKey, + searchScreenKey, + GlobalKey(), + GlobalKey(), + ]; + _pageList = _buildVisiblePages(); + _currentIndex = appProvider.hideSearchNavigation && + appProvider.sidebarChoice == SideBarChoice.Search + ? SideBarChoice.Home.index + : appProvider.sidebarChoice.index; + _replacePageController(_currentIndex); + } + late AnimationController darkModeController; Widget? darkModeWidget; final ScrollToHideController _scrollToHideController = @@ -59,15 +144,17 @@ class PanelScreenState extends BaseDynamicState NavigatorState? get panelNavigatorState => panelNavigatorKey.currentState; bool canRootPop = true; + bool _nestedPopInProgress = false; + final List> _nestedRoutes = []; @override void initState() { - updateStatusBar(); super.initState(); + updateStatusBar(); darkModeController = AnimationController(vsync: this); + _configurePages(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - initPage(); - darkModeWidget = LottieUtil.load( + darkModeWidget = LottieFiles.buildAnimation( LottieFiles.sunLight, size: 25, autoForward: !ColorUtil.isDark(context), @@ -76,95 +163,121 @@ class PanelScreenState extends BaseDynamicState }); } - login() { + void login() { popAll(); initPage(); } - logout() { + void logout() { popAll(); initPage(); } - popAll([bool initPage = true]) { + @override + void popAll([bool initPage = true]) { + _nestedPopInProgress = false; + _nestedRoutes.clear(); while (panelNavigatorState?.canPop() ?? false) { panelNavigatorState?.pop(); } canRootPop = !(panelNavigatorState?.canPop() ?? false); appProvider.showPanelNavigator = false; if (initPage) { - _pageController = - PageController(initialPage: appProvider.sidebarChoice.index); + _replacePageController(appProvider.sidebarChoice.index); } } - pushPage(Widget page) { - ResponsiveUtil.runByOrientation( - landscape: () { - appProvider.showPanelNavigator = true; - panelNavigatorState?.push(RouteUtil.getFadeRoute(page)); - canRootPop = false; - if (mounted) setState(() {}); - }, - portrait: () { - appProvider.showPanelNavigator = true; - RouteUtil.pushCupertinoRoute(panelNavigatorState!.context, page); - canRootPop = false; - if (mounted) setState(() {}); - }, - ); + @override + void pushPage(Widget page) { + final navigator = panelNavigatorState; + if (navigator == null) return; + appProvider.showPanelNavigator = true; + canRootPop = false; + if (mounted) setState(() {}); + final TransitionRoute route = ResponsiveUtil.isLandscapeLayout() + ? RouteUtil.getFadeRoute(page) + : CustomCupertinoPageRoute(builder: (context) => page); + _nestedRoutes.add(route); + unawaited(navigator.push(route)); + // Navigator.push completes when pop begins, not when the reverse transition + // has left the overlay. Keep the panel visible until the route is removed. + unawaited(route.completed.whenComplete(() { + _nestedRoutes.remove(route); + _nestedPopInProgress = false; + _syncPanelAfterRoutePop(); + })); } - popPage() { - if (panelNavigatorState?.canPop() ?? false) { - panelNavigatorState?.pop(); - Future.delayed(const Duration(milliseconds: 400), () { - if (!(panelNavigatorState?.canPop() ?? false)) { - appProvider.showPanelNavigator = false; - } - }); - } else { - appProvider.showPanelNavigator = false; - } - _pageController = - PageController(initialPage: appProvider.sidebarChoice.index); - canRootPop = !(panelNavigatorState?.canPop() ?? false); - if (mounted) setState(() {}); + void _syncPanelAfterRoutePop() { + if (!mounted) return; + final hasNestedPage = panelNavigatorState?.canPop() ?? false; + canRootPop = !hasNestedPage; + appProvider.showPanelNavigator = hasNestedPage; + setState(() {}); + } + + @override + void popPage() { + if (_nestedPopInProgress) return; + final navigator = panelNavigatorState; + if (navigator == null || !navigator.canPop()) return; + final route = _nestedRoutes.isEmpty ? null : _nestedRoutes.last; + // maybePop consults the active route's PopScope. A direct pop bypasses it + // and would remove a full-screen video instead of first restoring portrait. + final willRemoveRoute = route?.popDisposition == RoutePopDisposition.pop && + !(route?.willHandlePopInternally ?? false); + if (willRemoveRoute) _nestedPopInProgress = true; + unawaited(navigator.maybePop().whenComplete(() { + // PopScope and LocalHistoryEntry may handle back without removing the + // route. Only a real reverse transition stays locked until completed. + if (route?.animation?.status != AnimationStatus.reverse) { + _nestedPopInProgress = false; + } + })); } - updateStatusBar() { - SystemUiOverlayStyle systemUiOverlayStyle = const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.dark, + @override + void updateStatusBar() { + final brightness = appProvider.getBrightness() ?? + WidgetsBinding.instance.platformDispatcher.platformBrightness; + final systemUiOverlayStyle = + AppBarWrapper.systemUiOverlayStyleForBrightness( + brightness, + includeNavigationBar: true, ); SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle); } Future initPage() async { - _keyList = [ - homeScreenKey, - searchScreenKey, - GlobalKey(), - GlobalKey(), - ]; - _pageList = [ - HomeScreen(key: _keyList[0]), - SearchScreen(key: _keyList[1]), - DynamicScreen(key: _keyList[2]), - MineScreen(key: _keyList[3]), - ]; + _configurePages(); try { - ILogger.debug( - "init panel page and jump to ${appProvider.sidebarChoice.index.clamp(0, _pageList.length - 1)}"); + ILogger.debug("init panel page and jump to $_currentIndex"); } catch (e, t) { ILogger.error("Failed to init panel page", e, t); } - jumpToPage(appProvider.sidebarChoice.index.clamp(0, _pageList.length - 1)); + if (mounted) setState(() {}); + } + + void updateSearchNavigationVisibility() { + if (_keyList.length != SideBarChoice.values.length) return; + if (appProvider.hideSearchNavigation && + _currentIndex == SideBarChoice.Search.index) { + _currentIndex = SideBarChoice.Home.index; + } + _pageList = _buildVisiblePages(); + _replacePageController(_currentIndex); + if (mounted) setState(() {}); } + @override void jumpToPage(int index) { - if (_currentIndex == index) { + if (index < 0 || index >= SideBarChoice.values.length) return; + if (appProvider.hideSearchNavigation && + index == SideBarChoice.Search.index) { + index = SideBarChoice.Home.index; + } + _scrollToHideController.show(); + if (_currentIndex == index && _keyList.isNotEmpty) { BottomNavgationMixin? mixin = _keyList[_currentIndex].currentState is BottomNavgationMixin? ? _keyList[_currentIndex].currentState as BottomNavgationMixin? @@ -173,16 +286,21 @@ class PanelScreenState extends BaseDynamicState } else { _currentIndex = index; if (_pageController.hasClients) { - _pageController.jumpToPage(index); + // A PageView animation leaves the previously selected page on screen + // after the nav item has already changed. Switching tabs should be + // immediate, including quick taps across non-adjacent destinations. + _pageController.jumpToPage(_visibleIndexFor(index)); } } if (mounted) setState(() {}); } + @override void refreshScrollControllers() { setState(() {}); } + @override void showBottomNavigationBar() { _scrollToHideController.show(); } @@ -233,76 +351,88 @@ class PanelScreenState extends BaseDynamicState child: Navigator( key: panelNavigatorKey, onGenerateRoute: (settings) { - return MaterialPageRoute(builder: (context) => emptyWidget); + return RouteUtil.getFadeRoute( + emptyWidget, + duration: Duration.zero, + ); }, ), ), ), ], ); - return PopScope( - canPop: canRootPop, - onPopInvokedWithResult: (_, __) => popPage(), + return PanelBackScope( + canRootPop: canRootPop, + onNestedPop: popPage, child: scaffold, ); } - _buildBottomNavigationBar() { + Widget _buildBottomNavigationBar() { + if (!LoftifyGlassNavigationBar.shouldShowForKeyboard( + MediaQuery.of(context), + )) { + return const SizedBox.shrink(); + } return ScrollToHide.multi( controller: _scrollToHideController, scrollControllers: getScrollControllers(), hideDirection: Axis.vertical, - child: Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - ), - ), + child: Selector< + AppProvider, + ({ + bool reduceTransparency, + NavigationBarDisplayStyle displayStyle, + bool hideSearchNavigation, + })>( + selector: (context, appProvider) => ( + reduceTransparency: appProvider.reduceTransparency, + displayStyle: appProvider.navigationBarDisplayStyle, + hideSearchNavigation: appProvider.hideSearchNavigation, ), - child: MyBottomNavigationBar( - currentIndex: _currentIndex, - selectedItemColor: Theme.of(context).primaryColor, - showSelectedLabels: false, - unselectedItemColor: Colors.grey, - showUnselectedLabels: false, - type: BottomNavigationBarType.fixed, - elevation: 0, - items: [ - BottomNavigationBarItem( - icon: const Icon(Icons.explore_outlined, size: 28), - activeIcon: const Icon(Icons.explore_rounded, size: 28), - label: appLocalizations.home, - ), - BottomNavigationBarItem( - icon: const Icon(Icons.search_rounded, size: 28), - activeIcon: const Icon(Icons.manage_search_rounded, size: 28), - label: appLocalizations.search, + builder: (context, preferences, child) { + final visibleChoices = visiblePanelChoices( + hideSearch: preferences.hideSearchNavigation, + ); + return LoftifyGlassNavigationBar( + currentIndex: visibleChoices.indexOf( + SideBarChoice.fromInt(_currentIndex), ), - BottomNavigationBarItem( - icon: const Icon(Icons.favorite_border_rounded, size: 28), - activeIcon: const Icon(Icons.favorite_rounded, size: 28), - label: appLocalizations.dynamic, - ), - BottomNavigationBarItem( - icon: const Icon(Icons.person_outline_rounded, size: 28), - activeIcon: const Icon(Icons.person_rounded, size: 28), - label: appLocalizations.mine, - ), - ], - onTap: (index) { - appProvider.sidebarChoice = SideBarChoice.fromInt(index); - }, - onDoubleTap: (index) { - appProvider.sidebarChoice = SideBarChoice.fromInt(index); - }, - ), + enableBlur: !preferences.reduceTransparency, + displayStyle: preferences.displayStyle, + destinations: [ + LoftifyNavigationDestination( + icon: LoftifyIcons.home, + lottieAsset: LottieFiles.navHome, + label: appLocalizations.home, + ), + if (!preferences.hideSearchNavigation) + LoftifyNavigationDestination( + icon: LoftifyIcons.search, + lottieAsset: LottieFiles.navSearch, + label: appLocalizations.search, + ), + LoftifyNavigationDestination( + icon: LoftifyIcons.activity, + lottieAsset: LottieFiles.navHeart, + label: appLocalizations.dynamicTab, + ), + LoftifyNavigationDestination( + icon: LoftifyIcons.profile, + lottieAsset: LottieFiles.navUser, + label: appLocalizations.mine, + ), + ], + onSelect: (index) { + appProvider.sidebarChoice = visibleChoices[index]; + }, + ); + }, ), ); } - changeMode() { + void changeMode() { if (ColorUtil.isDark(context)) { appProvider.themeMode = ActiveThemeMode.light; darkModeController.forward(); @@ -314,14 +444,12 @@ class PanelScreenState extends BaseDynamicState @override List getScrollControllers() { - List res = []; - for (var page in _pageList) { - var state = _keyList[_pageList.indexOf(page)].currentState; - if (state is ScrollToHideMixin) { - res.addAll((state as ScrollToHideMixin).getScrollControllers()); - } + if (_currentIndex < 0 || _currentIndex >= _keyList.length) { + return const []; } - return res; + final state = _keyList[_currentIndex].currentState; + if (state is! ScrollToHideMixin) return const []; + return (state as ScrollToHideMixin).getScrollControllers(); } @override diff --git a/lib/Theme/loftify_design_theme.dart b/lib/Theme/loftify_design_theme.dart new file mode 100644 index 00000000..04741848 --- /dev/null +++ b/lib/Theme/loftify_design_theme.dart @@ -0,0 +1,1252 @@ +import 'dart:ui' show lerpDouble; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +/// Responsive width classes used by the Loftify design system. +enum LoftifyWindowClass { compact, medium, expanded, large } + +/// Semantic density roles. Density describes information rhythm rather than +/// shrinking accessibility targets. +enum LoftifyDensityRole { + contentDense, + contentComfortable, + controlComfortable, +} + +@immutable +class LoftifyColorTokens { + const LoftifyColorTokens({ + required this.page, + required this.surface, + required this.surfaceRaised, + required this.surfaceMuted, + required this.textPrimary, + required this.textSecondary, + required this.textMuted, + required this.outline, + required this.outlineStrong, + required this.accent, + required this.accentForeground, + required this.onAccent, + required this.accentContainer, + required this.onAccentContainer, + required this.success, + required this.warning, + required this.danger, + required this.scrim, + }); + + final Color page; + final Color surface; + final Color surfaceRaised; + final Color surfaceMuted; + final Color textPrimary; + final Color textSecondary; + final Color textMuted; + final Color outline; + final Color outlineStrong; + final Color accent; + final Color accentForeground; + final Color onAccent; + final Color accentContainer; + final Color onAccentContainer; + final Color success; + final Color warning; + final Color danger; + final Color scrim; + + LoftifyColorTokens copyWith({ + Color? page, + Color? surface, + Color? surfaceRaised, + Color? surfaceMuted, + Color? textPrimary, + Color? textSecondary, + Color? textMuted, + Color? outline, + Color? outlineStrong, + Color? accent, + Color? accentForeground, + Color? onAccent, + Color? accentContainer, + Color? onAccentContainer, + Color? success, + Color? warning, + Color? danger, + Color? scrim, + }) { + return LoftifyColorTokens( + page: page ?? this.page, + surface: surface ?? this.surface, + surfaceRaised: surfaceRaised ?? this.surfaceRaised, + surfaceMuted: surfaceMuted ?? this.surfaceMuted, + textPrimary: textPrimary ?? this.textPrimary, + textSecondary: textSecondary ?? this.textSecondary, + textMuted: textMuted ?? this.textMuted, + outline: outline ?? this.outline, + outlineStrong: outlineStrong ?? this.outlineStrong, + accent: accent ?? this.accent, + accentForeground: accentForeground ?? this.accentForeground, + onAccent: onAccent ?? this.onAccent, + accentContainer: accentContainer ?? this.accentContainer, + onAccentContainer: onAccentContainer ?? this.onAccentContainer, + success: success ?? this.success, + warning: warning ?? this.warning, + danger: danger ?? this.danger, + scrim: scrim ?? this.scrim, + ); + } + + static LoftifyColorTokens lerp( + LoftifyColorTokens a, + LoftifyColorTokens b, + double t, + ) { + return LoftifyColorTokens( + page: Color.lerp(a.page, b.page, t)!, + surface: Color.lerp(a.surface, b.surface, t)!, + surfaceRaised: Color.lerp(a.surfaceRaised, b.surfaceRaised, t)!, + surfaceMuted: Color.lerp(a.surfaceMuted, b.surfaceMuted, t)!, + textPrimary: Color.lerp(a.textPrimary, b.textPrimary, t)!, + textSecondary: Color.lerp(a.textSecondary, b.textSecondary, t)!, + textMuted: Color.lerp(a.textMuted, b.textMuted, t)!, + outline: Color.lerp(a.outline, b.outline, t)!, + outlineStrong: Color.lerp(a.outlineStrong, b.outlineStrong, t)!, + accent: Color.lerp(a.accent, b.accent, t)!, + accentForeground: Color.lerp(a.accentForeground, b.accentForeground, t)!, + onAccent: Color.lerp(a.onAccent, b.onAccent, t)!, + accentContainer: Color.lerp( + a.accentContainer, + b.accentContainer, + t, + )!, + onAccentContainer: Color.lerp( + a.onAccentContainer, + b.onAccentContainer, + t, + )!, + success: Color.lerp(a.success, b.success, t)!, + warning: Color.lerp(a.warning, b.warning, t)!, + danger: Color.lerp(a.danger, b.danger, t)!, + scrim: Color.lerp(a.scrim, b.scrim, t)!, + ); + } +} + +@immutable +class LoftifyTypographyTokens { + const LoftifyTypographyTokens({ + required this.display, + required this.pageTitle, + required this.sectionTitle, + required this.cardTitle, + required this.body, + required this.readingBody, + required this.metadata, + required this.label, + }); + + final TextStyle display; + final TextStyle pageTitle; + final TextStyle sectionTitle; + final TextStyle cardTitle; + final TextStyle body; + final TextStyle readingBody; + final TextStyle metadata; + final TextStyle label; + + LoftifyTypographyTokens copyWith({ + TextStyle? display, + TextStyle? pageTitle, + TextStyle? sectionTitle, + TextStyle? cardTitle, + TextStyle? body, + TextStyle? readingBody, + TextStyle? metadata, + TextStyle? label, + }) { + return LoftifyTypographyTokens( + display: display ?? this.display, + pageTitle: pageTitle ?? this.pageTitle, + sectionTitle: sectionTitle ?? this.sectionTitle, + cardTitle: cardTitle ?? this.cardTitle, + body: body ?? this.body, + readingBody: readingBody ?? this.readingBody, + metadata: metadata ?? this.metadata, + label: label ?? this.label, + ); + } + + static LoftifyTypographyTokens lerp( + LoftifyTypographyTokens a, + LoftifyTypographyTokens b, + double t, + ) { + return LoftifyTypographyTokens( + display: TextStyle.lerp(a.display, b.display, t)!, + pageTitle: TextStyle.lerp(a.pageTitle, b.pageTitle, t)!, + sectionTitle: TextStyle.lerp(a.sectionTitle, b.sectionTitle, t)!, + cardTitle: TextStyle.lerp(a.cardTitle, b.cardTitle, t)!, + body: TextStyle.lerp(a.body, b.body, t)!, + readingBody: TextStyle.lerp(a.readingBody, b.readingBody, t)!, + metadata: TextStyle.lerp(a.metadata, b.metadata, t)!, + label: TextStyle.lerp(a.label, b.label, t)!, + ); + } +} + +@immutable +class LoftifySpacingTokens { + const LoftifySpacingTokens({ + this.xxs = 2, + this.xs = 4, + this.sm = 6, + this.md = 8, + this.lg = 12, + this.xl = 16, + this.xxl = 20, + this.xxxl = 24, + this.huge = 32, + this.hero = 40, + this.sectionTop = 10, + }); + + final double xxs; + final double xs; + final double sm; + final double md; + final double lg; + final double xl; + final double xxl; + final double xxxl; + final double huge; + final double hero; + + /// Product requirement: each Caption-style setting group starts 10 px + /// below the preceding content. Kept semantic because it is intentionally + /// outside the general spacing ladder. + final double sectionTop; + + static LoftifySpacingTokens lerp( + LoftifySpacingTokens a, + LoftifySpacingTokens b, + double t, + ) { + return LoftifySpacingTokens( + xxs: lerpDouble(a.xxs, b.xxs, t)!, + xs: lerpDouble(a.xs, b.xs, t)!, + sm: lerpDouble(a.sm, b.sm, t)!, + md: lerpDouble(a.md, b.md, t)!, + lg: lerpDouble(a.lg, b.lg, t)!, + xl: lerpDouble(a.xl, b.xl, t)!, + xxl: lerpDouble(a.xxl, b.xxl, t)!, + xxxl: lerpDouble(a.xxxl, b.xxxl, t)!, + huge: lerpDouble(a.huge, b.huge, t)!, + hero: lerpDouble(a.hero, b.hero, t)!, + sectionTop: lerpDouble(a.sectionTop, b.sectionTop, t)!, + ); + } +} + +@immutable +class LoftifyRadiusTokens { + const LoftifyRadiusTokens({ + this.small = 6, + this.control = 10, + this.card = 14, + this.panel = 20, + this.dialog = 24, + this.full = 999, + }); + + final double small; + final double control; + final double card; + final double panel; + final double dialog; + final double full; + + static LoftifyRadiusTokens lerp( + LoftifyRadiusTokens a, + LoftifyRadiusTokens b, + double t, + ) { + return LoftifyRadiusTokens( + small: lerpDouble(a.small, b.small, t)!, + control: lerpDouble(a.control, b.control, t)!, + card: lerpDouble(a.card, b.card, t)!, + panel: lerpDouble(a.panel, b.panel, t)!, + dialog: lerpDouble(a.dialog, b.dialog, t)!, + full: lerpDouble(a.full, b.full, t)!, + ); + } +} + +@immutable +class LoftifyBorderTokens { + const LoftifyBorderTokens({ + this.hairline = 0.6, + this.regular = 1, + this.focus = 2, + }); + + final double hairline; + final double regular; + final double focus; + + static LoftifyBorderTokens lerp( + LoftifyBorderTokens a, + LoftifyBorderTokens b, + double t, + ) { + return LoftifyBorderTokens( + hairline: lerpDouble(a.hairline, b.hairline, t)!, + regular: lerpDouble(a.regular, b.regular, t)!, + focus: lerpDouble(a.focus, b.focus, t)!, + ); + } +} + +@immutable +class LoftifyShadowTokens { + const LoftifyShadowTokens({ + required this.floating, + required this.overlay, + }); + + final List floating; + final List overlay; + + static LoftifyShadowTokens lerp( + LoftifyShadowTokens a, + LoftifyShadowTokens b, + double t, + ) { + return LoftifyShadowTokens( + floating: BoxShadow.lerpList(a.floating, b.floating, t)!, + overlay: BoxShadow.lerpList(a.overlay, b.overlay, t)!, + ); + } +} + +@immutable +class LoftifyIconStateTokens { + const LoftifyIconStateTokens({ + this.small = 16, + this.regular = 20, + this.large = 24, + this.minimumTapTarget = 44, + this.disabledOpacity = 0.38, + this.hoverOpacity = 0.08, + this.focusOpacity = 0.10, + this.pressedOpacity = 0.12, + this.selectedContainerOpacity = 0.12, + }); + + final double small; + final double regular; + final double large; + final double minimumTapTarget; + final double disabledOpacity; + final double hoverOpacity; + final double focusOpacity; + final double pressedOpacity; + final double selectedContainerOpacity; + + static LoftifyIconStateTokens lerp( + LoftifyIconStateTokens a, + LoftifyIconStateTokens b, + double t, + ) { + return LoftifyIconStateTokens( + small: lerpDouble(a.small, b.small, t)!, + regular: lerpDouble(a.regular, b.regular, t)!, + large: lerpDouble(a.large, b.large, t)!, + minimumTapTarget: lerpDouble(a.minimumTapTarget, b.minimumTapTarget, t)!, + disabledOpacity: lerpDouble(a.disabledOpacity, b.disabledOpacity, t)!, + hoverOpacity: lerpDouble(a.hoverOpacity, b.hoverOpacity, t)!, + focusOpacity: lerpDouble(a.focusOpacity, b.focusOpacity, t)!, + pressedOpacity: lerpDouble(a.pressedOpacity, b.pressedOpacity, t)!, + selectedContainerOpacity: lerpDouble( + a.selectedContainerOpacity, + b.selectedContainerOpacity, + t, + )!, + ); + } +} + +@immutable +class LoftifyDensityTokens { + const LoftifyDensityTokens({ + this.contentDenseMinHeight = 48, + this.contentComfortableMinHeight = 56, + this.controlComfortableMinHeight = 56, + this.contentDenseVerticalPadding = 10, + this.contentComfortableVerticalPadding = 12, + this.controlComfortableVerticalPadding = 14, + }); + + final double contentDenseMinHeight; + final double contentComfortableMinHeight; + final double controlComfortableMinHeight; + final double contentDenseVerticalPadding; + final double contentComfortableVerticalPadding; + final double controlComfortableVerticalPadding; + + double minimumHeight(LoftifyDensityRole role) => switch (role) { + LoftifyDensityRole.contentDense => contentDenseMinHeight, + LoftifyDensityRole.contentComfortable => contentComfortableMinHeight, + LoftifyDensityRole.controlComfortable => controlComfortableMinHeight, + }; + + double verticalPadding(LoftifyDensityRole role) => switch (role) { + LoftifyDensityRole.contentDense => contentDenseVerticalPadding, + LoftifyDensityRole.contentComfortable => + contentComfortableVerticalPadding, + LoftifyDensityRole.controlComfortable => + controlComfortableVerticalPadding, + }; + + static LoftifyDensityTokens lerp( + LoftifyDensityTokens a, + LoftifyDensityTokens b, + double t, + ) { + return LoftifyDensityTokens( + contentDenseMinHeight: lerpDouble( + a.contentDenseMinHeight, + b.contentDenseMinHeight, + t, + )!, + contentComfortableMinHeight: lerpDouble( + a.contentComfortableMinHeight, + b.contentComfortableMinHeight, + t, + )!, + controlComfortableMinHeight: lerpDouble( + a.controlComfortableMinHeight, + b.controlComfortableMinHeight, + t, + )!, + contentDenseVerticalPadding: lerpDouble( + a.contentDenseVerticalPadding, + b.contentDenseVerticalPadding, + t, + )!, + contentComfortableVerticalPadding: lerpDouble( + a.contentComfortableVerticalPadding, + b.contentComfortableVerticalPadding, + t, + )!, + controlComfortableVerticalPadding: lerpDouble( + a.controlComfortableVerticalPadding, + b.controlComfortableVerticalPadding, + t, + )!, + ); + } +} + +@immutable +class LoftifyMotionTokens { + const LoftifyMotionTokens({ + this.press = const Duration(milliseconds: 90), + this.state = const Duration(milliseconds: 180), + this.page = const Duration(milliseconds: 220), + this.panel = const Duration(milliseconds: 260), + this.content = const Duration(milliseconds: 280), + this.enterCurve = Curves.easeOutCubic, + this.exitCurve = Curves.easeInCubic, + }); + + final Duration press; + final Duration state; + final Duration page; + final Duration panel; + final Duration content; + final Curve enterCurve; + final Curve exitCurve; + + Duration effective(BuildContext context, Duration duration) { + return MediaQuery.maybeOf(context)?.disableAnimations == true + ? Duration.zero + : duration; + } + + static LoftifyMotionTokens lerp( + LoftifyMotionTokens a, + LoftifyMotionTokens b, + double t, + ) { + Duration lerpDuration(Duration start, Duration end) => Duration( + microseconds: lerpDouble( + start.inMicroseconds.toDouble(), + end.inMicroseconds.toDouble(), + t, + )! + .round(), + ); + return LoftifyMotionTokens( + press: lerpDuration(a.press, b.press), + state: lerpDuration(a.state, b.state), + page: lerpDuration(a.page, b.page), + panel: lerpDuration(a.panel, b.panel), + content: lerpDuration(a.content, b.content), + enterCurve: t < 0.5 ? a.enterCurve : b.enterCurve, + exitCurve: t < 0.5 ? a.exitCurve : b.exitCurve, + ); + } +} + +@immutable +class LoftifyGridTokens { + const LoftifyGridTokens({ + this.compactBreakpoint = 600, + this.mediumBreakpoint = 840, + this.expandedBreakpoint = 1200, + this.compactPagePadding = 12, + this.phonePagePadding = 16, + this.widePagePadding = 24, + this.compactGutter = 10, + this.mediumGutter = 12, + this.wideGutter = 16, + this.minimumCardWidth = 168, + this.maximumDenseCardExtent = 300, + this.maximumContentWidth = 1440, + this.maximumReadingWidth = 720, + }); + + final double compactBreakpoint; + final double mediumBreakpoint; + final double expandedBreakpoint; + final double compactPagePadding; + final double phonePagePadding; + final double widePagePadding; + final double compactGutter; + final double mediumGutter; + final double wideGutter; + final double minimumCardWidth; + final double maximumDenseCardExtent; + final double maximumContentWidth; + final double maximumReadingWidth; + + LoftifyWindowClass windowClassFor(double width) { + if (width < compactBreakpoint) return LoftifyWindowClass.compact; + if (width < mediumBreakpoint) return LoftifyWindowClass.medium; + if (width < expandedBreakpoint) return LoftifyWindowClass.expanded; + return LoftifyWindowClass.large; + } + + double pagePaddingFor(double width) => switch (windowClassFor(width)) { + LoftifyWindowClass.compact => + width < 360 ? compactPagePadding : phonePagePadding, + LoftifyWindowClass.medium || + LoftifyWindowClass.expanded || + LoftifyWindowClass.large => + widePagePadding, + }; + + /// Denser edge rhythm for image-led waterfall feeds on phones. Reading + /// surfaces and discovery pages keep [pagePaddingFor] for calmer line + /// lengths, while the home feed gives artwork more of the viewport. + double denseFeedPagePaddingFor(double width) => + switch (windowClassFor(width)) { + LoftifyWindowClass.compact => width < 360 ? 8 : 10, + LoftifyWindowClass.medium || + LoftifyWindowClass.expanded || + LoftifyWindowClass.large => + widePagePadding, + }; + + double gutterFor(double width) => switch (windowClassFor(width)) { + LoftifyWindowClass.compact => compactGutter, + LoftifyWindowClass.medium => mediumGutter, + LoftifyWindowClass.expanded || LoftifyWindowClass.large => wideGutter, + }; + + int contentColumnCount(double width) { + final available = + width.clamp(0, maximumContentWidth) - pagePaddingFor(width) * 2; + final columns = + ((available + gutterFor(width)) / (minimumCardWidth + gutterFor(width))) + .floor(); + return columns.clamp(1, 6); + } + + static LoftifyGridTokens lerp( + LoftifyGridTokens a, + LoftifyGridTokens b, + double t, + ) { + return LoftifyGridTokens( + compactBreakpoint: + lerpDouble(a.compactBreakpoint, b.compactBreakpoint, t)!, + mediumBreakpoint: lerpDouble(a.mediumBreakpoint, b.mediumBreakpoint, t)!, + expandedBreakpoint: + lerpDouble(a.expandedBreakpoint, b.expandedBreakpoint, t)!, + compactPagePadding: + lerpDouble(a.compactPagePadding, b.compactPagePadding, t)!, + phonePagePadding: lerpDouble(a.phonePagePadding, b.phonePagePadding, t)!, + widePagePadding: lerpDouble(a.widePagePadding, b.widePagePadding, t)!, + compactGutter: lerpDouble(a.compactGutter, b.compactGutter, t)!, + mediumGutter: lerpDouble(a.mediumGutter, b.mediumGutter, t)!, + wideGutter: lerpDouble(a.wideGutter, b.wideGutter, t)!, + minimumCardWidth: lerpDouble(a.minimumCardWidth, b.minimumCardWidth, t)!, + maximumDenseCardExtent: lerpDouble( + a.maximumDenseCardExtent, + b.maximumDenseCardExtent, + t, + )!, + maximumContentWidth: + lerpDouble(a.maximumContentWidth, b.maximumContentWidth, t)!, + maximumReadingWidth: + lerpDouble(a.maximumReadingWidth, b.maximumReadingWidth, t)!, + ); + } +} + +/// Product-level theme contract for the "Quiet Content Atelier" direction. +@immutable +class LoftifyDesignThemeData extends ThemeExtension { + const LoftifyDesignThemeData({ + required this.colors, + required this.typography, + this.spacing = const LoftifySpacingTokens(), + this.radii = const LoftifyRadiusTokens(), + this.borders = const LoftifyBorderTokens(), + required this.shadows, + this.icons = const LoftifyIconStateTokens(), + this.density = const LoftifyDensityTokens(), + this.motion = const LoftifyMotionTokens(), + this.grid = const LoftifyGridTokens(), + }); + + final LoftifyColorTokens colors; + final LoftifyTypographyTokens typography; + final LoftifySpacingTokens spacing; + final LoftifyRadiusTokens radii; + final LoftifyBorderTokens borders; + final LoftifyShadowTokens shadows; + final LoftifyIconStateTokens icons; + final LoftifyDensityTokens density; + final LoftifyMotionTokens motion; + final LoftifyGridTokens grid; + + static LoftifyDesignThemeData of(BuildContext context) { + final extension = Theme.of(context).extension(); + return extension ?? LoftifyTheme._fallbackDesign(Theme.of(context)); + } + + @override + LoftifyDesignThemeData copyWith({ + LoftifyColorTokens? colors, + LoftifyTypographyTokens? typography, + LoftifySpacingTokens? spacing, + LoftifyRadiusTokens? radii, + LoftifyBorderTokens? borders, + LoftifyShadowTokens? shadows, + LoftifyIconStateTokens? icons, + LoftifyDensityTokens? density, + LoftifyMotionTokens? motion, + LoftifyGridTokens? grid, + }) { + return LoftifyDesignThemeData( + colors: colors ?? this.colors, + typography: typography ?? this.typography, + spacing: spacing ?? this.spacing, + radii: radii ?? this.radii, + borders: borders ?? this.borders, + shadows: shadows ?? this.shadows, + icons: icons ?? this.icons, + density: density ?? this.density, + motion: motion ?? this.motion, + grid: grid ?? this.grid, + ); + } + + @override + LoftifyDesignThemeData lerp( + covariant LoftifyDesignThemeData? other, + double t, + ) { + if (other == null) return this; + return LoftifyDesignThemeData( + colors: LoftifyColorTokens.lerp(colors, other.colors, t), + typography: LoftifyTypographyTokens.lerp(typography, other.typography, t), + spacing: LoftifySpacingTokens.lerp(spacing, other.spacing, t), + radii: LoftifyRadiusTokens.lerp(radii, other.radii, t), + borders: LoftifyBorderTokens.lerp(borders, other.borders, t), + shadows: LoftifyShadowTokens.lerp(shadows, other.shadows, t), + icons: LoftifyIconStateTokens.lerp(icons, other.icons, t), + density: LoftifyDensityTokens.lerp(density, other.density, t), + motion: LoftifyMotionTokens.lerp(motion, other.motion, t), + grid: LoftifyGridTokens.lerp(grid, other.grid, t), + ); + } +} + +/// Builds the app ThemeData and keeps legacy Chewie colors and custom accent +/// themes as the authoritative user preference source. +abstract final class LoftifyTheme { + static Color readableForegroundColor( + Color source, { + required List backgrounds, + required Brightness brightness, + double minimumContrast = 4.5, + }) { + assert(backgrounds.isNotEmpty); + assert(minimumContrast > 1); + return _readableForegroundColor( + source, + backgrounds: backgrounds, + isDark: brightness == Brightness.dark, + minimumContrast: minimumContrast, + ); + } + + static ThemeData build(ChewieThemeColorData source) { + final base = source.toThemeData(); + final isDark = source.isDarkMode; + final colors = _colors(source, isDark: isDark); + final typography = _typography(base, colors); + final materialTextTheme = _materialTextTheme(base.textTheme, typography); + final shadows = _shadows(isDark: isDark); + final design = LoftifyDesignThemeData( + colors: colors, + typography: typography, + shadows: shadows, + ); + final radii = design.radii; + final borders = design.borders; + final icons = design.icons; + final outline = OutlineInputBorder( + borderRadius: BorderRadius.circular(radii.control), + borderSide: BorderSide( + color: colors.outline, + width: borders.regular, + ), + ); + final iconTheme = + (base.extension() ?? ChewieIconThemeData.standard) + .copyWith( + smallSize: icons.small, + regularSize: icons.regular, + largeSize: icons.large, + minimumTapTarget: icons.minimumTapTarget, + cornerRadius: radii.control, + disabledOpacity: icons.disabledOpacity, + hoverOpacity: icons.hoverOpacity, + focusOpacity: icons.focusOpacity, + pressedOpacity: icons.pressedOpacity, + selectedContainerOpacity: icons.selectedContainerOpacity, + ); + final extensions = base.extensions.values + .where( + (extension) => + extension is! LoftifyDesignThemeData && + extension is! ChewieIconThemeData, + ) + .toList(growable: true) + ..add(iconTheme) + ..add(design); + + return base.copyWith( + colorScheme: base.colorScheme.copyWith( + primary: colors.accent, + onPrimary: colors.onAccent, + primaryContainer: colors.accentContainer, + onPrimaryContainer: colors.onAccentContainer, + surface: colors.page, + onSurface: colors.textPrimary, + onSurfaceVariant: colors.textSecondary, + outline: colors.outline, + outlineVariant: colors.outlineStrong, + error: colors.danger, + ), + scaffoldBackgroundColor: colors.page, + canvasColor: source.canvasColor, + // Legacy content controls intentionally use the theme card color as a + // soft grey information surface. Mapping this role to raised white made + // post-detail collection, grain and tag controls disappear on the white + // page background. + cardColor: source.cardColor, + dividerColor: colors.outline, + textTheme: materialTextTheme, + iconTheme: IconThemeData(color: colors.textSecondary, size: icons.large), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: colors.page, + foregroundColor: colors.textPrimary, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + titleTextStyle: materialTextTheme.titleLarge, + ), + bottomNavigationBarTheme: base.bottomNavigationBarTheme.copyWith( + backgroundColor: colors.page, + elevation: 0, + selectedItemColor: colors.accentForeground, + unselectedItemColor: colors.textSecondary, + ), + navigationBarTheme: base.navigationBarTheme.copyWith( + backgroundColor: colors.page, + surfaceTintColor: Colors.transparent, + elevation: 0, + indicatorColor: colors.accentContainer, + ), + cardTheme: CardThemeData( + color: source.cardColor, + surfaceTintColor: Colors.transparent, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radii.card), + ), + ), + dividerTheme: DividerThemeData( + color: colors.outline, + thickness: borders.hairline, + space: borders.hairline, + ), + inputDecorationTheme: base.inputDecorationTheme.copyWith( + filled: true, + fillColor: colors.surface, + hintStyle: typography.body.copyWith(color: colors.textMuted), + labelStyle: typography.label.copyWith(color: colors.textSecondary), + errorStyle: typography.metadata.copyWith(color: colors.danger), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: outline, + enabledBorder: outline, + focusedBorder: outline.copyWith( + borderSide: BorderSide( + color: colors.accentForeground, + width: borders.focus, + ), + ), + errorBorder: outline.copyWith( + borderSide: BorderSide( + color: colors.danger, + width: borders.regular, + ), + ), + focusedErrorBorder: outline.copyWith( + borderSide: BorderSide( + color: colors.danger, + width: borders.focus, + ), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: Size(0, icons.minimumTapTarget), + backgroundColor: colors.accent, + foregroundColor: colors.onAccent, + textStyle: typography.label, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radii.control), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: Size(0, icons.minimumTapTarget), + foregroundColor: colors.accentForeground, + side: BorderSide(color: colors.outlineStrong), + textStyle: typography.label, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radii.control), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: Size(0, icons.minimumTapTarget), + foregroundColor: colors.accentForeground, + textStyle: typography.label, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radii.control), + ), + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: colors.surfaceRaised, + surfaceTintColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radii.dialog), + side: BorderSide(color: colors.outline, width: borders.hairline), + ), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: colors.surfaceRaised, + surfaceTintColor: Colors.transparent, + modalBackgroundColor: colors.surfaceRaised, + modalBarrierColor: colors.scrim, + elevation: 0, + modalElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(radii.panel), + ), + ), + ), + popupMenuTheme: PopupMenuThemeData( + color: colors.surfaceRaised, + surfaceTintColor: Colors.transparent, + elevation: 0, + textStyle: typography.body, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radii.control), + side: BorderSide(color: colors.outline, width: borders.hairline), + ), + ), + chipTheme: base.chipTheme.copyWith( + backgroundColor: colors.surface, + selectedColor: colors.accentContainer, + disabledColor: colors.surfaceMuted, + labelStyle: typography.label, + secondaryLabelStyle: typography.label.copyWith( + color: colors.onAccentContainer, + ), + side: BorderSide(color: colors.outline, width: borders.hairline), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radii.full), + ), + ), + extensions: extensions, + ); + } + + static LoftifyColorTokens _colors( + ChewieThemeColorData source, { + required bool isDark, + }) { + return _semanticColors( + accent: source.primaryColor, + success: source.successColor, + warning: source.warningColor, + danger: source.errorColor, + isDark: isDark, + ); + } + + static LoftifyDesignThemeData _fallbackDesign(ThemeData base) { + final isDark = base.brightness == Brightness.dark; + final colors = _semanticColors( + accent: base.colorScheme.primary, + success: isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32), + warning: isDark ? const Color(0xFFFFB74D) : const Color(0xFF9A6700), + danger: base.colorScheme.error, + isDark: isDark, + ); + return LoftifyDesignThemeData( + colors: colors, + typography: _typography(base, colors), + shadows: _shadows(isDark: isDark), + ); + } + + static LoftifyColorTokens _semanticColors({ + required Color accent, + required Color success, + required Color warning, + required Color danger, + required bool isDark, + }) { + final page = isDark ? const Color(0xFF121412) : const Color(0xFFFFFFFF); + final surface = isDark ? const Color(0xFF191C1A) : const Color(0xFFF7F8F7); + final surfaceRaised = + isDark ? const Color(0xFF202421) : const Color(0xFFFFFFFF); + final surfaceMuted = + isDark ? const Color(0xFF252A27) : const Color(0xFFF0F3F1); + final textPrimary = + isDark ? const Color(0xFFF2F5F3) : const Color(0xFF202522); + final textMuted = _readableForegroundColor( + isDark ? const Color(0xFF839089) : const Color(0xFF8D9791), + backgrounds: [page, surface, surfaceRaised, surfaceMuted], + isDark: isDark, + ); + final accentForeground = _readableForegroundColor( + accent, + backgrounds: [page, surface, surfaceRaised, surfaceMuted], + isDark: isDark, + ); + final outlineStrong = _readableForegroundColor( + isDark ? const Color(0xFF485049) : const Color(0xFFCDD4CF), + backgrounds: [page, surface, surfaceRaised, surfaceMuted], + isDark: isDark, + minimumContrast: 3, + ); + final accentContainer = Color.alphaBlend( + accent.withAlpha(isDark ? 52 : 28), + surfaceRaised, + ); + final tonalCandidate = isDark + ? Color.lerp(accent, Colors.white, 0.30)! + : Color.lerp(accent, const Color(0xFF10201B), 0.38)!; + final onAccentContainer = _contrastRatio( + tonalCandidate, + accentContainer, + ) >= + 4.5 + ? tonalCandidate + : _contrastRatio(accent, accentContainer) >= 4.5 + ? accent + : textPrimary; + final readableSuccess = _readableStatusColor( + success, + backgrounds: [page, surfaceRaised], + isDark: isDark, + ); + final readableWarning = _readableStatusColor( + warning, + backgrounds: [page, surfaceRaised], + isDark: isDark, + ); + final readableDanger = _readableStatusColor( + danger, + backgrounds: [page, surfaceRaised], + isDark: isDark, + ); + return LoftifyColorTokens( + page: page, + surface: surface, + surfaceRaised: surfaceRaised, + surfaceMuted: surfaceMuted, + textPrimary: textPrimary, + textSecondary: isDark ? const Color(0xFFAEB8B2) : const Color(0xFF66706B), + textMuted: textMuted, + outline: isDark ? const Color(0xFF303630) : const Color(0xFFE5E9E6), + outlineStrong: outlineStrong, + accent: accent, + accentForeground: accentForeground, + onAccent: ColorUtil.getContrastColor(accent), + accentContainer: accentContainer, + onAccentContainer: onAccentContainer, + success: readableSuccess, + warning: readableWarning, + danger: readableDanger, + scrim: Colors.black.withAlpha(isDark ? 156 : 104), + ); + } + + static double _contrastRatio(Color foreground, Color background) { + final foregroundLuminance = foreground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance + ? foregroundLuminance + : backgroundLuminance; + final darker = foregroundLuminance > backgroundLuminance + ? backgroundLuminance + : foregroundLuminance; + return (lighter + 0.05) / (darker + 0.05); + } + + static Color _readableStatusColor( + Color source, { + required List backgrounds, + required bool isDark, + }) { + bool isReadable(Color candidate) { + for (final background in backgrounds) { + final tinted = Color.alphaBlend( + candidate.withValues(alpha: 0.10), + background, + ); + if (_contrastRatio(candidate, background) < 4.5 || + _contrastRatio(candidate, tinted) < 4.5) { + return false; + } + } + return true; + } + + if (isReadable(source)) return source; + final target = isDark ? Colors.white : Colors.black; + for (var step = 1; step <= 100; step++) { + final candidate = Color.lerp(source, target, step / 100)!; + if (isReadable(candidate)) return candidate; + } + return target; + } + + static Color _readableForegroundColor( + Color source, { + required List backgrounds, + required bool isDark, + double minimumContrast = 4.5, + }) { + bool isReadable(Color candidate) => backgrounds.every( + (background) => + _contrastRatio(candidate, background) >= minimumContrast, + ); + if (isReadable(source)) return source; + final target = isDark ? Colors.white : Colors.black; + for (var step = 1; step <= 100; step++) { + final candidate = Color.lerp(source, target, step / 100)!; + if (isReadable(candidate)) return candidate; + } + return target; + } + + static LoftifyTypographyTokens _typography( + ThemeData base, + LoftifyColorTokens colors, + ) { + TextStyle role({ + required double size, + required double height, + required FontWeight weight, + required Color color, + double letterSpacing = 0, + }) { + return TextStyle( + inherit: true, + fontFamily: base.textTheme.bodyMedium?.fontFamily, + fontFamilyFallback: base.textTheme.bodyMedium?.fontFamilyFallback, + fontSize: size, + height: height, + fontWeight: weight, + color: color, + letterSpacing: letterSpacing, + decoration: TextDecoration.none, + ); + } + + return LoftifyTypographyTokens( + display: role( + size: 28, + height: 1.25, + weight: FontWeight.w700, + color: colors.textPrimary, + ), + pageTitle: role( + size: 20, + height: 1.30, + weight: FontWeight.w700, + color: colors.textPrimary, + ), + sectionTitle: role( + size: 16, + height: 1.35, + weight: FontWeight.w600, + color: colors.textPrimary, + ), + cardTitle: role( + size: 15, + height: 1.40, + weight: FontWeight.w600, + color: colors.textPrimary, + ), + body: role( + size: 15, + height: 1.60, + weight: FontWeight.w400, + color: colors.textPrimary, + ), + readingBody: role( + size: 17, + height: 1.80, + weight: FontWeight.w400, + color: colors.textPrimary, + ), + metadata: role( + size: 12, + height: 1.45, + weight: FontWeight.w400, + color: colors.textSecondary, + letterSpacing: 0.1, + ), + label: role( + size: 13, + height: 1.30, + weight: FontWeight.w600, + color: colors.textPrimary, + letterSpacing: 0.1, + ), + ); + } + + static LoftifyShadowTokens _shadows({required bool isDark}) { + if (isDark) { + return const LoftifyShadowTokens( + floating: [ + BoxShadow( + color: Color(0x52000000), + blurRadius: 16, + offset: Offset(0, 6), + ), + ], + overlay: [ + BoxShadow( + color: Color(0x70000000), + blurRadius: 28, + offset: Offset(0, 12), + ), + ], + ); + } + return const LoftifyShadowTokens( + floating: [ + BoxShadow( + color: Color(0x140D1F18), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + overlay: [ + BoxShadow( + color: Color(0x1F0D1F18), + blurRadius: 32, + offset: Offset(0, 12), + ), + ], + ); + } + + static TextTheme _materialTextTheme( + TextTheme base, + LoftifyTypographyTokens type, + ) { + TextStyle? primary(TextStyle? style) => + style?.copyWith(color: type.body.color); + TextStyle? secondary(TextStyle? style) => + style?.copyWith(color: type.metadata.color); + + // Preserve the original app scale for legacy pages. Many of those pages + // intentionally apply small local font deltas; remapping the Material + // roles to larger design-token sizes compounded those deltas and made only + // some controls unexpectedly oversized. New components opt into [type] + // directly, while existing screens retain their established hierarchy. + return base.copyWith( + displayLarge: primary(base.displayLarge), + displayMedium: primary(base.displayMedium), + displaySmall: primary(base.displaySmall), + headlineLarge: primary(base.headlineLarge), + headlineMedium: primary(base.headlineMedium), + headlineSmall: primary(base.headlineSmall), + titleLarge: primary(base.titleLarge), + titleMedium: primary(base.titleMedium), + titleSmall: primary(base.titleSmall), + bodyLarge: primary(base.bodyLarge), + bodyMedium: primary(base.bodyMedium), + bodySmall: secondary(base.bodySmall), + labelLarge: secondary(base.labelLarge), + labelMedium: secondary(base.labelMedium), + labelSmall: secondary(base.labelSmall), + ); + } +} + +extension LoftifyThemeContext on BuildContext { + LoftifyDesignThemeData get design => LoftifyDesignThemeData.of(this); + + LoftifyWindowClass get windowClass => + design.grid.windowClassFor(MediaQuery.sizeOf(this).width); + + double get pageHorizontalPadding => + design.grid.pagePaddingFor(MediaQuery.sizeOf(this).width); +} diff --git a/lib/Utils/app_provider.dart b/lib/Utils/app_provider.dart index e3ad54b1..3b4d5c29 100644 --- a/lib/Utils/app_provider.dart +++ b/lib/Utils/app_provider.dart @@ -1,4 +1,6 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:loftify/Screens/Setting/general_setting_screen.dart'; import 'package:tuple/tuple.dart'; @@ -10,7 +12,7 @@ import '../l10n/l10n.dart'; import 'enums.dart'; import 'hive_util.dart'; -NavigatorState? get globalNavigatorState => globalNavigatorKey.currentState; +NavigatorState? get globalNavigatorState => chewieProvider.globalNavigatorState; BuildContext get rootContext => globalNavigatorState!.context; @@ -18,9 +20,10 @@ GlobalKey mainScreenKey = GlobalKey(); MainScreenState? get mainScreenState => mainScreenKey.currentState; -GlobalKey panelScreenKey = GlobalKey(); +GlobalKey panelScreenKey = chewieProvider.panelScreenKey; -PanelScreenState? get panelScreenState => panelScreenKey.currentState; +PanelScreenState? get panelScreenState => + panelScreenKey.currentState as PanelScreenState?; GlobalKey searchScreenKey = GlobalKey(); @@ -42,17 +45,18 @@ GlobalKey dialogNavigatorKey = DialogWrapperWidgetState? get dialogNavigatorState => dialogNavigatorKey.currentState; -GlobalKey keyboardHandlerKey = - GlobalKey(); - -KeyboardHandlerState? get keyboardHandlerState => - keyboardHandlerKey.currentState; - RouteObserver routeObserver = RouteObserver(); AppProvider appProvider = AppProvider(); class AppProvider with ChangeNotifier { + AppProvider() { + Intl.defaultLocale = (_locale ?? resolveSystemAppLocale()).toString(); + if (_hideSearchNavigation && _sidebarChoice == SideBarChoice.Search) { + _sidebarChoice = SideBarChoice.Home; + } + } + bool _pinSettled = HiveUtil.hasGuesturePasswd(); bool get pinSettled => _pinSettled; @@ -77,8 +81,9 @@ class AppProvider with ChangeNotifier { notifyListeners(); } - Map> _dynamicShortcuts = - KeyboardHandlerState.mainScreenShortcuts; + final FocusNode shortcutFocusNode = FocusNode(); + + Map> _dynamicShortcuts = {}; Map> get dynamicShortcuts => _dynamicShortcuts; @@ -104,6 +109,9 @@ class AppProvider with ChangeNotifier { SideBarChoice get sidebarChoice => _sidebarChoice; set sidebarChoice(SideBarChoice value) { + if (_hideSearchNavigation && value == SideBarChoice.Search) { + value = SideBarChoice.Home; + } _sidebarChoice = value; ChewieHiveUtil.put(HiveUtil.sidebarChoiceKey, value.key); notifyListeners(); @@ -124,8 +132,10 @@ class AppProvider with ChangeNotifier { CustomFont get currentFont => _currentFont; set currentFont(CustomFont value) { - _currentFont = value; - notifyListeners(); + if (value != _currentFont) { + _currentFont = value; + notifyListeners(); + } } ChewieThemeColorData _lightTheme = HiveUtil.getLightTheme(); @@ -134,12 +144,14 @@ class AppProvider with ChangeNotifier { set lightTheme(ChewieThemeColorData value) { _lightTheme = value; + chewieProvider.lightTheme = value; notifyListeners(); } - setLightTheme(int index) { + void setLightTheme(int index) { HiveUtil.setLightTheme(index); _lightTheme = HiveUtil.getLightTheme(); + chewieProvider.lightTheme = _lightTheme; notifyListeners(); } @@ -149,15 +161,109 @@ class AppProvider with ChangeNotifier { set darkTheme(ChewieThemeColorData value) { _darkTheme = value; + chewieProvider.darkTheme = value; notifyListeners(); } - setDarkTheme(int index) { + void setDarkTheme(int index) { HiveUtil.setDarkTheme(index); _darkTheme = HiveUtil.getDarkTheme(); + chewieProvider.darkTheme = _darkTheme; notifyListeners(); } + final List _customLightThemes = + ChewieHiveUtil.getCustomLightThemes(); + + List get customLightThemes => _customLightThemes; + + final List _customDarkThemes = + ChewieHiveUtil.getCustomDarkThemes(); + + List get customDarkThemes => _customDarkThemes; + + void addCustomLightTheme(ChewieThemeColorData theme) { + _customLightThemes.add(theme); + ChewieHiveUtil.setCustomLightThemes(_customLightThemes); + notifyListeners(); + } + + void addCustomDarkTheme(ChewieThemeColorData theme) { + _customDarkThemes.add(theme); + ChewieHiveUtil.setCustomDarkThemes(_customDarkThemes); + notifyListeners(); + } + + void updateCustomLightTheme(int index, ChewieThemeColorData theme) { + if (index < 0 || index >= _customLightThemes.length) return; + _customLightThemes[index] = theme; + ChewieHiveUtil.setCustomLightThemes(_customLightThemes); + final activeIndex = ChewieHiveUtil.getLightThemeIndex(); + final builtInCount = ChewieThemeColorData.defaultLightThemes.length; + if (activeIndex == builtInCount + index) { + lightTheme = ChewieHiveUtil.getLightTheme(); + } else { + notifyListeners(); + } + } + + void updateCustomDarkTheme(int index, ChewieThemeColorData theme) { + if (index < 0 || index >= _customDarkThemes.length) return; + _customDarkThemes[index] = theme; + ChewieHiveUtil.setCustomDarkThemes(_customDarkThemes); + final activeIndex = ChewieHiveUtil.getDarkThemeIndex(); + final builtInCount = ChewieThemeColorData.defaultDarkThemes.length; + if (activeIndex == builtInCount + index) { + darkTheme = ChewieHiveUtil.getDarkTheme(); + } else { + notifyListeners(); + } + } + + void deleteCustomLightTheme(int index) { + if (index < 0 || index >= _customLightThemes.length) return; + _customLightThemes.removeAt(index); + ChewieHiveUtil.setCustomLightThemes(_customLightThemes); + final activeIndex = ChewieHiveUtil.getLightThemeIndex(); + final builtInCount = ChewieThemeColorData.defaultLightThemes.length; + final deletedIndex = builtInCount + index; + if (activeIndex == deletedIndex) { + setLightTheme(0); + } else if (activeIndex > deletedIndex) { + setLightTheme(activeIndex - 1); + } else { + notifyListeners(); + } + } + + void deleteCustomDarkTheme(int index) { + if (index < 0 || index >= _customDarkThemes.length) return; + _customDarkThemes.removeAt(index); + ChewieHiveUtil.setCustomDarkThemes(_customDarkThemes); + final activeIndex = ChewieHiveUtil.getDarkThemeIndex(); + final builtInCount = ChewieThemeColorData.defaultDarkThemes.length; + final deletedIndex = builtInCount + index; + if (activeIndex == deletedIndex) { + setDarkTheme(0); + } else if (activeIndex > deletedIndex) { + setDarkTheme(activeIndex - 1); + } else { + notifyListeners(); + } + } + + void setLightPrimaryColorOverride(Color? color, int paletteIndex) { + ChewieHiveUtil.setCustomLightPrimaryColor(color); + ChewieHiveUtil.setLightThemePrimaryColorIndex(paletteIndex); + lightTheme = ChewieHiveUtil.getLightTheme(); + } + + void setDarkPrimaryColorOverride(Color? color, int paletteIndex) { + ChewieHiveUtil.setCustomDarkPrimaryColor(color); + ChewieHiveUtil.setDarkThemePrimaryColorIndex(paletteIndex); + darkTheme = ChewieHiveUtil.getDarkTheme(); + } + Locale? _locale = HiveUtil.getLocale(); Locale? get locale => _locale; @@ -165,20 +271,30 @@ class AppProvider with ChangeNotifier { set locale(Locale? value) { if (value != _locale) { _locale = value; - notifyListeners(); HiveUtil.setLocale(value); + Intl.defaultLocale = (value ?? resolveSystemAppLocale()).toString(); + notifyListeners(); } } - int? _fontSize = HiveUtil.getFontSize(); + void refreshSystemLocale() { + Intl.defaultLocale = (_locale ?? resolveSystemAppLocale()).toString(); + if (_locale == null) notifyListeners(); + } + + void refreshSystemTheme() { + if (_themeMode == ActiveThemeMode.system) notifyListeners(); + } + + int _fontSize = HiveUtil.getFontSize(); - int? get fontSize => _fontSize; + int get fontSize => _fontSize; - set fontSize(int? value) { + set fontSize(int value) { if (value != _fontSize) { _fontSize = value; + chewieProvider.fontSize = value; notifyListeners(); - HiveUtil.setFontSize(value); } } @@ -189,11 +305,75 @@ class AppProvider with ChangeNotifier { set themeMode(ActiveThemeMode value) { if (value != _themeMode) { _themeMode = value; + chewieProvider.themeMode = value; notifyListeners(); - HiveUtil.setThemeMode(value); } } + bool _reduceTransparency = ChewieHiveUtil.getBool( + HiveUtil.reduceTransparencyKey, + ); + + bool get reduceTransparency => _reduceTransparency; + + set reduceTransparency(bool value) { + if (value == _reduceTransparency) return; + _reduceTransparency = value; + ChewieHiveUtil.put(HiveUtil.reduceTransparencyKey, value); + notifyListeners(); + } + + NavigationBarDisplayStyle _navigationBarDisplayStyle = + NavigationBarDisplayStyle.fromKey( + ChewieHiveUtil.getString( + HiveUtil.navigationBarDisplayStyleKey, + defaultValue: NavigationBarDisplayStyle.iconOnly.key, + ), + ); + + NavigationBarDisplayStyle get navigationBarDisplayStyle => + _navigationBarDisplayStyle; + + set navigationBarDisplayStyle(NavigationBarDisplayStyle value) { + if (value == _navigationBarDisplayStyle) return; + _navigationBarDisplayStyle = value; + ChewieHiveUtil.put(HiveUtil.navigationBarDisplayStyleKey, value.key); + notifyListeners(); + } + + bool _hideHomeAppBarOnScroll = ChewieHiveUtil.getBool( + HiveUtil.hideHomeAppBarOnScrollKey, + defaultValue: false, + ); + + bool get hideHomeAppBarOnScroll => _hideHomeAppBarOnScroll; + + set hideHomeAppBarOnScroll(bool value) { + if (value == _hideHomeAppBarOnScroll) return; + _hideHomeAppBarOnScroll = value; + ChewieHiveUtil.put(HiveUtil.hideHomeAppBarOnScrollKey, value); + notifyListeners(); + } + + bool _hideSearchNavigation = ChewieHiveUtil.getBool( + HiveUtil.hideSearchNavigationKey, + defaultValue: false, + ); + + bool get hideSearchNavigation => _hideSearchNavigation; + + set hideSearchNavigation(bool value) { + if (value == _hideSearchNavigation) return; + _hideSearchNavigation = value; + ChewieHiveUtil.put(HiveUtil.hideSearchNavigationKey, value); + if (value && _sidebarChoice == SideBarChoice.Search) { + _sidebarChoice = SideBarChoice.Home; + ChewieHiveUtil.put(HiveUtil.sidebarChoiceKey, _sidebarChoice.key); + } + panelScreenState?.updateSearchNavigationVisibility(); + notifyListeners(); + } + List _searchHistoryList = ChewieHiveUtil.getStringList(HiveUtil.searchHistoryKey)!; diff --git a/lib/Utils/asset_util.dart b/lib/Utils/asset_util.dart index 3a874d2a..a134277b 100644 --- a/lib/Utils/asset_util.dart +++ b/lib/Utils/asset_util.dart @@ -1,67 +1,8 @@ import 'package:awesome_chewie/awesome_chewie.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:loftify/Utils/utils.dart'; +import 'package:flutter/widgets.dart'; class AssetUtil { static const String avatar = "assets/avatar.png"; - static const String collectionDarkIcon = "assets/icon/collection_dark.png"; - static const String collectionLightIcon = "assets/icon/collection_light.png"; - static const String collectionPrimaryIcon = - "assets/icon/collection_primary.png"; - static const String collectionWhiteIcon = "assets/icon/collection_white.png"; - static const String confirmIcon = "assets/icon/confirm.png"; - static const String downloadWhiteIcon = "assets/icon/download_white.png"; - static const String dressDarkIcon = "assets/icon/dress_dark.png"; - static const String dressLightIcon = "assets/icon/dress_light.png"; - static const String dynamicDarkIcon = "assets/icon/dynamic_dark.png"; - static const String dynamicDarkSelectedIcon = - "assets/icon/dynamic_dark_selected.png"; - static const String dynamicLightIcon = "assets/icon/dynamic_light.png"; - static const String dynamicLightSelectedIcon = - "assets/icon/dynamic_light_selected.png"; - static const String favoriteDarkIcon = "assets/icon/favorite_dark.png"; - static const String favoriteLightIcon = "assets/icon/favorite_light.png"; - static const String grainWhiteIcon = "assets/icon/grain_white.png"; - static const String homeDarkIcon = "assets/icon/home_dark.png"; - static const String homeDarkSelectedIcon = - "assets/icon/home_dark_selected.png"; - static const String homeLightIcon = "assets/icon/home_light.png"; - static const String homeLightSelectedIcon = - "assets/icon/home_light_selected.png"; - static const String hotIcon = "assets/icon/hot.png"; - static const String hotlessIcon = "assets/icon/hotless.png"; - static const String hottestIcon = "assets/icon/hottest.png"; - static const String hotWhiteIcon = "assets/icon/hot_white.png"; - static const String infoIcon = "assets/icon/info.png"; - static const String likeDarkIcon = "assets/icon/like_dark.png"; - static const String likeFilledIcon = "assets/icon/like_filled.png"; - static const String likeLightIcon = "assets/icon/like_light.png"; - static const String linkDarkIcon = "assets/icon/link_dark.png"; - static const String linkGreyIcon = "assets/icon/link_grey.png"; - static const String linkLightIcon = "assets/icon/link_light.png"; - static const String linkPrimaryIcon = "assets/icon/link_primary.png"; - static const String linkWhiteIcon = "assets/icon/link_white.png"; - static const String mineDarkIcon = "assets/icon/mine_dark.png"; - static const String mineDarkSelectedIcon = - "assets/icon/mine_dark_selected.png"; - static const String mineLightIcon = "assets/icon/mine_light.png"; - static const String mineLightSelectedIcon = - "assets/icon/mine_light_selected.png"; - static const String orderDownDarkIcon = "assets/icon/order_down_dark.png"; - static const String orderDownLightIcon = "assets/icon/order_down_light.png"; - static const String orderUpDarkIcon = "assets/icon/order_up_dark.png"; - static const String orderUpLightIcon = "assets/icon/order_up_light.png"; - static const String searchDarkIcon = "assets/icon/search_dark.png"; - static const String searchGreyIcon = "assets/icon/search_grey.png"; - static const String searchLightIcon = "assets/icon/search_light.png"; - static const String settingDarkIcon = "assets/icon/setting_dark.png"; - static const String settingLightIcon = "assets/icon/setting_light.png"; - static const String tagDarkIcon = "assets/icon/tag_dark.png"; - static const String tagGreyIcon = "assets/icon/tag_grey.png"; - static const String tagLightIcon = "assets/icon/tag_light.png"; - static const String tagWhiteIcon = "assets/icon/tag_white.png"; - static const String pinDarkIcon = "assets/icon/pin_dark.png"; - static const String pinLightIcon = "assets/icon/pin_light.png"; static const String collectionDarkIllust = "assets/illust/collection_dark.webp"; @@ -94,7 +35,7 @@ class AssetUtil { static const String tagRowBgMess = "assets/mess/tag_row_bg.png"; static const String tagRowBgDarkMess = "assets/mess/tag_row_bg_dark.png"; - static load( + static Widget load( String path, { double size = 24, double? width, @@ -109,7 +50,7 @@ class AssetUtil { ); } - static loadDouble( + static Widget loadDouble( BuildContext context, String light, String dark, { @@ -126,7 +67,7 @@ class AssetUtil { ); } - static loadDecorationImage( + static DecorationImage loadDecorationImage( String path, { BoxFit? fit, }) { diff --git a/lib/Utils/background_download_task_executor.dart b/lib/Utils/background_download_task_executor.dart new file mode 100644 index 00000000..4e393b15 --- /dev/null +++ b/lib/Utils/background_download_task_executor.dart @@ -0,0 +1,506 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:background_downloader/background_downloader.dart' as background; +import 'package:dio/dio.dart'; +import 'package:path/path.dart' as path_util; + +import '../Models/download_task.dart'; +import 'download_task_executor.dart'; +import 'hive_util.dart'; + +class BackgroundDownloadTaskExecutor implements DownloadTaskExecutor { + static const String _group = 'loftify_downloads'; + static const String _groupNotificationId = 'loftify_download_group'; + + final background.FileDownloader _downloader = background.FileDownloader(); + final Map _operations = {}; + StreamSubscription? _updatesSubscription; + Future? _initialization; + bool _notificationPermissionRequested = false; + + @override + Future initialize() => _initialization ??= _initialize(); + + Future _initialize() async { + await _downloader.ready; + _updatesSubscription ??= _downloader.updates.listen( + _handleUpdate, + onError: (Object error, StackTrace stackTrace) { + _logError( + 'Background download update stream failed', error, stackTrace); + }, + ); + _downloader.configureNotificationForGroup( + _group, + running: const background.TaskNotification( + 'Loftify 正在下载', + '{numFinished}/{numTotal} · {progress}', + ), + complete: const background.TaskNotification( + 'Loftify 下载完成', + '已完成 {numTotal} 个下载任务', + ), + error: const background.TaskNotification( + 'Loftify 下载遇到问题', + '{numFailed}/{numTotal} 个任务失败,可在下载管理中重试', + ), + paused: const background.TaskNotification( + 'Loftify 下载已暂停', + '可在下载管理中继续', + ), + progressBar: true, + groupNotificationId: _groupNotificationId, + ); + await _downloader.configure( + globalConfig: ( + background.Config.holdingQueue, + (2, 2, 2), + ), + androidConfig: <(String, dynamic)>[ + ( + background.Config.runInForeground, + background.Config.always, + ), + (background.Config.useCacheDir, background.Config.never), + ], + ); + await _downloader.start( + doTrackTasks: true, + markDownloadedComplete: true, + doRescheduleKilledTasks: true, + autoCleanDatabase: false, + ); + } + + @override + Future run( + DownloadTask task, { + required CancelToken cancelToken, + required DownloadProgressCallback onProgress, + }) async { + await initialize(); + await _requestNotificationPermissionOnce(); + final nativeTask = _toNativeTask(task); + final operation = _BackgroundDownloadOperation( + appTask: task, + nativeTask: nativeTask, + onProgress: onProgress, + ); + _operations[task.id] = operation; + unawaited(cancelToken.whenCancel.then((_) { + if (!operation.completer.isCompleted) { + return _stopFromCancelToken(operation, cancelToken); + } + })); + + try { + await _startOrReconcile(operation); + return await operation.completer.future; + } finally { + if (identical(_operations[task.id], operation)) { + _operations.remove(task.id); + } + } + } + + Future _startOrReconcile( + _BackgroundDownloadOperation operation, + ) async { + final record = await _downloader.database.recordForId(operation.appTask.id); + if (record == null) { + await _enqueue(operation.nativeTask); + return; + } + final recordTask = record.task; + final nativeTask = recordTask is background.DownloadTask + ? recordTask + : operation.nativeTask; + operation.nativeTask = nativeTask; + switch (record.status) { + case background.TaskStatus.complete: + unawaited(_completeOperation(operation, nativeTask)); + case background.TaskStatus.paused: + if (!await _downloader.resume(nativeTask)) { + await _restart(operation); + } + case background.TaskStatus.enqueued: + case background.TaskStatus.running: + case background.TaskStatus.waitingToRetry: + return; + case background.TaskStatus.failed: + if (!await _downloader.resume(nativeTask)) { + await _restart(operation); + } + case background.TaskStatus.notFound: + case background.TaskStatus.canceled: + await _restart(operation); + } + } + + Future _enqueue(background.DownloadTask task) async { + if (!await _downloader.enqueue(task)) { + throw const DownloadExecutionException( + message: '无法将任务加入系统下载队列', + kind: DownloadFailureKind.unknown, + ); + } + } + + Future _restart(_BackgroundDownloadOperation operation) async { + await _deleteNativeArtifacts(operation.appTask, deleteDatabaseRecord: true); + operation.nativeTask = _toNativeTask(operation.appTask); + await _enqueue(operation.nativeTask); + } + + void _handleUpdate(background.TaskUpdate update) { + if (update.task.group != _group) return; + final operation = _operations[update.task.taskId]; + if (operation == null || operation.completer.isCompleted) return; + if (update is background.TaskProgressUpdate) { + if (update.progress < 0 || update.progress > 1) return; + final total = update.hasExpectedFileSize ? update.expectedFileSize : 0; + final received = total > 0 ? (total * update.progress).round() : 0; + operation.onProgress(received, total); + return; + } + if (update is! background.TaskStatusUpdate) return; + final nativeTask = update.task is background.DownloadTask + ? update.task as background.DownloadTask + : operation.nativeTask; + operation.nativeTask = nativeTask; + switch (update.status) { + case background.TaskStatus.complete: + unawaited(_completeOperation(operation, nativeTask)); + case background.TaskStatus.notFound: + operation.completeError( + const DownloadExecutionException( + message: '下载资源不存在或已失效', + kind: DownloadFailureKind.server, + ), + ); + case background.TaskStatus.failed: + operation.completeError(_exceptionFromUpdate(update)); + case background.TaskStatus.canceled: + operation.completeError( + DioException.requestCancelled( + requestOptions: RequestOptions(path: operation.appTask.url), + reason: operation.stopReason ?? 'cancelled', + ), + ); + case background.TaskStatus.enqueued: + case background.TaskStatus.running: + case background.TaskStatus.waitingToRetry: + case background.TaskStatus.paused: + return; + } + } + + DownloadExecutionException _exceptionFromUpdate( + background.TaskStatusUpdate update, + ) { + final exception = update.exception; + if (exception is background.TaskConnectionException) { + return const DownloadExecutionException( + message: '网络连接中断,恢复网络后将自动继续', + kind: DownloadFailureKind.network, + ); + } + if (exception is background.TaskFileSystemException) { + return const DownloadExecutionException( + message: '文件保存失败,请检查剩余空间和保存路径', + kind: DownloadFailureKind.storage, + ); + } + if (exception is background.TaskHttpException || + exception is background.TaskUrlException) { + return const DownloadExecutionException( + message: '下载地址不可用,请稍后重试', + kind: DownloadFailureKind.server, + ); + } + return DownloadExecutionException( + message: exception?.description.trim().isNotEmpty == true + ? exception!.description.trim() + : '系统后台下载失败,请稍后重试', + ); + } + + Future _completeOperation( + _BackgroundDownloadOperation operation, + background.DownloadTask nativeTask, + ) async { + if (operation.finishing || operation.completer.isCompleted) return; + operation.finishing = true; + try { + final source = File(await nativeTask.filePath()); + final downloadedBytes = await source.exists() ? await source.length() : 0; + final savedPath = await _export(operation.appTask, nativeTask); + await _downloader.database.deleteRecordWithId(operation.appTask.id); + await _deleteNativeDirectory(operation.appTask, nativeTask); + if (downloadedBytes > 0) { + operation.onProgress(downloadedBytes, downloadedBytes); + } + if (!operation.completer.isCompleted) { + operation.completer.complete(DownloadTaskResult(savedPath: savedPath)); + } + } catch (error, stackTrace) { + _logError('Failed to export background download', error, stackTrace); + operation.completeError( + error is DownloadExecutionException + ? error + : const DownloadExecutionException( + message: '文件保存失败,请检查剩余空间和保存路径', + kind: DownloadFailureKind.storage, + ), + stackTrace, + ); + } + } + + Future _export( + DownloadTask task, + background.DownloadTask nativeTask, + ) async { + final configuredPath = ChewieHiveUtil.getString(HiveUtil.savePathKey); + if (ResponsiveUtil.isMobile() && configuredPath.nullOrEmpty) { + await _requestSharedStoragePermissionIfNeeded(); + final destination = switch (task.mediaType) { + DownloadMediaType.image => background.SharedStorage.images, + DownloadMediaType.video => background.SharedStorage.video, + DownloadMediaType.file => background.SharedStorage.downloads, + }; + final result = await _downloader.moveToSharedStorage( + nativeTask, + destination, + ); + if (result == null || result.isEmpty) { + throw const DownloadExecutionException( + message: '无法写入系统相册或下载目录', + kind: DownloadFailureKind.storage, + ); + } + return result; + } + if (configuredPath.nullOrEmpty) { + throw const DownloadExecutionException( + message: '请先在图片设置中选择下载路径', + kind: DownloadFailureKind.storage, + ); + } + final source = File(await nativeTask.filePath()); + if (!await source.exists()) { + throw const DownloadExecutionException( + message: '后台下载文件已被系统清理,请重试', + kind: DownloadFailureKind.storage, + ); + } + final directory = Directory(configuredPath!); + await directory.create(recursive: true); + final target = await _availableTarget(directory, task.fileName); + await source.copy(target.path); + return target.path; + } + + Future _availableTarget(Directory directory, String fileName) async { + final requested = File(path_util.join(directory.path, fileName)); + if (!await requested.exists()) return requested; + final extension = path_util.extension(fileName); + final stem = path_util.basenameWithoutExtension(fileName); + for (var index = 1; index < 10000; index++) { + final candidate = File( + path_util.join(directory.path, '$stem ($index)$extension'), + ); + if (!await candidate.exists()) return candidate; + } + throw const DownloadExecutionException( + message: '同名文件过多,无法生成可用文件名', + kind: DownloadFailureKind.storage, + ); + } + + @override + Future pause(DownloadTask task) async { + await initialize(); + final operation = _operations[task.id]; + final nativeTask = operation?.nativeTask ?? _toNativeTask(task); + operation?.stopReason = 'paused'; + final paused = await _downloader.pause(nativeTask); + if (!paused) await _downloader.cancelTaskWithId(task.id); + operation?.completeStopped(); + return true; + } + + @override + Future cancel(DownloadTask task) async { + await initialize(); + final operation = _operations[task.id]; + operation?.stopReason = 'cancelled'; + await _downloader.cancelTaskWithId(task.id); + operation?.completeStopped(); + } + + Future _stopFromCancelToken( + _BackgroundDownloadOperation operation, + CancelToken cancelToken, + ) async { + final reason = cancelToken.cancelError?.error?.toString() ?? 'cancelled'; + if (reason.contains('paused')) { + await pause(operation.appTask); + } else { + await cancel(operation.appTask); + } + } + + @override + Future onNetworkAvailable() async { + await initialize(); + await _downloader.resumeFromBackground(); + for (final operation in List.of(_operations.values)) { + if (operation.completer.isCompleted || operation.stopReason != null) { + continue; + } + final record = + await _downloader.database.recordForId(operation.appTask.id); + if (record?.status == background.TaskStatus.paused && + record?.task is background.DownloadTask) { + await _downloader.resume(record!.task as background.DownloadTask); + } + } + } + + @override + Future deleteTemporaryFiles(DownloadTask task) { + return _deleteNativeArtifacts(task, deleteDatabaseRecord: true); + } + + Future _deleteNativeArtifacts( + DownloadTask task, { + required bool deleteDatabaseRecord, + }) async { + try { + await initialize(); + final record = await _downloader.database.recordForId(task.id); + final nativeTask = record?.task is background.DownloadTask + ? record!.task as background.DownloadTask + : _toNativeTask(task); + await _deleteNativeDirectory(task, nativeTask); + if (deleteDatabaseRecord) { + await _downloader.database.deleteRecordWithId(task.id); + } + } catch (error, stackTrace) { + _logError('Failed to clean background download files', error, stackTrace); + } + } + + Future _deleteNativeDirectory( + DownloadTask task, + background.DownloadTask nativeTask, + ) async { + final file = File(await nativeTask.filePath()); + final taskDirectory = file.parent; + final isExpectedDirectory = path_util.basename(taskDirectory.path) == + task.id && + path_util.basename(taskDirectory.parent.path) == 'loftify_downloads'; + if (isExpectedDirectory && await taskDirectory.exists()) { + await taskDirectory.delete(recursive: true); + } else if (await file.exists()) { + await file.delete(); + } + } + + background.DownloadTask _toNativeTask(DownloadTask task) { + final displayName = (task.title?.trim().isNotEmpty == true + ? task.title!.trim() + : task.fileName) + .replaceAll(RegExp(r'[\r\n]+'), ' '); + return background.DownloadTask( + taskId: task.id, + url: task.url, + filename: task.fileName, + directory: path_util.join('loftify_downloads', task.id), + baseDirectory: background.BaseDirectory.applicationSupport, + group: _group, + updates: background.Updates.statusAndProgress, + retries: 5, + allowPause: true, + priority: 0, + metaData: task.mediaType.name, + displayName: displayName.length <= 80 + ? displayName + : '${displayName.substring(0, 79)}…', + creationTime: task.createdAt, + ); + } + + Future _requestNotificationPermissionOnce() async { + if (_notificationPermissionRequested) return; + _notificationPermissionRequested = true; + try { + final permissions = _downloader.permissions; + final current = await permissions.status( + background.PermissionType.notifications, + ); + if (current != background.PermissionStatus.granted) { + await permissions.request(background.PermissionType.notifications); + } + } catch (error, stackTrace) { + _logError('Failed to request download notification permission', error, + stackTrace); + } + } + + Future _requestSharedStoragePermissionIfNeeded() async { + final permissions = _downloader.permissions; + final current = await permissions.status( + background.PermissionType.androidSharedStorage, + ); + if (current == background.PermissionStatus.granted) return; + final requested = await permissions.request( + background.PermissionType.androidSharedStorage, + ); + if (requested != background.PermissionStatus.granted) { + throw const DownloadExecutionException( + message: '没有相册或下载目录写入权限', + kind: DownloadFailureKind.storage, + ); + } + } + + void _logError(String message, Object error, StackTrace stackTrace) { + try { + ILogger.error(message, error, stackTrace); + } catch (_) {} + } +} + +class _BackgroundDownloadOperation { + _BackgroundDownloadOperation({ + required this.appTask, + required this.nativeTask, + required this.onProgress, + }); + + final DownloadTask appTask; + background.DownloadTask nativeTask; + final DownloadProgressCallback onProgress; + final Completer completer = Completer(); + String? stopReason; + bool finishing = false; + + void completeStopped() { + if (completer.isCompleted) return; + completer.completeError( + DioException.requestCancelled( + requestOptions: RequestOptions(path: appTask.url), + reason: stopReason ?? 'cancelled', + ), + ); + } + + void completeError(Object error, [StackTrace? stackTrace]) { + if (completer.isCompleted) return; + completer.completeError(error, stackTrace); + } +} diff --git a/lib/Utils/constant.dart b/lib/Utils/constant.dart index ea2c0868..deec87c9 100644 --- a/lib/Utils/constant.dart +++ b/lib/Utils/constant.dart @@ -10,7 +10,7 @@ const defaultFilenameFormat = "{original_name}"; const defaultWindowSize = Size(1120, 740); -const minimumSize = Size(630, 700); +const postDetailTwoPaneMinWindowSize = Size(630, 700); String shareText = "Loftify - 简洁的LOFTER第三方APP\n$officialWebsite"; const String feedbackEmail = "2014027378@qq.com"; @@ -21,4 +21,3 @@ const String qqGroupUrl = "https://qm.qq.com/q/2HJ8PC1XcQ"; const String cloudControlUrl = "https://apps.cloudchewie.com/loftify/control.json"; - diff --git a/lib/Utils/default_download_task_executor.dart b/lib/Utils/default_download_task_executor.dart new file mode 100644 index 00000000..858bb2c9 --- /dev/null +++ b/lib/Utils/default_download_task_executor.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; + +import '../Models/download_task.dart'; +import 'background_download_task_executor.dart'; +import 'dio_download_task_executor.dart'; +import 'download_task_executor.dart'; + +/// Selects the most capable downloader available on the current platform. +/// +/// Android downloads are delegated to WorkManager so they can continue while +/// the Flutter process is suspended. Desktop keeps the proven Dio-based +/// implementation and its byte-range resume support. +class DefaultDownloadTaskExecutor extends DownloadTaskExecutor { + DefaultDownloadTaskExecutor({Dio? dio}) + : _delegate = Platform.isAndroid + ? BackgroundDownloadTaskExecutor() + : DioDownloadTaskExecutor(dio: dio); + + final DownloadTaskExecutor _delegate; + + @override + Future initialize() => _delegate.initialize(); + + @override + Future run( + DownloadTask task, { + required CancelToken cancelToken, + required DownloadProgressCallback onProgress, + }) { + return _delegate.run( + task, + cancelToken: cancelToken, + onProgress: onProgress, + ); + } + + @override + Future pause(DownloadTask task) => _delegate.pause(task); + + @override + Future cancel(DownloadTask task) => _delegate.cancel(task); + + @override + Future onNetworkAvailable() => _delegate.onNetworkAvailable(); + + @override + Future deleteTemporaryFiles(DownloadTask task) { + return _delegate.deleteTemporaryFiles(task); + } +} diff --git a/lib/Utils/dio_download_task_executor.dart b/lib/Utils/dio_download_task_executor.dart new file mode 100644 index 00000000..62cf3f7e --- /dev/null +++ b/lib/Utils/dio_download_task_executor.dart @@ -0,0 +1,213 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path/path.dart' as path_util; +import 'package:path_provider/path_provider.dart'; + +import '../Models/download_task.dart'; +import 'download_task_executor.dart'; +import 'hive_util.dart'; + +class DioDownloadTaskExecutor extends DownloadTaskExecutor { + DioDownloadTaskExecutor({Dio? dio}) : _dio = dio ?? Dio(); + + final Dio _dio; + + @override + Future run( + DownloadTask task, { + required CancelToken cancelToken, + required DownloadProgressCallback onProgress, + }) { + return _run( + task, + cancelToken: cancelToken, + onProgress: onProgress, + allowPartialReset: true, + ); + } + + Future _run( + DownloadTask task, { + required CancelToken cancelToken, + required DownloadProgressCallback onProgress, + required bool allowPartialReset, + }) async { + final temporaryFile = await _temporaryFile(task); + await temporaryFile.parent.create(recursive: true); + var existingBytes = + await temporaryFile.exists() ? await temporaryFile.length() : 0; + final headers = {}; + if (existingBytes > 0) { + headers[HttpHeaders.rangeHeader] = 'bytes=$existingBytes-'; + } + + final response = await _dio.get( + task.url, + cancelToken: cancelToken, + options: Options( + responseType: ResponseType.stream, + headers: headers, + followRedirects: true, + validateStatus: (status) => + status != null && status >= 200 && status < 500, + ), + ); + final statusCode = response.statusCode ?? 0; + if (statusCode == HttpStatus.requestedRangeNotSatisfiable && + existingBytes > 0) { + final serverLength = _totalBytesFromContentRange( + response.headers.value(HttpHeaders.contentRangeHeader), + ); + if (serverLength != null && existingBytes == serverLength) { + final savedPath = await _export(task, temporaryFile); + await deleteTemporaryFiles(task); + return DownloadTaskResult(savedPath: savedPath); + } + if (allowPartialReset) { + await deleteTemporaryFiles(task); + return _run( + task, + cancelToken: cancelToken, + onProgress: onProgress, + allowPartialReset: false, + ); + } + } + if (statusCode < 200 || statusCode >= 300 || response.data == null) { + throw DioException.badResponse( + statusCode: statusCode, + requestOptions: response.requestOptions, + response: response, + ); + } + + final canAppend = + statusCode == HttpStatus.partialContent && existingBytes > 0; + if (!canAppend) existingBytes = 0; + final contentLength = int.tryParse( + response.headers.value(Headers.contentLengthHeader) ?? '', + ) ?? + -1; + final totalBytes = _resolveTotalBytes( + response.headers.value(HttpHeaders.contentRangeHeader), + existingBytes, + contentLength, + ); + var receivedBytes = existingBytes; + onProgress(receivedBytes, totalBytes); + + final sink = temporaryFile.openWrite( + mode: canAppend ? FileMode.append : FileMode.write, + ); + try { + await for (final chunk in response.data!.stream) { + if (cancelToken.isCancelled) { + throw DioException.requestCancelled( + requestOptions: response.requestOptions, + reason: cancelToken.cancelError, + ); + } + sink.add(chunk); + receivedBytes += chunk.length; + onProgress(receivedBytes, totalBytes); + } + await sink.flush(); + } finally { + await sink.close(); + } + + final savedPath = await _export(task, temporaryFile); + await deleteTemporaryFiles(task); + return DownloadTaskResult(savedPath: savedPath); + } + + int _resolveTotalBytes( + String? contentRange, + int existingBytes, + int contentLength, + ) { + final totalFromRange = _totalBytesFromContentRange(contentRange); + if (totalFromRange != null && totalFromRange > 0) return totalFromRange; + if (contentLength > 0) return existingBytes + contentLength; + return 0; + } + + int? _totalBytesFromContentRange(String? contentRange) { + if (contentRange == null || !contentRange.contains('/')) return null; + return int.tryParse(contentRange.split('/').last.trim()); + } + + Future _export(DownloadTask task, File temporaryFile) async { + final configuredPath = ChewieHiveUtil.getString(HiveUtil.savePathKey); + if (ResponsiveUtil.isMobile() && configuredPath.nullOrEmpty) { + final result = await ImageGallerySaver.saveFile( + temporaryFile.path, + name: task.fileName, + ); + if (result == null || result['isSuccess'] != true) { + throw const FileSystemException('无法写入系统相册'); + } + return result['filePath']?.toString() ?? 'gallery://${task.fileName}'; + } + if (configuredPath.nullOrEmpty) { + throw const FileSystemException('请先在图片设置中选择下载路径'); + } + final directory = Directory(configuredPath!); + await directory.create(recursive: true); + final target = await _availableTarget(directory, task.fileName); + await temporaryFile.copy(target.path); + return target.path; + } + + Future _availableTarget(Directory directory, String fileName) async { + final requested = File(path_util.join(directory.path, fileName)); + if (!await requested.exists()) return requested; + final extension = path_util.extension(fileName); + final stem = path_util.basenameWithoutExtension(fileName); + for (var index = 1; index < 10000; index++) { + final candidate = File( + path_util.join(directory.path, '$stem ($index)$extension'), + ); + if (!await candidate.exists()) return candidate; + } + throw const FileSystemException('同名文件过多,无法生成可用文件名'); + } + + Future _temporaryFile(DownloadTask task) async { + final temporaryDirectory = await getTemporaryDirectory(); + return File( + path_util.join( + temporaryDirectory.path, + 'loftify_downloads', + task.id, + task.fileName, + ), + ); + } + + @override + Future deleteTemporaryFiles(DownloadTask task) async { + try { + final file = await _temporaryFile(task); + final taskDirectory = file.parent; + if (await taskDirectory.exists()) { + await taskDirectory.delete(recursive: true); + } + } catch (error, stackTrace) { + _logError( + 'Failed to clean download temporary files', + error, + stackTrace, + ); + } + } + + void _logError(String message, Object error, StackTrace stackTrace) { + try { + ILogger.error(message, error, stackTrace); + } catch (_) {} + } +} diff --git a/lib/Utils/display_mode_util.dart b/lib/Utils/display_mode_util.dart new file mode 100644 index 00000000..84c98c62 --- /dev/null +++ b/lib/Utils/display_mode_util.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_displaymode/flutter_displaymode.dart'; + +class DisplayModePreference { + const DisplayModePreference._(); + + static final RegExp _encodedModePattern = RegExp( + r'^(\d+)x(\d+)@(\d+(?:\.\d+)?)$', + ); + + static String encode(DisplayMode mode) { + if (isAutomatic(mode)) return 'auto'; + return '${mode.width}x${mode.height}@${mode.refreshRate.toStringAsFixed(3)}'; + } + + static bool isAutomatic(DisplayMode mode) => + mode.width == 0 && mode.height == 0 && mode.refreshRate == 0; + + static DisplayMode resolve({ + required List modes, + required DisplayMode activeMode, + String? encodedMode, + int legacyIndex = -1, + }) { + if (modes.isEmpty) return DisplayMode.auto; + + if (encodedMode != null && encodedMode.isNotEmpty) { + for (final mode in modes) { + if (encode(mode) == encodedMode) return mode; + } + + final savedMode = _decode(encodedMode); + if (savedMode != null) { + final sameResolution = modes.where( + (mode) => + !isAutomatic(mode) && + mode.width == savedMode.width && + mode.height == savedMode.height, + ); + if (sameResolution.isNotEmpty) { + final nearest = sameResolution.reduce( + (best, mode) => (mode.refreshRate - savedMode.refreshRate).abs() < + (best.refreshRate - savedMode.refreshRate).abs() + ? mode + : best, + ); + // Android may report the same physical mode as 60, 59.94, or a + // nearby fractional value after an OS update. Preserve the user's + // choice instead of silently falling back to the highest rate. + if ((nearest.refreshRate - savedMode.refreshRate).abs() <= 1) { + return nearest; + } + } + } + } else if (legacyIndex >= 0 && legacyIndex < modes.length) { + return modes[legacyIndex]; + } + + final matchingResolution = modes.where( + (mode) => + !isAutomatic(mode) && + mode.width == activeMode.width && + mode.height == activeMode.height, + ); + if (matchingResolution.isNotEmpty) { + return matchingResolution.reduce( + (best, mode) => mode.refreshRate > best.refreshRate ? mode : best, + ); + } + + final concreteModes = modes.where((mode) => !isAutomatic(mode)); + if (concreteModes.isNotEmpty) { + return concreteModes.reduce((best, mode) { + final bestPixels = best.width * best.height; + final modePixels = mode.width * mode.height; + if (modePixels != bestPixels) { + return modePixels > bestPixels ? mode : best; + } + return mode.refreshRate > best.refreshRate ? mode : best; + }); + } + return modes.first; + } + + static ({int width, int height, double refreshRate})? _decode( + String encodedMode, + ) { + final match = _encodedModePattern.firstMatch(encodedMode); + if (match == null) return null; + final width = int.tryParse(match.group(1)!); + final height = int.tryParse(match.group(2)!); + final refreshRate = double.tryParse(match.group(3)!); + if (width == null || height == null || refreshRate == null) return null; + return (width: width, height: height, refreshRate: refreshRate); + } + + static List ordered(List modes) { + final unique = {}; + for (final mode in modes) { + unique.putIfAbsent(encode(mode), () => mode); + } + final result = unique.values.toList() + ..sort((left, right) { + final leftAuto = isAutomatic(left); + final rightAuto = isAutomatic(right); + if (leftAuto != rightAuto) return leftAuto ? -1 : 1; + final widthOrder = right.width.compareTo(left.width); + if (widthOrder != 0) return widthOrder; + final heightOrder = right.height.compareTo(left.height); + if (heightOrder != 0) return heightOrder; + return right.refreshRate.compareTo(left.refreshRate); + }); + return result; + } + + static String label( + DisplayMode mode, { + required String automaticLabel, + }) { + if (isAutomatic(mode)) return automaticLabel; + final roundedRate = mode.refreshRate.roundToDouble(); + final rateLabel = (mode.refreshRate - roundedRate).abs() < 0.01 + ? roundedRate.toInt().toString() + : mode.refreshRate.toStringAsFixed(2); + return '${mode.width} × ${mode.height} · $rateLabel Hz'; + } +} + +class DisplayModeController { + const DisplayModeController._(); + + static const MethodChannel _channel = MethodChannel( + 'loftify/display_mode', + ); + + static double surfaceRefreshRate(DisplayMode mode) => + DisplayModePreference.isAutomatic(mode) ? 0 : mode.refreshRate; + + static Future setPreferredMode(DisplayMode mode) async { + await FlutterDisplayMode.setPreferredMode(mode); + if (!Platform.isAndroid) return; + await _channel.invokeMethod('setPreferredRefreshRate', { + 'refreshRate': surfaceRefreshRate(mode), + }); + } +} diff --git a/lib/Utils/download_source_router.dart b/lib/Utils/download_source_router.dart new file mode 100644 index 00000000..552ea53c --- /dev/null +++ b/lib/Utils/download_source_router.dart @@ -0,0 +1,104 @@ +import 'package:flutter/widgets.dart'; +import 'package:loftify/Utils/enums.dart'; + +import '../Models/download_task.dart'; +import '../Screens/Info/favorite_folder_detail_screen.dart'; +import '../Screens/Info/like_screen.dart'; +import '../Screens/Info/share_screen.dart'; +import '../Screens/Post/collection_detail_screen.dart'; +import '../Screens/Post/grain_detail_screen.dart'; +import '../Screens/Post/post_detail_screen.dart'; + +/// Resolves the persisted business source of a grouped download without +/// coupling the download model to route widgets. +abstract final class DownloadSourceRouter { + static Widget? destinationFor(DownloadSourceDescriptor source) { + final metadata = source.metadata; + return switch (source.type) { + DownloadSourceType.postAll => _postDestination(metadata), + DownloadSourceType.collection => _collectionDestination(source), + DownloadSourceType.grain => _grainDestination(source), + DownloadSourceType.likes => _likeDestination(source), + DownloadSourceType.recommendations => _recommendationDestination(source), + DownloadSourceType.favoriteFolder => _favoriteDestination(source), + DownloadSourceType.other => null, + }; + } + + static Widget? _postDestination(Map metadata) { + final postId = int.tryParse(metadata['postId'] ?? ''); + final blogId = int.tryParse(metadata['blogId'] ?? ''); + final blogName = metadata['blogName']?.trim() ?? ''; + if (postId == null || + postId <= 0 || + blogId == null || + blogId <= 0 || + blogName.isEmpty) { + return null; + } + return PostDetailScreen( + meta: { + 'postId': postId.toRadixString(16), + 'blogId': blogId.toRadixString(16), + 'blogName': blogName, + }, + isArticle: false, + ); + } + + static Widget? _collectionDestination(DownloadSourceDescriptor source) { + final collectionId = int.tryParse( + source.metadata['collectionId'] ?? source.sourceId, + ); + if (collectionId == null || collectionId <= 0) return null; + return CollectionDetailScreen( + collectionId: collectionId, + postId: int.tryParse(source.metadata['postId'] ?? '') ?? 0, + blogId: int.tryParse(source.metadata['blogId'] ?? '') ?? 0, + blogName: source.metadata['blogName'] ?? '', + ); + } + + static Widget? _grainDestination(DownloadSourceDescriptor source) { + final grainId = int.tryParse(source.metadata['grainId'] ?? source.sourceId); + final blogId = int.tryParse(source.metadata['blogId'] ?? ''); + if (grainId == null || grainId <= 0 || blogId == null || blogId <= 0) { + return null; + } + return GrainDetailScreen(grainId: grainId, blogId: blogId); + } + + static Widget? _likeDestination(DownloadSourceDescriptor source) { + if (source.sourceId == 'self') return LikeScreen(); + final blogId = int.tryParse(source.metadata['blogId'] ?? source.sourceId); + final blogName = source.metadata['blogName']?.trim() ?? ''; + if (blogId == null || blogId <= 0 || blogName.isEmpty) return null; + return LikeScreen( + infoMode: InfoMode.other, + blogId: blogId, + blogName: blogName, + ); + } + + static Widget? _recommendationDestination( + DownloadSourceDescriptor source, + ) { + if (source.sourceId == 'self') return ShareScreen(); + final blogId = int.tryParse(source.metadata['blogId'] ?? source.sourceId); + final blogName = source.metadata['blogName']?.trim() ?? ''; + if (blogId == null || blogId <= 0 || blogName.isEmpty) return null; + return ShareScreen( + infoMode: InfoMode.other, + blogId: blogId, + blogName: blogName, + ); + } + + static Widget? _favoriteDestination(DownloadSourceDescriptor source) { + final folderId = int.tryParse( + source.metadata['favoriteFolderId'] ?? source.sourceId, + ); + if (folderId == null || folderId <= 0) return null; + return FavoriteFolderDetailScreen(favoriteFolderId: folderId); + } +} diff --git a/lib/Utils/download_task_executor.dart b/lib/Utils/download_task_executor.dart new file mode 100644 index 00000000..65cad745 --- /dev/null +++ b/lib/Utils/download_task_executor.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; + +import '../Models/download_task.dart'; + +typedef DownloadProgressCallback = void Function(int received, int total); +typedef DownloadErrorLogger = void Function( + String message, + Object error, + StackTrace stackTrace, +); + +class DownloadTaskResult { + const DownloadTaskResult({required this.savedPath}); + + final String savedPath; +} + +class DownloadExecutionException implements Exception { + const DownloadExecutionException({ + required this.message, + this.kind = DownloadFailureKind.unknown, + }); + + final String message; + final DownloadFailureKind kind; + + @override + String toString() => message; +} + +abstract class DownloadTaskExecutor { + Future initialize() async {} + + Future run( + DownloadTask task, { + required CancelToken cancelToken, + required DownloadProgressCallback onProgress, + }); + + Future pause(DownloadTask task) async => false; + + Future cancel(DownloadTask task) async {} + + Future onNetworkAvailable() async {} + + Future deleteTemporaryFiles(DownloadTask task); +} diff --git a/lib/Utils/download_task_manager.dart b/lib/Utils/download_task_manager.dart new file mode 100644 index 00000000..e9bdff3f --- /dev/null +++ b/lib/Utils/download_task_manager.dart @@ -0,0 +1,789 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as path_util; + +import '../Models/download_task.dart'; +import 'default_download_task_executor.dart'; +import 'download_task_executor.dart'; +import 'hive_util.dart'; + +export 'download_task_executor.dart'; + +void _logDownloadError( + String message, + Object error, + StackTrace stackTrace, +) { + try { + ILogger.error(message, error, stackTrace); + } catch (_) { + // A logging backend must never turn a recoverable download error into an + // uncaught failure (for example before platform plugins are available). + } +} + +abstract class DownloadTaskStore { + Future> read(); + + Future write(List tasks); + + Future> readGroups() async => const []; + + Future writeGroups(List groups) async {} +} + +class HiveDownloadTaskStore implements DownloadTaskStore { + const HiveDownloadTaskStore(); + + @override + Future> read() async { + final records = ChewieHiveUtil.getList( + HiveUtil.downloadTasksKey, + defaultValue: const [], + ) ?? + const []; + final tasks = []; + for (final record in records) { + try { + final task = DownloadTask.fromJson( + Map.from(record as Map), + ); + if (task.id.isNotEmpty && task.url.isNotEmpty) tasks.add(task); + } catch (error, stackTrace) { + _logDownloadError( + 'Failed to restore a download task', + error, + stackTrace, + ); + } + } + return tasks; + } + + @override + Future write(List tasks) { + return ChewieHiveUtil.put( + HiveUtil.downloadTasksKey, + tasks.map((task) => task.toJson()).toList(growable: false), + ); + } + + @override + Future> readGroups() async { + final records = ChewieHiveUtil.getList( + HiveUtil.downloadGroupsKey, + defaultValue: const [], + ) ?? + const []; + final groups = []; + for (final record in records) { + try { + final group = DownloadGroup.fromJson( + Map.from(record as Map), + ); + if (group.id.isNotEmpty && group.taskIds.isNotEmpty) groups.add(group); + } catch (error, stackTrace) { + _logDownloadError( + 'Failed to restore a download group', + error, + stackTrace, + ); + } + } + return groups; + } + + @override + Future writeGroups(List groups) { + return ChewieHiveUtil.put( + HiveUtil.downloadGroupsKey, + groups.map((group) => group.toJson()).toList(growable: false), + ); + } +} + +class DownloadTaskManager extends ChangeNotifier { + DownloadTaskManager({ + DownloadTaskStore? store, + DownloadTaskExecutor? executor, + Future> Function()? connectivityCheck, + Stream>? connectivityChanges, + DownloadErrorLogger errorLogger = _logDownloadError, + this.maxConcurrentTasks = 2, + this.monitorConnectivity = false, + }) : assert(maxConcurrentTasks > 0), + _store = store ?? const HiveDownloadTaskStore(), + _executor = executor ?? DefaultDownloadTaskExecutor(), + _connectivityCheck = + connectivityCheck ?? Connectivity().checkConnectivity, + _connectivityChanges = + connectivityChanges ?? Connectivity().onConnectivityChanged, + _errorLogger = errorLogger; + + static final DownloadTaskManager instance = DownloadTaskManager( + monitorConnectivity: true, + ); + + final DownloadTaskStore _store; + final DownloadTaskExecutor _executor; + final Future> Function() _connectivityCheck; + final Stream> _connectivityChanges; + final DownloadErrorLogger _errorLogger; + final int maxConcurrentTasks; + final bool monitorConnectivity; + final List _tasks = []; + final List _groups = []; + final Map _cancelTokens = {}; + final Map _lastProgressNotification = {}; + final Map _lastProgressPersistence = {}; + StreamSubscription>? _connectivitySubscription; + Future _persistenceQueue = Future.value(); + Future? _initialization; + bool _initialized = false; + bool _wasOffline = false; + + List get tasks => List.unmodifiable(_tasks); + + List get groups => List.unmodifiable(_groups); + + DownloadGroup? groupById(String groupId) { + for (final group in _groups) { + if (group.id == groupId) return group; + } + return null; + } + + List tasksForGroup(String groupId) { + final group = groupById(groupId); + if (group == null) return const []; + final tasksById = { + for (final task in _tasks) task.id: task, + }; + return List.unmodifiable( + group.taskIds.map((id) => tasksById[id]).whereType(), + ); + } + + DownloadGroupSnapshot? snapshotForGroup(String groupId) { + final group = groupById(groupId); + if (group == null) return null; + return DownloadGroupSnapshot( + group: group, + tasks: tasksForGroup(groupId), + ); + } + + int get activeCount => _tasks.where((task) => task.isActive).length; + + Future initialize() => _initialization ??= _initialize(); + + Future _initialize() async { + await _executor.initialize(); + final restored = await _store.read(); + final restoredGroups = await _store.readGroups(); + _tasks + ..clear() + ..addAll(restored.map((task) { + if (task.status != DownloadTaskStatus.downloading) return task; + return task.copyWith( + status: DownloadTaskStatus.queued, + errorMessage: null, + failureKind: null, + ); + })); + final restoredTaskIds = _tasks.map((task) => task.id).toSet(); + _groups + ..clear() + ..addAll( + restoredGroups.where( + (group) => group.taskIds.any(restoredTaskIds.contains), + ), + ); + _initialized = true; + if (monitorConnectivity) await _startConnectivityMonitor(); + await _persist(); + notifyListeners(); + _pump(); + } + + Future enqueue({ + required String url, + required String fileName, + required DownloadMediaType mediaType, + String? title, + String? thumbnailUrl, + }) async { + await initialize(); + final normalizedUrl = url.trim(); + final existingIndex = + _tasks.indexWhere((task) => task.url == normalizedUrl); + if (existingIndex >= 0) { + final existing = _tasks[existingIndex]; + if (existing.status == DownloadTaskStatus.failed || + existing.status == DownloadTaskStatus.cancelled || + existing.status == DownloadTaskStatus.paused) { + _replace( + existing.copyWith( + status: DownloadTaskStatus.queued, + errorMessage: null, + failureKind: null, + ), + ); + await _persist(); + _pump(); + return _taskById(existing.id)!; + } + return existing; + } + + final now = DateTime.now(); + final task = DownloadTask( + id: '${now.microsecondsSinceEpoch}_${normalizedUrl.hashCode.abs()}', + url: normalizedUrl, + fileName: _sanitizeFileName(fileName), + title: title, + thumbnailUrl: thumbnailUrl, + mediaType: mediaType, + status: DownloadTaskStatus.queued, + createdAt: now, + updatedAt: now, + ); + _tasks.insert(0, task); + notifyListeners(); + await _persist(); + _pump(); + return task; + } + + /// Adds a group of resources with one state notification and one persisted + /// snapshot. The URL is the stable identity across batches. + /// + /// Active and completed resources are skipped. Failed, cancelled and paused + /// records are reset and requeued. This is intentionally a skip policy for + /// already downloaded files: batch actions never overwrite or create a + /// second copy of a completed resource. + Future enqueueBatch( + Iterable requests, { + DownloadSourceDescriptor? source, + int unavailableCount = 0, + }) async { + await initialize(); + final requestList = requests.toList(growable: false); + final seenUrls = {}; + final affectedTasks = []; + var queuedCount = 0; + var skippedCount = 0; + var invalidCount = 0; + var requeuedCount = 0; + var sequence = 0; + + for (final request in requestList) { + final normalizedUrl = request.url.trim(); + if (!_isValidDownloadUrl(normalizedUrl)) { + invalidCount++; + continue; + } + if (!seenUrls.add(normalizedUrl)) { + skippedCount++; + continue; + } + + final existingIndex = + _tasks.indexWhere((task) => task.url == normalizedUrl); + if (existingIndex >= 0) { + final existing = _tasks[existingIndex]; + if (existing.status == DownloadTaskStatus.failed || + existing.status == DownloadTaskStatus.cancelled || + existing.status == DownloadTaskStatus.paused) { + final requeued = existing.copyWith( + status: DownloadTaskStatus.queued, + progress: 0, + receivedBytes: 0, + totalBytes: 0, + savedPath: null, + errorMessage: null, + failureKind: null, + ); + _tasks[existingIndex] = requeued; + affectedTasks.add(requeued); + queuedCount++; + requeuedCount++; + } else { + affectedTasks.add(existing); + skippedCount++; + } + continue; + } + + final now = DateTime.now(); + final task = DownloadTask( + id: '${now.microsecondsSinceEpoch}_${sequence++}_' + '${normalizedUrl.hashCode.abs()}', + url: normalizedUrl, + fileName: _sanitizeFileName(request.fileName), + title: request.title, + thumbnailUrl: request.thumbnailUrl, + mediaType: request.mediaType, + status: DownloadTaskStatus.queued, + createdAt: now, + updatedAt: now, + ); + _tasks.add(task); + affectedTasks.add(task); + queuedCount++; + } + + DownloadGroup? group; + if (source != null && affectedTasks.isNotEmpty) { + final now = DateTime.now(); + final taskIds = affectedTasks.map((task) => task.id).toSet().toList(); + group = DownloadGroup( + id: 'group_${now.microsecondsSinceEpoch}_${source.stableKey.hashCode.abs()}', + source: source, + taskIds: taskIds, + requestedCount: requestList.length, + unavailableCount: unavailableCount + invalidCount, + skippedCount: skippedCount, + createdAt: now, + updatedAt: now, + ); + _groups.insert(0, group); + } + + if (queuedCount > 0 || group != null) { + notifyListeners(); + await _persist(); + _pump(); + } + return DownloadBatchResult( + requestedCount: requestList.length, + queuedCount: queuedCount, + skippedCount: skippedCount, + invalidCount: invalidCount, + requeuedCount: requeuedCount, + tasks: List.unmodifiable(affectedTasks), + group: group, + ); + } + + Future waitForCompletion( + String taskId, { + DownloadProgressCallback? onProgress, + }) async { + final initial = _taskById(taskId); + if (initial == null) return false; + if (initial.isTerminal) { + return initial.status == DownloadTaskStatus.completed; + } + final completer = Completer(); + void listener() { + final current = _taskById(taskId); + if (current == null) { + if (!completer.isCompleted) completer.complete(false); + return; + } + onProgress?.call(current.receivedBytes, current.totalBytes); + if (current.isTerminal && !completer.isCompleted) { + completer.complete(current.status == DownloadTaskStatus.completed); + } + } + + addListener(listener); + listener(); + try { + return await completer.future; + } finally { + removeListener(listener); + } + } + + Future pause(String taskId) async { + final task = _taskById(taskId); + if (task == null || + (task.status != DownloadTaskStatus.queued && + task.status != DownloadTaskStatus.downloading)) { + return; + } + final wasDownloading = task.status == DownloadTaskStatus.downloading; + _replace(task.copyWith(status: DownloadTaskStatus.paused)); + try { + if (wasDownloading) await _executor.pause(task); + } catch (error, stackTrace) { + _errorLogger('Failed to pause native download task', error, stackTrace); + } finally { + _cancelTokens[taskId]?.cancel('paused'); + } + await _persist(); + _pump(); + } + + Future resume(String taskId) async { + final task = _taskById(taskId); + if (task == null || task.status != DownloadTaskStatus.paused) return; + _replace(task.copyWith( + status: DownloadTaskStatus.queued, + errorMessage: null, + failureKind: null, + )); + await _persist(); + _pump(); + } + + Future cancel(String taskId) async { + final task = _taskById(taskId); + if (task == null || task.isTerminal) return; + _replace(task.copyWith(status: DownloadTaskStatus.cancelled)); + try { + await _executor.cancel(task); + } catch (error, stackTrace) { + _errorLogger('Failed to cancel native download task', error, stackTrace); + } finally { + _cancelTokens[taskId]?.cancel('cancelled'); + } + await _persist(); + await _executor.deleteTemporaryFiles(task); + _pump(); + } + + Future retry(String taskId) async { + final task = _taskById(taskId); + if (task == null || + (task.status != DownloadTaskStatus.failed && + task.status != DownloadTaskStatus.cancelled)) { + return; + } + _replace(task.copyWith( + status: DownloadTaskStatus.queued, + errorMessage: null, + failureKind: null, + savedPath: null, + )); + await _persist(); + _pump(); + } + + Future pauseGroup(String groupId) async { + await initialize(); + final taskIds = tasksForGroup(groupId) + .where((task) => + task.status == DownloadTaskStatus.queued || + task.status == DownloadTaskStatus.downloading) + .map((task) => task.id) + .toList(growable: false); + await Future.wait(taskIds.map(pause)); + } + + Future resumeGroup(String groupId) async { + await initialize(); + final taskIds = tasksForGroup(groupId) + .where((task) => task.status == DownloadTaskStatus.paused) + .map((task) => task.id) + .toList(growable: false); + await Future.wait(taskIds.map(resume)); + } + + Future cancelGroup(String groupId) async { + await initialize(); + final taskIds = tasksForGroup(groupId) + .where((task) => task.isActive) + .map((task) => task.id) + .toList(growable: false); + await Future.wait(taskIds.map(cancel)); + } + + Future retryFailedGroup(String groupId) async { + await initialize(); + final taskIds = tasksForGroup(groupId) + .where((task) => + task.status == DownloadTaskStatus.failed || + task.status == DownloadTaskStatus.cancelled) + .map((task) => task.id) + .toList(growable: false); + await Future.wait(taskIds.map(retry)); + } + + Future remove(String taskId) async { + final index = _tasks.indexWhere((task) => task.id == taskId); + if (index < 0 || !_tasks[index].isTerminal) return; + final task = _tasks.removeAt(index); + await _executor.deleteTemporaryFiles(task); + notifyListeners(); + await _persist(); + } + + Future clearFinished() async { + _groups.removeWhere((group) => group.snapshot(_tasks).isTerminal); + final retainedTaskIds = _groups.expand((group) => group.taskIds).toSet(); + final removed = _tasks + .where( + (task) => task.isTerminal && !retainedTaskIds.contains(task.id), + ) + .toList(); + if (removed.isEmpty) { + await _persist(); + notifyListeners(); + return; + } + _tasks.removeWhere((task) => removed.any((item) => item.id == task.id)); + for (final task in removed) { + await _executor.deleteTemporaryFiles(task); + } + notifyListeners(); + await _persist(); + } + + void _pump() { + if (!_initialized) return; + while (_cancelTokens.length < maxConcurrentTasks) { + final queued = _tasks.cast().firstWhere( + (task) => task?.status == DownloadTaskStatus.queued, + orElse: () => null, + ); + if (queued == null) return; + final cancelToken = CancelToken(); + _cancelTokens[queued.id] = cancelToken; + _replace(queued.copyWith( + status: DownloadTaskStatus.downloading, + errorMessage: null, + failureKind: null, + )); + unawaited(_execute(queued.id, cancelToken)); + } + } + + Future _execute(String taskId, CancelToken cancelToken) async { + final task = _taskById(taskId); + if (task == null) return; + await _persist(); + try { + final result = await _executor.run( + task, + cancelToken: cancelToken, + onProgress: (received, total) { + final current = _taskById(taskId); + if (current == null || + current.status != DownloadTaskStatus.downloading) { + return; + } + final now = DateTime.now(); + final lastNotification = _lastProgressNotification[taskId]; + final isComplete = total > 0 && received >= total; + if (!isComplete && + lastNotification != null && + now.difference(lastNotification) < + const Duration(milliseconds: 80)) { + return; + } + _lastProgressNotification[taskId] = now; + final progress = total > 0 ? received / total : current.progress; + _replace(current.copyWith( + progress: progress.clamp(0.0, 1.0).toDouble(), + receivedBytes: received, + totalBytes: total, + )); + final lastPersistence = _lastProgressPersistence[taskId]; + if (lastPersistence == null || + now.difference(lastPersistence) >= const Duration(seconds: 1)) { + _lastProgressPersistence[taskId] = now; + unawaited(_persist()); + } + }, + ); + final current = _taskById(taskId); + if (current?.status == DownloadTaskStatus.downloading) { + _replace(current!.copyWith( + status: DownloadTaskStatus.completed, + progress: 1, + savedPath: result.savedPath, + errorMessage: null, + failureKind: null, + )); + } + } catch (error, stackTrace) { + final current = _taskById(taskId); + final wasIntentionallyStopped = + current?.status == DownloadTaskStatus.paused || + current?.status == DownloadTaskStatus.cancelled; + if (!wasIntentionallyStopped && current != null) { + _errorLogger('Download task failed', error, stackTrace); + final failure = _friendlyFailure(error); + _replace(current.copyWith( + status: DownloadTaskStatus.failed, + errorMessage: failure.message, + failureKind: failure.kind, + )); + } + } finally { + _cancelTokens.remove(taskId); + _lastProgressNotification.remove(taskId); + _lastProgressPersistence.remove(taskId); + final current = _taskById(taskId); + if (current?.status == DownloadTaskStatus.cancelled) { + await _executor.deleteTemporaryFiles(current!); + } + await _persist(); + _pump(); + } + } + + ({String message, DownloadFailureKind kind}) _friendlyFailure(Object error) { + if (error is DownloadExecutionException) { + return (message: error.message, kind: error.kind); + } + if (error is FileSystemException) { + final message = error.message.trim(); + return ( + message: message.isNotEmpty ? message : '文件保存失败,请检查剩余空间和保存路径', + kind: DownloadFailureKind.storage, + ); + } + if (error is DioException) { + if (error.response?.statusCode == + HttpStatus.requestedRangeNotSatisfiable) { + return ( + message: '服务器不支持继续下载,请清理记录后重试', + kind: DownloadFailureKind.server, + ); + } + if (error.type == DioExceptionType.badResponse) { + return ( + message: '下载地址不可用,请稍后重试', + kind: DownloadFailureKind.server, + ); + } + return ( + message: '网络连接中断,恢复网络后将自动继续', + kind: DownloadFailureKind.network, + ); + } + return ( + message: '下载失败,请稍后重试', + kind: DownloadFailureKind.unknown, + ); + } + + Future _startConnectivityMonitor() async { + try { + final initial = await _connectivityCheck(); + _wasOffline = _isOffline(initial); + _connectivitySubscription ??= _connectivityChanges.listen( + (results) => unawaited(_handleConnectivityChange(results)), + onError: (Object error, StackTrace stackTrace) { + _errorLogger( + 'Download connectivity monitor failed', + error, + stackTrace, + ); + }, + ); + } catch (error, stackTrace) { + _errorLogger( + 'Unable to start download connectivity monitor', + error, + stackTrace, + ); + } + } + + bool _isOffline(List results) { + return results.isEmpty || + results.every((result) => result == ConnectivityResult.none); + } + + Future _handleConnectivityChange( + List results, + ) async { + final offline = _isOffline(results); + if (offline) { + _wasOffline = true; + return; + } + if (!_wasOffline) return; + _wasOffline = false; + try { + await _executor.onNetworkAvailable(); + } catch (error, stackTrace) { + _errorLogger( + 'Failed to wake native downloads after connectivity returned', + error, + stackTrace, + ); + } + + var requeued = false; + for (var index = 0; index < _tasks.length; index++) { + final task = _tasks[index]; + if (task.status != DownloadTaskStatus.failed || + task.failureKind != DownloadFailureKind.network) { + continue; + } + _tasks[index] = task.copyWith( + status: DownloadTaskStatus.queued, + errorMessage: null, + failureKind: null, + ); + requeued = true; + } + if (!requeued) return; + notifyListeners(); + await _persist(); + _pump(); + } + + DownloadTask? _taskById(String taskId) { + for (final task in _tasks) { + if (task.id == taskId) return task; + } + return null; + } + + void _replace(DownloadTask task) { + final index = _tasks.indexWhere((item) => item.id == task.id); + if (index < 0) return; + _tasks[index] = task; + notifyListeners(); + } + + Future _persist() { + final snapshot = List.from(_tasks); + final groupSnapshot = List.from(_groups); + _persistenceQueue = + _persistenceQueue.catchError((_) {}).then((_) => Future.wait([ + _store.write(snapshot), + _store.writeGroups(groupSnapshot), + ])); + return _persistenceQueue; + } + + String _sanitizeFileName(String value) { + final sanitized = value + .replaceAll(RegExp(r'[\\/:*?"<>|]'), '') + .replaceAll(RegExp(r'[\u0000-\u001f]'), '') + .trim(); + if (sanitized.isEmpty) return 'download'; + const maxLength = 180; + if (sanitized.length <= maxLength) return sanitized; + final extension = path_util.extension(sanitized); + final keepLength = + (maxLength - extension.length).clamp(1, maxLength).toInt(); + return '${sanitized.substring(0, keepLength)}$extension'; + } + + bool _isValidDownloadUrl(String value) { + final uri = Uri.tryParse(value); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; + } + + @override + void dispose() { + unawaited(_connectivitySubscription?.cancel()); + super.dispose(); + } +} diff --git a/lib/Utils/enums.dart b/lib/Utils/enums.dart index 40b523de..625fb849 100644 --- a/lib/Utils/enums.dart +++ b/lib/Utils/enums.dart @@ -2,7 +2,6 @@ import 'package:tuple/tuple.dart'; import '../l10n/l10n.dart'; - enum TokenType { none, captchCode, @@ -14,6 +13,32 @@ enum ImageQuality { small, medium, origin, raw } enum HistoryLayoutMode { waterFlow, nineGrid } +enum NavigationBarDisplayStyle { + iconAndText('iconAndText'), + iconOnly('iconOnly'), + textOnly('textOnly'); + + const NavigationBarDisplayStyle(this.key); + + final String key; + + static NavigationBarDisplayStyle fromKey(String? key) { + return NavigationBarDisplayStyle.values.firstWhere( + (style) => style.key == key, + orElse: () => NavigationBarDisplayStyle.iconOnly, + ); + } + + String get label => switch (this) { + NavigationBarDisplayStyle.iconAndText => + appLocalizations.navigationBarIconAndText, + NavigationBarDisplayStyle.iconOnly => + appLocalizations.navigationBarIconOnly, + NavigationBarDisplayStyle.textOnly => + appLocalizations.navigationBarTextOnly, + }; +} + enum FavoriteFolderDetailLayoutMode { list, nineGrid, flow } enum InfoMode { me, other } @@ -228,7 +253,7 @@ enum FilenameField { return appLocalizations.fieldUnderlineExample; case slack: return appLocalizations.fieldSlackExample; - } + } } String get description { diff --git a/lib/Utils/hive_util.dart b/lib/Utils/hive_util.dart index 8c069f0b..b3cb99bc 100644 --- a/lib/Utils/hive_util.dart +++ b/lib/Utils/hive_util.dart @@ -1,12 +1,9 @@ -import 'dart:convert'; - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:loftify/Models/account_response.dart'; import 'package:loftify/Utils/enums.dart'; import 'package:loftify/Utils/request_util.dart'; -import 'package:loftify/Utils/utils.dart'; import '../l10n/l10n.dart'; import 'app_provider.dart'; @@ -43,6 +40,10 @@ class HiveUtil { static const String inappWebviewKey = "inappWebview"; static const String doubleTapActionKey = "doubleTapAction"; static const String downloadSuccessActionKey = "downloadSuccessAction"; + static const String videoContinuousPlaybackKey = "videoContinuousPlayback"; + static const String videoDanmakuEnabledKey = "videoDanmakuEnabled"; + static const String downloadTasksKey = "downloadTasks"; + static const String downloadGroupsKey = "downloadGroups"; //Appearance static const String enableLandscapeInTabletKey = "enableLandscapeInTablet"; @@ -61,6 +62,11 @@ class HiveUtil { static const String customLightThemeListKey = "customLightThemeList"; static const String customDarkThemeListKey = "customDarkThemeListKey"; static const String themeModeKey = "themeMode"; + static const String reduceTransparencyKey = "reduceTransparency"; + static const String navigationBarDisplayStyleKey = + "navigationBarDisplayStyle"; + static const String hideHomeAppBarOnScrollKey = "hideHomeAppBarOnScroll"; + static const String hideSearchNavigationKey = "hideSearchNavigation"; static const String navItemsKey = "navItems"; static const String tagDetailPostLayoutTypeKey = "tagDetailPostLayoutType"; static const String showPostDetailFloatingOperationBarKey = @@ -102,10 +108,32 @@ class HiveUtil { //System static const String firstLoginKey = "firstLogin"; static const String refreshRateKey = "refreshRate"; + static const String refreshRateModeKey = "refreshRateMode"; + static const String dynamicTabIndexKey = "dynamicTabIndex"; + static const String dynamicTabIdKey = "dynamicTabId"; + static const String searchRankTabIndexKey = "searchRankTabIndex"; + static const String searchRankTabIdKey = "searchRankTabId"; + static const String searchResultTabIndexKey = "searchResultTabIndex"; + static const String searchResultTabIdKey = "searchResultTabId"; + static const String tagDetailTabIndexKey = "tagDetailTabIndex"; + static const String tagDetailTabIdKey = "tagDetailTabId"; + static const String systemNoticeTabIndexKey = "systemNoticeTabIndex"; + static const String systemNoticeTabIdKey = "systemNoticeTabId"; + static const String tagCollectionGrainTabIndexKey = + "tagCollectionGrainTabIndex"; + static const String tagCollectionGrainTabIdKey = "tagCollectionGrainTabId"; + static const String userDetailTabIndexKey = "userDetailTabIndex"; + static const String userDetailTabIdKey = "userDetailTabId"; + static const String suitTabIndexKey = "suitTabIndex"; + static const String suitTabIdKey = "suitTabId"; + static const String suitCustomPageIndexKey = "suitCustomPageIndex"; + static const String suitCustomPageIdKey = "suitCustomPageId"; + static const String tagNewestFilterKey = "tagNewestFilter"; + static const String tagHottestFilterKey = "tagHottestFilter"; static const String haveShownQQGroupDialogKey = "haveShownQQGroupDialog"; static const String overrideCloudControlKey = "overrideCloudControl"; - static confirmLogout(BuildContext context) { + static void confirmLogout(BuildContext context) { DialogBuilder.showConfirmDialog( context, title: "退出登录", @@ -127,7 +155,7 @@ class HiveUtil { ); } - static initConfig() async { + static Future initConfig() async { ChewieHiveUtil.put(HiveUtil.doubleTapActionKey, 1); ChewieHiveUtil.put(HiveUtil.showRecommendVideoKey, false); ChewieHiveUtil.put(HiveUtil.showRecommendArticleKey, true); @@ -138,20 +166,58 @@ class HiveUtil { ChewieHiveUtil.put(HiveUtil.showCollectionPreNextKey, true); ChewieHiveUtil.put( HiveUtil.waterfallFlowImageQualityKey, ImageQuality.medium.index); - ChewieHiveUtil.put(HiveUtil.postDetailImageQualityKey, ImageQuality.origin.index); - ChewieHiveUtil.put(HiveUtil.imageDetailImageQualityKey, ImageQuality.raw.index); - ChewieHiveUtil.put(HiveUtil.tapLinkButtonImageQualityKey, ImageQuality.raw.index); + ChewieHiveUtil.put( + HiveUtil.postDetailImageQualityKey, ImageQuality.origin.index); + ChewieHiveUtil.put( + HiveUtil.imageDetailImageQualityKey, ImageQuality.raw.index); + ChewieHiveUtil.put( + HiveUtil.tapLinkButtonImageQualityKey, ImageQuality.raw.index); ChewieHiveUtil.put( HiveUtil.longPressLinkButtonImageQualityKey, ImageQuality.raw.index); ChewieHiveUtil.put(HiveUtil.followMainColorKey, true); ChewieHiveUtil.put(HiveUtil.inappWebviewKey, true); } - static initBox() async { + static Future initBox() async { await Hive.openBox(HiveUtil.settingsBox, path: await FileUtil.getApplicationDir()); } + static void setWindowSize(Size size) => ChewieHiveUtil.setWindowSize(size); + + static void setWindowPosition(Offset offset) => + ChewieHiveUtil.setWindowPosition(offset); + + static void setCustomFonts(List fonts) => + ChewieHiveUtil.setCustomFonts(fonts); + + static List getCustomFonts() => ChewieHiveUtil.getCustomFonts(); + + static Locale? getLocale() => ChewieHiveUtil.getLocale(); + + static void setLocale(Locale? locale) => ChewieHiveUtil.setLocale(locale); + + static int getFontSize() => ChewieHiveUtil.getFontSize(); + + static void setFontSize(int fontSize) => ChewieHiveUtil.setFontSize(fontSize); + + static ActiveThemeMode getThemeMode() => ChewieHiveUtil.getThemeMode(); + + static void setThemeMode(ActiveThemeMode themeMode) => + ChewieHiveUtil.setThemeMode(themeMode); + + static int getLightThemeIndex() => ChewieHiveUtil.getLightThemeIndex(); + + static int getDarkThemeIndex() => ChewieHiveUtil.getDarkThemeIndex(); + + static ChewieThemeColorData getLightTheme() => ChewieHiveUtil.getLightTheme(); + + static ChewieThemeColorData getDarkTheme() => ChewieHiveUtil.getDarkTheme(); + + static void setLightTheme(int index) => ChewieHiveUtil.setLightTheme(index); + + static void setDarkTheme(int index) => ChewieHiveUtil.setDarkTheme(index); + static Future? setUserInfo(FullBlogInfo? blogInfo) { if (blogInfo != null) { return ChewieHiveUtil.put(HiveUtil.userInfoKey, blogInfo.toJson()); @@ -194,11 +260,12 @@ class HiveUtil { canLock() && ChewieHiveUtil.getBool(HiveUtil.autoLockKey); static bool canLock() => - ChewieHiveUtil.getBool(HiveUtil.enableGuesturePasswdKey) && hasGuesturePasswd(); + ChewieHiveUtil.getBool(HiveUtil.enableGuesturePasswdKey) && + hasGuesturePasswd(); static bool hasGuesturePasswd() => ChewieHiveUtil.getString(HiveUtil.guesturePasswdKey) != null && - ChewieHiveUtil.getString(HiveUtil.guesturePasswdKey)!.isNotEmpty; + ChewieHiveUtil.getString(HiveUtil.guesturePasswdKey)!.isNotEmpty; static Map getCookie() { Map map = {}; diff --git a/lib/Utils/like_archive_util.dart b/lib/Utils/like_archive_util.dart new file mode 100644 index 00000000..b165c4dd --- /dev/null +++ b/lib/Utils/like_archive_util.dart @@ -0,0 +1,68 @@ +import '../Models/history_response.dart'; + +typedef LikeArchiveDescriptionBuilder = String Function(int month, int year); + +List buildLikeArchives( + dynamic rawArchives, { + required LikeArchiveDescriptionBuilder descriptionBuilder, + void Function(Object error, StackTrace stackTrace)? onMalformed, +}) { + if (rawArchives is! List) return []; + final archiveItems = []; + for (final rawArchive in rawArchives) { + if (rawArchive is! Map) continue; + try { + archiveItems.add(ArchiveItem.fromJson( + Map.from(rawArchive), + )); + } catch (error, stackTrace) { + onMalformed?.call(error, stackTrace); + } + } + archiveItems.sort((a, b) => b.year.compareTo(a.year)); + + final archives = []; + for (final archive in archiveItems) { + for (var month = archive.monthCount.length - 1; month >= 0; month--) { + final count = archive.monthCount[month]; + if (count <= 0) continue; + archives.add(ArchiveData( + desc: descriptionBuilder(month + 1, archive.year), + count: count, + endTime: 0, + startTime: 0, + )); + } + } + return archives; +} + +void decrementLikeArchives( + List archives, + Iterable removedItemIndices, +) { + final removedByArchive = {}; + for (final itemIndex in removedItemIndices) { + final archive = likeArchiveForItemIndex(archives, itemIndex); + if (archive != null) { + removedByArchive[archive] = (removedByArchive[archive] ?? 0) + 1; + } + } + for (final entry in removedByArchive.entries) { + entry.key.count = (entry.key.count - entry.value).clamp(0, 0x7fffffff); + } + archives.removeWhere((archive) => archive.count <= 0); +} + +ArchiveData? likeArchiveForItemIndex( + List archives, + int itemIndex, +) { + var start = 0; + for (final archive in archives) { + final end = start + archive.count; + if (itemIndex >= start && itemIndex < end) return archive; + start = end; + } + return null; +} diff --git a/lib/Utils/loftify_file_util.dart b/lib/Utils/loftify_file_util.dart new file mode 100644 index 00000000..3dc19f6e --- /dev/null +++ b/lib/Utils/loftify_file_util.dart @@ -0,0 +1,333 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../Models/download_task.dart'; +import '../Models/illust.dart'; +import 'constant.dart'; +import 'download_task_manager.dart'; +import 'enums.dart'; +import 'hive_util.dart'; + +/// Aggregates independently downloaded resources into one stable 0–1 value. +/// +/// Every resource receives equal visual weight so a queued item with an +/// unknown byte total still reserves its share of the overall progress. +class DownloadProgressAccumulator { + DownloadProgressAccumulator(int itemCount) + : assert(itemCount > 0), + _progress = List.filled(itemCount, 0); + + final List _progress; + + double update(int index, int receivedBytes, int totalBytes) { + if (index < 0 || index >= _progress.length || totalBytes <= 0) { + return value; + } + _progress[index] = (receivedBytes / totalBytes).clamp(0.0, 1.0); + return value; + } + + double complete(int index) { + if (index >= 0 && index < _progress.length) _progress[index] = 1; + return value; + } + + double get value => + _progress.fold(0, (sum, current) => sum + current) / + _progress.length; +} + +class LoftifyFileUtil { + static void configureDownloadDelegates() { + FileUtil.saveImageDelegate = saveImage; + FileUtil.saveVideoDelegate = saveVideo; + } + + static Future saveImage( + BuildContext context, + String imageUrl, { + bool showToast = true, + String? fileName, + Function(int, int)? onReceiveProgress, + }) { + return _enqueueAndWait( + context, + url: imageUrl, + fileName: fileName ?? FileUtil.extractFileNameFromUrl(imageUrl), + mediaType: DownloadMediaType.image, + showToast: showToast, + onReceiveProgress: onReceiveProgress, + ); + } + + static Future saveImages( + BuildContext context, + List imageUrls, { + bool showToast = true, + Function(int, int)? onReceiveProgress, + DownloadSourceDescriptor? source, + }) async { + try { + if (imageUrls.isEmpty) return false; + if (ResponsiveUtil.isDesktop()) { + final saveDirectory = await FileUtil.checkSaveDirectory(context); + if (saveDirectory.nullOrEmpty) { + if (showToast) IToast.showTop('下载失败,请先选择保存路径'); + return false; + } + } + const progressUnits = 1000000; + final requests = imageUrls + .map( + (url) => DownloadRequest( + url: url, + fileName: FileUtil.extractFileNameFromUrl(url), + mediaType: DownloadMediaType.image, + thumbnailUrl: url, + ), + ) + .toList(growable: false); + final result = await DownloadTaskManager.instance.enqueueBatch( + requests, + source: source, + ); + if (result.tasks.isEmpty) return false; + final accumulator = DownloadProgressAccumulator(result.tasks.length); + void report(double progress) { + onReceiveProgress?.call( + (progress.clamp(0.0, 1.0) * progressUnits).round(), + progressUnits, + ); + } + + report(0); + final status = await Future.wait( + result.tasks.asMap().entries.map( + (entry) async { + final saved = await DownloadTaskManager.instance.waitForCompletion( + entry.value.id, + onProgress: (received, total) { + report(accumulator.update(entry.key, received, total)); + }, + ); + if (saved) report(accumulator.complete(entry.key)); + return saved; + }, + ), + ); + final success = status.every((saved) => saved); + if (showToast) { + IToast.showTop(success ? '所有图片已保存' : '保存失败,请在下载管理中重试'); + } + return success; + } catch (error, stackTrace) { + ILogger.error('Failed to enqueue image downloads', error, stackTrace); + if (showToast) IToast.showTop('保存失败,请重试'); + return false; + } + } + + static Future saveVideo( + BuildContext context, + String videoUrl, { + bool showToast = true, + String? fileName, + Function(int, int)? onReceiveProgress, + }) { + return _enqueueAndWait( + context, + url: videoUrl, + fileName: fileName ?? FileUtil.extractFileNameFromUrl(videoUrl), + mediaType: DownloadMediaType.video, + showToast: showToast, + onReceiveProgress: onReceiveProgress, + ); + } + + static Future _enqueueAndWait( + BuildContext context, { + required String url, + required String fileName, + required DownloadMediaType mediaType, + required bool showToast, + Function(int, int)? onReceiveProgress, + String? title, + String? thumbnailUrl, + }) async { + try { + if (ResponsiveUtil.isDesktop()) { + final saveDirectory = await FileUtil.checkSaveDirectory(context); + if (saveDirectory.nullOrEmpty) { + if (showToast) IToast.showTop('下载失败,请先选择保存路径'); + return false; + } + } + final task = await DownloadTaskManager.instance.enqueue( + url: url, + fileName: fileName, + mediaType: mediaType, + title: title, + thumbnailUrl: thumbnailUrl, + ); + final success = await DownloadTaskManager.instance.waitForCompletion( + task.id, + onProgress: onReceiveProgress, + ); + if (showToast) { + IToast.showTop(success ? '下载完成' : '下载失败,请在下载管理中重试'); + } + return success; + } catch (error, stackTrace) { + ILogger.error('Failed to enqueue download', error, stackTrace); + if (showToast) IToast.showTop('下载失败,请重试'); + return false; + } + } + + static Future saveIllust( + BuildContext context, + Illust illust, { + bool showToast = true, + Function(int, int)? onReceiveProgress, + }) { + return _enqueueAndWait( + context, + url: illust.url, + fileName: getFileNameByIllust(illust), + mediaType: DownloadMediaType.image, + showToast: showToast, + onReceiveProgress: onReceiveProgress, + title: illust.postTitle, + thumbnailUrl: illust.url, + ); + } + + static Future saveIllusts( + BuildContext context, + List illusts, { + bool showToast = true, + Function(int, int)? onReceiveProgress, + DownloadSourceDescriptor? source, + }) async { + try { + if (illusts.isEmpty) return false; + if (ResponsiveUtil.isDesktop()) { + final saveDirectory = await FileUtil.checkSaveDirectory(context); + if (saveDirectory.nullOrEmpty) { + if (showToast) IToast.showTop('下载失败,请先选择保存路径'); + return false; + } + } + const progressUnits = 1000000; + final requests = illusts + .map( + (illust) => DownloadRequest( + url: illust.url, + fileName: getFileNameByIllust(illust), + mediaType: DownloadMediaType.image, + title: illust.postTitle, + thumbnailUrl: illust.url, + ), + ) + .toList(growable: false); + final result = await DownloadTaskManager.instance.enqueueBatch( + requests, + source: source, + ); + if (result.tasks.isEmpty) return false; + final accumulator = DownloadProgressAccumulator(result.tasks.length); + void report(double progress) { + onReceiveProgress?.call( + (progress.clamp(0.0, 1.0) * progressUnits).round(), + progressUnits, + ); + } + + report(0); + final status = await Future.wait( + result.tasks.asMap().entries.map( + (entry) async { + final saved = await DownloadTaskManager.instance.waitForCompletion( + entry.value.id, + onProgress: (received, total) { + report( + accumulator.update(entry.key, received, total), + ); + }, + ); + if (saved) report(accumulator.complete(entry.key)); + return saved; + }, + ), + ); + final success = status.every((saved) => saved); + if (showToast) { + IToast.showTop(success ? "所有图片已保存" : "保存失败,请重试"); + } + return success; + } catch (error, stackTrace) { + ILogger.error("Failed to save illustrations", error, stackTrace); + if (showToast) { + IToast.showTop("保存失败,请重试"); + } + return false; + } + } + + static Future saveVideoByIllust( + BuildContext context, + Illust illust, { + bool showToast = true, + Function(int, int)? onReceiveProgress, + }) { + return _enqueueAndWait( + context, + url: illust.url, + fileName: getFileNameByIllust(illust), + mediaType: DownloadMediaType.video, + showToast: showToast, + onReceiveProgress: onReceiveProgress, + title: illust.postTitle, + thumbnailUrl: illust.url, + ); + } + + static String getFileNameByIllust(Illust illust) { + final format = ChewieHiveUtil.getString( + HiveUtil.filenameFormatKey, + defaultValue: defaultFilenameFormat, + ) ?? + defaultFilenameFormat; + final originalName = + illust.originalName.replaceAll(".${illust.extension}", ""); + final fileName = format + .replaceAll(FilenameField.originalName.format, originalName) + .replaceAll(FilenameField.blogId.format, illust.blogId.toString()) + .replaceAll(FilenameField.blogLofterId.format, illust.blogLofterId) + .replaceAll(FilenameField.blogNickName.format, illust.blogNickName) + .replaceAll(FilenameField.postId.format, illust.postId.toString()) + .replaceAll( + FilenameField.postTitle.format, + StringUtil.isNotEmpty(illust.postTitle) ? illust.postTitle : "无标题", + ) + .replaceAll( + FilenameField.postTags.format, + illust.tags.isNotEmpty ? illust.tags.join(",") : "无标签", + ) + .replaceAll( + FilenameField.postPublishTime.format, + TimeUtil.formatAll(illust.publishTime), + ) + .replaceAll(FilenameField.part.format, illust.part.toString()) + .replaceAll( + FilenameField.currentTime.format, + TimeUtil.formatAll(DateTime.now().millisecondsSinceEpoch), + ) + .replaceAll( + FilenameField.timestamp.format, + DateTime.now().millisecondsSinceEpoch.toString(), + ) + .replaceAll(RegExp(r'[\\/:*?"<>|]'), ''); + return '$fileName.${illust.extension}'; + } +} diff --git a/lib/Utils/lottie_files.dart b/lib/Utils/lottie_files.dart index eb8ccb34..1821570f 100644 --- a/lib/Utils/lottie_files.dart +++ b/lib/Utils/lottie_files.dart @@ -1,8 +1,26 @@ -import 'package:awesome_chewie/awesome_chewie.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:loftify/Utils/utils.dart'; +import 'package:flutter/material.dart'; + +import '../Widgets/Design/loftify_lottie.dart'; class LottieFiles { + static const List _loadingPalette = [ + LoftifyLottieColorBinding( + keyPath: ['draw 3', '**', '描边 1'], + role: LoftifyLottieColorRole.accent, + property: LoftifyLottieColorProperty.stroke, + ), + LoftifyLottieColorBinding( + keyPath: ['draw', '**', '描边 1'], + role: LoftifyLottieColorRole.accentSecondary, + property: LoftifyLottieColorProperty.stroke, + ), + LoftifyLottieColorBinding( + keyPath: ['形状图层 1', '**', '填充 1'], + role: LoftifyLottieColorRole.surfaceMuted, + property: LoftifyLottieColorProperty.fill, + ), + ]; + static const String brightness = "assets/lottie/brightness.json"; static const String celebrate = "assets/lottie/celebrate.json"; static const String collectionBigNormalDark = @@ -42,6 +60,10 @@ class LottieFiles { static const String loadingGradient = "assets/lottie/loading_gradient.json"; static const String loadingLight = "assets/lottie/loading_light.json"; static const String moonLight = "assets/lottie/moon_light.json"; + static const String navHome = "assets/lottie/nav_home.json"; + static const String navHeart = "assets/lottie/nav_heart.json"; + static const String navSearch = "assets/lottie/nav_search.json"; + static const String navUser = "assets/lottie/nav_user.json"; static const String recommendBigNormalDark = "assets/lottie/recommend_big_normal_dark.json"; static const String recommendBigNormalLight = @@ -65,14 +87,357 @@ class LottieFiles { static const String videoPlayingLight = "assets/lottie/video_playing_light.json"; + static const Map specs = { + brightness: LoftifyLottieSpec( + asset: brightness, + sourceSize: Size.square(48), + tintRole: LoftifyLottieTintRole.foreground, + ), + celebrate: LoftifyLottieSpec( + asset: celebrate, + sourceSize: Size(1126, 2436), + layout: LoftifyLottieLayout.effect, + ), + collectionBigNormalDark: LoftifyLottieSpec( + asset: collectionBigNormalDark, + sourceSize: Size(98, 140), + contentBounds: Rect.fromLTWH(0, 21, 98, 98), + opticalFill: 0.9, + ), + collectionBigNormalLight: LoftifyLottieSpec( + asset: collectionBigNormalLight, + sourceSize: Size(98, 140), + contentBounds: Rect.fromLTWH(0, 21, 98, 98), + opticalFill: 0.9, + ), + collectionMediumNormalDark: LoftifyLottieSpec( + asset: collectionMediumNormalDark, + sourceSize: Size.square(100), + opticalFill: 0.9, + ), + collectionMediumNormalLight: LoftifyLottieSpec( + asset: collectionMediumNormalLight, + sourceSize: Size.square(100), + opticalFill: 0.9, + ), + followDark: LoftifyLottieSpec( + asset: followDark, + sourceSize: Size(48, 32), + opticalFill: 0.9, + ), + followLight: LoftifyLottieSpec( + asset: followLight, + sourceSize: Size(48, 32), + opticalFill: 0.9, + ), + followVideo: LoftifyLottieSpec( + asset: followVideo, + sourceSize: Size(48, 32), + opticalFill: 0.9, + ), + giftDark: LoftifyLottieSpec( + asset: giftDark, + sourceSize: Size.square(204), + layout: LoftifyLottieLayout.effect, + repeat: true, + ), + letter: LoftifyLottieSpec( + asset: letter, + sourceSize: Size(750, 1624), + layout: LoftifyLottieLayout.effect, + ), + likeBigNormalDark: LoftifyLottieSpec( + asset: likeBigNormalDark, + sourceSize: Size(100, 140), + contentBounds: Rect.fromLTWH(0, 20, 100, 100), + opticalFill: 0.92, + ), + likeBigNormalLight: LoftifyLottieSpec( + asset: likeBigNormalLight, + sourceSize: Size(100, 140), + contentBounds: Rect.fromLTWH(0, 20, 100, 100), + opticalFill: 0.92, + ), + likeDoubleClickDark: LoftifyLottieSpec( + asset: likeDoubleClickDark, + sourceSize: Size(750, 500), + layout: LoftifyLottieLayout.effect, + ), + likeDoubleClickLight: LoftifyLottieSpec( + asset: likeDoubleClickLight, + sourceSize: Size(750, 500), + layout: LoftifyLottieLayout.effect, + ), + likeDoubleTap: LoftifyLottieSpec( + asset: likeDoubleTap, + sourceSize: Size(135, 246), + layout: LoftifyLottieLayout.effect, + ), + likeMediumDark: LoftifyLottieSpec( + asset: likeMediumDark, + sourceSize: Size.square(100), + opticalFill: 0.92, + ), + likeMediumLight: LoftifyLottieSpec( + asset: likeMediumLight, + sourceSize: Size.square(100), + opticalFill: 0.92, + ), + likeVibrateLight: LoftifyLottieSpec( + asset: likeVibrateLight, + sourceSize: Size.square(72), + opticalFill: 0.92, + repeat: true, + ), + likeVideoNormal: LoftifyLottieSpec( + asset: likeVideoNormal, + sourceSize: Size(120, 136), + contentBounds: Rect.fromLTWH(0, 8, 120, 120), + opticalFill: 0.92, + ), + likeVideoVibrate: LoftifyLottieSpec( + asset: likeVideoVibrate, + sourceSize: Size.square(108), + opticalFill: 0.92, + repeat: true, + ), + loading01: LoftifyLottieSpec( + asset: loading01, + sourceSize: Size.square(128), + repeat: true, + ), + loading02: LoftifyLottieSpec( + asset: loading02, + sourceSize: Size.square(128), + repeat: true, + ), + loadingDark: LoftifyLottieSpec( + asset: loadingDark, + sourceSize: Size.square(128), + colorBindings: _loadingPalette, + repeat: true, + ), + loadingDarkTransparent: LoftifyLottieSpec( + asset: loadingDarkTransparent, + sourceSize: Size.square(128), + colorBindings: _loadingPalette, + repeat: true, + ), + loadingGradient: LoftifyLottieSpec( + asset: loadingGradient, + sourceSize: Size(750, 4), + layout: LoftifyLottieLayout.effect, + tintRole: LoftifyLottieTintRole.accent, + repeat: true, + ), + loadingLight: LoftifyLottieSpec( + asset: loadingLight, + sourceSize: Size.square(128), + colorBindings: _loadingPalette, + repeat: true, + ), + moonLight: LoftifyLottieSpec( + asset: moonLight, + sourceSize: Size.square(48), + tintRole: LoftifyLottieTintRole.foreground, + ), + navHome: LoftifyLottieSpec( + asset: navHome, + sourceSize: Size.square(128), + // The loading mark occupies only the middle of its original canvas. + // Crop its transparent margins so it remains legible at 22dp. + contentBounds: Rect.fromLTWH(34, 22, 60, 76), + opticalFill: 1, + ), + navHeart: LoftifyLottieSpec( + asset: navHeart, + sourceSize: Size.square(48), + contentBounds: Rect.fromLTWH(4, 4, 40, 40), + opticalFill: 0.9, + ), + navSearch: LoftifyLottieSpec( + asset: navSearch, + sourceSize: Size.square(48), + contentBounds: Rect.fromLTWH(4, 4, 40, 40), + opticalFill: 0.9, + ), + navUser: LoftifyLottieSpec( + asset: navUser, + sourceSize: Size.square(48), + contentBounds: Rect.fromLTWH(4, 4, 40, 40), + opticalFill: 0.9, + ), + recommendBigNormalDark: LoftifyLottieSpec( + asset: recommendBigNormalDark, + sourceSize: Size.square(110), + opticalFill: 0.92, + ), + recommendBigNormalLight: LoftifyLottieSpec( + asset: recommendBigNormalLight, + sourceSize: Size(100, 140), + contentBounds: Rect.fromLTWH(0, 20, 100, 100), + opticalFill: 0.92, + ), + recommendBigVibrateDark: LoftifyLottieSpec( + asset: recommendBigVibrateDark, + sourceSize: Size.square(110), + opticalFill: 0.92, + ), + recommendBigVibrateLight: LoftifyLottieSpec( + asset: recommendBigVibrateLight, + sourceSize: Size.square(110), + opticalFill: 0.92, + ), + recommendMediumFocusDark: LoftifyLottieSpec( + asset: recommendMediumFocusDark, + sourceSize: Size.square(100), + opticalFill: 0.92, + ), + recommendMediumFocusLight: LoftifyLottieSpec( + asset: recommendMediumFocusLight, + sourceSize: Size.square(100), + opticalFill: 0.92, + ), + recommendVideoNormal: LoftifyLottieSpec( + asset: recommendVideoNormal, + sourceSize: Size(120, 136), + contentBounds: Rect.fromLTWH(0, 8, 120, 120), + opticalFill: 0.92, + ), + shareVideoVibrate: LoftifyLottieSpec( + asset: shareVideoVibrate, + sourceSize: Size.square(108), + opticalFill: 0.92, + repeat: true, + ), + shine: LoftifyLottieSpec( + asset: shine, + sourceSize: Size(557, 223), + layout: LoftifyLottieLayout.effect, + ), + sunLight: LoftifyLottieSpec( + asset: sunLight, + sourceSize: Size.square(48), + tintRole: LoftifyLottieTintRole.foreground, + ), + videoPlayingDark: LoftifyLottieSpec( + asset: videoPlayingDark, + sourceSize: Size.square(64), + opticalFill: 0.9, + repeat: true, + ), + videoPlayingLight: LoftifyLottieSpec( + asset: videoPlayingLight, + sourceSize: Size.square(64), + opticalFill: 0.9, + repeat: true, + ), + }; + + static LoftifyLottieSpec specFor(String path) { + return specs[path] ?? + LoftifyLottieSpec( + asset: path, + sourceSize: const Size.square(1), + layout: LoftifyLottieLayout.effect, + ); + } + + static Widget buildAnimation( + String path, { + Key? key, + required double size, + bool? autoForward, + AnimationController? controller, + VoidCallback? onLoaded, + bool? repeat, + Color? tint, + double? strokeWidth, + }) { + return Builder( + key: key, + builder: (context) => LoftifyLottie( + spec: specFor(path), + size: size, + controller: controller, + repeat: repeat, + tint: tint, + strokeWidth: strokeWidth, + onLoaded: (_) { + if (controller != null && autoForward == true) controller.value = 1; + onLoaded?.call(); + }, + ), + ); + } + + static Widget buildLoadingAnimation(double size, bool forceDark) { + return _LoopingLoadingAnimation(size: size, forceDark: forceDark); + } + static String getLoadingPath( BuildContext context, { bool forceDark = false, }) { - return ColorUtil.isDark(context) || forceDark + return Theme.of(context).brightness == Brightness.dark || forceDark ? forceDark ? LottieFiles.loadingDarkTransparent : LottieFiles.loadingDark : LottieFiles.loadingLight; } } + +/// Keeps the loading timeline alive while a refresh indicator rebuilds on +/// every drag update. Lottie's implicit controller restarts in didUpdateWidget, +/// which otherwise makes the artwork look frozen throughout the gesture. +class _LoopingLoadingAnimation extends StatefulWidget { + const _LoopingLoadingAnimation({required this.size, required this.forceDark}); + + final double size; + final bool forceDark; + + @override + State<_LoopingLoadingAnimation> createState() => + _LoopingLoadingAnimationState(); +} + +class _LoopingLoadingAnimationState extends State<_LoopingLoadingAnimation> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController(vsync: this); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (LoftifyLottie.shouldReduceMotion(context)) { + _controller + ..stop() + ..value = 0; + } else if (_controller.duration != null && !_controller.isAnimating) { + _controller.repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LoftifyLottie( + spec: LottieFiles.specFor( + LottieFiles.getLoadingPath(context, forceDark: widget.forceDark), + ), + size: widget.size, + controller: _controller, + onLoaded: (_) { + if (mounted && + !LoftifyLottie.shouldReduceMotion(context) && + !_controller.isAnimating) { + _controller.repeat(); + } + }, + ); + } +} diff --git a/lib/Utils/paged_data_controller.dart b/lib/Utils/paged_data_controller.dart new file mode 100644 index 00000000..e26cb383 --- /dev/null +++ b/lib/Utils/paged_data_controller.dart @@ -0,0 +1,216 @@ +import 'dart:collection'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/foundation.dart'; + +typedef PagedDataLoader = Future> Function( + C cursor, + bool refresh, +); + +typedef PagedMetadataMerger = M? Function( + M? current, + M? incoming, + bool refresh, +); + +class PagedDataPage { + const PagedDataPage({ + required this.items, + required this.nextCursor, + required this.hasMore, + this.total, + this.metadata, + }); + + final List items; + final C nextCursor; + final bool hasMore; + final int? total; + final M? metadata; +} + +class PagedDataException implements Exception { + const PagedDataException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Keeps refresh and load-more state consistent across list presentations. +/// +/// The cursor is committed only after a successful request. This avoids gaps +/// when a page fails and is retried, while [keyOf] prevents repeated cards when +/// adjacent pages overlap. +class PagedDataController extends ChangeNotifier { + PagedDataController({ + required C initialCursor, + required this.keyOf, + required this.loader, + this.onError, + this.metadataMerger, + }) : _initialCursor = initialCursor, + _cursor = initialCursor; + + final C _initialCursor; + final K Function(T item) keyOf; + final PagedDataLoader loader; + final void Function(Object error, StackTrace stackTrace)? onError; + final PagedMetadataMerger? metadataMerger; + + final List _items = []; + late final List _itemsView = UnmodifiableListView(_items); + late C _cursor; + bool _loading = false; + bool _noMore = false; + bool _disposed = false; + int _generation = 0; + int? _total; + M? _metadata; + Object? _lastError; + + List get items => _itemsView; + bool get loading => _loading; + bool get noMore => _noMore; + int? get total => _total; + M? get metadata => _metadata; + Object? get lastError => _lastError; + C get cursor => _cursor; + + Future refresh() => _load(refresh: true); + + Future load() => _load(refresh: false); + + Future _load({required bool refresh}) async { + if (_loading) return IndicatorResult.none; + if (!refresh && _noMore) return IndicatorResult.noMore; + + _loading = true; + _lastError = null; + final requestGeneration = _generation; + _notifySafely(); + try { + final requestCursor = refresh ? _initialCursor : _cursor; + final page = await loader(requestCursor, refresh); + if (_disposed || requestGeneration != _generation) { + return IndicatorResult.none; + } + + final merged = refresh ? [] : List.of(_items); + final seenKeys = merged.map(keyOf).toSet(); + var addedCount = 0; + for (final item in page.items) { + if (seenKeys.add(keyOf(item))) { + merged.add(item); + addedCount++; + } + } + + _items + ..clear() + ..addAll(merged); + _cursor = page.nextCursor; + _total = page.total; + if (metadataMerger != null) { + _metadata = metadataMerger!( + refresh ? null : _metadata, + page.metadata, + refresh, + ); + } else if (page.metadata != null || refresh) { + _metadata = page.metadata; + } + _noMore = !page.hasMore || + (_total != null && _items.length >= _total!) || + (!refresh && page.items.isNotEmpty && addedCount == 0); + + return !refresh && _noMore + ? IndicatorResult.noMore + : IndicatorResult.success; + } catch (error, stackTrace) { + if (_disposed || requestGeneration != _generation) { + return IndicatorResult.none; + } + _lastError = error; + onError?.call(error, stackTrace); + return IndicatorResult.fail; + } finally { + if (!_disposed && requestGeneration == _generation) { + _loading = false; + _notifySafely(); + } + } + } + + void reset({bool notify = true}) { + _generation++; + _items.clear(); + _cursor = _initialCursor; + _loading = false; + _noMore = false; + _total = null; + _metadata = null; + _lastError = null; + if (notify) _notifySafely(); + } + + /// Discards an in-flight response without clearing the current page. + void cancelPending() { + if (_disposed || !_loading) return; + _generation++; + _loading = false; + _notifySafely(); + } + + int removeWhere( + bool Function(T item) test, { + C Function(C cursor, int removedCount)? updateCursor, + }) { + final previousLength = _items.length; + _items.removeWhere(test); + final removedCount = previousLength - _items.length; + if (removedCount == 0) return 0; + + if (_total != null) { + _total = (_total! - removedCount).clamp(0, 0x7fffffff); + } + if (updateCursor != null) { + _cursor = updateCursor(_cursor, removedCount); + } + _noMore = _total != null ? _items.length >= _total! : _noMore; + _notifySafely(); + return removedCount; + } + + void _notifySafely() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _generation++; + _disposed = true; + super.dispose(); + } +} + +List parsePagedDataItems( + dynamic rawItems, + T? Function(Map json) parser, { + void Function(Object error, StackTrace stackTrace)? onMalformed, +}) { + if (rawItems is! List) return []; + final items = []; + for (final rawItem in rawItems) { + if (rawItem is! Map) continue; + try { + final item = parser(Map.from(rawItem)); + if (item != null) items.add(item); + } catch (error, stackTrace) { + onMalformed?.call(error, stackTrace); + } + } + return items; +} diff --git a/lib/Utils/post_batch_download_resolver.dart b/lib/Utils/post_batch_download_resolver.dart new file mode 100644 index 00000000..92bd9006 --- /dev/null +++ b/lib/Utils/post_batch_download_resolver.dart @@ -0,0 +1,270 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; + +import '../Api/post_api.dart'; +import '../Models/download_task.dart'; +import '../Models/illust.dart'; +import '../Models/post_detail_response.dart'; +import '../Widgets/PostItem/general_post_item.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'loftify_file_util.dart'; +import 'package:loftify/Utils/utils.dart'; + +typedef PostDetailLoader = Future Function({ + required int postId, + required int blogId, + required String blogName, +}); + +class PostDownloadResolution { + const PostDownloadResolution({ + required this.postId, + required this.requests, + this.failed = false, + }); + + final int postId; + final List requests; + final bool failed; +} + +/// Resolves a post card into original image or video resources understood by +/// the persistent download queue. +class PostBatchDownloadResolver { + PostBatchDownloadResolver({PostDetailLoader? detailLoader}) + : _detailLoader = detailLoader ?? PostApi.getDetail; + + final PostDetailLoader _detailLoader; + + Future resolve(GeneralPostItem item) async { + if (item.type == PostType.invalid || item.postId <= 0 || item.blogId <= 0) { + return PostDownloadResolution( + postId: item.postId, + requests: const [], + failed: true, + ); + } + + try { + if (item.type == PostType.image && item.photoLinks.isNotEmpty) { + return PostDownloadResolution( + postId: item.postId, + requests: _imageRequests( + item, + item.photoLinks.map((photo) => photo.middle), + ), + ); + } + + if (item.type == PostType.article) { + final inlineImages = HtmlUtil.extractImagesFromHtml(item.content) + .where((url) => url.trim().isNotEmpty) + .toList(growable: false); + if (inlineImages.isNotEmpty) { + return PostDownloadResolution( + postId: item.postId, + requests: _imageRequests(item, inlineImages), + ); + } + } + + final detail = await _loadDetail(item); + final post = detail?.post; + if (post == null || + post.valid == 0 || + (post.needPay && !post.payingView)) { + return PostDownloadResolution( + postId: item.postId, + requests: const [], + failed: true, + ); + } + + final detailedItem = _metadataItem(item, post); + if (post.type == 4) { + final info = post.videoInfo ?? post.videoPostView?.videoInfo; + final videoUrl = _firstValidUrl([ + info?.originUrl ?? '', + info?.h265Url ?? '', + info?.flashurl ?? '', + ]); + if (videoUrl == null) { + return PostDownloadResolution( + postId: item.postId, + requests: const [], + failed: true, + ); + } + return PostDownloadResolution( + postId: item.postId, + requests: [ + _requestForUrl( + detailedItem, + videoUrl, + part: 0, + mediaType: DownloadMediaType.video, + ), + ], + ); + } + + final urls = [ + ..._photoUrls(post.photoLinks), + ...HtmlUtil.extractImagesFromHtml(post.content), + ]; + final requests = _imageRequests(detailedItem, urls); + return PostDownloadResolution( + postId: item.postId, + requests: requests, + failed: requests.isEmpty, + ); + } catch (error, stackTrace) { + try { + ILogger.error('Failed to resolve post downloads', error, stackTrace); + } catch (_) {} + return PostDownloadResolution( + postId: item.postId, + requests: const [], + failed: true, + ); + } + } + + Future _loadDetail(GeneralPostItem item) async { + final value = await _detailLoader( + postId: item.postId, + blogId: item.blogId, + blogName: item.blogName, + ).timeout(const Duration(seconds: 20)); + if (value is! Map || value['meta']?['status'] != 200) return null; + final posts = value['response']?['posts']; + if (posts is! List || posts.isEmpty || posts.first is! Map) return null; + return PostDetailData.fromJson( + Map.from(posts.first as Map), + ); + } + + GeneralPostItem _metadataItem(GeneralPostItem item, PostDetail post) { + return GeneralPostItem( + type: post.type == 4 + ? PostType.video + : post.photoLinks.isNotEmpty + ? PostType.image + : PostType.article, + photoLinks: item.photoLinks, + blogId: post.blogId, + postId: post.id, + permalink: post.permalink, + collectionId: post.collectionId, + liked: item.liked, + blogName: post.blogInfo?.blogName ?? item.blogName, + blogNickName: post.blogInfo?.blogNickName ?? item.blogNickName, + title: post.title, + digest: post.digest, + content: post.content, + firstImageUrl: post.firstImageUrl, + duration: post.videoInfo?.duration ?? 0, + likeCount: item.likeCount, + tags: post.tagList, + bigAvaImg: post.blogInfo?.bigAvaImg ?? item.bigAvaImg, + publishTime: post.publishTime, + ); + } + + List _photoUrls(String encodedLinks) { + final urls = []; + for (final raw in Utils.parseJsonList(encodedLinks)) { + try { + urls.add(PhotoLink.fromJson(raw).middle); + } catch (_) {} + } + return urls; + } + + List _imageRequests( + GeneralPostItem item, + Iterable urls, + ) { + final seen = {}; + final requests = []; + var part = 0; + for (final sourceUrl in urls) { + final normalized = _normalizeUrl(sourceUrl); + if (normalized == null) continue; + final rawUrl = _normalizeUrl( + Utils.getUrlByQuality(normalized, ImageQuality.raw), + ) ?? + normalized; + if (!seen.add(rawUrl)) continue; + requests.add(_requestForUrl( + item, + rawUrl, + part: part++, + mediaType: DownloadMediaType.image, + )); + } + return requests; + } + + DownloadRequest _requestForUrl( + GeneralPostItem item, + String url, { + required int part, + required DownloadMediaType mediaType, + }) { + final defaultExtension = + mediaType == DownloadMediaType.video ? 'mp4' : 'jpg'; + final parsedExtension = FileUtil.extractFileExtensionFromUrl(url).trim(); + final extension = + parsedExtension.isEmpty ? defaultExtension : parsedExtension; + final parsedName = FileUtil.extractFileNameFromUrl(url).trim(); + final originalName = + parsedName.isEmpty ? '${item.postId}_$part.$extension' : parsedName; + final illust = Illust( + extension: extension, + url: url, + postId: item.postId, + originalName: originalName, + blogId: item.blogId, + blogLofterId: item.blogName, + blogNickName: item.blogNickName, + part: part, + postTitle: item.title, + postDigest: item.digest, + tags: item.tags, + publishTime: item.publishTime, + ); + return DownloadRequest( + url: url, + fileName: LoftifyFileUtil.getFileNameByIllust(illust), + mediaType: mediaType, + title: item.processedTitle, + thumbnailUrl: mediaType == DownloadMediaType.image + ? url + : _normalizeUrl( + item.photoLinks.isNotEmpty + ? item.photoLinks.first.middle + : item.firstImageUrl, + ), + ); + } + + String? _firstValidUrl(Iterable urls) { + for (final url in urls) { + final normalized = _normalizeUrl(url); + if (normalized != null) return normalized; + } + return null; + } + + String? _normalizeUrl(String value) { + var normalized = value.trim(); + if (normalized.startsWith('//')) normalized = 'https:$normalized'; + final uri = Uri.tryParse(normalized); + if (uri == null || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.host.isEmpty) { + return null; + } + return normalized; + } +} diff --git a/lib/Utils/post_sequence_source.dart b/lib/Utils/post_sequence_source.dart new file mode 100644 index 00000000..c409466d --- /dev/null +++ b/lib/Utils/post_sequence_source.dart @@ -0,0 +1,73 @@ +import 'enums.dart'; + +typedef PostSequenceLoadMore = Future Function(); + +class PostSequenceEntry { + const PostSequenceEntry({ + required this.postId, + required this.blogId, + required this.blogName, + required this.type, + }); + + final int postId; + final int blogId; + final String blogName; + final PostType type; + + bool get isNavigable => type == PostType.article || type == PostType.image; +} + +class PostSequenceSource { + PostSequenceSource({PostSequenceLoadMore? loadMore}) : _loadMore = loadMore; + + final PostSequenceLoadMore? _loadMore; + final List _entries = []; + bool _hasMore = false; + Future? _loadMoreTask; + + List get entries => List.unmodifiable(_entries); + + void synchronize( + Iterable entries, { + required bool hasMore, + }) { + final unique = {}; + for (final entry in entries) { + if (entry.isNavigable) unique[entry.postId] = entry; + } + _entries + ..clear() + ..addAll(unique.values); + _hasMore = hasMore; + } + + bool canNavigateFrom(int postId, {required bool previous}) { + final index = _entries.indexWhere((entry) => entry.postId == postId); + if (index < 0) return false; + if (previous) return index > 0; + return index + 1 < _entries.length || _hasMore; + } + + Future adjacentTo( + int postId, { + required bool previous, + }) async { + var index = _entries.indexWhere((entry) => entry.postId == postId); + if (index < 0) return null; + var target = previous ? index - 1 : index + 1; + if (target >= 0 && target < _entries.length) return _entries[target]; + if (previous || !_hasMore || _loadMore == null) return null; + + _loadMoreTask ??= _loadMore(); + try { + await _loadMoreTask; + } finally { + _loadMoreTask = null; + } + + index = _entries.indexWhere((entry) => entry.postId == postId); + target = index + 1; + return index >= 0 && target < _entries.length ? _entries[target] : null; + } +} diff --git a/lib/Utils/post_swipe_gesture.dart b/lib/Utils/post_swipe_gesture.dart new file mode 100644 index 00000000..88c7840d --- /dev/null +++ b/lib/Utils/post_swipe_gesture.dart @@ -0,0 +1,72 @@ +import 'dart:math' as math; + +class PostSwipeGesturePolicy { + const PostSwipeGesturePolicy._(); + + static const double minimumCommitDistance = 72; + static const double maximumCommitDistance = 128; + static const double minimumFlingDistance = 36; + static const double commitVelocity = 900; + static const double boundaryResistance = 0.22; + static const double hintRevealStartDistance = 44; + static const double hintRevealFullDistance = 88; + + static double commitDistance(double viewportWidth) => + (viewportWidth * 0.22).clamp( + minimumCommitDistance, + maximumCommitDistance, + ); + + static bool hasReachedCommitDistance({ + required double rawOffset, + required double viewportWidth, + }) => + rawOffset.abs() >= commitDistance(viewportWidth); + + static double visualOffset({ + required double rawOffset, + required double viewportWidth, + required bool available, + }) { + final maxOffset = math.max(96.0, viewportWidth * 0.42); + final effective = available ? rawOffset : rawOffset * boundaryResistance; + return effective.clamp(-maxOffset, maxOffset); + } + + static double hintRevealProgress(double rawOffset) => + ((rawOffset.abs() - hintRevealStartDistance) / + (hintRevealFullDistance - hintRevealStartDistance)) + .clamp(0, 1); + + static double boundaryHintRevealProgress({ + required double rawOffset, + required double viewportWidth, + required bool hasSequenceContext, + }) { + if (!hasSequenceContext) return 0; + return hasReachedCommitDistance( + rawOffset: rawOffset, + viewportWidth: viewportWidth, + ) + ? 1 + : 0; + } + + static bool shouldCommit({ + required double rawOffset, + required double velocity, + required double viewportWidth, + required bool available, + }) { + if (!available) return false; + if (hasReachedCommitDistance( + rawOffset: rawOffset, + viewportWidth: viewportWidth, + )) { + return true; + } + return rawOffset.abs() >= minimumFlingDistance && + velocity.abs() >= commitVelocity && + rawOffset.sign == velocity.sign; + } +} diff --git a/lib/Utils/request_util.dart b/lib/Utils/request_util.dart index 4b61e356..c12a344b 100644 --- a/lib/Utils/request_util.dart +++ b/lib/Utils/request_util.dart @@ -220,12 +220,12 @@ class RequestUtil { list['Data'] = response.data.toString(); // .substring(0, min(1000, response.data.toString().length)); } - IPrint.format( - tag: response.requestOptions.method, - status: "Success", - list: list, - useLogger: true, - ); + // IPrint.format( + // tag: response.requestOptions.method, + // status: "Success", + // list: list, + // useLogger: true, + // ); } static get( diff --git a/lib/Utils/shortcuts_util.dart b/lib/Utils/shortcuts_util.dart index e4be5821..161a0a52 100644 --- a/lib/Utils/shortcuts_util.dart +++ b/lib/Utils/shortcuts_util.dart @@ -21,6 +21,9 @@ import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import '../Widgets/Shortcuts/keyboard_widget.dart'; +import '../Screens/Setting/about_setting_screen.dart'; +import '../Screens/Setting/experiment_setting_screen.dart'; +import '../Screens/Setting/setting_screen.dart'; import '../generated/app_localizations.dart'; import '../l10n/l10n.dart'; import '../main.dart'; @@ -242,12 +245,7 @@ class ShortcutsUtil { ]; static void focusSearch() { - appProvider.searchFocusNode.requestFocus(); - appProvider.searchFocusNode.addListener(() { - if (!appProvider.searchFocusNode.hasFocus) { - appProvider.shortcutFocusNode.requestFocus(); - } - }); + searchScreenState?.focusSearch(); } static void lock(BuildContext context) { @@ -287,20 +285,14 @@ class ShortcutsUtil { } static void jumpToSetting(BuildContext context) { - RouteUtil.pushDialogRoute(context, const SettingNavigationScreen()); + RouteUtil.pushPanelCupertinoRoute(context, const SettingScreen()); } static void jumpToSetLock(BuildContext context) { - RouteUtil.pushDialogRoute( - context, const SettingNavigationScreen(initPageIndex: 4)); + RouteUtil.pushPanelCupertinoRoute(context, const ExperimentSettingScreen()); } static void jumpToAbout(BuildContext context) { - RouteUtil.pushDialogRoute( - context, const SettingNavigationScreen(initPageIndex: 5)); - } - - static void jumpToMain() { - RouteUtil.pushRootPage(getRootPage(true)); + RouteUtil.pushPanelCupertinoRoute(context, const AboutSettingScreen()); } } diff --git a/lib/Utils/tab_state_util.dart b/lib/Utils/tab_state_util.dart new file mode 100644 index 00000000..0e9c438f --- /dev/null +++ b/lib/Utils/tab_state_util.dart @@ -0,0 +1,201 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; + +/// Tab selection is a durable user preference, while loaded response objects +/// and scroll offsets intentionally remain page-lifetime state. +class TabStateRetentionPolicy { + const TabStateRetentionPolicy({ + required this.selectionAcrossRestart, + required this.loadedDataAcrossRestart, + required this.scrollOffsetAcrossRestart, + }); + + static const userFacingTabs = TabStateRetentionPolicy( + selectionAcrossRestart: true, + loadedDataAcrossRestart: false, + scrollOffsetAcrossRestart: false, + ); + + final bool selectionAcrossRestart; + final bool loadedDataAcrossRestart; + final bool scrollOffsetAcrossRestart; +} + +class TabStateResolution { + const TabStateResolution({ + required this.index, + required this.id, + required this.usedFallback, + required this.migratedLegacyIndex, + }); + + final int index; + final String? id; + final bool usedFallback; + final bool migratedLegacyIndex; +} + +class TabStatePreference { + const TabStatePreference._(); + + static int restoreIndex( + int savedIndex, + int itemCount, { + int fallbackIndex = 0, + }) { + if (itemCount <= 0) return 0; + final safeFallback = fallbackIndex.clamp(0, itemCount - 1); + if (savedIndex < 0 || savedIndex >= itemCount) return safeFallback; + return savedIndex; + } + + static TabStateResolution resolve({ + required List itemIds, + String? savedId, + int legacySavedIndex = -1, + String? fallbackId, + int fallbackIndex = 0, + }) { + if (itemIds.isEmpty) { + return const TabStateResolution( + index: 0, + id: null, + usedFallback: true, + migratedLegacyIndex: false, + ); + } + + final fallbackIdIndex = + fallbackId == null ? -1 : itemIds.indexOf(fallbackId); + final safeFallback = restoreIndex( + fallbackIdIndex >= 0 ? fallbackIdIndex : fallbackIndex, + itemIds.length, + ); + final normalizedSavedId = savedId?.trim() ?? ''; + final savedIdIndex = itemIds.indexOf(normalizedSavedId); + if (savedIdIndex >= 0) { + return TabStateResolution( + index: savedIdIndex, + id: itemIds[savedIdIndex], + usedFallback: false, + migratedLegacyIndex: false, + ); + } + + // Only use the old numeric value when no stable identifier has ever been + // stored. An unknown identifier means the data source changed and must + // safely return to the declared default instead of trusting stale order. + if (normalizedSavedId.isEmpty && + legacySavedIndex >= 0 && + legacySavedIndex < itemIds.length) { + return TabStateResolution( + index: legacySavedIndex, + id: itemIds[legacySavedIndex], + usedFallback: false, + migratedLegacyIndex: true, + ); + } + + return TabStateResolution( + index: safeFallback, + id: itemIds[safeFallback], + usedFallback: true, + migratedLegacyIndex: false, + ); + } + + static String? idAt(List itemIds, int index) { + if (itemIds.isEmpty || index < 0 || index >= itemIds.length) return null; + return itemIds[index]; + } +} + +class PersistentTabState { + const PersistentTabState._(); + + static TabStateResolution restore({ + required String idKey, + required String legacyIndexKey, + required List itemIds, + String? fallbackId, + int fallbackIndex = 0, + }) { + final resolution = TabStatePreference.resolve( + itemIds: itemIds, + savedId: ChewieHiveUtil.getString(idKey, autoCreate: false), + legacySavedIndex: ChewieHiveUtil.getInt( + legacyIndexKey, + defaultValue: -1, + ), + fallbackId: fallbackId, + fallbackIndex: fallbackIndex, + ); + if (resolution.id != null && + (resolution.usedFallback || resolution.migratedLegacyIndex)) { + save( + idKey: idKey, + legacyIndexKey: legacyIndexKey, + itemIds: itemIds, + index: resolution.index, + ); + } + return resolution; + } + + static void save({ + required String idKey, + required String legacyIndexKey, + required List itemIds, + required int index, + }) { + final safeIndex = TabStatePreference.restoreIndex(index, itemIds.length); + final id = TabStatePreference.idAt(itemIds, safeIndex); + if (id == null) return; + ChewieHiveUtil.put(idKey, id); + ChewieHiveUtil.put(legacyIndexKey, safeIndex); + } +} + +class LazyTabLoadState { + LazyTabLoadState({ + int? itemCount, + List? itemIds, + String? savedId, + int savedIndex = -1, + String? fallbackId, + int fallbackIndex = 0, + }) : assert(itemCount != null || itemIds != null), + _itemIds = itemIds ?? + List.generate(itemCount!, (index) => index.toString()), + currentIndex = TabStatePreference.resolve( + itemIds: itemIds ?? + List.generate(itemCount!, (index) => index.toString()), + savedId: savedId, + legacySavedIndex: savedIndex, + fallbackId: fallbackId, + fallbackIndex: fallbackIndex, + ).index; + + final List _itemIds; + final Set _loadedIds = {}; + int currentIndex; + + bool selectAndShouldLoad(int index) { + currentIndex = TabStatePreference.restoreIndex(index, _itemIds.length); + return _loadedIds.add(_itemIds[currentIndex]); + } + + bool get isCurrentLoaded => + _itemIds.isNotEmpty && _loadedIds.contains(_itemIds[currentIndex]); + + void markLoadFailed(int index) { + if (index < 0 || index >= _itemIds.length) return; + _loadedIds.remove(_itemIds[index]); + } + + void reset({int? index}) { + _loadedIds.clear(); + if (index != null) { + currentIndex = TabStatePreference.restoreIndex(index, _itemIds.length); + } + } +} diff --git a/lib/Utils/uri_util.dart b/lib/Utils/uri_util.dart index 395c7302..bb451e23 100644 --- a/lib/Utils/uri_util.dart +++ b/lib/Utils/uri_util.dart @@ -7,12 +7,11 @@ import 'package:loftify/Screens/Post/tag_detail_screen.dart'; import 'package:loftify/Utils/hive_util.dart'; import 'package:loftify/Utils/request_header_util.dart'; import 'package:loftify/Utils/request_util.dart'; -import 'package:loftify/Utils/utils.dart'; import 'package:share_plus/share_plus.dart'; import '../Screens/Info/user_detail_screen.dart'; import '../Screens/Post/post_detail_screen.dart'; -import '../generated/l10n.dart'; +import '../l10n/l10n.dart'; class LoftifyUriUtil { static bool isShortLinkUrl(String url) { @@ -202,8 +201,17 @@ class LoftifyUriUtil { bool quiet = false, }) async { try { - if (!quiet) CustomLoadingDialog.showLoading(title: appLocalizations.loading); - url = Uri.decodeComponent(url); + if (!quiet) { + CustomLoadingDialog.showLoading(title: appLocalizations.loading); + } + // 搜索词可能包含未转义的 %,它不是 URI 编码且不应阻断普通搜索。 + if (url.contains('%')) { + try { + url = Uri.decodeComponent(url); + } on FormatException { + // 保留原始文本,让调用方按普通搜索词继续处理。 + } + } if (LoftifyUriUtil.isShortLinkUrl(url)) { var tmp = url; url = await UriUtil.getRedirectUrl(url); diff --git a/lib/Utils/utils.dart b/lib/Utils/utils.dart index bc3d55b1..b45370c1 100644 --- a/lib/Utils/utils.dart +++ b/lib/Utils/utils.dart @@ -19,10 +19,26 @@ import 'package:window_manager/window_manager.dart'; import '../Screens/Setting/about_setting_screen.dart'; import '../Screens/Setting/setting_screen.dart'; -import '../generated/l10n.dart'; +import '../Widgets/Shortcuts/keyboard_widget.dart'; +import '../l10n/l10n.dart'; import 'app_provider.dart'; class Utils { + static int parseToInt(dynamic value) => NumberUtil.parseToInt(value); + + static List parseJsonList(String json) => + StringUtil.parseJsonList(json); + + static String formatDuration(int seconds) => + TimeUtil.formatDuration(Duration(seconds: seconds)); + + static void copy( + BuildContext context, + dynamic data, { + String? toastText, + }) => + ChewieUtils.copy(context, data, toastText: toastText); + static String getHeroTag({ String? tagPrefix, String? tagSuffix, @@ -83,7 +99,8 @@ class Utils { } static addSearchHistory(String str) { - if (ChewieHiveUtil.getBool(HiveUtil.showSearchHistoryKey, defaultValue: true)) { + if (ChewieHiveUtil.getBool(HiveUtil.showSearchHistoryKey, + defaultValue: true)) { while (appProvider.searchHistoryList.contains(str)) { appProvider.searchHistoryList.remove(str); } @@ -106,9 +123,10 @@ class Utils { Function()? onUnlike, Function()? onUnrecommend, }) { - DownloadSuccessAction action = DownloadSuccessAction.values[ChewieUtils.patchEnum( - ChewieHiveUtil.getInt(HiveUtil.downloadSuccessActionKey), - DownloadSuccessAction.values.length)]; + DownloadSuccessAction action = DownloadSuccessAction.values[ + ChewieUtils.patchEnum( + ChewieHiveUtil.getInt(HiveUtil.downloadSuccessActionKey), + DownloadSuccessAction.values.length)]; switch (action) { case DownloadSuccessAction.none: break; @@ -263,7 +281,7 @@ class Utils { entry = OverlayEntry( builder: (context) { return KeyboardWidget( - bindings: defaultLoftifyShortcuts, + bindings: ShortcutsUtil.shortcuts, callbackOnHide: () { appProvider.shownShortcutHelp = false; entry.remove(); @@ -297,7 +315,10 @@ class Utils { IToast.showDesktopNotification( appLocalizations.noGestureLock, body: appLocalizations.noGestureLockTip, - actions: [appLocalizations.cancel, appLocalizations.goToSetGestureLock], + actions: [ + appLocalizations.cancel, + appLocalizations.goToSetGestureLock + ], onClick: () { ChewieUtils.displayApp(); RouteUtil.pushPanelCupertinoRoute( diff --git a/lib/Widgets/BottomSheet/collection_bottom_sheet.dart b/lib/Widgets/BottomSheet/collection_bottom_sheet.dart index 837d73ea..cb816bab 100644 --- a/lib/Widgets/BottomSheet/collection_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/collection_bottom_sheet.dart @@ -5,14 +5,16 @@ import 'package:loftify/Api/collection_api.dart'; import 'package:loftify/Models/post_detail_response.dart'; import 'package:loftify/Models/recommend_response.dart'; import 'package:loftify/Screens/Post/collection_detail_screen.dart'; -import 'package:loftify/Utils/asset_util.dart'; import '../../Models/history_response.dart'; -import '../../Utils/utils.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../l10n/l10n.dart'; +import '../Design/loftify_controls.dart'; +import '../Design/loftify_surfaces.dart'; import '../Item/item_builder.dart'; -import '../Item/loftify_item_builder.dart'; import '../PostItem/common_info_post_item_builder.dart'; +import '../PostItem/loftify_post_archive_grid.dart'; +import '../loftify_icons.dart'; class CollectionBottomSheet extends StatefulWidget { const CollectionBottomSheet({ @@ -49,19 +51,30 @@ class CollectionBottomSheetState extends State { @override void initState() { - setState(() { - subscribed = widget.postCollection.subscribed; - }); super.initState(); + subscribed = widget.postCollection.subscribed; } - _fetchData({ + @override + void dispose() { + _refreshController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _fetchData({ int upDown = -1, int startPostId = 0, bool showLoading = false, }) async { - if (loading || (upDown != -1 && startPostId == 0)) return; - if (showLoading) CustomLoadingDialog.showLoading(title: appLocalizations.loading); + if (loading || + (upDown != -1 && startPostId == 0) || + (upDown == 1 && bottomNoMore)) { + return IndicatorResult.none; + } + if (showLoading) { + CustomLoadingDialog.showLoading(title: appLocalizations.loading); + } loading = true; return await CollectionApi.getCollection( postId: widget.postId, @@ -107,7 +120,7 @@ class CollectionBottomSheetState extends State { } Map monthCount = {}; for (var e in posts) { - String yearMonth = TimeUtil.formatYearMonth(e.post!.publishTime); + String yearMonth = formatLocalizedYearMonth(e.post!.publishTime); monthCount.putIfAbsent(yearMonth, () => 0); monthCount[yearMonth] = monthCount[yearMonth]! + 1; } @@ -143,7 +156,7 @@ class CollectionBottomSheetState extends State { }); } - _onRefresh({ + Future _onRefresh({ bool showLoading = false, }) async { if (!isInited) { @@ -162,43 +175,48 @@ class CollectionBottomSheetState extends State { } } - _onLoad() async { + Future _onLoad() async { return await _fetchData( upDown: 1, startPostId: posts.length > 1 ? posts.last.post!.id : 0); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), - ), - color: ChewieTheme.getBackground(context), - ), - height: MediaQuery.sizeOf(context).height * 0.8, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - children: [ - _buildHeader(), - Expanded( - child: EasyRefresh( - refreshOnStart: true, - controller: _refreshController, - onRefresh: _onRefresh, - onLoad: _onLoad, - triggerAxis: Axis.vertical, - child: _buildNineGridGroup(), + return LoftifyCollectionPanelFrame( + body: Padding( + padding: EdgeInsets.symmetric(horizontal: context.design.spacing.lg), + child: Column( + children: [ + _buildHeader(), + Expanded( + child: EasyRefresh( + refreshOnStart: true, + controller: _refreshController, + onRefresh: _onRefresh, + onLoad: bottomNoMore ? null : _onLoad, + triggerAxis: Axis.vertical, + child: _buildNineGridGroup(), + ), ), - ), - ], + ], + ), ), ); } - _buildHeader() { + Widget _buildHeader() { + final design = context.design; + final followButton = LoftifyCompactToggleButton( + label: + subscribed ? appLocalizations.subscribed : appLocalizations.subscribe, + selected: subscribed, + onPressed: _toggleSubscribe, + ); return Container( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 5), + padding: EdgeInsets.symmetric( + vertical: design.spacing.lg, + horizontal: design.spacing.xs, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -214,66 +232,68 @@ class CollectionBottomSheetState extends State { ), ); }, - child: ClickableWrapper(child: - Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: ChewieItemBuilder.buildCachedImage( - context: context, - imageUrl: widget.postCollection.coverUrl, - width: 50, - height: 50, - showLoading: false, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "${widget.postCollection.name}(${widget.postCollection.postCount}篇)", - style: Theme.of(context) - .textTheme - .titleMedium - ?.apply(fontWeightDelta: 2), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: ClickableWrapper( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 360 || + MediaQuery.textScalerOf(context).scale(1) > 1.35; + final summary = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: + BorderRadius.circular(design.radii.control), + child: ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: widget.postCollection.coverUrl, + width: 50, + height: 50, + showLoading: false, + fit: BoxFit.cover, ), - if (widget.postCollection.description.isNotEmpty) - Text( - widget.postCollection.description, - style: Theme.of(context).textTheme.labelMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + ), + SizedBox(width: design.spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${widget.postCollection.name}(${widget.postCollection.postCount}篇)", + style: design.typography.sectionTitle, + maxLines: compact ? 2 : 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.postCollection.description.isNotEmpty) + Text( + widget.postCollection.description, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + maxLines: compact ? 2 : 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (!compact) ...[ + SizedBox(width: design.spacing.md), + followButton, ], - ), - ), - SizedBox(width: subscribed ? 8 : 20), - LoftifyItemBuilder.buildFramedDoubleButton( - context: context, - isFollowed: subscribed, - positiveText: appLocalizations.subscribed, - negtiveText: appLocalizations.subscribe, - onTap: () { - HapticFeedback.mediumImpact(); - CollectionApi.subscribeOrUnSubscribe( - collectionId: widget.collectionId, - isSubscribe: !subscribed, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop( - value['meta']['desc'] ?? value['meta']['msg']); - } else { - subscribed = !subscribed; - setState(() {}); - } - }); - }), - ], + ], + ); + if (!compact) return summary; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + summary, + SizedBox(height: design.spacing.md), + Align( + alignment: Alignment.centerRight, + child: followButton, + ), + ], + ); + }, ), ), ), @@ -283,11 +303,15 @@ class CollectionBottomSheetState extends State { ItemBuilder.buildIconTextButton( context, text: isOldest ? "正序" : "倒序", - icon: AssetUtil.load( - isOldest - ? AssetUtil.orderDownDarkIcon - : AssetUtil.orderUpDarkIcon, - size: 15, + icon: AnimatedRotation( + turns: isOldest ? 0 : 0.5, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: ChewieIcon( + LoftifyIcons.sortDirection, + size: 16, + color: Theme.of(context).textTheme.labelMedium?.color, + ), ), fontSizeDelta: 1, color: Theme.of(context).textTheme.labelMedium?.color, @@ -314,6 +338,20 @@ class CollectionBottomSheetState extends State { ); } + void _toggleSubscribe() { + HapticFeedback.mediumImpact(); + CollectionApi.subscribeOrUnSubscribe( + collectionId: widget.collectionId, + isSubscribe: !subscribed, + ).then((value) { + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } else if (mounted) { + setState(() => subscribed = !subscribed); + } + }); + } + Widget _buildNineGridGroup() { List widgets = []; int startIndex = 0; @@ -336,34 +374,54 @@ class CollectionBottomSheetState extends State { widgets.add(_buildNineGrid(startIndex, count)); startIndex += e.count; } - return LoadMoreNotification( - child: ListView( - controller: _scrollController, - padding: EdgeInsets.zero, - children: widgets, - ), - noMore: bottomNoMore, - onLoad: _onLoad, + return ListView( + controller: _scrollController, + padding: EdgeInsets.zero, + children: widgets, ); } Widget _buildNineGrid(int startIndex, int count) { - return GridView.extent( - padding: const EdgeInsets.only(top: 12), - shrinkWrap: true, - maxCrossAxisExtent: 160, - mainAxisSpacing: 6, - crossAxisSpacing: 6, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(count, (index) { - int trueIndex = startIndex + index; + return LoftifyPostArchiveGrid( + itemCount: count, + itemBuilder: (context, index, tileExtent) { + final trueIndex = startIndex + index; return CommonInfoItemBuilder.buildNineGridPostItem( context, posts[trueIndex], - wh: (MediaQuery.sizeOf(context).width - 22) / 3, + wh: tileExtent, activePostId: widget.postId, ); - }), + }, + ); + } +} + +class LoftifyCollectionPanelFrame extends StatelessWidget { + const LoftifyCollectionPanelFrame({super.key, required this.body}); + + final Widget body; + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final visibleHeight = (media.size.height - media.viewInsets.bottom) + .clamp(0.0, double.infinity); + final compact = media.size.width < 380 || + media.textScaler.scale(1) > 1.35 || + visibleHeight * 0.8 < 420; + final panelHeight = + (visibleHeight * (compact ? 0.92 : 0.8)).clamp(0.0, 760.0); + return SafeArea( + top: false, + child: SizedBox( + key: const ValueKey('loftify-collection-panel'), + height: panelHeight, + child: LoftifyPanel( + expandBody: true, + body: body, + ), + ), ); } } diff --git a/lib/Widgets/BottomSheet/comment_bottom_sheet.dart b/lib/Widgets/BottomSheet/comment_bottom_sheet.dart index ec96c238..db7e81a2 100644 --- a/lib/Widgets/BottomSheet/comment_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/comment_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -5,7 +7,7 @@ import 'package:loftify/Models/post_detail_response.dart'; import '../../Api/post_api.dart'; import '../../l10n/l10n.dart'; -import '../Item/item_builder.dart'; +import '../Design/loftify_surfaces.dart'; import '../Item/loftify_item_builder.dart'; class CommentBottomSheet extends StatefulWidget { @@ -29,24 +31,33 @@ class CommentBottomSheet extends StatefulWidget { class CommentBottomSheetState extends State { int l1CommentOffset = 0; bool isInited = false; - int totalHotComments = 0; - List hotComments = []; List newComments = []; bool loading = false; + bool _loadFailed = false; final EasyRefreshController _refreshController = EasyRefreshController(); - final bool _noMore = false; + bool _noMore = false; @override void initState() { super.initState(); - Future.delayed(const Duration(milliseconds: 200), () { - _onRefresh(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onRefresh(); }); } - _fetchComments({bool refresh = false}) async { - if (loading) return; + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); + } + + Future _fetchComments({bool refresh = false}) async { + if (loading) return IndicatorResult.none; loading = true; + if (refresh) { + _loadFailed = false; + _noMore = false; + } return await PostApi.getL1Comments( postId: widget.postId, blogId: widget.blogId, @@ -64,14 +75,16 @@ class CommentBottomSheetState extends State { for (var comment in comments) { newComments.add(Comment.fromJson(comment)); } + _loadFailed = false; if (comments.isEmpty && !refresh) { + _noMore = true; return IndicatorResult.noMore; } return IndicatorResult.success; } } catch (e, t) { ILogger.error("Failed to load newest comments", e, t); - IToast.showTop(appLocalizations.loadFailed); + if (refresh) _loadFailed = true; return IndicatorResult.fail; } finally { loading = false; @@ -81,7 +94,7 @@ class CommentBottomSheetState extends State { }); } - _fetchL2Comments(Comment currentComment) async { + Future _fetchL2Comments(Comment currentComment) async { currentComment.l2CommentLoading = true; if (mounted) setState(() {}); return await PostApi.getL2Comments( @@ -114,39 +127,24 @@ class CommentBottomSheetState extends State { }); } - _onRefresh() async { - // var t1 = await _fetchHotComments(); - var t1 = IndicatorResult.success; - var t2 = await _fetchComments(refresh: true); - return t1 == IndicatorResult.success && t2 == IndicatorResult.success - ? IndicatorResult.success - : IndicatorResult.fail; + Future _onRefresh() async { + return _fetchComments(refresh: true); } - _onLoad() async { + Future _onLoad() async { return await _fetchComments(); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: ChewieTheme.getBackground(context), - borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), - ), - height: MediaQuery.sizeOf(context).height * 0.8, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Column( - children: [ - Expanded( - child: EasyRefresh( - controller: _refreshController, - onLoad: _onLoad, - triggerAxis: Axis.vertical, - child: _buildBody(), - ), - ), - ], + return LoftifyCommentPanel( + title: appLocalizations.latestComment, + body: EasyRefresh( + controller: _refreshController, + onRefresh: _onRefresh, + onLoad: _noMore ? null : _onLoad, + triggerAxis: Axis.vertical, + child: _buildBody(), ), ); } @@ -154,24 +152,6 @@ class CommentBottomSheetState extends State { Widget _buildBody() { return CustomScrollView( slivers: [ - // if (hotComments.isNotEmpty) - // SliverPersistentHeader( - // pinned: true, - // delegate: SliverHeaderDelegate( - // maxHeight: 50, - // minHeight: 50, - // child: Container( - // color: AppTheme.getBackground(context), - // child: ItemBuilder.buildTitle( - // context, - // title: "热门评论", - // bottomMargin: 0, - // topMargin: 0, - // ), - // ), - // ), - // ), - // if (hotComments.isNotEmpty) _buildComments(hotComments), if (newComments.isEmpty) SliverToBoxAdapter( child: !isInited @@ -184,65 +164,70 @@ class CommentBottomSheetState extends State { topPadding: 40, ), ) - : Container( - margin: const EdgeInsets.symmetric(vertical: 24), - child: EmptyPlaceholder(text: appLocalizations.noComment), - ), - ), - if (newComments.isNotEmpty) - SliverPersistentHeader( - pinned: true, - delegate: SliverHeaderDelegate( - maxHeight: 50, - minHeight: 50, - child: Container( - color: ChewieTheme.getBackground(context), - child: ItemBuilder.buildTitle( - context, - title: appLocalizations.latestComment, - left: 8, - bottomMargin: 0, - topMargin: 0, - textStyle: Theme.of(context) - .textTheme - .titleMedium - ?.apply(fontWeightDelta: 2), - ), - ), - ), + : _loadFailed + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: CustomErrorWidget(onTap: _onRefresh), + ) + : Container( + margin: const EdgeInsets.symmetric(vertical: 24), + child: + EmptyPlaceholder(text: appLocalizations.noComment), + ), ), if (newComments.isNotEmpty) _buildComments(newComments), ], ); } - _buildComments(List comments) { - return SliverList( - delegate: SliverChildListDelegate( - [ - LoadMoreNotification( - child: ListView.builder( - shrinkWrap: true, - itemCount: comments.length, - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => - LoftifyItemBuilder.buildCommentRow( - context, - comments[index], - padding: const EdgeInsets.only(bottom: 12), - writerId: widget.blogId, - l2Padding: const EdgeInsets.only(top: 12, right: 0), - onL2CommentTap: (comment) { - HapticFeedback.mediumImpact(); - _fetchL2Comments(comment); - }, - ), - ), - noMore: _noMore, - onLoad: _onLoad, - ), - ], + Widget _buildComments(List comments) { + return SliverPadding( + padding: const EdgeInsets.only(bottom: 8), + sliver: SliverList.builder( + itemCount: comments.length, + itemBuilder: (context, index) => LoftifyItemBuilder.buildCommentRow( + context, + comments[index], + writerId: widget.blogId, + onL2CommentTap: (comment) { + HapticFeedback.mediumImpact(); + _fetchL2Comments(comment); + }, + ), + ), + ); + } +} + +/// Responsive frame shared by the live comment sheet and deterministic layout +/// tests. The body owns the only scroll chain; the panel header remains stable. +class LoftifyCommentPanel extends StatelessWidget { + const LoftifyCommentPanel({ + super.key, + required this.title, + required this.body, + }); + + final String title; + final Widget body; + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final visibleHeight = max(0.0, media.size.height - media.viewInsets.bottom); + final panelHeight = min(visibleHeight * 0.82, 760.0); + return SafeArea( + top: false, + child: SizedBox( + key: const ValueKey('loftify-comment-panel'), + height: panelHeight, + child: LoftifyPanel( + title: title, + semanticLabel: title, + compactHeader: true, + expandBody: true, + body: body, + ), ), ); } diff --git a/lib/Widgets/BottomSheet/custom_bg_avatar_detail_bottom_sheet.dart b/lib/Widgets/BottomSheet/custom_bg_avatar_detail_bottom_sheet.dart index 821d745e..78ca637c 100644 --- a/lib/Widgets/BottomSheet/custom_bg_avatar_detail_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/custom_bg_avatar_detail_bottom_sheet.dart @@ -7,8 +7,78 @@ import 'package:loftify/Models/suit_response.dart'; import 'package:loftify/Screens/Info/user_detail_screen.dart'; import 'package:loftify/Widgets/Item/item_builder.dart'; +import '../../Theme/loftify_design_theme.dart'; +import '../../Utils/loftify_file_util.dart'; import '../../Screens/Suit/custom_bg_avatar_list_screen.dart'; import '../../l10n/l10n.dart'; +import '../Design/loftify_controls.dart'; +import '../Design/loftify_download_progress_button.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; + +class CustomBgAvatarDetailPanel extends StatelessWidget { + const CustomBgAvatarDetailPanel({ + super.key, + required this.title, + required this.body, + required this.footer, + }); + + final String title; + final Widget body; + final Widget footer; + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final design = context.design; + final availableHeight = (media.size.height - + media.padding.top - + media.viewInsets.bottom - + design.spacing.sm) + .clamp(0.0, 760.0); + final textScale = media.textScaler.scale(14) / 14; + final scrollFooter = availableHeight < 640 || textScale > 1.3; + final scrollingContent = SingleChildScrollView( + key: const ValueKey('custom-bg-avatar-detail-scroll'), + physics: const ClampingScrollPhysics(), + child: scrollFooter + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + body, + Divider( + height: design.borders.hairline, + thickness: design.borders.hairline, + color: design.colors.outline, + ), + Padding( + key: const ValueKey( + 'custom-bg-avatar-detail-scrolling-footer', + ), + padding: EdgeInsets.all(design.spacing.xl), + child: footer, + ), + ], + ) + : body, + ); + return ConstrainedBox( + key: const ValueKey('custom-bg-avatar-detail-panel'), + constraints: BoxConstraints(maxHeight: availableHeight), + child: LoftifyPanel( + title: title, + expandBody: true, + body: scrollingContent, + footer: scrollFooter ? null : footer, + footerPadding: EdgeInsets.all(design.spacing.xl), + ), + ); + } +} + +enum _DecorationDownloadAction { current, all } class CustomBgAvatarDetailBottomSheet extends StatefulWidget { const CustomBgAvatarDetailBottomSheet({super.key, required this.item}); @@ -27,6 +97,8 @@ class CustomBgAvatarDetailBottomSheetState final SwiperController _swiperController = SwiperController(); int _currentIndex = 0; String currentUserNickName = ""; + _DecorationDownloadAction? _activeDownload; + double _downloadProgress = 0; bool get isLootBox => item.type != 0; @@ -70,7 +142,7 @@ class CustomBgAvatarDetailBottomSheetState } } - refreshCurrentUser() { + void refreshCurrentUser() { if (isLootBox) { var blogInfo = map[item.lootBox!.productItems[_currentIndex].userId]; currentUserNickName = blogInfo?.blogNickName ?? ""; @@ -83,48 +155,18 @@ class CustomBgAvatarDetailBottomSheetState @override Widget build(BuildContext context) { - return Wrap( - runAlignment: WrapAlignment.center, - children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).canvasColor, - borderRadius: BorderRadius.vertical( - top: const Radius.circular(20), - bottom: ResponsiveUtil.isWideDevice() - ? const Radius.circular(20) - : Radius.zero), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildHeader(), - MyDivider( horizontal: 12, vertical: 0), - _buildContent(), - _buildDesc(), - MyDivider( horizontal: 12, vertical: 0), - _buildFooter(), - ], - ), - ), - ], - ); - } - - _buildHeader() { - return Container( - padding: const EdgeInsets.symmetric(vertical: 20), - alignment: Alignment.center, - child: Text( - appLocalizations.dressDetail, - style: Theme.of(context).textTheme.titleLarge, + return CustomBgAvatarDetailPanel( + title: appLocalizations.dressDetail, + body: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [_buildContent(), _buildDesc()], ), + footer: _buildFooter(), ); } - getUrlByIndex(int index) { + String getUrlByIndex(int index) { if (isLootBox) { return item.lootBox!.productItems[index].img.raw; } else { @@ -137,7 +179,7 @@ class CustomBgAvatarDetailBottomSheetState } } - getIsAvatarByIndex(int index) { + bool getIsAvatarByIndex(int index) { if (isLootBox) { return false; } else { @@ -149,7 +191,7 @@ class CustomBgAvatarDetailBottomSheetState } } - getAllImages() { + List getAllImages() { if (isLootBox) { return item.lootBox!.productItems.map((e) => e.img.raw).toList(); } else { @@ -158,7 +200,7 @@ class CustomBgAvatarDetailBottomSheetState } } - _buildContent() { + Widget _buildContent() { return Container( padding: const EdgeInsets.only(top: 24, bottom: 16), child: Column( @@ -215,56 +257,40 @@ class CustomBgAvatarDetailBottomSheetState if (count > 1 && ResponsiveUtil.isDesktop()) Positioned( left: 16, - child: Container( - width: 40, - height: 40, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: _currentIndex == 0 - ? Colors.black.withOpacity(0.1) - : Colors.black.withOpacity(0.4), - borderRadius: BorderRadius.circular(20), - ), - child: GestureDetector( - onTap: () { - _swiperController.previous(); - }, - child: ClickableWrapper( - clickable: _currentIndex != 0, - child: const Icon( - Icons.keyboard_arrow_left_rounded, - size: 30, - color: Colors.white, - ), + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: ChewieIconButton( + icon: LoftifyIcons.previous, + iconSize: 30, + tapTargetSize: 44, + foregroundColor: Colors.white, + backgroundColor: Colors.black.withValues( + alpha: _currentIndex == 0 ? 0.26 : 0.4, ), + cornerRadius: 22, + onPressed: _currentIndex == 0 + ? null + : _swiperController.previous, ), ), ), if (count > 1 && ResponsiveUtil.isDesktop()) Positioned( right: 16, - child: Container( - width: 40, - height: 40, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: _currentIndex == count - 1 - ? Colors.black.withOpacity(0.1) - : Colors.black.withOpacity(0.4), - borderRadius: BorderRadius.circular(20), - ), - child: GestureDetector( - onTap: () { - _swiperController.next(); - }, - child: ClickableWrapper( - clickable: _currentIndex != count - 1, - child: const Icon( - Icons.keyboard_arrow_right_rounded, - size: 30, - color: Colors.white, - ), + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: ChewieIconButton( + icon: LoftifyIcons.next, + iconSize: 30, + tapTargetSize: 44, + foregroundColor: Colors.white, + backgroundColor: Colors.black.withValues( + alpha: _currentIndex == count - 1 ? 0.26 : 0.4, ), + cornerRadius: 22, + onPressed: _currentIndex == count - 1 + ? null + : _swiperController.next, ), ), ), @@ -283,41 +309,41 @@ class CustomBgAvatarDetailBottomSheetState top: 6, right: 15, child: ClickableGestureDetector( - onTap: () { - if (ResponsiveUtil.isLandscapeLayout()) { - Navigator.pop(context); - } - try { - if (isLootBox) { - var blogInfo = map[item - .lootBox!.productItems[_currentIndex].userId]; - RouteUtil.pushPanelCupertinoRoute( - context, - UserDetailScreen( - blogName: blogInfo!.blogName, - blogId: blogInfo.blogId, - ), - ); - } else { - var blogInfo = map[item.product!.blogId]; - RouteUtil.pushPanelCupertinoRoute( - context, - UserDetailScreen( - blogName: blogInfo!.blogName, - blogId: blogInfo.blogId, - ), - ); - } - } catch (e, t) { - ILogger.error("Failed to open user detail", e, t); - IToast.showTop(appLocalizations.jumpFailed); + onTap: () { + if (ResponsiveUtil.isLandscapeLayout()) { + Navigator.pop(context); + } + try { + if (isLootBox) { + var blogInfo = map[ + item.lootBox!.productItems[_currentIndex].userId]; + RouteUtil.pushPanelCupertinoRoute( + context, + UserDetailScreen( + blogName: blogInfo!.blogName, + blogId: blogInfo.blogId, + ), + ); + } else { + var blogInfo = map[item.product!.blogId]; + RouteUtil.pushPanelCupertinoRoute( + context, + UserDetailScreen( + blogName: blogInfo!.blogName, + blogId: blogInfo.blogId, + ), + ); } - }, - child: ItemBuilder.buildTranslucentTag( - context, - text: currentUserNickName, - opacity: 0.5, - ), + } catch (e, t) { + ILogger.error("Failed to open user detail", e, t); + IToast.showTop(appLocalizations.jumpFailed); + } + }, + child: ItemBuilder.buildTranslucentTag( + context, + text: currentUserNickName, + opacity: 0.5, + ), ), ), ], @@ -327,7 +353,7 @@ class CustomBgAvatarDetailBottomSheetState ); } - _buildDesc() { + Widget _buildDesc() { return Container( padding: const EdgeInsets.symmetric(horizontal: 16) .add(const EdgeInsets.only(bottom: 20)), @@ -351,74 +377,158 @@ class CustomBgAvatarDetailBottomSheetState ); } - _buildFooter() { - return Container( - padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), - alignment: Alignment.center, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Center( - child: ItemBuilder.buildIconTextButton( - context, - icon: const Icon(Icons.download_done_rounded, size: 24), - direction: Axis.vertical, - text: appLocalizations.singleImage, - fontSizeDelta: -2, - onTap: () async { - CustomLoadingDialog.showLoading(title: appLocalizations.downloading); - String url = getUrlByIndex(_currentIndex); - await FileUtil.saveImage(context, url); - CustomLoadingDialog.dismissLoading(); - }, - ), - ), - const SizedBox(width: 20), - if (count > 1) - Center( - child: ItemBuilder.buildIconTextButton( - context, - icon: const Icon(Icons.done_all_rounded, size: 24), - direction: Axis.vertical, - text: appLocalizations.all, - fontSizeDelta: -2, - onTap: () async { - CustomLoadingDialog.showLoading(title: appLocalizations.downloading); - List urls = []; - if (isLootBox) { - for (var item in item.lootBox!.productItems) { - urls.add(item.img.raw); - } - } else { - for (var item in item.product!.wallpapers) { - urls.add(item.img.raw); - } - for (var item in item.product!.avatars) { - urls.add(item.img.raw); - } - } - await FileUtil.saveImages(context, urls); - CustomLoadingDialog.dismissLoading(); - }, - ), - ), - if (count > 1) const SizedBox(width: 20), - Expanded( - child: SizedBox( - height: 50, - child: RoundIconTextButton( - background: Theme.of(context).primaryColor, - text: appLocalizations.confirm, - onPressed: () async { - Navigator.of(context).pop(); - }, - fontSizeDelta: 2, - ), - ), - ), - ], + Widget _buildFooter() { + final design = context.design; + final actions = [ + LoftifyDownloadProgressButton( + label: appLocalizations.singleImage, + icon: LoftifyIcons.download, + variant: LoftifyButtonVariant.secondary, + expand: true, + progress: _activeDownload == _DecorationDownloadAction.current + ? _downloadProgress + : null, + onPressed: _activeDownload == null ? _downloadCurrent : null, ), + if (count > 1) + LoftifyDownloadProgressButton( + label: appLocalizations.all, + icon: LoftifyIcons.batchDownload, + variant: LoftifyButtonVariant.secondary, + expand: true, + progress: _activeDownload == _DecorationDownloadAction.all + ? _downloadProgress + : null, + onPressed: _activeDownload == null ? _downloadAll : null, + ), + LoftifyButton( + label: appLocalizations.confirm, + variant: LoftifyButtonVariant.primary, + expand: true, + onPressed: + _activeDownload == null ? () => Navigator.of(context).pop() : null, + ), + ]; + return LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + final vertical = constraints.maxWidth < 560 || textScale > 1.3; + if (vertical) { + return Column( + key: const ValueKey('custom-bg-avatar-detail-actions-vertical'), + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < actions.length; index++) ...[ + actions[index], + if (index != actions.length - 1) + SizedBox(height: design.spacing.md), + ], + ], + ); + } + return Row( + key: const ValueKey('custom-bg-avatar-detail-actions-horizontal'), + children: [ + for (var index = 0; index < actions.length; index++) ...[ + Expanded(child: actions[index]), + if (index != actions.length - 1) + SizedBox(width: design.spacing.md), + ], + ], + ); + }, ); } + + Future _downloadCurrent() async { + if (_activeDownload != null) return; + _beginDownload(_DecorationDownloadAction.current); + try { + final url = getUrlByIndex(_currentIndex); + final success = await LoftifyFileUtil.saveImage( + context, + url, + onReceiveProgress: (received, total) => _updateDownloadProgress( + _DecorationDownloadAction.current, + received, + total, + ), + ); + await _showDownloadCompletion( + _DecorationDownloadAction.current, + success, + ); + } finally { + _endDownload(_DecorationDownloadAction.current); + } + } + + Future _downloadAll() async { + if (_activeDownload != null) return; + _beginDownload(_DecorationDownloadAction.all); + try { + final urls = []; + if (isLootBox) { + for (final product in item.lootBox!.productItems) { + urls.add(product.img.raw); + } + } else { + for (final wallpaper in item.product!.wallpapers) { + urls.add(wallpaper.img.raw); + } + for (final avatar in item.product!.avatars) { + urls.add(avatar.img.raw); + } + } + final success = await LoftifyFileUtil.saveImages( + context, + urls, + onReceiveProgress: (received, total) => _updateDownloadProgress( + _DecorationDownloadAction.all, + received, + total, + ), + ); + await _showDownloadCompletion(_DecorationDownloadAction.all, success); + } finally { + _endDownload(_DecorationDownloadAction.all); + } + } + + void _beginDownload(_DecorationDownloadAction action) { + if (!mounted) return; + setState(() { + _activeDownload = action; + _downloadProgress = 0; + }); + } + + void _updateDownloadProgress( + _DecorationDownloadAction action, + int received, + int total, + ) { + if (!mounted || _activeDownload != action || total <= 0) return; + final next = (received / total).clamp(0.0, 1.0).toDouble(); + if (next < 1 && (next - _downloadProgress).abs() < 0.002) return; + setState(() => _downloadProgress = next); + } + + Future _showDownloadCompletion( + _DecorationDownloadAction action, + bool success, + ) async { + if (!mounted || _activeDownload != action || !success) return; + setState(() => _downloadProgress = 1); + final motion = context.design.motion; + await Future.delayed(motion.effective(context, motion.state)); + } + + void _endDownload(_DecorationDownloadAction action) { + if (!mounted || _activeDownload != action) return; + setState(() { + _activeDownload = null; + _downloadProgress = 0; + }); + } } diff --git a/lib/Widgets/BottomSheet/list_bottom_sheet.dart b/lib/Widgets/BottomSheet/list_bottom_sheet.dart new file mode 100644 index 00000000..4cb4167a --- /dev/null +++ b/lib/Widgets/BottomSheet/list_bottom_sheet.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../Design/loftify_controls.dart'; +import '../Design/loftify_surfaces.dart'; + +class TileList extends StatelessWidget { + const TileList( + this.children, { + required this.title, + this.onCloseTap, + this.showTitle = true, + this.crossAxisAlignment = CrossAxisAlignment.start, + this.showCancel = false, + super.key, + }); + + TileList.fromOptions( + List> options, + Function onSelected, { + List redOptions = const [], + dynamic selected, + this.onCloseTap, + this.title = "", + this.showTitle = true, + this.crossAxisAlignment = CrossAxisAlignment.start, + required BuildContext context, + this.showCancel = false, + super.key, + }) : children = options + .map( + (option) => LoftifyMenuItem( + label: option.item1, + selected: option.item2 == selected, + showTrailing: option.item2 == selected, + status: redOptions.contains(option.item2) + ? LoftifyMenuStatus.danger + : LoftifyMenuStatus.normal, + onTap: () => onSelected(option.item2), + ), + ) + .toList(); + + final Iterable children; + final String title; + final Function()? onCloseTap; + final bool showTitle; + final bool showCancel; + final CrossAxisAlignment crossAxisAlignment; + + @override + Widget build(BuildContext context) { + final design = context.design; + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.82, + ), + child: LoftifyPanel( + title: showTitle ? title : null, + centerTitle: false, + expandBody: true, + body: ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.fromLTRB( + design.spacing.lg, + design.spacing.xs, + design.spacing.lg, + design.spacing.sm, + ), + children: [ + for (final child in children) + Padding( + padding: EdgeInsets.only(bottom: design.spacing.xs), + child: ClipRRect( + borderRadius: BorderRadius.circular(design.radii.control), + child: ColoredBox( + color: design.colors.surfaceMuted, + child: child, + ), + ), + ), + ], + ), + footer: showCancel + ? LoftifyButton( + label: MaterialLocalizations.of(context).cancelButtonLabel, + variant: LoftifyButtonVariant.secondary, + onPressed: onCloseTap ?? () => Navigator.of(context).maybePop(), + expand: true, + ) + : null, + footerPadding: EdgeInsets.fromLTRB( + design.spacing.lg, + design.spacing.sm, + design.spacing.lg, + design.spacing.lg, + ), + ), + ); + } +} diff --git a/lib/Widgets/BottomSheet/newest_filter_bottom_sheet.dart b/lib/Widgets/BottomSheet/newest_filter_bottom_sheet.dart index 4dba79f8..f8e04874 100644 --- a/lib/Widgets/BottomSheet/newest_filter_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/newest_filter_bottom_sheet.dart @@ -1,10 +1,12 @@ -import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/tag_api.dart'; -import 'package:loftify/Widgets/Item/item_builder.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../Utils/enums.dart'; import '../../l10n/l10n.dart'; +import '../Design/loftify_controls.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; class NewestFilterBottomSheet extends StatefulWidget { const NewestFilterBottomSheet({ @@ -21,202 +23,141 @@ class NewestFilterBottomSheet extends StatefulWidget { } class NewestFilterBottomSheetState extends State { - final GroupButtonController _rangeController = GroupButtonController(); - final GroupButtonController _postTypeController = GroupButtonController(); - final GroupButtonController _recentDayController = GroupButtonController(); - final GroupButtonController _tagProtectedController = GroupButtonController(); late GetTagPostListParams params; @override void initState() { super.initState(); params = widget.params; - init(); } - init() { - _rangeController.selectIndex(params.tagRangeType.index); - _postTypeController.selectIndex(params.postTypes.index); - _recentDayController.selectIndex(params.recentDayType.index); - _tagProtectedController.selectIndex(params.protectedFlag ? 0 : -1); - } - - reset() { - params.tagRangeType = TagRangeType.noLimit; - params.postTypes = TagPostType.noLimit; - params.recentDayType = TagRecentDayType.noLimit; - params.protectedFlag = false; - init(); + void reset() { + setState(() { + params.tagRangeType = TagRangeType.noLimit; + params.postTypes = TagPostType.noLimit; + params.recentDayType = TagRecentDayType.noLimit; + params.protectedFlag = false; + }); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), - ), - color: Theme - .of(context) - .canvasColor, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(), - MyDivider(horizontal: 0, vertical: 0), - _buildButtons(), - MyDivider(horizontal: 0, vertical: 0), - _buildFooter(), - ], + return LoftifyPanel( + title: appLocalizations.filter, + expandBody: true, + body: SingleChildScrollView( + primary: false, + child: _buildButtons(), ), + footer: _buildFooter(), ); } - _buildHeader() { + Widget _buildButtons() { + final design = context.design; return Container( - padding: const EdgeInsets.symmetric(vertical: 16), - alignment: Alignment.center, - child: Text( - appLocalizations.filter, - style: Theme - .of(context) - .textTheme - .titleLarge, + width: double.infinity, + padding: EdgeInsets.only( + left: design.spacing.xl, + right: design.spacing.xl, + top: design.spacing.sectionTop, + bottom: design.spacing.xxl, ), - ); - } - - _buildButtons() { - return Container( - padding: const EdgeInsets.only(left: 16, right: 16, top: 10, bottom: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ItemBuilder.buildTitle( - context, - title: appLocalizations.contentRange, - left: 0, - bottomMargin: 12, - textStyle: Theme - .of(context) - .textTheme - .titleMedium - ?.apply(fontSizeDelta: -2), - ), - ChewieItemBuilder.buildGroupButtons( - buttons: [ - appLocalizations.noLimit, - appLocalizations.followingUser, - appLocalizations.haveNotVisitRecentSevenDays + _buildTitle(appLocalizations.contentRange), + LoftifyChoiceGroup( + values: const [ + TagRangeType.noLimit, + TagRangeType.follow, + TagRangeType.notViewInPastSevenDays, ], - controller: _rangeController, - onSelected: (value, index, selected) { - params.tagRangeType = TagRangeType.values[index]; - }, - ), - ItemBuilder.buildTitle( - context, - title: appLocalizations.contentType, - left: 0, - topMargin: 20, - bottomMargin: 12, - textStyle: Theme - .of(context) - .textTheme - .titleMedium - ?.apply(fontSizeDelta: -2), - ), - ChewieItemBuilder.buildGroupButtons( - buttons: [appLocalizations.noLimit, appLocalizations.words, appLocalizations.images], - controller: _postTypeController, - onSelected: (value, index, selected) { - params.postTypes = TagPostType.values[index]; - }, - ), - ItemBuilder.buildTitle( - context, - title: appLocalizations.releaseTime, - left: 0, - topMargin: 20, - bottomMargin: 12, - textStyle: Theme - .of(context) - .textTheme - .titleMedium - ?.apply(fontSizeDelta: -2), + selectedValue: params.tagRangeType, + labelBuilder: EnumsLabelGetter.getTagRangeTypeLabel, + onSelected: (value) => setState(() { + params.tagRangeType = value; + }), ), - ChewieItemBuilder.buildGroupButtons( - buttons: [ - appLocalizations.noLimit, - appLocalizations.inOneDay, - appLocalizations.inOneWeek, - appLocalizations.inOneMonth + _buildTitle(appLocalizations.contentType, top: true), + LoftifyChoiceGroup( + values: const [ + TagPostType.noLimit, + TagPostType.article, + TagPostType.image, ], - controller: _recentDayController, - onSelected: (value, index, selected) { - params.recentDayType = TagRecentDayType.values[index]; - }, + selectedValue: params.postTypes, + labelBuilder: EnumsLabelGetter.getTagPostTypeLabel, + onSelected: (value) => setState(() { + params.postTypes = value; + }), ), - ItemBuilder.buildTitle( - context, - title: appLocalizations.tagProtection, - left: 0, - topMargin: 20, - bottomMargin: 12, - textStyle: Theme - .of(context) - .textTheme - .titleMedium - ?.apply(fontSizeDelta: -2), + _buildTitle(appLocalizations.releaseTime, top: true), + LoftifyChoiceGroup( + values: const [ + TagRecentDayType.noLimit, + TagRecentDayType.oneDay, + TagRecentDayType.oneWeek, + TagRecentDayType.oneMonth, + ], + selectedValue: params.recentDayType, + labelBuilder: EnumsLabelGetter.getTagRecentDayTypeLabel, + onSelected: (value) => setState(() { + params.recentDayType = value; + }), ), - ChewieItemBuilder.buildGroupButtons( - buttons: [appLocalizations.tagProtection], - controller: _tagProtectedController, - enableDeselect: true, - onSelected: (value, index, selected) { - params.protectedFlag = selected; - }, + _buildTitle(appLocalizations.tagProtection, top: true), + LoftifyTag( + label: appLocalizations.tagProtection, + selected: params.protectedFlag, + onPressed: () => setState(() { + params.protectedFlag = !params.protectedFlag; + }), ), ], ), ); } - _buildFooter() { - return Container( - height: 45, - margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 28), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Center( - child: ItemBuilder.buildIconTextButton( - context, - icon: const Icon(Icons.refresh_rounded, size: 24), - direction: Axis.vertical, - text: appLocalizations.reset, - fontSizeDelta: -2, - onTap: reset, - ), + Widget _buildTitle(String title, {bool top = false}) { + final design = context.design; + return Padding( + padding: EdgeInsets.only( + top: top ? design.spacing.xxl : 0, + bottom: design.spacing.lg, + ), + child: Text(title, style: design.typography.sectionTitle), + ); + } + + Widget _buildFooter() { + final design = context.design; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: LoftifyButton( + label: appLocalizations.reset, + icon: LoftifyIcons.reset, + variant: LoftifyButtonVariant.secondary, + onPressed: reset, + expand: true, ), - const SizedBox(width: 20), - Expanded( - child: RoundIconTextButton( - text: appLocalizations.confirm, - background: Theme - .of(context) - .primaryColor, - color: Colors.white, - onPressed: () { - widget.onConfirm?.call(params); - Navigator.pop(context, params); - }, - ), + ), + SizedBox(width: design.spacing.lg), + Expanded( + flex: 2, + child: LoftifyButton( + label: appLocalizations.confirm, + onPressed: () { + widget.onConfirm?.call(params); + Navigator.pop(context, params); + }, + expand: true, ), - ], - ), + ), + ], ); } } diff --git a/lib/Widgets/BottomSheet/shield_bottom_sheet.dart b/lib/Widgets/BottomSheet/shield_bottom_sheet.dart index 4e253c9c..99f48b02 100644 --- a/lib/Widgets/BottomSheet/shield_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/shield_bottom_sheet.dart @@ -1,9 +1,10 @@ -import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; -import 'package:loftify/Widgets/Item/item_builder.dart'; -import '../../Utils/enums.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../l10n/l10n.dart'; +import '../Design/loftify_controls.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; class ShieldBottomSheet extends StatefulWidget { const ShieldBottomSheet({ @@ -34,105 +35,141 @@ class ShieldBottomSheetState extends State { @override Widget build(BuildContext context) { - return Wrap( - runAlignment: WrapAlignment.center, - children: [ - Container( - margin: const EdgeInsets.symmetric(horizontal: 50), - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: ChewieTheme.getBackground(context), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildHeader(), - _buildButtons(), - const MyDivider(horizontal: 12, vertical: 0), - _buildFooter(), - ], - ), - ), - ], - ); - } - - _buildHeader() { - return Container( - padding: const EdgeInsets.symmetric(vertical: 16), - alignment: Alignment.center, - child: Text( - appLocalizations.reduceRecommend, - style: Theme.of(context).textTheme.titleLarge, + final design = context.design; + return LoftifyPanel( + title: appLocalizations.reduceRecommend, + compactHeader: true, + body: _buildButtons(), + footer: _buildFooter(), + footerPadding: EdgeInsets.fromLTRB( + design.spacing.xl, + design.spacing.sm, + design.spacing.xl, + design.spacing.xl, ), ); } - _buildButtons() { - return Container( - padding: const EdgeInsets.only(left: 12, right: 12, top: 10, bottom: 20), + Widget _buildButtons() { + final design = context.design; + return Padding( + padding: EdgeInsets.fromLTRB( + design.spacing.xl, + design.spacing.md, + design.spacing.xl, + design.spacing.sm, + ), child: Column( - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Wrap( - alignment: WrapAlignment.start, - runAlignment: WrapAlignment.start, - crossAxisAlignment: WrapCrossAlignment.start, - spacing: 10, - runSpacing: 10, - children: tags.map((tag) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: ItemBuilder.buildTagItem( - context, - tag, - TagType.normal, - fontWeightDelta: 2, - fontSizeDelta: 1, - jumpToTag: false, - color: Theme.of(context).textTheme.titleMedium?.color, - onTap: () { - widget.onShieldTag?.call(tag); - }, - ), - ); - }).toList(), - ), + if (tags.isNotEmpty) + Wrap( + spacing: design.spacing.sm, + runSpacing: design.spacing.sm, + children: [ + for (final tag in tags) + LoftifyTag( + label: tag, + leading: LoftifyIcons.tag, + showSelectedIcon: false, + onPressed: () => widget.onShieldTag?.call(tag), + ), + ], + ), ], ), ); } - _buildFooter() { - return Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 32), - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 45, - child: RoundIconTextButton( - text: appLocalizations.uninterestedInContent, - onPressed: widget.onShieldContent, - fontSizeDelta: 2, - ), - ), - const SizedBox(height: 10), - SizedBox( - height: 45, - child: RoundIconTextButton( - text: appLocalizations.uninterestedInUser, - onPressed: widget.onShieldUser, - fontSizeDelta: 2, + Widget _buildFooter() { + final design = context.design; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ShieldActionRow( + label: appLocalizations.uninterestedInContent, + icon: LoftifyIcons.block, + onPressed: widget.onShieldContent, + ), + SizedBox(height: design.spacing.sm), + _ShieldActionRow( + label: appLocalizations.uninterestedInUser, + icon: LoftifyIcons.unfollow, + destructive: true, + onPressed: widget.onShieldUser, + ), + ], + ); + } +} + +class _ShieldActionRow extends StatelessWidget { + const _ShieldActionRow({ + required this.label, + required this.icon, + required this.onPressed, + this.destructive = false, + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + final bool destructive; + + @override + Widget build(BuildContext context) { + final design = context.design; + final foreground = + destructive ? design.colors.danger : design.colors.textPrimary; + final background = destructive + ? Color.alphaBlend( + design.colors.danger.withValues(alpha: 0.06), + design.colors.surfaceRaised, + ) + : design.colors.surfaceMuted; + final radius = BorderRadius.circular(design.radii.control); + return Semantics( + button: true, + enabled: onPressed != null, + label: label, + child: Material( + color: background, + borderRadius: radius, + child: InkWell( + onTap: onPressed, + borderRadius: radius, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return foreground.withValues(alpha: design.icons.pressedOpacity); + } + if (states.contains(WidgetState.focused)) { + return foreground.withValues(alpha: design.icons.focusOpacity); + } + return Colors.transparent; + }), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.lg), + child: Row( + children: [ + Icon(icon, size: design.icons.regular, color: foreground), + SizedBox(width: design.spacing.md), + Expanded( + child: Text( + label, + style: design.typography.label.copyWith( + color: foreground, + ), + ), + ), + ], + ), ), ), - ], + ), ), ); } diff --git a/lib/Widgets/BottomSheet/slide_captcha_bottom_sheet.dart b/lib/Widgets/BottomSheet/slide_captcha_bottom_sheet.dart index 64473971..28e2dd37 100644 --- a/lib/Widgets/BottomSheet/slide_captcha_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/slide_captcha_bottom_sheet.dart @@ -1,37 +1,132 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Api/login_api.dart'; import '../../Utils/app_provider.dart'; import '../../l10n/l10n.dart'; +import '../loftify_icons.dart'; + +typedef SlideCaptchaChallengeLoader = Future Function(); +typedef SlideCaptchaChallengeVerifier = Future Function({ + required String id, + required double offset, + required String rawKey, + required String rawIv, +}); + +@immutable +class SlideCaptchaGeometry { + const SlideCaptchaGeometry._({ + required this.panelWidth, + required this.contentWidth, + required this.imageHeight, + required this.sourceScale, + required this.sourceTravel, + required this.puzzleTravel, + required this.handleTravel, + required this.puzzleWidth, + }); + + static const double outerPadding = 16; + static const double contentPadding = 16; + static const double handleWidth = 40; + + factory SlideCaptchaGeometry.calculate({ + required double availableWidth, + required Size sourceSize, + required double puzzleSourceWidth, + }) { + final safeSourceWidth = math.max(1.0, sourceSize.width); + final safeSourceHeight = math.max(1.0, sourceSize.height); + final safeAvailableWidth = availableWidth.isFinite + ? math.max(1.0, availableWidth) + : safeSourceWidth + outerPadding * 2; + final panelWidth = math.min( + safeSourceWidth, + math.max(1.0, safeAvailableWidth - outerPadding * 2), + ); + final contentWidth = math.max( + 1.0, + panelWidth - contentPadding * 2, + ); + final sourceScale = safeSourceWidth / contentWidth; + final safePuzzleSourceWidth = + puzzleSourceWidth.clamp(1.0, safeSourceWidth).toDouble(); + final puzzleWidth = safePuzzleSourceWidth / sourceScale; + + return SlideCaptchaGeometry._( + panelWidth: panelWidth, + contentWidth: contentWidth, + imageHeight: safeSourceHeight / sourceScale, + sourceScale: sourceScale, + sourceTravel: math.max(0.0, safeSourceWidth - safePuzzleSourceWidth), + puzzleTravel: math.max(0.0, contentWidth - puzzleWidth), + handleTravel: math.max(0.0, contentWidth - handleWidth), + puzzleWidth: puzzleWidth, + ); + } + + final double panelWidth; + final double contentWidth; + final double imageHeight; + final double sourceScale; + final double sourceTravel; + final double puzzleTravel; + final double handleTravel; + final double puzzleWidth; + + double clampProgress(double progress) => progress.clamp(0.0, 1.0); + + double progressAfterDelta(double progress, double logicalDelta) { + if (handleTravel <= 0) return 0; + return clampProgress(progress + logicalDelta / handleTravel); + } + + double handleOffset(double progress) => + handleTravel * clampProgress(progress); + + double puzzleOffset(double progress) => + puzzleTravel * clampProgress(progress); + + double sourceOffset(double progress) => + sourceTravel * clampProgress(progress); +} class SlideCaptchaBottomSheet extends StatefulWidget { const SlideCaptchaBottomSheet({ super.key, + this.challengeLoader, + this.challengeVerifier, }); + final SlideCaptchaChallengeLoader? challengeLoader; + final SlideCaptchaChallengeVerifier? challengeVerifier; + @override SlideCaptchaBottomSheetState createState() => SlideCaptchaBottomSheetState(); } class SlideCaptchaBottomSheetState extends State { - String? bg; - String? front; String? id; - double frontLeftOffset = 0; - double scale = 1.1; + double _dragProgress = 0; Uint8List? bg64; Uint8List? front64; - String secret = - "KrHL6WUUlI9mKt+4JApPjwjhn+2UNa8tbe+NzFF31msY94ZXTYjj2trGN3MQMv6yXON9fgpGkesyKgCQ1lBf0hzviueHHjQYNEtEE9tvK8PkrXBOgmChlgFF/khdqpVvPxuEUc9wFu+nx8CX/M9V9RkLG7/x6jO4tuZMs3gscXyFEOSy8htUclnY8Msu6GipKGrAJZGU/Qc18HeHBKRj3cbPvzhBD/p1HOG8d51faoEYHri98dsl/30aXd3BkAIlnNDvlJ8cqKncXP1LDFwZ8VrEZtSaLV8IShZy25/ncHVBQjRxOvojUqY/DQzti/rrQmWSPNLmNDkiR2QiEVZfCQ=="; String status = ""; Color statusBackground = Colors.green; bool showStatus = false; - ImageInfo? bgInfo; + bool _isLoading = true; + bool _isVerifying = false; + Size? _backgroundSize; + Size? _puzzleSize; + int _requestGeneration = 0; + Timer? _statusTimer; @override void initState() { @@ -39,74 +134,155 @@ class SlideCaptchaBottomSheetState extends State { _fetchCaptcha(); } - _fetchCaptcha() async { - await LoginApi.getSlideCaptcha().then((value) { - try { - if (value['code'] != 0) { - IToast.showTop(value['msg']); - } else { - frontLeftOffset = 0; - bg = value['data']['bg']; - front = value['data']['front']; - id = value['data']['id']; - bg64 = base64Decode(bg!); - front64 = base64Decode(front!); - getImageInfo(Image.memory(bg64!)); - } - } catch (e, t) { - ILogger.error("Failed to load captcha", e, t); - IToast.showTop(appLocalizations.getSlideCaptchaFailed); - } finally { - if (mounted) setState(() {}); + @override + void dispose() { + _requestGeneration++; + _statusTimer?.cancel(); + super.dispose(); + } + + Future _fetchCaptcha() async { + final generation = ++_requestGeneration; + _statusTimer?.cancel(); + if (mounted) { + setState(() { + _dragProgress = 0; + _isLoading = true; + _isVerifying = false; + showStatus = false; + id = null; + bg64 = null; + front64 = null; + _backgroundSize = null; + _puzzleSize = null; + }); + } + + try { + final value = + await (widget.challengeLoader?.call() ?? LoginApi.getSlideCaptcha()); + if (!mounted || generation != _requestGeneration) return; + if (value['code'] != 0) { + IToast.showTop( + value['msg']?.toString() ?? appLocalizations.getSlideCaptchaFailed); + setState(() => _isLoading = false); + return; } - }); + + final data = value['data']; + final backgroundBytes = base64Decode(data['bg'] as String); + final puzzleBytes = base64Decode(data['front'] as String); + final sizes = await Future.wait([ + _decodeImageSize(backgroundBytes), + _decodeImageSize(puzzleBytes), + ]); + if (!mounted || generation != _requestGeneration) return; + + setState(() { + id = data['id']?.toString(); + bg64 = backgroundBytes; + front64 = puzzleBytes; + _backgroundSize = sizes[0]; + _puzzleSize = sizes[1]; + _isLoading = false; + }); + } catch (error, stackTrace) { + ILogger.error("Failed to load captcha", error, stackTrace); + if (!mounted || generation != _requestGeneration) return; + setState(() => _isLoading = false); + IToast.showTop(appLocalizations.getSlideCaptchaFailed); + } } - double get preferWidth => - bgInfo != null ? bgInfo!.image.width.toDouble() : 320; + Future _decodeImageSize(Uint8List bytes) async { + final codec = await ui.instantiateImageCodec(bytes); + try { + final frame = await codec.getNextFrame(); + final size = Size( + frame.image.width.toDouble(), + frame.image.height.toDouble(), + ); + frame.image.dispose(); + return size; + } finally { + codec.dispose(); + } + } @override Widget build(BuildContext context) { - return Center( - child: Container( - width: preferWidth, - margin: const EdgeInsets.symmetric(horizontal: 30), - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).canvasColor, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildHeader(), - const MyDivider(horizontal: 12, vertical: 0), - Stack( - children: [ - LoadingWidget( - text: appLocalizations.loading, - background: Colors.transparent, - size: 40, - topPadding: 77, - bottomPadding: 77, + return SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final backgroundSize = _backgroundSize ?? const Size(352, 160); + final puzzleSourceWidth = _puzzleSize?.width ?? 44; + final geometry = SlideCaptchaGeometry.calculate( + availableWidth: constraints.maxWidth, + sourceSize: backgroundSize, + puzzleSourceWidth: puzzleSourceWidth, + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(SlideCaptchaGeometry.outerPadding), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max( + 0, + constraints.maxHeight - SlideCaptchaGeometry.outerPadding * 2, ), - Column( - children: [ - if (bg64 != null && front64 != null) _buildCaptcha(), - if (bg64 != null && front64 != null) _buildDragHandle(), - ], + ), + child: Center( + child: Container( + key: const ValueKey('slideCaptchaPanel'), + width: geometry.panelWidth, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).canvasColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildHeader(), + const MyDivider(horizontal: 12, vertical: 0), + if (_isLoading) + SizedBox( + height: math.max(180, geometry.imageHeight + 64), + child: LoadingWidget( + text: appLocalizations.loading, + background: Colors.transparent, + size: 40, + ), + ) + else if (bg64 != null && front64 != null) + Column( + children: [ + _buildCaptcha(geometry), + _buildDragHandle(geometry), + ], + ) + else + Padding( + padding: const EdgeInsets.all(16), + child: TextButton.icon( + onPressed: _fetchCaptcha, + icon: const ChewieIcon(LoftifyIcons.retry), + label: Text(appLocalizations.retry), + ), + ), + ], + ), ), - ], + ), ), - ], - ), + ); + }, ), ); } - _buildHeader() { + Widget _buildHeader() { return Container( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 4), alignment: Alignment.centerLeft, @@ -118,197 +294,267 @@ class SlideCaptchaBottomSheetState extends State { style: Theme.of(context).textTheme.titleLarge, ), ), - CircleIconButton( - icon: Icon( - Icons.close_rounded, - color: Theme.of(context).textTheme.labelSmall?.color, - size: 20, - ), - onTap: () { - Navigator.pop(context); - }, + ChewieIconButton( + icon: LoftifyIcons.close, + iconSize: 20, + tooltip: appLocalizations.cancel, + foregroundColor: Theme.of(context).textTheme.labelSmall?.color, + onPressed: () => Navigator.pop(context), ), ], ), ); } - updateState(bool success) { - if (success) { - statusBackground = Colors.green; - status = appLocalizations.validSuccess; - showStatus = true; - setState(() {}); - Future.delayed(const Duration(milliseconds: 500), () { - Navigator.pop(context); - }); - } else { - statusBackground = Colors.redAccent; - status = appLocalizations.validFailed; + void _showVerificationStatus(bool success) { + if (!mounted) return; + _statusTimer?.cancel(); + setState(() { + _isVerifying = false; + statusBackground = success ? Colors.green : Colors.redAccent; + status = success + ? appLocalizations.validSuccess + : appLocalizations.validFailed; showStatus = true; - setState(() {}); - Future.delayed(const Duration(milliseconds: 1000), () { - showStatus = false; - if (mounted) setState(() {}); - }); - } + }); + _statusTimer = Timer( + Duration(milliseconds: success ? 500 : 900), + () { + if (!mounted) return; + if (success) { + Navigator.maybePop(context); + } else { + _fetchCaptcha(); + } + }, + ); } - getImageInfo(Image image) async { - var completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener(ImageStreamListener((imageInfo, _) { - completer.complete(imageInfo); - })); - bgInfo = await completer.future; - setState(() {}); + Future _verifyCaptcha(SlideCaptchaGeometry geometry) async { + final captchaId = id; + if (captchaId == null || _isVerifying) return; + setState(() => _isVerifying = true); + + final rawKey = StringUtil.getRandomString(length: 16); + final rawIv = StringUtil.getRandomString(length: 16); + try { + final sourceOffset = geometry.sourceOffset(_dragProgress); + final value = await (widget.challengeVerifier?.call( + id: captchaId, + offset: sourceOffset, + rawKey: rawKey, + rawIv: rawIv, + ) ?? + LoginApi.verifySlideCaptcha( + id: captchaId, + offset: sourceOffset, + rawKey: rawKey, + rawIv: rawIv, + )); + if (!mounted) return; + if (value == null) { + IToast.showTop(appLocalizations.sendValidationFailed); + _showVerificationStatus(false); + return; + } + + final decrypted = CryptUtil.decryptDataByAES(value, rawKey, rawIv); + final response = json.decode(decrypted); + if (response['code'] != 0) { + IToast.showTop( + response['msg']?.toString() ?? appLocalizations.validFailed); + _showVerificationStatus(false); + } else if (response['data']['success'] == true) { + appProvider.captchaToken = response['data']['token']; + _showVerificationStatus(true); + } else { + _showVerificationStatus(false); + } + } catch (error, stackTrace) { + ILogger.error("Failed to verify captcha", error, stackTrace); + if (!mounted) return; + IToast.showTop(appLocalizations.sendValidationFailed); + _showVerificationStatus(false); + } } - _buildCaptcha() { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.memory(bg64!), - ), - Positioned( - left: frontLeftOffset, - top: 0, - child: Image.memory(front64!, scale: scale), - ), - Positioned( - right: 5, - top: 5, - child: GestureDetector( - child: const Icon( - Icons.refresh_rounded, - color: Colors.white, + Widget _buildCaptcha(SlideCaptchaGeometry geometry) { + final puzzleSize = _puzzleSize ?? const Size(44, 44); + final puzzleHeight = puzzleSize.height / geometry.sourceScale; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: SlideCaptchaGeometry.contentPadding, + vertical: 8, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: geometry.contentWidth, + height: geometry.imageHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: Image.memory( + bg64!, + key: const ValueKey('slideCaptchaBackground'), + fit: BoxFit.fill, + gaplessPlayback: true, + filterQuality: FilterQuality.medium, + ), ), - onTap: () { - _fetchCaptcha(); - }, - ), - ), - Positioned( - bottom: 0, - child: Visibility( - visible: showStatus, - child: Container( - height: 40, - alignment: Alignment.center, - width: preferWidth - 32, - decoration: BoxDecoration( - color: statusBackground, - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(8))), - child: Text( - status, - style: Theme.of(context).textTheme.labelMedium?.apply( - color: Colors.white, - fontSizeDelta: 1, - fontWeightDelta: 2, + Positioned( + left: geometry.puzzleOffset(_dragProgress), + top: 0, + child: Image.memory( + front64!, + key: const ValueKey('slideCaptchaPuzzle'), + width: geometry.puzzleWidth, + height: puzzleHeight, + fit: BoxFit.fill, + gaplessPlayback: true, + filterQuality: FilterQuality.medium, + ), + ), + Positioned( + right: 6, + top: 6, + child: ChewieIconButton( + icon: LoftifyIcons.refresh, + tooltip: appLocalizations.refresh, + iconSize: 20, + tapTargetSize: 44, + foregroundColor: Colors.white, + backgroundColor: Colors.black45, + cornerRadius: 22, + onPressed: _isVerifying ? null : _fetchCaptcha, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: showStatus ? 1 : 0, + duration: const Duration(milliseconds: 160), + child: Container( + height: 40, + alignment: Alignment.center, + color: statusBackground, + child: Text( + status, + style: Theme.of(context).textTheme.labelMedium?.apply( + color: Colors.white, + fontSizeDelta: 1, + fontWeightDelta: 2, + ), ), + ), + ), ), ), - ), + ], ), - ], + ), ), ); } - _buildDragHandle() { - return Container( - width: preferWidth, - margin: const EdgeInsets.only(left: 16, right: 16, top: 6, bottom: 6), - child: Stack( - children: [ - Container( - height: 40, - width: preferWidth, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).dividerColor, - ), - alignment: Alignment.center, - child: Text( - appLocalizations.slideToComplete, - style: Theme.of(context).textTheme.labelLarge, - ), - ), - Container( - height: 40, - width: frontLeftOffset > 30 ? frontLeftOffset : 0, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).dividerColor, - ), - alignment: Alignment.center, - ), - Positioned( - left: frontLeftOffset, - child: GestureDetector( - onHorizontalDragUpdate: (details) { - setState(() { - double newOffset = frontLeftOffset + details.delta.dx; - double maxOffset = preferWidth - 72; - if (newOffset >= 0 && newOffset <= maxOffset) { - frontLeftOffset = newOffset; - } - }); - }, - onHorizontalDragEnd: (details) { - String rawKey = StringUtil.getRandomString(length: 16); - String rawIv = StringUtil.getRandomString(length: 16); - LoginApi.verifySlideCaptcha( - id: id!, - offset: frontLeftOffset * scale, - rawKey: rawKey, - rawIv: rawIv, - ).then((value) { - if (value == null) { - IToast.showTop(appLocalizations.sendValidationFailed); - } else { - var res = CryptUtil.decryptDataByAES(value, rawKey, rawIv); - res = json.decode(res); - if (res['code'] != 0) { - updateState(false); - IToast.showTop(res['msg']); - _fetchCaptcha(); - } else if (!res['data']['success']) { - updateState(false); - _fetchCaptcha(); - } else if (res['data']['success']) { - appProvider.captchaToken = res['data']['token']; - updateState(true); - } - } - }); - }, - child: ClickableWrapper( + Widget _buildDragHandle(SlideCaptchaGeometry geometry) { + final colorScheme = Theme.of(context).colorScheme; + final handleOffset = geometry.handleOffset(_dragProgress); + final fillWidth = _dragProgress <= 0 + ? 0.0 + : handleOffset + SlideCaptchaGeometry.handleWidth; + + return Semantics( + label: appLocalizations.slideToComplete, + value: '${(_dragProgress * 100).round()}%', + slider: true, + child: Padding( + padding: const EdgeInsets.only( + left: SlideCaptchaGeometry.contentPadding, + right: SlideCaptchaGeometry.contentPadding, + top: 6, + bottom: 6, + ), + child: SizedBox( + key: const ValueKey('slideCaptchaTrack'), + width: geometry.contentWidth, + height: SlideCaptchaGeometry.handleWidth, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).dividerColor, + ), + child: Center( + child: Text( + appLocalizations.slideToComplete, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, child: Container( - width: 40, - height: 40, + key: const ValueKey('slideCaptchaProgress'), + width: fillWidth, decoration: BoxDecoration( - color: ChewieTheme.getBackground(context), borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 5, - spreadRadius: 1, - ), - ], + color: colorScheme.primary.withValues(alpha: 0.14), ), - child: const Icon(Icons.keyboard_double_arrow_right_rounded), ), ), - ), + Positioned( + left: handleOffset, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + dragStartBehavior: DragStartBehavior.down, + onHorizontalDragUpdate: _isVerifying + ? null + : (details) { + setState(() { + _dragProgress = geometry.progressAfterDelta( + _dragProgress, + details.delta.dx, + ); + }); + }, + onHorizontalDragEnd: + _isVerifying ? null : (_) => _verifyCaptcha(geometry), + child: Container( + key: const ValueKey('slideCaptchaHandle'), + width: SlideCaptchaGeometry.handleWidth, + height: SlideCaptchaGeometry.handleWidth, + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 5, + spreadRadius: 1, + ), + ], + ), + child: ChewieIcon( + _isVerifying ? LoftifyIcons.more : LoftifyIcons.slide, + color: Colors.white, + ), + ), + ), + ), + ], ), - ], + ), ), ); } diff --git a/lib/Widgets/BottomSheet/subscribe_post_bottom_sheet.dart b/lib/Widgets/BottomSheet/subscribe_post_bottom_sheet.dart index bf69c7fd..fc00390c 100644 --- a/lib/Widgets/BottomSheet/subscribe_post_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/subscribe_post_bottom_sheet.dart @@ -1,10 +1,15 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Models/favorites_response.dart'; +import 'package:loftify/Widgets/Item/loftify_item_builder.dart'; import '../../Api/user_api.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../Utils/utils.dart'; import '../../l10n/l10n.dart'; +import '../Design/loftify_controls.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; class SubscribePostBottomSheet extends StatefulWidget { const SubscribePostBottomSheet({ @@ -34,8 +39,10 @@ class SubscribePostBottomSheetState extends State { super.initState(); } - _fetchFavoriteFolderList({bool refresh = false}) async { - if (_loading) return; + Future _fetchFavoriteFolderList({ + bool refresh = false, + }) async { + if (_loading) return IndicatorResult.none; _loading = true; int offset = refresh ? 0 : _favoriteFolderList.length; return await UserApi.getFavoriteFolderList(offset: offset).then((value) { @@ -67,7 +74,7 @@ class SubscribePostBottomSheetState extends State { }); } - _fetchSubscribeFolderList() async { + Future _fetchSubscribeFolderList() async { return await UserApi.getSubscribeFolderList( postId: widget.postId, blogId: widget.blogId) .then((value) { @@ -97,103 +104,68 @@ class SubscribePostBottomSheetState extends State { }); } - _onRefresh() async { + Future _onRefresh() async { return await _fetchFavoriteFolderList(refresh: true); } - _onLoad() async { + Future _onLoad() async { return await _fetchFavoriteFolderList(); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), - ), - color: Theme.of(context).canvasColor, - ), - height: MediaQuery.sizeOf(context).height * 0.8, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(), - const MyDivider(horizontal: 0, vertical: 0), - Expanded(child: _buildButtons()), - const MyDivider(horizontal: 0, vertical: 0), - _buildFooter(), - ], - ), + return LoftifySubscribePanelFrame( + title: appLocalizations.selectFolder, + createLabel: appLocalizations.newOp, + onCreate: _showCreateFolder, + itemCount: _favoriteFolderList.length, + body: _buildButtons(), + footer: _buildFooter(), ); } - _buildHeader() { - return Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - alignment: Alignment.center, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container(), - Text( - appLocalizations.selectFolder, - style: Theme.of(context).textTheme.titleLarge, - ), - ClickableGestureDetector( - onTap: () { - BottomSheetBuilder.showBottomSheet( - context, - (sheetContext) => InputBottomSheet( - title: appLocalizations.newFolder, - hint: appLocalizations.inputFolderTitle, - text: "", - onConfirm: (text) { - UserApi.createFolder(name: text).then((value) { - if (value['code'] == 0) { - IToast.showTop(appLocalizations.createSuccess); - _fetchFavoriteFolderList(); - } else { - IToast.showTop(value['msg']); - } - }); - }, - ), - preferMinWidth: 400, - responsive: true, - ); - }, - child: Text( - appLocalizations.newOp, - style: Theme.of(context).textTheme.titleLarge?.apply( - fontSizeDelta: -2, - color: Theme.of(context).primaryColor, - ), - ), - ), - ], + void _showCreateFolder() { + BottomSheetBuilder.showBottomSheet( + context, + (sheetContext) => InputBottomSheet( + title: appLocalizations.newFolder, + hint: appLocalizations.inputFolderTitle, + text: "", + onConfirm: (text) { + UserApi.createFolder(name: text).then((value) { + if (value['code'] == 0) { + IToast.showTop(appLocalizations.createSuccess); + _fetchFavoriteFolderList(); + } else { + IToast.showTop(value['msg']); + } + }); + }, ), + preferMinWidth: 400, + responsive: true, ); } - _buildButtons() { + Widget _buildButtons() { return EasyRefresh( refreshOnStart: true, onRefresh: _onRefresh, onLoad: _onLoad, triggerAxis: Axis.vertical, child: _favoriteFolderList.isNotEmpty - ? ListView( - cacheExtent: 9999, - padding: const EdgeInsets.symmetric(vertical: 6), - children: List.generate( - _favoriteFolderList.length, - (index) { - return _buildFolderItem( - context, - _favoriteFolderList[index], - ); - }, + ? ListView.builder( + cacheExtent: MediaQuery.sizeOf(context).height, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: _favoriteFolderList.length, + itemBuilder: (context, index) => KeyedSubtree( + key: ValueKey( + 'subscribe-folder-${_favoriteFolderList[index].id}', + ), + child: _buildFolderItem( + context, + _favoriteFolderList[index], + ), ), ) : EmptyPlaceholder(text: appLocalizations.noFavoriteFolder), @@ -201,29 +173,54 @@ class SubscribePostBottomSheetState extends State { } Widget _buildFolderItem(BuildContext context, FavoriteFolder item) { + final design = context.design; + final selected = item.postSubscribed == 1; return Material( + color: Colors.transparent, child: InkWell( + splashFactory: NoSplash.splashFactory, + borderRadius: BorderRadius.circular(design.radii.control), onTap: () { item.postSubscribed = item.postSubscribed == 1 ? 0 : 1; setState(() {}); }, - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: AnimatedContainer( + duration: design.motion.effective(context, design.motion.state), + margin: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.xs, + ), + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.md, + ), + decoration: BoxDecoration( + color: selected + ? design.colors.accentContainer.withValues(alpha: 0.6) + : design.colors.surface, + borderRadius: BorderRadius.circular(design.radii.control), + border: Border.all( + color: selected + ? design.colors.accent.withValues(alpha: 0.5) + : design.colors.outline, + width: design.borders.hairline, + ), + ), child: Row( children: [ Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).dividerColor, width: 0.5), - borderRadius: BorderRadius.circular(10), + color: design.colors.outline, + width: design.borders.hairline), + borderRadius: BorderRadius.circular(design.radii.control), color: Colors.transparent, ), child: ClipRRect( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(design.radii.control), child: SizedBox( - height: 80, - width: 80, + height: 60, + width: 60, child: ChewieItemBuilder.buildCachedImage( context: context, fit: BoxFit.cover, @@ -233,43 +230,37 @@ class SubscribePostBottomSheetState extends State { ), ), ), + SizedBox(width: design.spacing.md), Expanded( - child: Container( - padding: const EdgeInsets.only(left: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - children: [ - Text( - item.name ?? "", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Text( - "ID: ${item.id}", - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 10), - Text( - "${item.postCount}${appLocalizations.chapter}", - style: Theme.of(context).textTheme.titleSmall, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.name ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.cardTitle, + ), + SizedBox(height: design.spacing.xs), + Text( + "ID: ${item.id} · ${item.postCount}${appLocalizations.chapter}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.textMuted, ), - ], - ), + ), + ], ), ), - CircleIconButton( + ChewieIconButton( icon: item.postSubscribed == 1 - ? Icon( - Icons.check_circle_outline_rounded, - color: Theme.of(context).primaryColor, - ) - : const Icon(Icons.circle_outlined), - onTap: () { + ? LoftifyIcons.check + : LoftifyIcons.select, + selected: item.postSubscribed == 1, + semanticLabel: item.name, + onPressed: () { item.postSubscribed = item.postSubscribed == 1 ? 0 : 1; setState(() {}); }, @@ -281,39 +272,137 @@ class SubscribePostBottomSheetState extends State { ); } - _buildFooter() { - return Container( - height: 45, - margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 28), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: RoundIconTextButton( - text: appLocalizations.cancel, - onPressed: () { - Navigator.pop(context); - }, - ), - ), - const SizedBox(width: 10), - Expanded( - child: RoundIconTextButton( - text: appLocalizations.confirm, - background: Theme.of(context).primaryColor, - color: Colors.white, - onPressed: () { - widget.onConfirm?.call(_favoriteFolderList - .where((e) => e.postSubscribed == 1) - .map((e) => e.id.toString()) - .toList()); - Navigator.pop(context); - }, - ), + Widget _buildFooter() { + return LoftifyResponsivePanelActions( + secondary: LoftifyButton( + label: appLocalizations.cancel, + variant: LoftifyButtonVariant.secondary, + expand: true, + onPressed: () => Navigator.pop(context), + ), + primary: LoftifyButton( + label: appLocalizations.confirm, + expand: true, + onPressed: () { + widget.onConfirm?.call(_favoriteFolderList + .where((e) => e.postSubscribed == 1) + .map((e) => e.id.toString()) + .toList()); + Navigator.pop(context); + }, + ), + ); + } +} + +class LoftifySubscribePanelFrame extends StatelessWidget { + const LoftifySubscribePanelFrame({ + super.key, + required this.title, + required this.createLabel, + required this.onCreate, + required this.body, + required this.footer, + this.itemCount, + }); + + final String title; + final String createLabel; + final VoidCallback onCreate; + final Widget body; + final Widget footer; + final int? itemCount; + + @override + Widget build(BuildContext context) { + final design = context.design; + final media = MediaQuery.of(context); + final visibleHeight = (media.size.height - media.viewInsets.bottom) + .clamp(0.0, double.infinity); + final compactHeader = media.size.width < 380 || + media.textScaler.scale(1) > 1.35 || + visibleHeight * 0.8 < 420; + final maxPanelHeight = + (visibleHeight * (compactHeader ? 0.92 : 0.8)).clamp(0.0, 720.0); + final desiredHeight = itemCount == null + ? maxPanelHeight + : (196.0 + itemCount! * 84).clamp(300.0, 720.0); + final panelHeight = desiredHeight.clamp(0.0, maxPanelHeight); + final createAction = LoftifyItemBuilder.buildFramedDoubleButton( + context: context, + isFollowed: false, + onTap: onCreate, + positiveText: createLabel, + negtiveText: createLabel, + ); + return SafeArea( + top: false, + child: SizedBox( + key: const ValueKey('loftify-subscribe-panel'), + height: panelHeight, + child: LoftifyPanel( + title: title, + compactHeader: true, + trailing: compactHeader ? null : createAction, + expandBody: true, + body: compactHeader + ? Column( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xl, + vertical: design.spacing.md, + ), + child: createAction, + ), + Expanded(child: body), + ], + ) + : body, + footer: footer, + footerPadding: EdgeInsets.fromLTRB( + design.spacing.lg, + design.spacing.sm, + design.spacing.lg, + design.spacing.lg, ), - ], + ), ), ); } } + +class LoftifyResponsivePanelActions extends StatelessWidget { + const LoftifyResponsivePanelActions({ + super.key, + required this.secondary, + required this.primary, + }); + + final Widget secondary; + final Widget primary; + + @override + Widget build(BuildContext context) { + final design = context.design; + final stack = MediaQuery.sizeOf(context).width < 360 || + MediaQuery.textScalerOf(context).scale(1) > 1.35; + if (stack) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + primary, + SizedBox(height: design.spacing.md), + secondary, + ], + ); + } + return Row( + children: [ + Expanded(child: secondary), + SizedBox(width: design.spacing.lg), + Expanded(child: primary), + ], + ); + } +} diff --git a/lib/Widgets/Design/loftify_content_reference.dart b/lib/Widgets/Design/loftify_content_reference.dart new file mode 100644 index 00000000..1d1243a4 --- /dev/null +++ b/lib/Widgets/Design/loftify_content_reference.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../loftify_icons.dart'; +import 'loftify_surfaces.dart'; + +@immutable +class LoftifyContentReferenceAction { + const LoftifyContentReferenceAction({ + required this.label, + required this.onPressed, + this.onDisabledPressed, + this.icon, + this.enabled = true, + this.emphasized = false, + }); + + final String label; + final VoidCallback onPressed; + final VoidCallback? onDisabledPressed; + final IconData? icon; + final bool enabled; + final bool emphasized; +} + +/// A low-noise content relationship used for collections, grains and other +/// containers a post belongs to. +/// +/// The component grows with localized labels and dynamic text. Its quiet +/// filled surface restores the compact relationship treatment used by post +/// details without competing with the work itself. +class LoftifyContentReferenceCard extends StatelessWidget { + const LoftifyContentReferenceCard({ + super.key, + required this.icon, + required this.title, + this.eyebrow, + this.onTap, + this.trailing, + this.actions = const [], + this.semanticLabel, + }); + + final IconData icon; + final String title; + final String? eyebrow; + final VoidCallback? onTap; + final Widget? trailing; + final List actions; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + return LoftifyCard( + key: const ValueKey('loftify-content-reference-card'), + variant: LoftifyCardVariant.muted, + onTap: onTap, + semanticLabel: semanticLabel ?? title, + radius: design.radii.control, + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40), + child: LayoutBuilder( + builder: (context, constraints) { + final textScale = + MediaQuery.textScalerOf(context).scale(1).clamp(1, 3); + final stackTrailing = trailing != null && + (constraints.maxWidth < 300 || textScale > 1.35); + if (!stackTrailing) { + return _buildHeaderRow(context, includeTrailing: true); + } + return Column( + key: const ValueKey('content-reference-header-stacked'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeaderRow(context, includeTrailing: false), + SizedBox(height: design.spacing.xs), + Align(alignment: Alignment.centerRight, child: trailing!), + ], + ); + }, + ), + ), + if (actions.isNotEmpty) ...[ + LayoutBuilder( + builder: (context, constraints) { + final textScale = + MediaQuery.textScalerOf(context).scale(1).clamp(1, 3); + final stackActions = + constraints.maxWidth < 300 || textScale > 1.35; + final children = actions + .map((action) => _ContentReferenceActionButton(action)) + .toList(growable: false); + if (stackActions) { + return Column( + key: const ValueKey('content-reference-actions-stacked'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < children.length; index++) ...[ + children[index], + if (index != children.length - 1) + SizedBox(height: design.spacing.sm), + ], + ], + ); + } + return Row( + key: const ValueKey('content-reference-actions-inline'), + children: [ + for (var index = 0; index < children.length; index++) ...[ + Expanded(child: children[index]), + if (index != children.length - 1) + SizedBox(width: design.spacing.sm), + ], + ], + ); + }, + ), + ], + ], + ), + ); + } + + Widget _buildHeaderRow( + BuildContext context, { + required bool includeTrailing, + }) { + final design = context.design; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + key: const ValueKey('loftify-content-reference-icon'), + icon, + size: design.icons.small, + color: eyebrow?.isNotEmpty == true + ? design.colors.accentForeground + : design.colors.textSecondary, + ), + SizedBox(width: design.spacing.sm), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final textScale = + MediaQuery.textScalerOf(context).scale(1).clamp(1, 3); + final stackText = textScale > 1.35; + final titleWidget = Text( + title, + maxLines: stackText ? 3 : 1, + overflow: TextOverflow.ellipsis, + style: design.typography.label.copyWith( + color: design.colors.textPrimary, + ), + ); + if (eyebrow?.isNotEmpty != true) return titleWidget; + final eyebrowWidget = Text( + eyebrow!, + maxLines: stackText ? 2 : 1, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.accentForeground, + fontWeight: FontWeight.w600, + ), + ); + if (stackText) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + eyebrowWidget, + SizedBox(height: design.spacing.xxs), + titleWidget, + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible(child: eyebrowWidget), + SizedBox(width: design.spacing.sm), + Expanded(child: titleWidget), + ], + ); + }, + ), + ), + if (includeTrailing && trailing != null) ...[ + SizedBox(width: design.spacing.md), + trailing!, + ] else if (includeTrailing && onTap != null) ...[ + SizedBox(width: design.spacing.md), + Icon( + LoftifyIcons.next, + size: design.icons.small, + color: design.colors.textMuted, + ), + ], + ], + ); + } +} + +class _ContentReferenceActionButton extends StatelessWidget { + const _ContentReferenceActionButton(this.action); + + final LoftifyContentReferenceAction action; + + @override + Widget build(BuildContext context) { + final design = context.design; + final enabled = action.enabled; + final foreground = action.emphasized + ? design.colors.onAccentContainer + : enabled + ? design.colors.textPrimary + : design.colors.textMuted; + final background = action.emphasized + ? Color.lerp(design.colors.accentContainer, design.colors.accent, 0.25)! + : design.colors.surface; + return Semantics( + button: true, + enabled: enabled, + label: action.label, + child: Opacity( + opacity: enabled ? 1 : design.icons.disabledOpacity, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: 40, + ), + child: Center( + child: Material( + color: background, + borderRadius: BorderRadius.circular(design.radii.small), + child: InkWell( + onTap: enabled ? action.onPressed : action.onDisabledPressed, + splashFactory: NoSplash.splashFactory, + borderRadius: BorderRadius.circular(design.radii.small), + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return design.colors.accent.withValues( + alpha: design.icons.pressedOpacity, + ); + } + if (states.contains(WidgetState.focused)) { + return design.colors.accent.withValues( + alpha: design.icons.focusOpacity, + ); + } + if (states.contains(WidgetState.hovered)) { + return design.colors.accent.withValues( + alpha: design.icons.hoverOpacity, + ); + } + return Colors.transparent; + }), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.xs, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (action.icon != null) ...[ + Icon( + action.icon, + size: design.icons.small, + color: foreground, + ), + SizedBox(width: design.spacing.xs), + ], + Flexible( + child: Text( + action.label, + textAlign: TextAlign.center, + style: design.typography.metadata.copyWith( + color: foreground, + fontWeight: action.emphasized + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Compact contextual pill for an AppBar. Standalone navigation actions still +/// use circular icon buttons; this shape is reserved for a text relationship. +class LoftifyContextPill extends StatelessWidget { + const LoftifyContextPill({ + super.key, + required this.icon, + required this.label, + required this.onPressed, + this.semanticLabel, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + final mediaQuery = MediaQuery.of(context); + final screenWidth = mediaQuery.size.width; + final textScale = mediaQuery.textScaler.scale(1).clamp(1, 3); + final maxWidth = switch (screenWidth) { + <= 360 => 96.0, + <= 420 when textScale > 1.35 => 104.0, + <= 420 => 128.0, + _ => 180.0, + }; + return Semantics( + button: true, + label: semanticLabel ?? label, + child: Material( + key: const ValueKey('loftify-context-pill'), + color: design.colors.surfaceMuted, + shape: StadiumBorder( + side: BorderSide( + color: design.colors.outline, + width: design.borders.hairline, + ), + ), + child: InkWell( + onTap: onPressed, + customBorder: const StadiumBorder(), + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return design.colors.accent.withValues( + alpha: design.icons.pressedOpacity, + ); + } + if (states.contains(WidgetState.focused)) { + return design.colors.accent.withValues( + alpha: design.icons.focusOpacity, + ); + } + if (states.contains(WidgetState.hovered)) { + return design.colors.accent.withValues( + alpha: design.icons.hoverOpacity, + ); + } + return Colors.transparent; + }), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: 36, maxWidth: maxWidth), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.lg), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: design.icons.small, + color: design.colors.textSecondary, + ), + SizedBox(width: design.spacing.sm), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.label.copyWith( + color: design.colors.textPrimary, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/Widgets/Design/loftify_controls.dart b/lib/Widgets/Design/loftify_controls.dart new file mode 100644 index 00000000..825fcc23 --- /dev/null +++ b/lib/Widgets/Design/loftify_controls.dart @@ -0,0 +1,753 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +enum LoftifyControlStatus { idle, loading, success, warning, error } + +enum LoftifyButtonVariant { primary, secondary, tonal, ghost, danger } + +enum LoftifyButtonSize { compact, regular, large } + +enum LoftifyFieldStatus { normal, success, warning, error } + +/// Token-driven action button. Its visual height may grow for localized text, +/// while every size keeps at least a 48 px interaction target. +class LoftifyButton extends StatelessWidget { + const LoftifyButton({ + super.key, + required this.label, + this.onPressed, + this.icon, + this.trailing, + this.variant = LoftifyButtonVariant.primary, + this.size = LoftifyButtonSize.regular, + this.status = LoftifyControlStatus.idle, + this.expand = false, + this.semanticLabel, + }); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final Widget? trailing; + final LoftifyButtonVariant variant; + final LoftifyButtonSize size; + final LoftifyControlStatus status; + final bool expand; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + final colors = design.colors; + final highContrast = MediaQuery.highContrastOf(context); + final enabled = onPressed != null && status != LoftifyControlStatus.loading; + final statusColor = switch (status) { + LoftifyControlStatus.success => colors.success, + LoftifyControlStatus.warning => colors.warning, + LoftifyControlStatus.error => colors.danger, + LoftifyControlStatus.idle || LoftifyControlStatus.loading => null, + }; + final solid = statusColor != null || + variant == LoftifyButtonVariant.primary || + variant == LoftifyButtonVariant.danger; + final baseColor = statusColor ?? + switch (variant) { + LoftifyButtonVariant.primary => colors.accent, + LoftifyButtonVariant.danger => colors.danger, + LoftifyButtonVariant.tonal => colors.accentContainer, + LoftifyButtonVariant.secondary => colors.surfaceRaised, + LoftifyButtonVariant.ghost => Colors.transparent, + }; + final foreground = solid + ? _contrastForeground(baseColor) + : variant == LoftifyButtonVariant.tonal + ? colors.onAccentContainer + : colors.textPrimary; + final borderColor = switch (variant) { + LoftifyButtonVariant.secondary => + Colors.grey.shade300, // Use a neutral border for secondary buttons + LoftifyButtonVariant.ghost => Colors.transparent, + LoftifyButtonVariant.primary || + LoftifyButtonVariant.tonal || + LoftifyButtonVariant.danger => + highContrast ? foreground.withValues(alpha: 0.68) : Colors.transparent, + }; + final verticalPadding = switch (size) { + LoftifyButtonSize.compact => design.spacing.md, + LoftifyButtonSize.regular => design.spacing.lg, + LoftifyButtonSize.large => design.spacing.xl, + }; + final horizontalPadding = switch (size) { + LoftifyButtonSize.compact => design.spacing.lg, + LoftifyButtonSize.regular => design.spacing.xl, + LoftifyButtonSize.large => design.spacing.xxl, + }; + + return Semantics( + button: true, + enabled: enabled, + label: semanticLabel, + child: Opacity( + opacity: enabled + ? 1 + : highContrast + ? 0.56 + : design.icons.disabledOpacity, + child: AnimatedContainer( + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + width: expand ? double.infinity : null, + constraints: BoxConstraints( + minHeight: design.icons.minimumTapTarget, + minWidth: design.icons.minimumTapTarget, + ), + decoration: BoxDecoration( + color: baseColor, + borderRadius: BorderRadius.circular(design.radii.control), + border: Border.all( + color: borderColor, + width: + highContrast ? design.borders.focus : design.borders.regular, + ), + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(design.radii.control), + child: InkWell( + onTap: enabled ? onPressed : null, + splashFactory: NoSplash.splashFactory, + borderRadius: BorderRadius.circular(design.radii.control), + overlayColor: WidgetStateProperty.resolveWith((states) { + final overlayBase = solid ? foreground : colors.accent; + if (states.contains(WidgetState.pressed)) { + return overlayBase.withValues( + alpha: design.icons.pressedOpacity, + ); + } + if (states.contains(WidgetState.focused)) { + return overlayBase.withValues( + alpha: design.icons.focusOpacity, + ); + } + if (states.contains(WidgetState.hovered)) { + return overlayBase.withValues( + alpha: design.icons.hoverOpacity, + ); + } + return Colors.transparent; + }), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + child: AnimatedSwitcher( + duration: + design.motion.effective(context, design.motion.state), + switchInCurve: design.motion.enterCurve, + switchOutCurve: design.motion.exitCurve, + child: _buildContent(context, foreground, solid), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildContent(BuildContext context, Color foreground, bool solid) { + final design = context.design; + final stateIcon = switch (status) { + LoftifyControlStatus.success => LoftifyIcons.check, + LoftifyControlStatus.warning || + LoftifyControlStatus.error => + LoftifyIcons.warning, + LoftifyControlStatus.idle || LoftifyControlStatus.loading => null, + }; + final leading = status == LoftifyControlStatus.loading + ? SizedBox.square( + dimension: design.icons.large, + child: ExcludeSemantics( + child: LottieFiles.buildLoadingAnimation( + design.icons.large, + solid, + ), + ), + ) + : stateIcon != null + ? Icon(stateIcon, size: design.icons.regular, color: foreground) + : icon != null + ? Icon(icon, size: design.icons.regular, color: foreground) + : null; + return Row( + key: ValueKey(status), + mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (leading != null) ...[ + leading, + SizedBox(width: design.spacing.md), + ], + Flexible( + child: Text( + label, + textAlign: TextAlign.center, + style: design.typography.label.copyWith( + fontSize: size == LoftifyButtonSize.large ? 17 : null, + height: size == LoftifyButtonSize.large ? 1.25 : null, + color: variant == LoftifyButtonVariant.primary + ? Colors.white + : foreground), + ), + ), + if (trailing != null) ...[ + SizedBox(width: design.spacing.md), + trailing!, + ], + ], + ); + } +} + +/// A compact relationship action used for follow and subscription states. +/// +/// The visible capsule deliberately stays close to the original Loftify +/// treatment while the surrounding 48 px box remains an accessible target. +class LoftifyCompactToggleButton extends StatelessWidget { + const LoftifyCompactToggleButton({ + super.key, + required this.label, + required this.selected, + required this.onPressed, + this.semanticLabel, + }); + + final String label; + final bool selected; + final VoidCallback? onPressed; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + final enabled = onPressed != null; + final foreground = + selected ? design.colors.textSecondary : design.colors.accentForeground; + final borderColor = selected + ? design.colors.outlineStrong + : design.colors.accent.withValues(alpha: 0.52); + + return Semantics( + button: true, + selected: selected, + enabled: enabled, + label: semanticLabel ?? label, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: design.icons.minimumTapTarget, + minHeight: design.icons.minimumTapTarget, + ), + child: Center( + child: AnimatedContainer( + key: const ValueKey('loftify-compact-toggle-surface'), + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + decoration: BoxDecoration( + color: + selected ? design.colors.surfaceRaised : Colors.transparent, + borderRadius: BorderRadius.circular(design.radii.full), + border: Border.all( + color: borderColor, + width: design.borders.regular, + ), + ), + child: Material( + color: Colors.transparent, + shape: const StadiumBorder(), + child: InkWell( + onTap: enabled ? onPressed : null, + customBorder: const StadiumBorder(), + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return design.colors.accent.withValues( + alpha: design.icons.pressedOpacity, + ); + } + if (states.contains(WidgetState.focused)) { + return design.colors.accent.withValues( + alpha: design.icons.focusOpacity, + ); + } + if (states.contains(WidgetState.hovered)) { + return design.colors.accent.withValues( + alpha: design.icons.hoverOpacity, + ); + } + return Colors.transparent; + }), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.sm, + ), + child: Text( + label, + textAlign: TextAlign.center, + style: design.typography.metadata.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Content-driven text field with one semantic state model for login and +/// settings forms. +class LoftifyTextField extends StatefulWidget { + const LoftifyTextField({ + super.key, + this.controller, + this.focusNode, + this.labelText, + this.hintText, + this.helperText, + this.statusText, + this.status = LoftifyFieldStatus.normal, + this.enabled = true, + this.readOnly = false, + this.obscureText = false, + this.prefix, + this.suffix, + this.backgroundColor, + this.keyboardType, + this.textInputAction, + this.autofillHints, + this.inputFormatters, + this.minLines, + this.maxLines = 1, + this.onChanged, + this.onSubmitted, + this.onTap, + this.contextMenuBuilder, + this.semanticLabel, + }); + + final TextEditingController? controller; + final FocusNode? focusNode; + final String? labelText; + final String? hintText; + final String? helperText; + final String? statusText; + final LoftifyFieldStatus status; + final bool enabled; + final bool readOnly; + final bool obscureText; + final Widget? prefix; + final Widget? suffix; + final Color? backgroundColor; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final Iterable? autofillHints; + final List? inputFormatters; + final int? minLines; + final int? maxLines; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onTap; + final EditableTextContextMenuBuilder? contextMenuBuilder; + final String? semanticLabel; + + @override + State createState() => _LoftifyTextFieldState(); +} + +class _LoftifyTextFieldState extends State { + FocusNode? _ownedFocusNode; + bool _hovered = false; + + FocusNode get _focusNode => + widget.focusNode ?? (_ownedFocusNode ??= FocusNode()); + + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChanged); + } + + @override + void didUpdateWidget(covariant LoftifyTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + (oldWidget.focusNode ?? _ownedFocusNode) + ?.removeListener(_handleFocusChanged); + _ownedFocusNode?.dispose(); + _ownedFocusNode = null; + _focusNode.addListener(_handleFocusChanged); + } + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChanged); + _ownedFocusNode?.dispose(); + super.dispose(); + } + + void _handleFocusChanged() => setState(() {}); + + @override + Widget build(BuildContext context) { + final design = context.design; + final colors = design.colors; + final highContrast = MediaQuery.highContrastOf(context); + final focused = _focusNode.hasFocus; + final stateColor = switch (widget.status) { + LoftifyFieldStatus.success => colors.success, + LoftifyFieldStatus.warning => colors.warning, + LoftifyFieldStatus.error => colors.danger, + LoftifyFieldStatus.normal => null, + }; + final borderColor = stateColor ?? + (focused + ? colors.accentForeground + : _hovered + ? colors.outlineStrong + : colors.outline); + final borderWidth = highContrast + ? design.borders.focus + : focused + ? 1.5 + : design.borders.regular; + final supportingText = widget.statusText ?? widget.helperText; + + return Semantics( + textField: true, + enabled: widget.enabled, + label: widget.semanticLabel, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.labelText != null) ...[ + Padding( + padding: EdgeInsets.only( + left: design.spacing.xs, + bottom: design.spacing.sm, + ), + child: Text( + widget.labelText!, + style: design.typography.label.copyWith( + color: colors.textSecondary, + ), + ), + ), + ], + MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + alignment: Alignment.center, + clipBehavior: Clip.antiAlias, + constraints: BoxConstraints( + minHeight: design.density.minimumHeight( + LoftifyDensityRole.controlComfortable, + ), + ), + decoration: BoxDecoration( + color: widget.enabled + ? widget.backgroundColor ?? colors.surface + : colors.surfaceMuted, + borderRadius: BorderRadius.circular(design.radii.control), + border: Border.all(color: borderColor, width: borderWidth), + ), + child: TextField( + textAlignVertical: TextAlignVertical.center, + controller: widget.controller, + focusNode: _focusNode, + enabled: widget.enabled, + readOnly: widget.readOnly, + obscureText: widget.obscureText, + enableSuggestions: !widget.obscureText, + autocorrect: !widget.obscureText, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + autofillHints: widget.autofillHints, + inputFormatters: widget.inputFormatters, + minLines: widget.obscureText ? 1 : widget.minLines, + maxLines: widget.obscureText ? 1 : widget.maxLines, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + onTap: widget.onTap, + style: design.typography.body.copyWith( + color: colors.textPrimary, + ), + decoration: InputDecoration( + isDense: true, + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + disabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + hintText: widget.hintText, + hintStyle: design.typography.body.copyWith( + color: colors.textMuted, + ), + contentPadding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: 0, + ), + prefixIcon: widget.prefix, + prefixIconConstraints: BoxConstraints( + minWidth: design.icons.minimumTapTarget, + minHeight: design.icons.minimumTapTarget, + ), + suffixIcon: widget.suffix, + suffixIconConstraints: BoxConstraints( + minWidth: design.icons.minimumTapTarget, + minHeight: design.icons.minimumTapTarget, + ), + ), + contextMenuBuilder: widget.contextMenuBuilder, + ), + ), + ), + if (supportingText != null && supportingText.isNotEmpty) ...[ + SizedBox(height: design.spacing.sm), + AnimatedDefaultTextStyle( + duration: design.motion.effective(context, design.motion.state), + style: design.typography.metadata.copyWith( + color: stateColor ?? colors.textSecondary, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (stateColor != null) ...[ + Icon( + widget.status == LoftifyFieldStatus.success + ? LoftifyIcons.check + : LoftifyIcons.warning, + size: design.icons.small, + color: stateColor, + ), + SizedBox(width: design.spacing.sm), + ], + Expanded(child: Text(supportingText)), + ], + ), + ), + ], + ], + ), + ); + } +} + +/// A filter/tag control with explicit selected state and no ripple animation. +class LoftifyTag extends StatelessWidget { + const LoftifyTag({ + super.key, + required this.label, + this.selected = false, + this.enabled = true, + this.onPressed, + this.leading, + this.showSelectedIcon = true, + this.maxWidth, + this.semanticLabel, + }); + + final String label; + final bool selected; + final bool enabled; + final VoidCallback? onPressed; + final IconData? leading; + final bool showSelectedIcon; + final double? maxWidth; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + final colors = design.colors; + final highContrast = MediaQuery.highContrastOf(context); + final effectiveEnabled = enabled && onPressed != null; + final foreground = selected + ? colors.onAccentContainer + : effectiveEnabled + ? colors.textSecondary + : colors.textMuted; + final background = selected ? colors.accentContainer : colors.surface; + final borderColor = selected + ? colors.accentForeground + : highContrast + ? colors.outlineStrong + : colors.outline; + + return Semantics( + button: true, + selected: selected, + enabled: effectiveEnabled, + label: semanticLabel, + child: Opacity( + opacity: effectiveEnabled + ? 1 + : highContrast + ? 0.56 + : design.icons.disabledOpacity, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: design.icons.minimumTapTarget, + maxWidth: maxWidth ?? double.infinity, + ), + child: Align( + widthFactor: 1, + heightFactor: 1, + child: AnimatedContainer( + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + constraints: const BoxConstraints(minHeight: 36), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(design.radii.control), + border: Border.all( + color: borderColor, + width: highContrast || selected + ? design.borders.focus + : design.borders.regular, + ), + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(design.radii.control), + child: InkWell( + onTap: effectiveEnabled ? onPressed : null, + splashFactory: NoSplash.splashFactory, + borderRadius: BorderRadius.circular(design.radii.control), + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return colors.accent.withValues( + alpha: design.icons.pressedOpacity, + ); + } + if (states.contains(WidgetState.focused)) { + return colors.accent.withValues( + alpha: design.icons.focusOpacity, + ); + } + if (states.contains(WidgetState.hovered)) { + return colors.accent.withValues( + alpha: design.icons.hoverOpacity, + ); + } + return Colors.transparent; + }), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.md, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (leading != null || + (selected && showSelectedIcon)) ...[ + Icon( + selected && showSelectedIcon + ? LoftifyIcons.check + : leading, + size: design.icons.small, + color: foreground, + ), + SizedBox(width: design.spacing.sm), + ], + Flexible( + child: Text( + label, + textAlign: TextAlign.center, + style: design.typography.label.copyWith( + color: foreground, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class LoftifyChoiceGroup extends StatelessWidget { + const LoftifyChoiceGroup({ + super.key, + required this.values, + required this.labelBuilder, + required this.selectedValue, + required this.onSelected, + this.enabled = true, + this.spacing, + this.runSpacing, + }); + + final List values; + final String Function(T value) labelBuilder; + final T? selectedValue; + final ValueChanged onSelected; + final bool enabled; + final double? spacing; + final double? runSpacing; + + @override + Widget build(BuildContext context) { + final design = context.design; + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : design.grid.minimumCardWidth * 2; + return Wrap( + spacing: spacing ?? design.spacing.md, + runSpacing: runSpacing ?? design.spacing.xs, + children: [ + for (final value in values) + LoftifyTag( + label: labelBuilder(value), + selected: value == selectedValue, + enabled: enabled, + maxWidth: maxWidth, + onPressed: () => onSelected(value), + ), + ], + ); + }, + ); + } +} + +Color _contrastForeground(Color background) { + return ThemeData.estimateBrightnessForColor(background) == Brightness.dark + ? Colors.white + : const Color(0xFF151815); +} diff --git a/lib/Widgets/Design/loftify_download_progress_button.dart b/lib/Widgets/Design/loftify_download_progress_button.dart new file mode 100644 index 00000000..6140834b --- /dev/null +++ b/lib/Widgets/Design/loftify_download_progress_button.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import 'loftify_controls.dart'; + +/// A regular Loftify action button with real transfer progress at its edge. +/// +/// This intentionally uses a determinate progress track rather than the app's +/// Lottie loading language: downloads represent measurable byte transfer, not +/// an indeterminate page state. +class LoftifyDownloadProgressButton extends StatelessWidget { + const LoftifyDownloadProgressButton({ + super.key, + required this.label, + required this.icon, + required this.onPressed, + this.progress, + this.variant = LoftifyButtonVariant.secondary, + this.expand = true, + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + final double? progress; + final LoftifyButtonVariant variant; + final bool expand; + + @override + Widget build(BuildContext context) { + final design = context.design; + final normalized = progress?.clamp(0.0, 1.0).toDouble(); + final percent = normalized == null ? null : (normalized * 100).round(); + final semanticValue = percent == null ? null : '$percent%'; + return Semantics( + button: true, + liveRegion: normalized != null, + label: label, + value: semanticValue, + enabled: normalized == null && onPressed != null, + child: ExcludeSemantics( + child: Stack( + clipBehavior: Clip.antiAlias, + children: [ + LoftifyButton( + label: percent == null ? label : '$label $percent%', + icon: icon, + variant: variant, + expand: expand, + onPressed: normalized == null ? onPressed : null, + ), + if (normalized != null) + Positioned( + left: design.spacing.sm, + right: design.spacing.sm, + bottom: 0, + child: ClipRRect( + borderRadius: BorderRadius.circular(design.radii.full), + child: LinearProgressIndicator( + key: const ValueKey('loftify-download-progress-track'), + value: normalized, + minHeight: 3, + color: design.colors.accentForeground, + backgroundColor: design.colors.outline, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Compact circular counterpart for dense cards and app-bar-sized actions. +class LoftifyDownloadProgressIconButton extends StatelessWidget { + const LoftifyDownloadProgressIconButton({ + super.key, + required this.semanticLabel, + required this.icon, + required this.onPressed, + this.progress, + }); + + final String semanticLabel; + final IconData icon; + final VoidCallback? onPressed; + final double? progress; + + @override + Widget build(BuildContext context) { + final design = context.design; + final normalized = progress?.clamp(0.0, 1.0).toDouble(); + final percent = normalized == null ? null : (normalized * 100).round(); + return Semantics( + button: true, + liveRegion: normalized != null, + label: semanticLabel, + value: percent == null ? null : '$percent%', + enabled: normalized == null && onPressed != null, + child: ExcludeSemantics( + child: SizedBox.square( + dimension: 44, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: IconButton.outlined( + onPressed: normalized == null ? onPressed : null, + icon: Icon(icon, size: design.icons.regular), + ), + ), + if (normalized != null) + Positioned.fill( + child: IgnorePointer( + child: Padding( + padding: const EdgeInsets.all(2), + child: CircularProgressIndicator( + key: const ValueKey( + 'loftify-download-progress-ring', + ), + value: normalized, + strokeWidth: 2.5, + strokeCap: StrokeCap.round, + color: design.colors.accentForeground, + backgroundColor: design.colors.outline, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/Widgets/Design/loftify_lottie.dart b/lib/Widgets/Design/loftify_lottie.dart new file mode 100644 index 00000000..e7bfcdba --- /dev/null +++ b/lib/Widgets/Design/loftify_lottie.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +import '../../Theme/loftify_design_theme.dart'; + +enum LoftifyLottieLayout { icon, effect } + +enum LoftifyLottieTintRole { none, accent, foreground, danger } + +enum LoftifyLottieColorRole { + accent, + accentSecondary, + foreground, + surfaceMuted, + danger, +} + +enum LoftifyLottieColorProperty { fill, stroke } + +@immutable +class LoftifyLottieColorBinding { + const LoftifyLottieColorBinding({ + required this.keyPath, + required this.role, + required this.property, + }); + + final List keyPath; + final LoftifyLottieColorRole role; + final LoftifyLottieColorProperty property; +} + +@immutable +class LoftifyLottieSpec { + const LoftifyLottieSpec({ + required this.asset, + required this.sourceSize, + this.contentBounds, + this.layout = LoftifyLottieLayout.icon, + this.tintRole = LoftifyLottieTintRole.none, + this.colorBindings = const [], + this.opticalFill = 0.86, + this.repeat = false, + this.fit = BoxFit.contain, + this.alignment = Alignment.center, + }) : assert(opticalFill > 0 && opticalFill <= 1); + + final String asset; + final Size sourceSize; + + /// The animated content's stable optical viewport in source coordinates. + /// + /// This is deliberately independent from the JSON canvas. Several legacy + /// assets reserve space for labels or effects, so fitting the raw canvas + /// makes visually identical icons render at unrelated sizes. + final Rect? contentBounds; + final LoftifyLottieLayout layout; + final LoftifyLottieTintRole tintRole; + final List colorBindings; + final double opticalFill; + final bool repeat; + final BoxFit fit; + final Alignment alignment; + + Rect get effectiveContentBounds => contentBounds ?? Offset.zero & sourceSize; +} + +/// A stable square viewport for icon-like Lottie assets. +/// +/// Source compositions and keyframes are left untouched. [LoftifyLottieSpec] +/// maps their meaningful content into one optical canvas so callers can size +/// them exactly like a normal icon. Scene/effect animations retain their +/// original aspect ratio and fitting behaviour. +class LoftifyLottie extends StatelessWidget { + const LoftifyLottie({ + super.key, + required this.spec, + required this.size, + this.controller, + this.animate = true, + this.repeat, + this.tint, + this.strokeWidth, + this.onLoaded, + }); + + final LoftifyLottieSpec spec; + final double size; + final AnimationController? controller; + final bool animate; + final bool? repeat; + final Color? tint; + final double? strokeWidth; + final ValueChanged? onLoaded; + + static bool shouldReduceMotion(BuildContext context) { + final mediaQuery = MediaQuery.maybeOf(context); + final platformReduceMotion = WidgetsBinding + .instance.platformDispatcher.accessibilityFeatures.reduceMotion; + return platformReduceMotion || + (mediaQuery?.disableAnimations ?? false) || + (mediaQuery?.accessibleNavigation ?? false); + } + + Color? _resolveTint(BuildContext context) { + if (tint != null) return tint; + final colors = LoftifyDesignThemeData.of(context).colors; + return switch (spec.tintRole) { + LoftifyLottieTintRole.none => null, + LoftifyLottieTintRole.accent => colors.accent, + LoftifyLottieTintRole.foreground => colors.textPrimary, + LoftifyLottieTintRole.danger => colors.danger, + }; + } + + Color _resolveColorRole( + BuildContext context, + LoftifyLottieColorRole role, + ) { + final colors = LoftifyDesignThemeData.of(context).colors; + final isDark = Theme.of(context).brightness == Brightness.dark; + return switch (role) { + LoftifyLottieColorRole.accent => colors.accent, + LoftifyLottieColorRole.accentSecondary => Color.lerp( + colors.accent, + colors.page, + isDark ? 0.28 : 0.42, + )!, + LoftifyLottieColorRole.foreground => colors.textPrimary, + LoftifyLottieColorRole.surfaceMuted => colors.surfaceMuted, + LoftifyLottieColorRole.danger => colors.danger, + }; + } + + @override + Widget build(BuildContext context) { + final reduceMotion = shouldReduceMotion(context); + final resolvedTint = _resolveTint(context); + final colorDelegates = []; + if (resolvedTint != null) { + colorDelegates.add( + ValueDelegate.colorFilter( + const ['**'], + value: ColorFilter.mode(resolvedTint, BlendMode.srcIn), + ), + ); + } else { + for (final binding in spec.colorBindings) { + final color = _resolveColorRole(context, binding.role); + colorDelegates.add( + switch (binding.property) { + LoftifyLottieColorProperty.fill => + ValueDelegate.color(binding.keyPath, value: color), + LoftifyLottieColorProperty.stroke => + ValueDelegate.strokeColor(binding.keyPath, value: color), + }, + ); + } + } + if (strokeWidth != null) { + colorDelegates.add( + ValueDelegate.strokeWidth(['**', '描边 1'], value: strokeWidth), + ); + } + final delegates = + colorDelegates.isEmpty ? null : LottieDelegates(values: colorDelegates); + final animation = Lottie.asset( + spec.asset, + controller: controller, + animate: animate && !reduceMotion, + repeat: (repeat ?? spec.repeat) && !reduceMotion, + fit: spec.fit, + alignment: spec.alignment, + delegates: delegates, + addRepaintBoundary: true, + onLoaded: (composition) { + if (controller != null) controller!.duration = composition.duration; + onLoaded?.call(composition); + }, + ); + + if (spec.layout == LoftifyLottieLayout.effect) { + return Align( + widthFactor: 1, + heightFactor: 1, + child: SizedBox.square( + key: ValueKey('loftify-lottie-effect-${spec.asset}'), + dimension: size, + child: animation, + ), + ); + } + + final bounds = spec.effectiveContentBounds; + final scale = size * + spec.opticalFill / + (bounds.width > bounds.height ? bounds.width : bounds.height); + final compositionWidth = spec.sourceSize.width * scale; + final compositionHeight = spec.sourceSize.height * scale; + final left = size / 2 - bounds.center.dx * scale; + final top = size / 2 - bounds.center.dy * scale; + + return Align( + widthFactor: 1, + heightFactor: 1, + child: SizedBox.square( + key: ValueKey('loftify-lottie-icon-${spec.asset}'), + dimension: size, + child: ClipRect( + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: left, + top: top, + width: compositionWidth, + height: compositionHeight, + child: animation, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/Widgets/Design/loftify_media_overlays.dart b/lib/Widgets/Design/loftify_media_overlays.dart new file mode 100644 index 00000000..a7dcbcbb --- /dev/null +++ b/lib/Widgets/Design/loftify_media_overlays.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// Stable cover scrim for white content rendered over user-provided artwork. +/// +/// The lightest stop keeps primary and secondary content readable even when +/// the source image is entirely white. Keep this shared across profile, +/// collection and grain headers so their contrast does not drift independently. +class LoftifyCoverScrim extends StatelessWidget { + const LoftifyCoverScrim({super.key}); + + static const topColor = Color.fromRGBO(0, 0, 0, 0.58); + static const bottomColor = Color.fromRGBO(0, 0, 0, 0.72); + static const secondaryForeground = Color.fromRGBO(255, 255, 255, 0.88); + static const minimumBadgeOpacity = 0.58; + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + key: ValueKey('loftify-cover-scrim'), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [topColor, bottomColor], + ), + ), + ); + } +} + +/// Top-edge scrim for clocks, titles and compact controls over media previews. +/// +/// Unlike [LoftifyCoverScrim], this fades to transparent so the rest of the +/// artwork remains visually accurate. Content should stay inside the protected +/// top region represented by [protectedColor]. +class LoftifyTopTextScrim extends StatelessWidget { + const LoftifyTopTextScrim({super.key}); + + static const topColor = Color.fromRGBO(0, 0, 0, 0.66); + static const protectedColor = Color.fromRGBO(0, 0, 0, 0.58); + static const bottomColor = Colors.transparent; + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + key: ValueKey('loftify-top-text-scrim'), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [topColor, protectedColor, bottomColor], + stops: [0, 0.45, 1], + ), + ), + ); + } +} diff --git a/lib/Widgets/Design/loftify_reading.dart b/lib/Widgets/Design/loftify_reading.dart new file mode 100644 index 00000000..c08d050d --- /dev/null +++ b/lib/Widgets/Design/loftify_reading.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; + +/// Centers reading-oriented content without allowing long lines on wide +/// windows. The horizontal inset follows the responsive page grid while the +/// actual text measure remains capped independently from the surrounding UI. +class LoftifyReadingFrame extends StatelessWidget { + const LoftifyReadingFrame({ + super.key, + required this.child, + this.topPadding = 0, + this.bottomPadding = 0, + this.applyHorizontalPadding = true, + this.maximumContentWidth, + }); + + final Widget child; + final double topPadding; + final double bottomPadding; + final bool applyHorizontalPadding; + final double? maximumContentWidth; + + @override + Widget build(BuildContext context) { + final design = context.design; + return LayoutBuilder( + builder: (context, constraints) { + final viewportWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.sizeOf(context).width; + final horizontalPadding = design.grid.pagePaddingFor(viewportWidth); + final contentWidth = + maximumContentWidth ?? design.grid.maximumReadingWidth; + final frameWidth = contentWidth + horizontalPadding * 2; + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + key: const ValueKey('loftify-reading-frame'), + constraints: BoxConstraints(maxWidth: frameWidth), + child: Padding( + padding: EdgeInsets.fromLTRB( + applyHorizontalPadding ? horizontalPadding : 0, + topPadding, + applyHorizontalPadding ? horizontalPadding : 0, + bottomPadding, + ), + child: SizedBox( + key: const ValueKey('loftify-reading-content'), + width: double.infinity, + child: child, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/Widgets/Design/loftify_section.dart b/lib/Widgets/Design/loftify_section.dart new file mode 100644 index 00000000..b5f9d929 --- /dev/null +++ b/lib/Widgets/Design/loftify_section.dart @@ -0,0 +1,417 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +enum LoftifyEntryVisualState { + normal, + selected, + loading, + success, + warning, + error, +} + +/// Token-driven list entry used by settings, management and personal-center +/// sections. It deliberately grows with text scale instead of fixing row height. +class LoftifyEntryItem extends StatelessWidget { + const LoftifyEntryItem({ + super.key, + required this.title, + this.description = '', + this.tip = '', + this.leading, + this.leadingWidget, + this.trailing, + this.showLeading = false, + this.showTrailing = true, + this.onTap, + this.enabled = true, + this.visualState = LoftifyEntryVisualState.normal, + this.density = LoftifyDensityRole.controlComfortable, + this.semanticLabel, + }); + + final String title; + final String description; + final String tip; + final IconData? leading; + final Widget? leadingWidget; + final Widget? trailing; + final bool showLeading; + final bool showTrailing; + final VoidCallback? onTap; + final bool enabled; + final LoftifyEntryVisualState visualState; + final LoftifyDensityRole density; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + final colors = design.colors; + final effectiveEnabled = + enabled && visualState != LoftifyEntryVisualState.loading; + final foreground = _foregroundColor(colors); + final opacity = effectiveEnabled ? 1.0 : design.icons.disabledOpacity; + final background = switch (visualState) { + LoftifyEntryVisualState.selected => colors.accentContainer, + LoftifyEntryVisualState.success => + Color.alphaBlend(colors.success.withAlpha(20), Colors.transparent), + LoftifyEntryVisualState.warning => + Color.alphaBlend(colors.warning.withAlpha(20), Colors.transparent), + LoftifyEntryVisualState.error => + Color.alphaBlend(colors.danger.withAlpha(20), Colors.transparent), + LoftifyEntryVisualState.normal || + LoftifyEntryVisualState.loading => + Colors.transparent, + }; + + return Semantics( + button: onTap != null, + enabled: effectiveEnabled, + selected: visualState == LoftifyEntryVisualState.selected, + label: semanticLabel, + child: AnimatedContainer( + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + color: background, + constraints: BoxConstraints( + minHeight: design.density.minimumHeight(density), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + splashFactory: NoSplash.splashFactory, + onTap: effectiveEnabled ? onTap : null, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return colors.accent.withAlpha( + (design.icons.pressedOpacity * 255).round(), + ); + } + if (states.contains(WidgetState.focused)) { + return colors.accent.withAlpha( + (design.icons.focusOpacity * 255).round(), + ); + } + if (states.contains(WidgetState.hovered)) { + return colors.accent.withAlpha( + (design.icons.hoverOpacity * 255).round(), + ); + } + return Colors.transparent; + }), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xl, + vertical: design.density.verticalPadding(density), + ), + child: Opacity( + opacity: opacity, + child: Row( + crossAxisAlignment: description.isEmpty + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + if (showLeading || leadingWidget != null) ...[ + _buildLeading(context, foreground), + SizedBox(width: design.spacing.lg), + ], + Expanded(child: _buildText(context, foreground)), + if (_hasTrailing) ...[ + SizedBox(width: design.spacing.lg), + _buildTrailing(context, foreground), + ], + ], + ), + ), + ), + ), + ), + ), + ); + } + + bool get _hasTrailing => + trailing != null || + tip.isNotEmpty || + showTrailing || + visualState != LoftifyEntryVisualState.normal; + + Color _foregroundColor(LoftifyColorTokens colors) => switch (visualState) { + LoftifyEntryVisualState.success => colors.success, + LoftifyEntryVisualState.warning => colors.warning, + LoftifyEntryVisualState.error => colors.danger, + LoftifyEntryVisualState.normal || + LoftifyEntryVisualState.selected || + LoftifyEntryVisualState.loading => + colors.accentForeground, + }; + + Widget _buildLeading(BuildContext context, Color foreground) { + final design = context.design; + if (leadingWidget != null) { + return SizedBox.square( + dimension: design.icons.minimumTapTarget - design.spacing.xl, + child: Center(child: leadingWidget), + ); + } + return Container( + width: design.icons.minimumTapTarget - design.spacing.xl, + height: design.icons.minimumTapTarget - design.spacing.xl, + decoration: BoxDecoration( + color: foreground.withAlpha( + (design.icons.selectedContainerOpacity * 255).round(), + ), + borderRadius: BorderRadius.circular(design.radii.control), + ), + alignment: Alignment.center, + child: Icon( + leading ?? LoftifyIcons.info, + size: design.icons.regular, + color: foreground, + ), + ); + } + + Widget _buildText(BuildContext context, Color foreground) { + final design = context.design; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: design.typography.cardTitle.copyWith( + color: visualState == LoftifyEntryVisualState.error + ? foreground + : design.colors.textPrimary, + ), + ), + if (description.isNotEmpty) ...[ + SizedBox(height: design.spacing.xs), + Text( + description, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + ), + ], + ], + ); + } + + Widget _buildTrailing(BuildContext context, Color foreground) { + final design = context.design; + final stateWidget = switch (visualState) { + LoftifyEntryVisualState.loading => + LottieFiles.buildLoadingAnimation(design.icons.large, false), + LoftifyEntryVisualState.success => Icon( + LoftifyIcons.check, + size: design.icons.regular, + color: foreground, + ), + LoftifyEntryVisualState.warning || LoftifyEntryVisualState.error => Icon( + LoftifyIcons.warning, + size: design.icons.regular, + color: foreground, + ), + LoftifyEntryVisualState.normal || + LoftifyEntryVisualState.selected => + null, + }; + if (stateWidget != null) return stateWidget; + if (trailing != null) return trailing!; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (tip.isNotEmpty) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 140), + child: Text( + tip, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + ), + ), + if (tip.isNotEmpty && showTrailing) SizedBox(width: design.spacing.sm), + if (showTrailing) + Icon( + LoftifyIcons.next, + size: design.icons.small, + color: design.colors.textMuted, + ), + ], + ); + } +} + +/// A low-noise section surface with tokenized rhythm and optional expansion. +class LoftifySection extends StatefulWidget { + const LoftifySection({ + super.key, + required this.title, + this.children = const [], + this.initiallyExpanded = true, + this.collapsible = true, + this.margin, + }); + + final String title; + final List children; + final bool initiallyExpanded; + final bool collapsible; + final EdgeInsetsGeometry? margin; + + @override + State createState() => _LoftifySectionState(); +} + +class _LoftifySectionState extends State { + late bool _expanded; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + } + + @override + void didUpdateWidget(covariant LoftifySection oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initiallyExpanded != widget.initiallyExpanded) { + _expanded = widget.initiallyExpanded; + } + } + + @override + Widget build(BuildContext context) { + final design = context.design; + if (widget.children.isEmpty) { + return Padding( + padding: widget.margin ?? + EdgeInsets.only( + top: design.spacing.xxl, + left: design.spacing.xl, + right: design.spacing.xl, + bottom: design.spacing.md, + ), + child: Text(widget.title, style: design.typography.sectionTitle), + ); + } + + return Padding( + padding: widget.margin ?? EdgeInsets.only(top: design.spacing.sectionTop), + child: ClipRRect( + borderRadius: BorderRadius.circular(design.radii.card), + child: ColoredBox( + color: design.colors.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context), + AnimatedSize( + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + alignment: Alignment.topCenter, + child: _expanded + ? Column(children: _childrenWithDividers(context)) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final design = context.design; + return Semantics( + button: widget.collapsible, + expanded: _expanded, + child: Material( + color: Colors.transparent, + child: InkWell( + splashFactory: NoSplash.splashFactory, + onTap: widget.collapsible + ? () => setState(() => _expanded = !_expanded) + : null, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return design.colors.accent.withAlpha( + (design.icons.pressedOpacity * 255).round(), + ); + } + if (states.contains(WidgetState.focused)) { + return design.colors.accent.withAlpha( + (design.icons.focusOpacity * 255).round(), + ); + } + if (states.contains(WidgetState.hovered)) { + return design.colors.accent.withAlpha( + (design.icons.hoverOpacity * 255).round(), + ); + } + return Colors.transparent; + }), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xl, + vertical: 8, + ), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: ChewieTheme.labelMedium, + ), + ), + if (widget.collapsible) ...[ + SizedBox(width: design.spacing.md), + AnimatedRotation( + turns: _expanded ? 0.5 : 0, + duration: design.motion.effective( + context, + design.motion.state, + ), + curve: design.motion.enterCurve, + child: Icon( + LoftifyIcons.expand, + size: design.icons.regular, + color: design.colors.textSecondary, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + List _childrenWithDividers(BuildContext context) { + final design = context.design; + final result = []; + for (var index = 0; index < widget.children.length; index++) { + result.add( + Divider( + height: design.borders.hairline, + thickness: design.borders.hairline, + indent: design.spacing.xl, + endIndent: design.spacing.xl, + color: design.colors.outline, + ), + ); + result.add(widget.children[index]); + } + return result; + } +} diff --git a/lib/Widgets/Design/loftify_state_view.dart b/lib/Widgets/Design/loftify_state_view.dart new file mode 100644 index 00000000..d71dc01b --- /dev/null +++ b/lib/Widgets/Design/loftify_state_view.dart @@ -0,0 +1,264 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../../Utils/lottie_files.dart'; +import '../loftify_icons.dart'; +import 'loftify_controls.dart'; + +enum LoftifyStateVisual { loading, empty, error, success, warning } + +/// Unified low-noise feedback for page, card and panel states. +class LoftifyStateView extends StatelessWidget { + const LoftifyStateView({ + super.key, + required this.visual, + required this.title, + this.message, + this.actionLabel, + this.onAction, + this.icon, + this.indicatorSize = 30, + this.showTitle = true, + this.forceDark = false, + this.background, + this.padding, + this.scrollWhenConstrained = true, + }); + + final LoftifyStateVisual visual; + final String title; + final String? message; + final String? actionLabel; + final VoidCallback? onAction; + final IconData? icon; + final double indicatorSize; + final bool showTitle; + final bool forceDark; + final Color? background; + final EdgeInsetsGeometry? padding; + final bool scrollWhenConstrained; + + static Widget fromChewie( + BuildContext context, + ChewieStateViewConfig config, + ) { + final visual = switch (config.type) { + ChewieStateViewType.loading => LoftifyStateVisual.loading, + ChewieStateViewType.empty => LoftifyStateVisual.empty, + ChewieStateViewType.error => LoftifyStateVisual.error, + }; + final view = LoftifyStateView( + visual: visual, + title: config.text, + actionLabel: config.actionLabel ?? + (config.onAction != null ? chewieLocalizations.retry : null), + onAction: config.onAction, + icon: config.icon, + indicatorSize: config.size, + showTitle: config.showText, + forceDark: config.forceDark, + background: config.background, + scrollWhenConstrained: config.type != ChewieStateViewType.empty, + padding: config.type == ChewieStateViewType.empty + ? const EdgeInsets.symmetric(horizontal: 20, vertical: 16) + : EdgeInsets.only( + top: config.topPadding, + bottom: config.bottomPadding, + ), + ); + if (config.type != ChewieStateViewType.empty) return view; + return _LoftifyScrollableEmptyState(config: config, child: view); + } + + @override + Widget build(BuildContext context) { + final design = context.design; + final colors = design.colors; + final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations == true; + final foreground = forceDark ? Colors.white : colors.textPrimary; + final secondary = forceDark ? Colors.white70 : colors.textSecondary; + final stateColor = switch (visual) { + LoftifyStateVisual.loading => colors.accent, + LoftifyStateVisual.empty => forceDark ? Colors.white70 : colors.textMuted, + LoftifyStateVisual.error => colors.danger, + LoftifyStateVisual.success => colors.success, + LoftifyStateVisual.warning => colors.warning, + }; + final semanticsLabel = [ + if (title.isNotEmpty) title, + if (message?.isNotEmpty == true) message, + ].join(', '); + final stateContent = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: padding ?? EdgeInsets.all(design.spacing.xxl), + child: AnimatedSwitcher( + duration: reduceMotion ? Duration.zero : design.motion.state, + switchInCurve: design.motion.enterCurve, + switchOutCurve: design.motion.exitCurve, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.center, + children: [ + for (final child in previousChildren) + IgnorePointer(child: child), + if (currentChild != null) currentChild, + ], + ); + }, + child: Column( + key: ValueKey(visual), + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics(child: _buildVisual(context, stateColor)), + if (showTitle) ...[ + SizedBox(height: design.spacing.lg), + ExcludeSemantics( + child: Text( + title, + textAlign: TextAlign.center, + style: design.typography.cardTitle.copyWith( + color: foreground, + ), + ), + ), + ], + if (message?.isNotEmpty == true) ...[ + SizedBox(height: design.spacing.sm), + ExcludeSemantics( + child: Text( + message!, + textAlign: TextAlign.center, + style: design.typography.body.copyWith( + color: secondary, + ), + ), + ), + ], + if (onAction != null && actionLabel?.isNotEmpty == true) ...[ + SizedBox(height: design.spacing.xl), + LoftifyButton( + label: actionLabel!, + icon: LoftifyIcons.retry, + variant: LoftifyButtonVariant.secondary, + size: LoftifyButtonSize.compact, + onPressed: onAction, + ), + ], + ], + ), + ), + ), + ); + final stateBody = scrollWhenConstrained + ? LayoutBuilder( + builder: (context, constraints) { + if (!constraints.hasBoundedHeight || + !constraints.maxHeight.isFinite) { + return Center(child: stateContent); + } + return SingleChildScrollView( + key: const ValueKey('loftify-state-scroll-view'), + physics: const ClampingScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center(child: stateContent), + ), + ); + }, + ) + : Center(child: stateContent); + + return Semantics( + container: true, + liveRegion: visual != LoftifyStateVisual.empty, + label: semanticsLabel, + child: ColoredBox( + color: background ?? Colors.transparent, + child: stateBody, + ), + ); + } + + Widget _buildVisual(BuildContext context, Color stateColor) { + final design = context.design; + if (visual == LoftifyStateVisual.loading) { + return SizedBox.square( + dimension: indicatorSize, + child: LottieFiles.buildLoadingAnimation(indicatorSize, forceDark), + ); + } + final effectiveIcon = icon ?? + switch (visual) { + LoftifyStateVisual.empty => LoftifyIcons.empty, + LoftifyStateVisual.error => LoftifyIcons.error, + LoftifyStateVisual.success => LoftifyIcons.check, + LoftifyStateVisual.warning => LoftifyIcons.warning, + LoftifyStateVisual.loading => LoftifyIcons.info, + }; + final visualSize = + (indicatorSize + design.spacing.xxl).clamp(48.0, 64.0).toDouble(); + return Container( + width: visualSize, + height: visualSize, + decoration: BoxDecoration( + color: stateColor.withValues(alpha: forceDark ? 0.16 : 0.10), + borderRadius: BorderRadius.circular(design.radii.control), + border: Border.all( + color: stateColor.withValues(alpha: 0.24), + width: design.borders.hairline, + ), + ), + alignment: Alignment.center, + child: Icon( + effectiveIcon, + size: indicatorSize.clamp(20.0, 32.0).toDouble(), + color: stateColor, + ), + ); + } +} + +class _LoftifyScrollableEmptyState extends StatelessWidget { + const _LoftifyScrollableEmptyState({ + required this.config, + required this.child, + }); + + final ChewieStateViewConfig config; + final Widget child; + + @override + Widget build(BuildContext context) { + if (config.shrinkWrap) { + return ListView( + physics: config.physics, + shrinkWrap: true, + controller: config.scrollController, + padding: EdgeInsets.only( + top: config.topPadding, + bottom: config.bottomPadding, + ), + children: [child], + ); + } + return CustomScrollView( + physics: config.physics, + controller: config.scrollController, + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + top: config.topPadding, + bottom: config.bottomPadding, + ), + sliver: SliverFillRemaining( + hasScrollBody: false, + fillOverscroll: true, + child: child, + ), + ), + ], + ); + } +} diff --git a/lib/Widgets/Design/loftify_surfaces.dart b/lib/Widgets/Design/loftify_surfaces.dart new file mode 100644 index 00000000..c41c48da --- /dev/null +++ b/lib/Widgets/Design/loftify_surfaces.dart @@ -0,0 +1,514 @@ +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../loftify_icons.dart'; + +enum LoftifyCardVariant { flat, outlined, raised, muted } + +enum LoftifySurfaceStatus { + normal, + selected, + success, + warning, + error, + disabled +} + +/// Token-driven content surface shared by cards across product families. +/// +/// It deliberately has no fixed height. Localized copy, large text and dense +/// metadata can grow without weakening the minimum interaction target. +class LoftifyCard extends StatelessWidget { + const LoftifyCard({ + super.key, + required this.child, + this.onTap, + this.onLongPress, + this.padding = EdgeInsets.zero, + this.margin, + this.variant = LoftifyCardVariant.flat, + this.status = LoftifySurfaceStatus.normal, + this.backgroundColor, + this.radius, + this.semanticLabel, + this.clipBehavior = Clip.antiAlias, + }); + + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry? margin; + final LoftifyCardVariant variant; + final LoftifySurfaceStatus status; + final Color? backgroundColor; + final double? radius; + final String? semanticLabel; + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + final design = context.design; + final colors = design.colors; + final highContrast = MediaQuery.highContrastOf(context); + final enabled = status != LoftifySurfaceStatus.disabled; + final interactive = enabled && (onTap != null || onLongPress != null); + final stateColor = switch (status) { + LoftifySurfaceStatus.selected => colors.accentForeground, + LoftifySurfaceStatus.success => colors.success, + LoftifySurfaceStatus.warning => colors.warning, + LoftifySurfaceStatus.error => colors.danger, + LoftifySurfaceStatus.normal || LoftifySurfaceStatus.disabled => null, + }; + final baseColor = backgroundColor ?? + switch (status) { + LoftifySurfaceStatus.selected => colors.accentContainer, + LoftifySurfaceStatus.success => + colors.success.withValues(alpha: 0.08), + LoftifySurfaceStatus.warning => + colors.warning.withValues(alpha: 0.08), + LoftifySurfaceStatus.error => colors.danger.withValues(alpha: 0.08), + LoftifySurfaceStatus.normal || + LoftifySurfaceStatus.disabled => + switch (variant) { + LoftifyCardVariant.muted => colors.surfaceMuted, + LoftifyCardVariant.flat || + LoftifyCardVariant.outlined || + LoftifyCardVariant.raised => + colors.surface, + }, + }; + final showBorder = highContrast || + variant == LoftifyCardVariant.outlined || + status != LoftifySurfaceStatus.normal; + final borderColor = + stateColor ?? (highContrast ? colors.outlineStrong : colors.outline); + final effectiveRadius = radius ?? design.radii.card; + + return Semantics( + button: interactive, + enabled: enabled, + selected: status == LoftifySurfaceStatus.selected, + label: semanticLabel, + child: Opacity( + opacity: enabled + ? 1 + : highContrast + ? 0.56 + : design.icons.disabledOpacity, + child: AnimatedContainer( + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + margin: margin, + clipBehavior: clipBehavior, + decoration: BoxDecoration( + color: baseColor, + borderRadius: BorderRadius.circular(effectiveRadius), + border: showBorder + ? Border.all( + color: borderColor, + width: highContrast || stateColor != null + ? design.borders.focus + : design.borders.hairline, + ) + : null, + boxShadow: variant == LoftifyCardVariant.raised + ? design.shadows.floating + : const [], + ), + child: interactive + ? Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return colors.accent.withValues( + alpha: design.icons.pressedOpacity, + ); + } + if (states.contains(WidgetState.focused)) { + return colors.accent.withValues( + alpha: design.icons.focusOpacity, + ); + } + if (states.contains(WidgetState.hovered)) { + return colors.accent.withValues( + alpha: design.icons.hoverOpacity, + ); + } + return Colors.transparent; + }), + child: Padding(padding: padding, child: child), + ), + ) + : Padding(padding: padding, child: child), + ), + ), + ); + } +} + +enum LoftifyMenuStatus { normal, success, warning, danger } + +/// One menu row for both inline lists and bottom/desktop panels. +class LoftifyMenuItem extends StatelessWidget { + const LoftifyMenuItem({ + super.key, + required this.label, + this.description, + this.icon, + this.trailing, + this.onTap, + this.selected = false, + this.enabled = true, + this.showTrailing = true, + this.status = LoftifyMenuStatus.normal, + this.semanticLabel, + }); + + final String label; + final String? description; + final IconData? icon; + final Widget? trailing; + final VoidCallback? onTap; + final bool selected; + final bool enabled; + final bool showTrailing; + final LoftifyMenuStatus status; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + final colors = design.colors; + final highContrast = MediaQuery.highContrastOf(context); + final effectiveEnabled = enabled && onTap != null; + final statusColor = switch (status) { + LoftifyMenuStatus.success => colors.success, + LoftifyMenuStatus.warning => colors.warning, + LoftifyMenuStatus.danger => colors.danger, + LoftifyMenuStatus.normal => + selected ? colors.accentForeground : colors.textPrimary, + }; + + return Semantics( + button: true, + selected: selected, + enabled: effectiveEnabled, + label: semanticLabel, + child: Opacity( + opacity: effectiveEnabled + ? 1 + : highContrast + ? 0.56 + : design.icons.disabledOpacity, + child: AnimatedContainer( + duration: design.motion.effective(context, design.motion.state), + curve: design.motion.enterCurve, + constraints: BoxConstraints( + minHeight: design.icons.minimumTapTarget, + ), + color: selected ? colors.accentContainer : Colors.transparent, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: effectiveEnabled ? onTap : null, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return colors.accent.withValues( + alpha: design.icons.pressedOpacity, + ); + } + if (states.contains(WidgetState.focused)) { + return colors.accent.withValues( + alpha: design.icons.focusOpacity, + ); + } + if (states.contains(WidgetState.hovered)) { + return colors.accent.withValues( + alpha: design.icons.hoverOpacity, + ); + } + return Colors.transparent; + }), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xl, + vertical: design.spacing.lg, + ), + child: Row( + crossAxisAlignment: description?.isNotEmpty == true + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (icon != null) ...[ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular( + design.radii.control, + ), + ), + alignment: Alignment.center, + child: Icon( + icon, + size: design.icons.small, + color: statusColor, + ), + ), + SizedBox(width: design.spacing.lg), + ], + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: design.typography.body.copyWith( + color: status == LoftifyMenuStatus.normal && + !selected + ? colors.textPrimary + : statusColor, + ), + ), + if (description?.isNotEmpty == true) ...[ + SizedBox(height: design.spacing.xs), + Text( + description!, + style: design.typography.metadata.copyWith( + color: colors.textSecondary, + ), + ), + ], + ], + ), + ), + if (trailing != null || selected || showTrailing) ...[ + SizedBox(width: design.spacing.lg), + trailing ?? + Icon( + selected ? LoftifyIcons.check : LoftifyIcons.next, + size: design.icons.small, + color: selected + ? colors.accentForeground + : colors.textMuted, + ), + ], + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Shared bottom/desktop panel shell with one handle, header, divider and +/// responsive corner policy. +class LoftifyPanel extends StatelessWidget { + const LoftifyPanel({ + super.key, + required this.body, + this.title, + this.subtitle, + this.leading, + this.trailing, + this.footer, + this.bodyPadding, + this.footerPadding, + this.showHandle = true, + this.compactHeader = true, + this.centerTitle = true, + this.expandBody = false, + this.semanticLabel, + }); + + final Widget body; + final String? title; + final String? subtitle; + final Widget? leading; + final Widget? trailing; + final Widget? footer; + final EdgeInsetsGeometry? bodyPadding; + final EdgeInsetsGeometry? footerPadding; + final bool showHandle; + final bool compactHeader; + final bool centerTitle; + final bool expandBody; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + final width = MediaQuery.sizeOf(context).width; + final wide = + design.grid.windowClassFor(width) != LoftifyWindowClass.compact; + final borderRadius = BorderRadius.vertical( + top: Radius.circular(design.radii.panel), + bottom: wide ? Radius.circular(design.radii.panel) : Radius.zero, + ); + final bodyWidget = bodyPadding == null + ? body + : Padding(padding: bodyPadding!, child: body); + + return Semantics( + container: true, + explicitChildNodes: true, + label: semanticLabel ?? title, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + boxShadow: wide ? design.shadows.overlay : const [], + ), + child: ClipRRect( + borderRadius: borderRadius, + child: DecoratedBox( + decoration: BoxDecoration( + color: design.colors.surfaceRaised, + borderRadius: borderRadius, + border: Border.all( + color: design.colors.outline, + width: design.borders.hairline, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showHandle && !wide) _buildHandle(context), + if (title != null || + subtitle != null || + leading != null || + trailing != null) + _buildHeader(context), + if (!compactHeader && + (title != null || + subtitle != null || + leading != null || + trailing != null)) + _divider(context), + if (expandBody) Flexible(child: bodyWidget) else bodyWidget, + if (footer != null) ...[ + if (!compactHeader) _divider(context), + Padding( + padding: footerPadding ?? + EdgeInsets.symmetric( + horizontal: design.spacing.xxl, + vertical: design.spacing.xl, + ), + child: footer!, + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildHandle(BuildContext context) { + final design = context.design; + return SizedBox( + height: 24, + child: Center( + child: Container( + key: const ValueKey('loftify-panel-handle'), + width: 36, + height: 4, + decoration: BoxDecoration( + color: design.colors.outlineStrong.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(design.radii.full), + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final design = context.design; + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: compactHeader + ? 40 + : design.density.minimumHeight( + LoftifyDensityRole.contentComfortable, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xl, + vertical: compactHeader ? design.spacing.sm : design.spacing.lg, + ), + child: Row( + crossAxisAlignment: subtitle?.isNotEmpty == true + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (leading != null) ...[ + leading!, + SizedBox(width: design.spacing.lg), + ], + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: centerTitle && + compactHeader && + trailing == null && + leading == null + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + if (title != null) + Text( + title!, + textAlign: centerTitle && + compactHeader && + trailing == null && + leading == null + ? TextAlign.center + : null, + style: compactHeader + ? design.typography.sectionTitle + : design.typography.pageTitle, + ), + if (subtitle?.isNotEmpty == true) ...[ + SizedBox(height: design.spacing.xs), + Text( + subtitle!, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + ), + ], + ], + ), + ), + if (trailing != null) ...[ + SizedBox(width: design.spacing.lg), + trailing!, + ], + ], + ), + ), + ); + } + + Widget _divider(BuildContext context) { + final design = context.design; + return Divider( + height: design.borders.hairline, + thickness: design.borders.hairline, + color: design.colors.outline, + ); + } +} diff --git a/lib/Widgets/Dynamic/dynamic_collection_card_frame.dart b/lib/Widgets/Dynamic/dynamic_collection_card_frame.dart new file mode 100644 index 00000000..51e25699 --- /dev/null +++ b/lib/Widgets/Dynamic/dynamic_collection_card_frame.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../Design/loftify_surfaces.dart'; + +/// Page-level rhythm shared by subscribed and suggested collection grids. +abstract final class DynamicCollectionGridLayout { + static EdgeInsets paddingFor(double width) => EdgeInsets.symmetric( + horizontal: width < 600 ? 12 : 16, + vertical: 6, + ); + + static double spacingFor(double width) => width < 600 ? 10 : 12; +} + +/// Shared responsive frame for collection cards in the dynamic feed. +/// +/// The card deliberately has no fixed height: translated copy, large text and +/// badges may make the information column taller than the cover. The row then +/// grows with its content instead of clipping or overflowing at the bottom. +class DynamicCollectionCardFrame extends StatelessWidget { + const DynamicCollectionCardFrame({ + super.key, + required this.cover, + required this.child, + this.onTap, + this.coverSize = 100, + this.gap = 12, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + }); + + final Widget cover; + final Widget child; + final VoidCallback? onTap; + final double coverSize; + final double gap; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return LoftifyCard( + onTap: onTap, + padding: padding, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: coverSize), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox.square( + dimension: coverSize, + child: cover, + ), + SizedBox(width: gap), + Expanded(child: child), + ], + ), + ), + ); + } +} diff --git a/lib/Widgets/Favorite/favorite_folder_card.dart b/lib/Widgets/Favorite/favorite_folder_card.dart new file mode 100644 index 00000000..9d94b73d --- /dev/null +++ b/lib/Widgets/Favorite/favorite_folder_card.dart @@ -0,0 +1,186 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; + +/// Responsive folder card shared by the favorite-folder management page. +/// +/// A compact folder row; only large-text layouts move the actions below it. +class LoftifyFavoriteFolderCard extends StatelessWidget { + const LoftifyFavoriteFolderCard({ + super.key, + required this.title, + required this.folderIdLabel, + required this.postCountLabel, + required this.cover, + required this.onTap, + required this.onEdit, + this.onDelete, + this.onCopyTitle, + this.onCopyFolderId, + this.editLabel, + this.deleteLabel, + }); + + final String title; + final String folderIdLabel; + final String postCountLabel; + final Widget cover; + final VoidCallback onTap; + final VoidCallback onEdit; + final VoidCallback? onDelete; + final VoidCallback? onCopyTitle; + final VoidCallback? onCopyFolderId; + final String? editLabel; + final String? deleteLabel; + + @override + Widget build(BuildContext context) { + final design = context.design; + return LayoutBuilder( + builder: (context, constraints) { + final stackedActions = constraints.maxWidth < 310 || + MediaQuery.textScalerOf(context).scale(1) > 1.35; + final actions = _buildActions(context); + final summary = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(design.radii.control), + child: SizedBox.square( + dimension: 64, + child: cover, + ), + ), + SizedBox(width: design.spacing.lg), + Expanded(child: _buildText(context)), + if (!stackedActions) ...[ + SizedBox(width: design.spacing.xs), + actions, + ], + ], + ); + return LoftifyCard( + variant: LoftifyCardVariant.outlined, + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.md, + ), + semanticLabel: title, + onTap: onTap, + child: stackedActions + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + summary, + SizedBox(height: design.spacing.xs), + Align(alignment: Alignment.centerRight, child: actions), + ], + ) + : summary, + ); + }, + ); + } + + Widget _buildText(BuildContext context) { + final design = context.design; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: onCopyTitle, + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: design.typography.cardTitle.copyWith( + color: design.colors.textPrimary, + ), + ), + ), + SizedBox(height: design.spacing.xs), + if (MediaQuery.textScalerOf(context).scale(1) > 1.35) ...[ + GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: onCopyFolderId, + child: Text( + folderIdLabel, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.textMuted, + ), + ), + ), + Text( + postCountLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + ), + ] else + Row( + children: [ + Flexible( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: onCopyFolderId, + child: Text( + folderIdLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.textMuted, + ), + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.xs), + child: Text('·', + style: design.typography.metadata.copyWith( + color: design.colors.textMuted, + )), + ), + Text( + postCountLabel, + maxLines: 1, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + ), + ], + ), + ], + ); + } + + Widget _buildActions(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ChewieIconButton( + icon: LoftifyIcons.edit, + tooltip: editLabel, + onPressed: onEdit, + ), + if (onDelete != null) ...[ + const SizedBox(width: 4), + ChewieIconButton( + icon: LoftifyIcons.delete, + tooltip: deleteLabel, + foregroundColor: context.design.colors.danger, + onPressed: onDelete, + ), + ], + ], + ); + } +} diff --git a/lib/Widgets/Item/item_builder.dart b/lib/Widgets/Item/item_builder.dart index e98c0b79..754dc061 100644 --- a/lib/Widgets/Item/item_builder.dart +++ b/lib/Widgets/Item/item_builder.dart @@ -1,15 +1,132 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; +import 'package:loftify/Widgets/Design/loftify_media_overlays.dart'; import 'package:provider/provider.dart'; import '../../Screens/Post/tag_detail_screen.dart'; import '../../Utils/app_provider.dart'; -import '../../Utils/asset_util.dart'; import '../../Utils/enums.dart'; import '../../Utils/utils.dart'; +import '../loftify_icons.dart'; enum TailingType { none, clear, password, icon, text, widget } class ItemBuilder { + static Widget buildEntryItem({ + required BuildContext context, + double radius = 10, + bool roundTop = false, + bool roundBottom = false, + bool showLeading = false, + bool showTrailing = true, + bool isCaption = false, + Color? backgroundColor, + Color? titleColor, + Color? descriptionColor, + CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.start, + IconData leading = LoftifyIcons.home, + required String title, + String tip = "", + String description = "", + Function()? onTap, + double padding = 18, + double trailingLeftMargin = 5, + bool dividerPadding = true, + IconData trailing = LoftifyIcons.next, + }) { + return EntryItem( + context: context, + radius: radius, + roundTop: roundTop, + roundBottom: roundBottom, + showLeading: showLeading, + showTrailing: showTrailing, + backgroundColor: backgroundColor, + titleColor: titleColor, + descriptionColor: descriptionColor, + crossAxisAlignment: crossAxisAlignment, + leading: leading, + title: title, + tip: tip, + description: description, + onTap: onTap, + paddingVertical: padding, + trailingLeftMargin: trailingLeftMargin, + dividerPadding: dividerPadding, + trailing: trailing, + ); + } + + static Widget buildContainerItem({ + double radius = 10, + bool topRadius = false, + bool bottomRadius = false, + required Widget child, + required BuildContext context, + Color? backgroundColor, + Border? border, + }) { + return ContainerItem( + radius: radius, + roundTop: topRadius, + roundBottom: bottomRadius, + backgroundColor: backgroundColor, + border: border, + child: child, + ); + } + + static Widget buildFontItem({ + required CustomFont font, + required CustomFont currentFont, + required BuildContext context, + required Function(CustomFont?)? onChanged, + Function(CustomFont?)? onDelete, + bool showDelete = false, + double width = 110, + double height = 154, + }) { + return FontItem( + font: font, + currentFont: currentFont, + onChanged: onChanged, + onDelete: onDelete, + showDelete: showDelete, + width: width, + height: height, + ); + } + + static Widget buildEmptyFontItem({ + required BuildContext context, + required Function()? onTap, + double width = 110, + double height = 154, + }) { + return EmptyFontItem(onTap: onTap, width: width, height: height); + } + + static Widget buildThemeItem({ + required ChewieThemeColorData themeColorData, + required int index, + required int groupIndex, + required BuildContext context, + required Function(int?)? onChanged, + }) { + return ThemeItem( + themeColorData: themeColorData, + index: index, + groupIndex: groupIndex, + onChanged: onChanged, + ); + } + + static Widget buildEmptyThemeItem({ + required BuildContext context, + required Function()? onTap, + }) { + return EmptyThemeItem(onTap: onTap); + } + static Widget buildTextDivider({ required BuildContext context, required String text, @@ -49,42 +166,61 @@ class ItemBuilder { ); } - static Tab buildAnimatedTab(BuildContext context, { + static Tab buildAnimatedTab( + BuildContext context, { required bool selected, required String text, + TabController? controller, + int? tabIndex, bool normalUserBold = false, bool sameFontSize = false, double fontSizeDelta = 0, }) { - TextStyle normalStyle = Theme - .of(context) - .textTheme - .titleLarge! - .apply( - color: Colors.grey, - fontSizeDelta: fontSizeDelta - (sameFontSize ? 0 : 1), - fontWeightDelta: normalUserBold ? 0 : -2, - ); - TextStyle selectedStyle = Theme - .of(context) - .textTheme - .titleLarge! - .apply( - fontSizeDelta: fontSizeDelta + (sameFontSize ? 0 : 1), - ); - return Tab( - child: AnimatedDefaultTextStyle( - style: selected ? selectedStyle : normalStyle, - duration: const Duration(milliseconds: 100), - child: Container( + TextStyle normalStyle = Theme.of(context).textTheme.titleLarge!.apply( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSizeDelta: fontSizeDelta - (sameFontSize ? 0 : 1), + fontWeightDelta: normalUserBold ? 0 : -2, + ); + TextStyle selectedStyle = Theme.of(context).textTheme.titleLarge!.apply( + fontSizeDelta: fontSizeDelta + (sameFontSize ? 0 : 1), + ); + Widget buildLabel() => Container( alignment: Alignment.center, - child: Text(text), - ), - ), + child: Text( + text, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.center, + ), + ); + final animation = controller?.animation; + return Tab( + child: animation != null && tabIndex != null + ? AnimatedBuilder( + animation: animation, + builder: (context, _) { + final proximity = + (1 - (animation.value - tabIndex).abs()).clamp(0.0, 1.0); + final progress = Curves.easeOutCubic.transform(proximity); + return DefaultTextStyle( + style: TextStyle.lerp( + normalStyle, + selectedStyle, + progress, + )!, + child: buildLabel(), + ); + }, + ) + : AnimatedDefaultTextStyle( + style: selected ? selectedStyle : normalStyle, + duration: const Duration(milliseconds: 100), + child: buildLabel(), + ), ); } - static Widget buildDynamicIconButton({ required BuildContext context, required dynamic icon, @@ -113,13 +249,15 @@ class ItemBuilder { return ToolButton( context: context, iconBuilder: iconBuilder, + onPressed: onTap, padding: const EdgeInsets.all(7), ); }, ); } - static Widget buildTranslucentTag(BuildContext context, { + static Widget buildTranslucentTag( + BuildContext context, { required String text, bool isCircle = false, int? width, @@ -130,6 +268,10 @@ class ItemBuilder { double? fontSizeDelta, dynamic icon, }) { + final effectiveOpacity = opacity.clamp( + LoftifyCoverScrim.minimumBadgeOpacity, + 1.0, + ); return Container( padding: isCircle ? padding ?? const EdgeInsets.all(5) @@ -137,7 +279,7 @@ class ItemBuilder { alignment: Alignment.center, decoration: BoxDecoration( shape: isCircle ? BoxShape.circle : BoxShape.rectangle, - color: Colors.black.withOpacity(opacity), + color: Colors.black.withValues(alpha: effectiveOpacity), borderRadius: isCircle ? null : BorderRadius.all(Radius.circular(borderRadius ?? 50)), @@ -149,21 +291,18 @@ class ItemBuilder { const SizedBox(width: 3), Text( text, - style: Theme - .of(context) - .textTheme - .bodySmall - ?.apply( - color: Colors.white, - fontSizeDelta: fontSizeDelta ?? -1, - ), + style: Theme.of(context).textTheme.bodySmall?.apply( + color: Colors.white, + fontSizeDelta: fontSizeDelta ?? -1, + ), ), ], ), ); } - static Widget buildCopyable(BuildContext context, { + static Widget buildCopyable( + BuildContext context, { required Widget child, Function()? onTap, required String? text, @@ -205,119 +344,122 @@ class ItemBuilder { url: tagUrl, ); String avatarTag = - hasAvatarBox && showDetailMode == ShowDetailMode.avatarBox - ? StringUtil.getRandomString() - : heroTag; + hasAvatarBox && showDetailMode == ShowDetailMode.avatarBox + ? StringUtil.getRandomString() + : heroTag; String avatarBoxTag = - hasAvatarBox && showDetailMode == ShowDetailMode.avatarBox - ? heroTag - : StringUtil.getRandomString(); + hasAvatarBox && showDetailMode == ShowDetailMode.avatarBox + ? heroTag + : StringUtil.getRandomString(); return Container( decoration: BoxDecoration( border: showBorder && !hasAvatarBox ? Border.all( - color: Theme - .of(context) - .dividerColor, - width: 0.5, - ) + color: Theme.of(context).dividerColor, + width: 0.5, + ) : const Border.fromBorderSide(BorderSide.none), shape: BoxShape.circle, ), child: useDefaultAvatar || tagUrl.isEmpty ? ClipOval( - child: Image.asset( - "assets/avatar.png", - width: size, - height: size, - ), - ) - : ClickableGestureDetector( - clickable: clickable, - onTap: showDetailMode != ShowDetailMode.not - ? () { - RouteUtil.pushDialogRoute( - context, - showClose: false, - fullScreen: true, - useFade: true, - HeroPhotoViewScreen( - tagPrefix: tagPrefix, - tagSuffix: tagSuffix, - imageUrls: [tagUrl], - useMainColor: false, - title: title, - captions: [caption ?? ""], - ), - ); - } - : null, - child: hasAvatarBox - ? Stack( - children: [ - Positioned( - top: avatarBoxDeltaSize / 2, - left: avatarBoxDeltaSize / 2, - child: Hero( - tag: avatarTag, - child: ClipOval( - child: ChewieItemBuilder.buildCachedImage( - context: context, - imageUrl: imageUrl, - width: size, - showLoading: showLoading, - height: size, - simpleError: true, - ), - ), - ), - ), - Hero( - tag: avatarBoxTag, - child: ChewieItemBuilder.buildCachedImage( - context: context, - imageUrl: avatarBoxImageUrl!, - width: size + avatarBoxDeltaSize, - showLoading: false, - placeholderBackground: Colors.transparent, - topPadding: 0, - bottomPadding: 0, - height: size + avatarBoxDeltaSize, - simpleError: true, + child: Image.asset( + "assets/avatar.png", + width: size, + height: size, ), + ) + : ClickableGestureDetector( + clickable: clickable, + onTap: showDetailMode != ShowDetailMode.not + ? () { + RouteUtil.pushDialogRoute( + context, + showClose: false, + fullScreen: true, + useFade: true, + opaque: false, + HeroPhotoViewScreen( + tagPrefix: tagPrefix, + tagSuffix: tagSuffix, + imageUrls: [tagUrl], + useMainColor: false, + title: title, + captions: [caption ?? ""], + ), + ); + } + : null, + child: hasAvatarBox + ? Stack( + children: [ + Positioned( + top: avatarBoxDeltaSize / 2, + left: avatarBoxDeltaSize / 2, + child: Hero( + tag: avatarTag, + child: ClipOval( + child: ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: imageUrl, + width: size, + showLoading: showLoading, + height: size, + fit: BoxFit.cover, + simpleError: true, + ), + ), + ), + ), + Hero( + tag: avatarBoxTag, + child: ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: avatarBoxImageUrl!, + width: size + avatarBoxDeltaSize, + showLoading: false, + placeholderBackground: Colors.transparent, + topPadding: 0, + bottomPadding: 0, + height: size + avatarBoxDeltaSize, + fit: BoxFit.contain, + simpleError: true, + ), + ), + ], + ) + : ClipOval( + child: ChewieItemBuilder.buildCachedImage( + context: context, + imageUrl: tagUrl, + width: size, + showLoading: showLoading, + height: size, + fit: BoxFit.cover, + simpleError: true, + ), + ), ), - ], - ) - : ClipOval( - child: ChewieItemBuilder.buildCachedImage( - context: context, - imageUrl: tagUrl, - width: size, - showLoading: showLoading, - height: size, - simpleError: true, - ), - ), - ), ); } - static Widget buildTagItem(BuildContext context, - String tag, - TagType tagType, { - String? shownTag, - Function()? onTap, - Color? backgroundColor, - Color? color, - bool showIcon = true, - bool showRightIcon = false, - bool showTagLabel = true, - bool jumpToTag = true, - EdgeInsets padding = + static Widget buildTagItem( + BuildContext context, + String tag, + TagType tagType, { + String? shownTag, + Function()? onTap, + Color? backgroundColor, + Color? color, + bool showIcon = true, + bool showRightIcon = false, + bool showTagLabel = true, + bool jumpToTag = true, + EdgeInsets padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - double fontSizeDelta = 0, - int fontWeightDelta = 0, - }) { + double fontSizeDelta = 0, + int fontWeightDelta = 0, + }) { String str = StringUtil.isNotEmpty(shownTag) ? shownTag! : tag; return GestureDetector( onTap: () { @@ -333,61 +475,53 @@ class ItemBuilder { decoration: BoxDecoration( color: tagType != TagType.normal ? ChewieColors.getHotTagBackground(context) - : backgroundColor ?? Theme - .of(context) - .cardColor, + : backgroundColor ?? Theme.of(context).cardColor, borderRadius: BorderRadius.circular(50), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - // if (tagType == TagType.normal && showIcon) - // AssetUtil.load( - // AssetUtil.tagDarkIcon, - // size: 15, - // ), if (tagType == TagType.hot && showIcon) - AssetUtil.load(AssetUtil.hotIcon, size: 12), + ChewieIcon( + LoftifyIcons.hot, + size: 14, + color: ChewieColors.getHotTagTextColor(context), + ), if (tagType == TagType.hot && showIcon) const SizedBox(width: 2), - // Icon(Icons.local_fire_department_rounded, - // size: 15, color: ChewieColors.getHotTagTextColor(context)), if (tagType == TagType.egg && showIcon) - Icon(Icons.egg_rounded, - size: 15, color: ChewieColors.getHotTagTextColor(context)), + ChewieIcon( + LoftifyIcons.egg, + size: 15, + color: ChewieColors.getHotTagTextColor(context), + ), if (tagType == TagType.catutu && showIcon) Container( margin: const EdgeInsets.only(right: 2), - child: Icon(Icons.auto_fix_high_outlined, - size: 15, - color: ChewieColors.getHotTagTextColor(context)), + child: ChewieIcon( + LoftifyIcons.magic, + size: 15, + color: ChewieColors.getHotTagTextColor(context), + ), ), Text( ((tagType == TagType.normal || !showIcon) && showTagLabel) ? "#$str" : str, style: tagType != TagType.normal - ? Theme - .of(context) - .textTheme - .labelMedium - ?.apply( - color: color ?? ChewieColors.hotTagTextColor, - fontSizeDelta: fontSizeDelta, - fontWeightDelta: fontWeightDelta, - ) - : Theme - .of(context) - .textTheme - .labelMedium - ?.apply( - color: color, - fontSizeDelta: fontSizeDelta, - fontWeightDelta: fontWeightDelta, - ), + ? Theme.of(context).textTheme.labelMedium?.apply( + color: color ?? ChewieColors.hotTagTextColor, + fontSizeDelta: fontSizeDelta, + fontWeightDelta: fontWeightDelta, + ) + : Theme.of(context).textTheme.labelMedium?.apply( + color: color, + fontSizeDelta: fontSizeDelta, + fontWeightDelta: fontWeightDelta, + ), ), if (showRightIcon) - Icon( - Icons.keyboard_arrow_right_rounded, + ChewieIcon( + LoftifyIcons.next, size: 16, color: color, ), @@ -398,12 +532,13 @@ class ItemBuilder { ); } - static Widget buildSmallTagItem(BuildContext context, - String tag, { - Function()? onTap, - Color? backgroundColor, - bool showIcon = true, - }) { + static Widget buildSmallTagItem( + BuildContext context, + String tag, { + Function()? onTap, + Color? backgroundColor, + bool showIcon = true, + }) { return GestureDetector( onTap: () { panelScreenState?.pushPage(TagDetailScreen(tag: tag)); @@ -412,17 +547,12 @@ class ItemBuilder { child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: Theme - .of(context) - .cardColor, + color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(50), ), child: Text( "#$tag", - style: Theme - .of(context) - .textTheme - .labelSmall, + style: Theme.of(context).textTheme.labelSmall, ), ), ); @@ -442,9 +572,7 @@ class ItemBuilder { return Container( padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( - color: background ?? Theme - .of(context) - .cardColor, + color: background ?? Theme.of(context).cardColor, borderRadius: BorderRadius.circular(borderRadius), ), child: Row( @@ -455,6 +583,7 @@ class ItemBuilder { color: Colors.transparent, child: TextField( focusNode: focusNode, + textAlignVertical: TextAlignVertical.center, contextMenuBuilder: (contextMenuContext, details) => ChewieItemBuilder.editTextContextMenuBuilder( contextMenuContext, details, @@ -462,55 +591,41 @@ class ItemBuilder { controller: controller, textInputAction: TextInputAction.search, onSubmitted: onSubmitted, - style: Theme - .of(context) - .textTheme - .titleSmall - ?.apply( - fontSizeDelta: hintFontSizeDelta, - ), + style: Theme.of(context).textTheme.titleSmall?.apply( + fontSizeDelta: hintFontSizeDelta, + ), decoration: InputDecoration( - contentPadding: const EdgeInsets.only(left: 8), - border: - const OutlineInputBorder(borderSide: BorderSide.none), + isDense: true, + filled: false, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, hintText: hintText, - hintStyle: Theme - .of(context) - .textTheme - .titleSmall - ?.apply( - color: Theme - .of(context) - .textTheme - .labelSmall - ?.color, - fontSizeDelta: hintFontSizeDelta, - ), + hintStyle: Theme.of(context).textTheme.titleSmall?.apply( + color: Theme.of(context).textTheme.labelSmall?.color, + fontSizeDelta: hintFontSizeDelta, + ), ), ), ), ), ), - GestureDetector( - onTap: () { - onSubmitted(controller?.text); - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: AssetUtil.loadDouble( - context, - AssetUtil.searchLightIcon, - AssetUtil.searchDarkIcon, - size: 20, - ), - ), + ChewieIconButton( + icon: LoftifyIcons.search, + tooltip: hintText.toString(), + onPressed: () => onSubmitted(controller?.text), ), ], ), ); } - static Widget buildTitle(BuildContext context, { + static Widget buildTitle( + BuildContext context, { String? title, IconData? icon, String? suffixText, @@ -535,25 +650,18 @@ class ItemBuilder { child: Text( title ?? "", style: textStyle ?? - Theme - .of(context) + Theme.of(context) .textTheme .titleSmall ?.apply(fontWeightDelta: 2, fontSizeDelta: 1), ), ), if (icon != null) - CircleIconButton( - icon: Icon( - icon, - size: 18, - color: Theme - .of(context) - .textTheme - .labelSmall - ?.color, - ), - onTap: onTap, + ChewieIconButton( + icon: icon, + iconSize: 18, + foregroundColor: Theme.of(context).textTheme.labelSmall?.color, + onPressed: onTap, ), if (StringUtil.isNotEmpty(suffixText)) GestureDetector( @@ -562,19 +670,12 @@ class ItemBuilder { children: [ Text( suffixText!, - style: Theme - .of(context) - .textTheme - .labelMedium, + style: Theme.of(context).textTheme.labelMedium, ), - Icon( - Icons.keyboard_arrow_right_rounded, + ChewieIcon( + LoftifyIcons.next, size: 18, - color: Theme - .of(context) - .textTheme - .labelSmall - ?.color, + color: Theme.of(context).textTheme.labelSmall?.color, ), ], ), @@ -584,7 +685,8 @@ class ItemBuilder { ); } - static buildStatisticItem(BuildContext context, { + static buildStatisticItem( + BuildContext context, { Color? labelColor, Color? countColor, int labelFontWeightDelta = 0, @@ -596,7 +698,7 @@ class ItemBuilder { Map countWithScale = NumberUtil.formatCountToMap(count ?? 0); return MouseRegion( cursor: - onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic, + onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic, child: GestureDetector( onTap: onTap, child: Container( @@ -604,56 +706,43 @@ class ItemBuilder { child: Column( children: [ count != null - ? Row( - children: [ - Text( - countWithScale['count'], - style: Theme - .of(context) - .textTheme - .titleLarge - ?.apply( - color: countColor, - fontWeightDelta: countFontWeightDelta), - ), - if (countWithScale.containsKey("scale")) - const SizedBox(width: 2), - if (countWithScale.containsKey("scale")) - Text( - countWithScale['scale'], - style: Theme - .of(context) - .textTheme - .titleSmall - ?.apply( - fontSizeDelta: -2, + ? Text.rich( + TextSpan( + text: countWithScale['count'], + children: [ + if (countWithScale.containsKey('scale')) + TextSpan( + text: '\u2009${countWithScale['scale']}', + style: Theme.of(context) + .textTheme + .titleSmall + ?.apply( + fontSizeDelta: -2, + color: countColor, + fontWeightDelta: countFontWeightDelta), + ), + ], + ), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.apply( color: countColor, fontWeightDelta: countFontWeightDelta), - ), - ], - ) + ) : Text( - "-", - style: Theme - .of(context) - .textTheme - .titleLarge - ?.apply( - color: countColor, - fontWeightDelta: countFontWeightDelta), - ), + "-", + style: Theme.of(context).textTheme.titleLarge?.apply( + color: countColor, + fontWeightDelta: countFontWeightDelta), + ), const SizedBox(height: 4), Text( title, - style: Theme - .of(context) - .textTheme - .labelMedium - ?.apply( - fontSizeDelta: -1, - color: labelColor, - fontWeightDelta: labelFontWeightDelta, - ), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium?.apply( + fontSizeDelta: -1, + color: labelColor, + fontWeightDelta: labelFontWeightDelta, + ), ), ], ), @@ -662,7 +751,8 @@ class ItemBuilder { ); } - static buildIconTextButton(BuildContext context, { + static buildIconTextButton( + BuildContext context, { Axis direction = Axis.horizontal, double spacing = 2, Widget? icon, @@ -682,61 +772,54 @@ class ItemBuilder { onTap: onTap, child: direction == Axis.horizontal ? Row( - mainAxisAlignment: - start ? MainAxisAlignment.start : MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (icon != null && showIcon) - RotatedBox(quarterTurns: quarterTurns, child: icon), - if (icon != null && showIcon) SizedBox(width: spacing), - if (text.isNotEmpty) - Text( - text, - style: style ?? - Theme - .of(context) - .textTheme - .titleSmall - ?.apply( - fontSizeDelta: fontSizeDelta, - color: color, - fontWeightDelta: fontWeightDelta, + mainAxisAlignment: + start ? MainAxisAlignment.start : MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (icon != null && showIcon) + RotatedBox(quarterTurns: quarterTurns, child: icon), + if (icon != null && showIcon) SizedBox(width: spacing), + if (text.isNotEmpty) + Text( + text, + style: style ?? + Theme.of(context).textTheme.titleSmall?.apply( + fontSizeDelta: fontSizeDelta, + color: color, + fontWeightDelta: fontWeightDelta, + ), ), - ), - ], - ) + ], + ) : Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (icon != null && showIcon) - RotatedBox(quarterTurns: quarterTurns, child: icon), - if (icon != null && showIcon) SizedBox(height: spacing), - if (text.isNotEmpty) - Text( - text, - style: style ?? - Theme - .of(context) - .textTheme - .titleSmall - ?.apply( - fontSizeDelta: fontSizeDelta, - color: color, - fontWeightDelta: fontWeightDelta, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (icon != null && showIcon) + RotatedBox(quarterTurns: quarterTurns, child: icon), + if (icon != null && showIcon) SizedBox(height: spacing), + if (text.isNotEmpty) + Text( + text, + style: style ?? + Theme.of(context).textTheme.titleSmall?.apply( + fontSizeDelta: fontSizeDelta, + color: color, + fontWeightDelta: fontWeightDelta, + ), ), + ], ), - ], - ), ), ); } - static Widget buildWrapTagList(BuildContext context, - List list, { - Function(String)? onTap, - EdgeInsets? margin, - }) { + static Widget buildWrapTagList( + BuildContext context, + List list, { + Function(String)? onTap, + EdgeInsets? margin, + }) { return Container( margin: margin ?? const EdgeInsets.only(left: 16, right: 16, bottom: 4), child: Wrap( @@ -750,10 +833,11 @@ class ItemBuilder { ); } - static Widget buildWrapTagItem(BuildContext context, - String str, { - Function(String)? onTap, - }) { + static Widget buildWrapTagItem( + BuildContext context, + String str, { + Function(String)? onTap, + }) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( @@ -766,18 +850,13 @@ class ItemBuilder { color: Colors.transparent, borderRadius: BorderRadius.circular(50), border: Border.all( - color: Theme - .of(context) - .dividerColor, + color: Theme.of(context).dividerColor, width: 0.5, ), ), child: Text( str, - style: Theme - .of(context) - .textTheme - .titleSmall, + style: Theme.of(context).textTheme.titleSmall, maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/Widgets/Item/loftify_item_builder.dart b/lib/Widgets/Item/loftify_item_builder.dart index d661ea37..0fa70e11 100644 --- a/lib/Widgets/Item/loftify_item_builder.dart +++ b/lib/Widgets/Item/loftify_item_builder.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -13,11 +15,15 @@ import '../../Models/search_response.dart'; import '../../Models/user_response.dart'; import '../../Screens/Info/user_detail_screen.dart'; import '../../Screens/Login/login_by_captcha_screen.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../Utils/app_provider.dart'; import '../../Utils/asset_util.dart'; import '../../Utils/enums.dart'; -import '../../Utils/utils.dart'; +import '../PostDetail/comment_item.dart'; +import '../Design/loftify_controls.dart'; import '../../l10n/l10n.dart'; +import '../loftify_icons.dart'; +import '../loftify_reaction_icon.dart'; import 'item_builder.dart'; const CircleColor shareButtonCircleColor = @@ -37,234 +43,77 @@ class LoftifyItemBuilder { EdgeInsets? l2Padding, required int writerId, }) { - String richContent = comment.content; - for (var e in comment.emotes) { - String img = - ''; - richContent = richContent.replaceAll(e.name, img); - } - return GestureDetector( + final remainingReplies = + max(0, comment.l2Count - comment.l2Comments.length); + return CommentItem( + margin: padding, onTap: onTap, - child: Container( - padding: - padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClickableGestureDetector( - onTap: () { - panelScreenState?.pushPage( - UserDetailScreen( - blogId: comment.publisherBlogInfo.blogId, - blogName: comment.publisherBlogInfo.blogName), - ); - }, - child: ItemBuilder.buildAvatar( - context: context, - imageUrl: comment.publisherBlogInfo.bigAvaImg, - showBorder: true, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClickableGestureDetector( - onTap: () { - panelScreenState?.pushPage( - UserDetailScreen( - blogId: comment.publisherBlogInfo.blogId, - blogName: - comment.publisherBlogInfo.blogName), - ); - }, - child: Row( - children: [ - Flexible( - child: Text( - comment.publisherBlogInfo.blogNickName, - style: - Theme.of(context).textTheme.bodySmall, - ), - ), - if (writerId == - comment.publisherBlogInfo.blogId) - const SizedBox(width: 3), - if (writerId == - comment.publisherBlogInfo.blogId) - RoundIconTextButton( - text: appLocalizations.author, - padding: const EdgeInsets.symmetric( - horizontal: 3, vertical: 2), - radius: 3, - color: Theme.of(context).primaryColor, - fontSizeDelta: -2, - ), - if (comment.top == 1) - const SizedBox(width: 3), - if (comment.top == 1) - RoundIconTextButton( - text: appLocalizations.pin, - padding: const EdgeInsets.symmetric( - horizontal: 3, vertical: 2), - radius: 3, - color: ChewieColors.likeButtonColor, - fontSizeDelta: -2, - ), - ], - ), - ), - const SizedBox(height: 5), - ItemBuilder.buildCopyable( - context, - text: comment.content, - toastText: appLocalizations.haveCopiedComment( - comment.publisherBlogInfo.blogNickName), - child: CustomHtmlWidget( - content: richContent, - parseImage: false, - showLoading: false, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - const SizedBox(height: 5), - Row( - children: [ - Text( - TimeUtil.formatTimestamp(comment.publishTime), - style: - Theme.of(context).textTheme.labelMedium, - ), - if (StringUtil.isNotEmpty(comment.ipLocation)) - LoftifyItemBuilder.buildDot( - context, - style: - Theme.of(context).textTheme.labelMedium, - ), - if (StringUtil.isNotEmpty(comment.ipLocation)) - Text( - comment.ipLocation, - style: - Theme.of(context).textTheme.labelMedium, - ), - ], - ), - ], - ), - ), - const SizedBox(width: 16), - LoftifyItemBuilder.buildLikedButton( - context, - isLiked: comment.liked, - size: 20, - iconSize: 16, - defaultColor: - Theme.of(context).textTheme.labelMedium?.color, - countStyle: Theme.of(context).textTheme.labelSmall, - position: CountPostion.bottom, - showCount: true, - likeCount: comment.likeCount, - zeroPlaceHolder: "", - onTap: (_) async { - HapticFeedback.mediumImpact(); - await PostApi.likeOrUnlikeComment( - isLike: !comment.liked, - postId: comment.postId, - blogId: comment.blogId, - commentId: comment.id, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop(value['meta']['desc'] ?? - value['meta']['msg']); - } else { - comment.liked = !comment.liked; - comment.likeCount += comment.liked ? 1 : -1; - } - }); - return Future.sync(() => comment.liked); - }, - ), - ], - ), - if (comment.l2Comments.isNotEmpty) - ...List.generate( - comment.l2Comments.length, - (l2Index) => buildL2CommentRow( - context, - padding: l2Padding, - comment.l2Comments[l2Index], - writerId: writerId, - ), + avatar: _buildCommentAvatar(context, comment, size: 38), + header: _buildCommentHeader(context, comment, writerId: writerId), + content: _buildCommentContent(context, comment), + metadata: _buildCommentMetadata(context, comment), + trailing: _buildCommentLikeButton(context, comment), + replies: [ + for (final reply in comment.l2Comments) + buildL2CommentRow( + context, + reply, + padding: l2Padding, + writerId: writerId, + ), + ], + footer: remainingReplies == 0 + ? null + : comment.l2CommentLoading + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: chewieProvider.loadingWidgetBuilder(16, false), ), - if (comment.l2Count - comment.l2Comments.length > 0) - const SizedBox(height: 5), - if (comment.l2Count - comment.l2Comments.length > 0 && - comment.l2CommentLoading) - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Container( - alignment: Alignment.center, - width: 12, - height: 12, - child: CircularProgressIndicator( + const SizedBox(width: 6), + Text( + appLocalizations.loading, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ) + : Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => onL2CommentTap?.call(comment), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 7, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + appLocalizations.moreComments(remainingReplies), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium, + ), + ), + const SizedBox(width: 2), + ChewieIcon( + LoftifyIcons.expand, + size: 17, color: Theme.of(context).textTheme.labelMedium?.color, - strokeWidth: 1.2, - ), - ), - const SizedBox(width: 5), - Text( - appLocalizations.loading, - style: Theme.of(context).textTheme.labelMedium, - ), - ], - ), - if (comment.l2Count - comment.l2Comments.length > 0 && - !comment.l2CommentLoading) - GestureDetector( - onTap: () => onL2CommentTap?.call(comment), - child: ClickableWrapper( - child: Text.rich( - style: Theme.of(context).textTheme.labelMedium, - TextSpan( - style: Theme.of(context).textTheme.labelMedium, - children: [ - TextSpan( - text: appLocalizations.moreComments(comment.l2Count - - comment.l2Comments.length), - style: Theme.of(context).textTheme.labelMedium, - ), - WidgetSpan( - child: Icon( - Icons.keyboard_arrow_down_rounded, - size: 16, - color: Theme.of(context) - .textTheme - .labelMedium - ?.color, - ), - ), - ], ), - ), + ], ), ), - ], - ), - ), - ], - ), - ), + ), + ), ); } @@ -275,136 +124,202 @@ class LoftifyItemBuilder { EdgeInsets? padding, required int writerId, }) { - String richContent = comment.content; - for (var e in comment.emotes) { - String img = - ''; - richContent = richContent.replaceAll(e.name, img); - } - return GestureDetector( + return CommentItem( + nested: true, + margin: padding, onTap: onTap, - child: Container( - padding: padding ?? const EdgeInsets.only(top: 12, right: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClickableGestureDetector( - onTap: () { - panelScreenState?.pushPage( - UserDetailScreen( - blogId: comment.publisherBlogInfo.blogId, - blogName: comment.publisherBlogInfo.blogName), - ); - }, - child: Row( - children: [ - ItemBuilder.buildAvatar( - context: context, - imageUrl: comment.publisherBlogInfo.bigAvaImg, - showBorder: true, - size: 24, - ), - const SizedBox(width: 8), - Text( - comment.publisherBlogInfo.blogNickName, - style: Theme.of(context).textTheme.bodySmall, - ), - if (writerId == comment.publisherBlogInfo.blogId) - const SizedBox(width: 3), - if (writerId == comment.publisherBlogInfo.blogId) - RoundIconTextButton( - text: appLocalizations.author, - padding: const EdgeInsets.symmetric( - horizontal: 3, vertical: 2), - radius: 3, - color: Theme.of(context).primaryColor, - fontSizeDelta: -2, - ), - if (comment.top == 1) const SizedBox(width: 3), - if (comment.top == 1) - RoundIconTextButton( - text: appLocalizations.pin, - padding: const EdgeInsets.symmetric( - horizontal: 3, vertical: 2), - radius: 3, - color: ChewieColors.likeButtonColor, - fontSizeDelta: -2, - ), - ], - ), - ), - const SizedBox(height: 5), - ItemBuilder.buildCopyable( - context, - text: comment.content, - toastText: appLocalizations.haveCopiedComment( - comment.publisherBlogInfo.blogNickName), - child: CustomHtmlWidget( - content: richContent, - showLoading: false, - parseImage: false, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - const SizedBox(height: 5), - Row( - children: [ - Text( - TimeUtil.formatTimestamp(comment.publishTime), - style: Theme.of(context).textTheme.labelMedium, + avatar: _buildCommentAvatar(context, comment, size: 28), + header: _buildCommentHeader(context, comment, writerId: writerId), + content: _buildCommentContent(context, comment), + metadata: _buildCommentMetadata(context, comment), + trailing: _buildCommentLikeButton(context, comment), + ); + } + + static Widget _buildCommentAvatar( + BuildContext context, + Comment comment, { + required double size, + }) { + return ClickableGestureDetector( + onTap: () => _openCommentAuthor(comment), + child: ItemBuilder.buildAvatar( + context: context, + imageUrl: comment.publisherBlogInfo.bigAvaImg, + showBorder: true, + size: size, + ), + ); + } + + static Widget _buildCommentHeader( + BuildContext context, + Comment comment, { + required int writerId, + }) { + final replyName = comment.replyBlogInfo?.blogNickName; + return ClickableGestureDetector( + onTap: () => _openCommentAuthor(comment), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + comment.publisherBlogInfo.blogNickName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w700, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.78), ), - if (StringUtil.isNotEmpty(comment.ipLocation)) - LoftifyItemBuilder.buildDot( - context, - style: Theme.of(context).textTheme.labelMedium, - ), - if (StringUtil.isNotEmpty(comment.ipLocation)) - Text( - comment.ipLocation, - style: Theme.of(context).textTheme.labelMedium, - ), - ], - ), - ], + ), ), - ), - const SizedBox(width: 16), - LoftifyItemBuilder.buildLikedButton( - context, - isLiked: comment.liked, - size: 20, - iconSize: 16, - defaultColor: Theme.of(context).textTheme.labelMedium?.color, - countStyle: Theme.of(context).textTheme.labelSmall, - position: CountPostion.bottom, - showCount: true, - likeCount: comment.likeCount, - zeroPlaceHolder: "", - onTap: (_) async { - HapticFeedback.mediumImpact(); - await PostApi.likeOrUnlikeComment( - isLike: !comment.liked, - postId: comment.postId, - blogId: comment.blogId, - commentId: comment.id, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop( - value['meta']['desc'] ?? value['meta']['msg']); - } else { - comment.liked = !comment.liked; - comment.likeCount += comment.liked ? 1 : -1; - } - }); - return Future.sync(() => comment.liked); - }, + if (writerId == comment.publisherBlogInfo.blogId) ...[ + const SizedBox(width: 4), + _buildCommentBadge( + context, + appLocalizations.author, + Theme.of(context).primaryColor, + ), + ], + if (comment.top == 1) ...[ + const SizedBox(width: 4), + _buildCommentBadge( + context, + appLocalizations.pin, + ChewieColors.likeButtonColor, + ), + ], + ], + ), + if (StringUtil.isNotEmpty(replyName)) ...[ + const SizedBox(height: 2), + Text( + appLocalizations.replyTo(replyName!), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.52), + ), ), ], - ), + ], + ), + ); + } + + static Widget _buildCommentBadge( + BuildContext context, + String text, + Color color, + ) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + text, + maxLines: 1, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + fontSize: 10, + ), + ), + ); + } + + static Widget _buildCommentContent(BuildContext context, Comment comment) { + var richContent = comment.content; + for (final emote in comment.emotes) { + final image = + ''; + richContent = richContent.replaceAll(emote.name, image); + } + return ItemBuilder.buildCopyable( + context, + text: comment.content, + toastText: appLocalizations.haveCopiedComment( + comment.publisherBlogInfo.blogNickName, + ), + child: CustomHtmlWidget( + content: richContent, + parseImage: false, + showLoading: false, + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + static Widget _buildCommentMetadata(BuildContext context, Comment comment) { + final style = Theme.of(context).textTheme.labelSmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.56), + ); + return Wrap( + spacing: 5, + runSpacing: 3, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(TimeUtil.formatTimestamp(comment.publishTime), style: style), + if (StringUtil.isNotEmpty(comment.ipLocation)) Text('·', style: style), + if (StringUtil.isNotEmpty(comment.ipLocation)) + Text(comment.ipLocation, style: style), + ], + ); + } + + static Widget _buildCommentLikeButton( + BuildContext context, + Comment comment, + ) { + return LoftifyItemBuilder.buildLikedButton( + context, + isLiked: comment.liked, + size: 22, + iconSize: 17, + defaultColor: Theme.of(context).textTheme.labelMedium?.color, + countStyle: Theme.of(context).textTheme.labelSmall, + position: CountPostion.bottom, + showCount: true, + likeCount: comment.likeCount, + zeroPlaceHolder: '', + onTap: (_) async { + HapticFeedback.mediumImpact(); + final value = await PostApi.likeOrUnlikeComment( + isLike: !comment.liked, + postId: comment.postId, + blogId: comment.blogId, + commentId: comment.id, + ); + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } else { + comment.liked = !comment.liked; + comment.likeCount += comment.liked ? 1 : -1; + } + return comment.liked; + }, + ); + } + + static void _openCommentAuthor(Comment comment) { + panelScreenState?.pushPage( + UserDetailScreen( + blogId: comment.publisherBlogInfo.blogId, + blogName: comment.publisherBlogInfo.blogName, ), ); } @@ -415,84 +330,142 @@ class LoftifyItemBuilder { FollowingUserItem item, { Function()? onFollowOrUnFollow, }) { - return ClickableGestureDetector( + final design = context.design; + final relationButton = LoftifyItemBuilder.buildFramedDoubleButton( + context: context, + isFollowed: item.following, + positiveText: item.follower + ? appLocalizations.followEach + : appLocalizations.followed, onTap: () { - panelScreenState?.pushPage( - UserDetailScreen( - blogId: item.blogInfo.blogId, - blogName: item.blogInfo.blogName, - ), - ); + UserApi.followOrUnfollow( + isFollow: !item.following, + blogId: item.blogInfo.blogId, + blogName: item.blogInfo.blogName, + ).then((value) { + if (value['meta']['status'] != 200) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } else { + item.following = !item.following; + IToast.showTop(item.following + ? appLocalizations.followed + : appLocalizations.followEach); + onFollowOrUnFollow?.call(); + } + }); }, - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - child: Row( - children: [ - ItemBuilder.buildAvatar( - context: context, - size: 40, - imageUrl: item.blogInfo.bigAvaImg, - tagPrefix: "$index", - showDetailMode: ShowDetailMode.not, - ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.blogInfo.blogNickName, - style: Theme.of(context).textTheme.titleMedium, + ); + + Widget buildSummary({Widget? trailing}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ItemBuilder.buildAvatar( + context: context, + size: 48, + imageUrl: item.blogInfo.bigAvaImg, + tagPrefix: "relation-${item.blogInfo.blogId}", + showDetailMode: ShowDetailMode.not, + ), + SizedBox(width: design.spacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.blogInfo.blogNickName, + style: design.typography.cardTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: design.spacing.xs), + Text( + 'ID: ${item.blogInfo.blogName}', + style: design.typography.metadata.copyWith( + color: design.colors.textMuted, ), - if (item.blogInfo.selfIntro.isNotEmpty) - const SizedBox(height: 5), - if (item.blogInfo.selfIntro.isNotEmpty) - Text( - item.blogInfo.selfIntro, - style: Theme.of(context).textTheme.labelMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.blogInfo.selfIntro.isNotEmpty) ...[ + SizedBox(height: design.spacing.xs), + Text( + item.blogInfo.selfIntro, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ], - ), + ], ), - if (item.follower) - Container( - margin: const EdgeInsets.only(right: 10), - child: Icon( - Icons.star_rate_rounded, - size: 22, - color: ChewieColors.getHotTagTextColor(context), - ), - ), - LoftifyItemBuilder.buildFramedDoubleButton( - context: context, - isFollowed: item.following, - positiveText: - item.follower ? appLocalizations.followEach : appLocalizations.followed, + ), + if (trailing != null) ...[ + SizedBox(width: design.spacing.md), + trailing, + ], + ], + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + final stacked = constraints.maxWidth < 280 || + (constraints.maxWidth < 520 && textScale > 1.4); + return Semantics( + key: ValueKey('loftify-relation-row-${item.blogInfo.blogId}'), + button: true, + label: item.blogInfo.blogNickName, + explicitChildNodes: true, + child: Material( + color: design.colors.page, + child: InkWell( + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return design.colors.textPrimary.withValues(alpha: 0.045); + } + if (states.contains(WidgetState.focused)) { + return design.colors.accent.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.hovered)) { + return design.colors.textPrimary.withValues(alpha: 0.025); + } + return Colors.transparent; + }), onTap: () { - UserApi.followOrUnfollow( - isFollow: !item.following, - blogId: item.blogInfo.blogId, - blogName: item.blogInfo.blogName, - ).then((value) { - if (value['meta']['status'] != 200) { - IToast.showTop( - value['meta']['desc'] ?? value['meta']['msg']); - } else { - item.following = !item.following; - IToast.showTop(item.following - ? appLocalizations.followed - : appLocalizations.followEach); - onFollowOrUnFollow?.call(); - } - }); + panelScreenState?.pushPage( + UserDetailScreen( + blogId: item.blogInfo.blogId, + blogName: item.blogInfo.blogName, + ), + ); }, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.md, + ), + child: stacked + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildSummary(), + SizedBox(height: design.spacing.md), + Align( + alignment: Alignment.centerRight, + child: relationButton, + ), + ], + ) + : buildSummary(trailing: relationButton), + ), ), - ], - ), - ), + ), + ); + }, ); } @@ -519,12 +492,11 @@ class LoftifyItemBuilder { size: size, isLiked: isLiked, likeBuilder: (bool isLiked) { - return Icon( - isLiked || filled - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, + return LoftifyReactionIcon( + kind: LoftifyReactionKind.like, + selected: isLiked, color: isLiked - ? ChewieColors.likeButtonColor + ? LoftifyReactionColors.like : defaultColor ?? Theme.of(context).iconTheme.color, size: iconSize, ); @@ -535,16 +507,6 @@ class LoftifyItemBuilder { // size: iconSize, // controller: animationController, // ); - // return AssetUtil.loadDouble( - // context, - // isLiked || filled - // ? AssetUtil.likeFilledIcon - // : AssetUtil.likeLightIcon, - // isLiked || filled - // ? AssetUtil.likeFilledIcon - // : AssetUtil.likeLightIcon, - // size: iconSize, - // ); }, likeCount: likeCount, countPostion: position, @@ -583,12 +545,11 @@ class LoftifyItemBuilder { child: Stack( clipBehavior: Clip.none, children: [ - LottieUtil.load( + LottieFiles.buildAnimation( ColorUtil.isDark(context) ? LottieFiles.likeMediumDark : LottieFiles.likeMediumLight, size: iconSize, - fit: BoxFit.cover, controller: animationController, onLoaded: () { animationController?.value = isLiked! ? 1 : 0; @@ -631,12 +592,11 @@ class LoftifyItemBuilder { child: Stack( clipBehavior: Clip.none, children: [ - LottieUtil.load( + LottieFiles.buildAnimation( ColorUtil.isDark(context) ? LottieFiles.recommendMediumFocusDark : LottieFiles.recommendMediumFocusLight, size: iconSize, - fit: BoxFit.fill, controller: animationController, ), if (showCount) @@ -679,12 +639,11 @@ class LoftifyItemBuilder { circleColor: shareButtonCircleColor, bubblesColor: shareButtonBubblesColor, likeBuilder: (bool isShared) { - return Icon( - isShared || filled - ? Icons.thumb_up_rounded - : Icons.thumb_up_outlined, + return LoftifyReactionIcon( + kind: LoftifyReactionKind.recommend, + selected: isShared, color: isShared - ? ChewieColors.shareButtonColor + ? LoftifyReactionColors.recommend : defaultColor ?? Theme.of(context).iconTheme.color, size: iconSize, ); @@ -726,45 +685,15 @@ class LoftifyItemBuilder { required Function() onTap, String? positiveText, String? negtiveText, - double radius = 50, - Color? outline, }) { - return Material( - color: isFollowed ? Theme.of(context).cardColor : Colors.transparent, - borderRadius: BorderRadius.circular(radius), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(radius), - child: ClickableWrapper( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(radius), - border: Border.all( - color: isFollowed - ? Theme.of(context).dividerColor - : outline ?? Theme.of(context).primaryColor.withAlpha(127), - width: 1, - ), - ), - child: Row( - children: [ - Text( - isFollowed - ? positiveText ?? appLocalizations.followed - : negtiveText ?? appLocalizations.follow, - style: TextStyle( - color: isFollowed - ? Theme.of(context).textTheme.labelSmall?.color - : Theme.of(context).primaryColor, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ), + final label = isFollowed + ? positiveText ?? appLocalizations.followed + : negtiveText ?? appLocalizations.follow; + return LoftifyCompactToggleButton( + label: label, + semanticLabel: label, + selected: isFollowed, + onPressed: onTap, ); } @@ -819,6 +748,13 @@ class LoftifyItemBuilder { Function()? onTap, bool useBackground = false, }) { + Widget buildEnterButton() => RoundIconTextButton( + text: appLocalizations.enter, + height: null, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + color: Theme.of(context).primaryColor, + onPressed: onTap, + ); return ClickableGestureDetector( onTap: onTap, child: Container( @@ -834,90 +770,103 @@ class LoftifyItemBuilder { ) : null, ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - alignment: Alignment.center, - decoration: const BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.all(Radius.circular(12)), - image: DecorationImage( - image: AssetImage(AssetUtil.tagIconBgMess), - fit: BoxFit.cover, + child: LayoutBuilder(builder: (context, constraints) { + final stacked = constraints.maxWidth < 280 || + MediaQuery.textScalerOf(context).scale(14) > 19; + return Row( + children: [ + Container( + width: 48, + height: 48, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.all(Radius.circular(12)), + image: DecorationImage( + image: AssetImage(AssetUtil.tagIconBgMess), + fit: BoxFit.cover, + ), + ), + child: Text( + textAlign: TextAlign.center, + tag.tagName, + style: Theme.of(context).textTheme.titleSmall?.apply( + color: Colors.white, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), - child: Text( - textAlign: TextAlign.center, - tag.tagName, - style: Theme.of(context).textTheme.titleSmall?.apply( - color: Colors.white, + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "#${tag.tagName}", + style: Theme.of(context).textTheme.titleMedium, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - "#${tag.tagName}", - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 5), - if (StringUtil.isNotEmpty(tag.rankName)) - RoundIconTextButton( - text: tag.rankName!, - padding: const EdgeInsets.symmetric( - horizontal: 3, - vertical: 2, + const SizedBox(height: 5), + Wrap( + spacing: 5, + runSpacing: 5, + children: [ + if (StringUtil.isNotEmpty(tag.rankName)) + _buildTagStatus( + context, + tag.rankName!, + ChewieColors.likeButtonColor, ), - radius: 3, - color: ChewieColors.likeButtonColor, - fontSizeDelta: -2, - ), - if (tag.subscribed) const SizedBox(width: 5), - if (tag.subscribed) - RoundIconTextButton( - text: appLocalizations.subscribed, - padding: const EdgeInsets.symmetric( - horizontal: 3, vertical: 2), - radius: 3, - color: Theme.of(context).primaryColor, - fontSizeDelta: -2, - ), - ], - ), - const SizedBox(height: 5), - Text( - appLocalizations.joinCount(tag.joinCount.toString()), - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(fontWeightDelta: 1), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + if (tag.subscribed) + _buildTagStatus( + context, + appLocalizations.subscribed, + Theme.of(context).primaryColor, + ), + ], + ), + const SizedBox(height: 5), + Text( + appLocalizations.joinCount(tag.joinCount.toString()), + style: Theme.of(context) + .textTheme + .bodySmall + ?.apply(fontWeightDelta: 1), + ), + if (stacked) + Align( + alignment: Alignment.centerRight, + child: buildEnterButton(), + ), + ], + ), ), + if (!stacked) ...[ + const SizedBox(width: 8), + buildEnterButton(), + ], + ], + ); + }), + ), + ); + } + + static Widget _buildTagStatus( + BuildContext context, String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: ChewieTheme.cardColor, + ), + child: Text( + text, + style: Theme.of(context).textTheme.titleSmall?.apply( + color: color, + fontWeightDelta: 2, + fontSizeDelta: -2, ), - RoundIconTextButton( - text: appLocalizations.enter, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - color: Theme.of(context).primaryColor, - onPressed: onTap, - ), - ], - ), ), ); } @@ -927,38 +876,67 @@ class LoftifyItemBuilder { TagInfo tag, { Function()? onTap, double verticalPadding = 12, + double horizontalPadding = 16, }) { return GestureDetector( onTap: onTap, child: Container( color: Colors.transparent, - padding: - EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 16), + padding: EdgeInsets.symmetric( + vertical: verticalPadding, + horizontal: horizontalPadding, + ), child: Row( children: [ - Icon( - tag.joinCount == -1 ? Icons.search_rounded : Icons.tag_rounded, + ChewieIcon( + tag.joinCount == -1 ? LoftifyIcons.search : LoftifyIcons.hash, size: 20, color: Theme.of(context).textTheme.labelMedium?.color, ), const SizedBox(width: 8), Expanded( - child: Text( - tag.tagName, - style: Theme.of(context).textTheme.titleMedium, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tag.tagName, + style: Theme.of(context).textTheme.titleMedium, + ), + if (tag.joinCount != -1) ...[ + const SizedBox(height: 4), + Text( + appLocalizations.joinCount(tag.joinCount.toString()), + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ], ), ), - if (tag.joinCount != -1) - Text( - appLocalizations.joinCount(tag.joinCount.toString()), - style: Theme.of(context).textTheme.labelMedium, - ), ], ), ), ); } + static Widget buildSearchSuggestionSurface( + BuildContext context, + Widget child, + ) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.65), + width: 0.6, + ), + ), + clipBehavior: Clip.antiAlias, + child: child, + ); + } + static buildCollectionRow( BuildContext context, Collection collection, { @@ -988,8 +966,8 @@ class LoftifyItemBuilder { ), const SizedBox(width: 8), Expanded( - child: SizedBox( - height: 80, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 80), child: Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -1010,11 +988,11 @@ class LoftifyItemBuilder { maxLines: 1, overflow: TextOverflow.ellipsis, ), - SizedBox( - height: 20, - child: ListView( + Padding( + padding: const EdgeInsets.only(top: 6), + child: SingleChildScrollView( scrollDirection: Axis.horizontal, - children: [ + child: Row(children: [ ...List.generate( collection.tags.length, (index) => Container( @@ -1026,7 +1004,7 @@ class LoftifyItemBuilder { ), ), ), - ], + ]), ), ), ], @@ -1070,8 +1048,8 @@ class LoftifyItemBuilder { ), const SizedBox(width: 8), Expanded( - child: SizedBox( - height: 80, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 80), child: Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -1093,11 +1071,11 @@ class LoftifyItemBuilder { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 1), - SizedBox( - height: 20, - child: ListView( + Padding( + padding: const EdgeInsets.only(top: 6), + child: SingleChildScrollView( scrollDirection: Axis.horizontal, - children: [ + child: Row(children: [ ...List.generate( grain.tags.length, (index) => Container( @@ -1109,7 +1087,7 @@ class LoftifyItemBuilder { ), ), ), - ], + ]), ), ), ], @@ -1126,38 +1104,76 @@ class LoftifyItemBuilder { static buildUserRow(BuildContext context, SearchBlogData blog, {Function()? onTap}) { - return GestureDetector( - onTap: onTap, - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Row( - children: [ - ItemBuilder.buildAvatar( - context: context, - imageUrl: blog.blogInfo.bigAvaImg, - showLoading: false, - size: 40, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - blog.blogInfo.blogNickName, - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - "ID: ${blog.blogInfo.blogName}${blog.blogCount != null && blog.blogCount!.publicPostCount > 0 ? " ${appLocalizations.article}: ${blog.blogCount!.publicPostCount}" : ""}${blog.blogCount != null && blog.blogCount!.followerCount > 0 ? " ${appLocalizations.follower}: ${blog.blogCount!.followerCount}" : ""}", - style: Theme.of(context).textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + final theme = Theme.of(context); + final metadataStyle = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + return Material( + color: theme.colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.65), + width: 0.6, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + children: [ + ItemBuilder.buildAvatar( + context: context, + imageUrl: blog.blogInfo.bigAvaImg, + showLoading: false, + size: 48, ), - ), - ], + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + blog.blogInfo.blogNickName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'ID: ${blog.blogInfo.blogName}', + style: metadataStyle, + ), + if (blog.blogCount != null && + (blog.blogCount!.publicPostCount > 0 || + blog.blogCount!.followerCount > 0)) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + if (blog.blogCount!.publicPostCount > 0) + Text( + '${appLocalizations.article}: ${blog.blogCount!.publicPostCount}', + style: metadataStyle, + ), + if (blog.blogCount!.followerCount > 0) + Text( + '${appLocalizations.follower}: ${blog.blogCount!.followerCount}', + style: metadataStyle, + ), + ], + ), + ], + ], + ), + ), + ], + ), ), ), ); diff --git a/lib/Widgets/Item/login_input_item.dart b/lib/Widgets/Item/login_input_item.dart new file mode 100644 index 00000000..652ac259 --- /dev/null +++ b/lib/Widgets/Item/login_input_item.dart @@ -0,0 +1,183 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../Design/loftify_controls.dart'; +import '../loftify_icons.dart'; + +/// 登录流程使用的一体式输入框。 +/// +/// 登录页原本将图标、输入区和操作区放在同一块背景中。通用 InputItem +/// 更适合设置表单,因此这里保留登录页的视觉和键盘交互语义。 +class LoginInputItem extends StatefulWidget { + const LoginInputItem({ + super.key, + required this.controller, + this.hint, + this.focusNode, + this.keyboardType, + this.textInputAction, + this.onSubmitted, + this.leadingConfig, + this.tailingConfig, + this.autofillHints, + this.backgroundColor, + }); + + final TextEditingController controller; + final String? hint; + final FocusNode? focusNode; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final InputItemLeadingTailingConfig? leadingConfig; + final InputItemLeadingTailingConfig? tailingConfig; + final Iterable? autofillHints; + final Color? backgroundColor; + + @override + State createState() => _LoginInputItemState(); +} + +class _LoginInputItemState extends State { + late bool _obscurePassword; + + bool get _isPassword => + widget.tailingConfig?.type == InputItemLeadingTailingType.password; + + @override + void initState() { + super.initState(); + _obscurePassword = _isPassword; + } + + @override + void didUpdateWidget(covariant LoginInputItem oldWidget) { + super.didUpdateWidget(oldWidget); + final wasPassword = + oldWidget.tailingConfig?.type == InputItemLeadingTailingType.password; + if (!wasPassword && _isPassword) { + _obscurePassword = true; + } + } + + @override + Widget build(BuildContext context) { + final leading = _buildAccessory(widget.leadingConfig, isTrailing: false); + final tailing = _buildAccessory(widget.tailingConfig, isTrailing: true); + return Padding( + padding: EdgeInsets.only(top: context.design.spacing.sectionTop), + child: LoftifyTextField( + controller: widget.controller, + focusNode: widget.focusNode, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + onSubmitted: widget.onSubmitted, + autofillHints: widget.autofillHints, + obscureText: _isPassword && _obscurePassword, + hintText: widget.hint, + prefix: leading, + suffix: tailing, + backgroundColor: widget.backgroundColor, + contextMenuBuilder: (contextMenuContext, details) => + ChewieItemBuilder.editTextContextMenuBuilder( + contextMenuContext, + details, + context: context, + ), + ), + ); + } + + Widget? _buildAccessory( + InputItemLeadingTailingConfig? config, { + required bool isTrailing, + }) { + if (config == null || !config.show) return null; + final iconColor = Theme.of(context).iconTheme.color; + Widget? child; + VoidCallback? defaultAction; + switch (config.type) { + case InputItemLeadingTailingType.none: + return null; + case InputItemLeadingTailingType.clear: + child = ChewieIcon( + LoftifyIcons.clear, + color: iconColor?.withAlpha(120), + ); + defaultAction = widget.controller.clear; + break; + case InputItemLeadingTailingType.password: + child = ChewieIcon( + _obscurePassword ? LoftifyIcons.visible : LoftifyIcons.hidden, + color: iconColor?.withAlpha(120), + ); + defaultAction = () { + setState(() => _obscurePassword = !_obscurePassword); + }; + break; + case InputItemLeadingTailingType.icon: + if (config.icon == null) return null; + child = ChewieIcon(config.icon!, color: iconColor); + break; + case InputItemLeadingTailingType.text: + if (config.text == null) return null; + child = Text( + config.text!, + maxLines: 1, + softWrap: false, + style: Theme.of(context).textTheme.labelMedium?.apply( + color: config.enable + ? Theme.of(context).primaryColor + : Theme.of(context).textTheme.labelSmall?.color, + fontWeightDelta: 1, + ), + ); + break; + case InputItemLeadingTailingType.widget: + child = config.widget; + break; + default: + if (config.icon == null) return null; + child = ChewieIcon(config.icon!, color: iconColor); + } + if (child == null) return null; + if (!isTrailing) return child; + return Semantics( + button: config.enable, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: config.enable + ? () { + config.onTap?.call(); + defaultAction?.call(); + } + : null, + child: MouseRegion( + cursor: config.enable + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: SizedBox( + width: switch (config.type) { + InputItemLeadingTailingType.text => 104, + InputItemLeadingTailingType.widget => 84, + _ => 48, + }, + height: 48, + child: Center( + child: config.type == InputItemLeadingTailingType.text + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: FittedBox( + fit: BoxFit.scaleDown, + child: child, + ), + ) + : child, + ), + ), + ), + ), + ); + } +} diff --git a/lib/Widgets/Item/setting_management_item.dart b/lib/Widgets/Item/setting_management_item.dart new file mode 100644 index 00000000..daa48fbe --- /dev/null +++ b/lib/Widgets/Item/setting_management_item.dart @@ -0,0 +1,72 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../loftify_icons.dart'; +import '../Design/loftify_state_view.dart'; + +/// Shares the surrounding settings list's scroll behavior and grows with text. +class SettingManagementEmptyState extends StatelessWidget { + const SettingManagementEmptyState({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 140), + child: LoftifyStateView( + visual: LoftifyStateVisual.empty, + title: text, + scrollWhenConstrained: false, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + ), + ); + } +} + +class SettingManagementItem extends StatelessWidget { + const SettingManagementItem({ + super.key, + required this.title, + required this.actionLabel, + required this.onAction, + this.description = '', + this.leading, + this.leadingIcon, + this.onTap, + }); + + final String title; + final String description; + final Widget? leading; + final IconData? leadingIcon; + final String actionLabel; + final VoidCallback onAction; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + return EntryItem( + title: title, + description: description, + crossAxisAlignment: CrossAxisAlignment.start, + showLeading: leading != null || leadingIcon != null, + leadingWidget: leading, + leading: leadingIcon ?? LoftifyIcons.settings, + showTrailing: false, + onTap: onTap, + tipWidget: RoundIconTextButton( + text: actionLabel, + height: null, + minHeight: 32, + radius: 16, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + background: primary.withAlpha(22), + color: primary, + border: Border.all(color: primary.withAlpha(72)), + onPressed: onAction, + ), + ); + } +} diff --git a/lib/Widgets/Login/loftify_login_layout.dart b/lib/Widgets/Login/loftify_login_layout.dart new file mode 100644 index 00000000..844b734a --- /dev/null +++ b/lib/Widgets/Login/loftify_login_layout.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; + +@immutable +class LoftifyLoginMethod { + const LoftifyLoginMethod({ + required this.label, + required this.icon, + required this.onPressed, + }); + + final String label; + final IconData icon; + final VoidCallback onPressed; +} + +/// Shared content shell for every login method. +/// +/// The old screens positioned the alternative methods over the form. Keeping +/// everything in one scroll chain preserves the spacious bottom alignment on +/// tall phones while making all fields and actions reachable with a keyboard, +/// split screen, landscape, large text, and narrow Android displays. +class LoftifyLoginLayout extends StatelessWidget { + const LoftifyLoginLayout({ + super.key, + required this.formChildren, + required this.primaryAction, + required this.alternativeTitle, + required this.alternativeMethods, + }); + + final List formChildren; + final Widget primaryAction; + final String alternativeTitle; + final List alternativeMethods; + + @override + Widget build(BuildContext context) { + final design = context.design; + return LayoutBuilder( + builder: (context, constraints) { + final horizontal = design.grid.pagePaddingFor(constraints.maxWidth); + final textScale = MediaQuery.textScalerOf(context).scale(1); + final compactHeight = constraints.maxHeight < 560 || textScale > 1.35; + final top = compactHeight ? design.spacing.xl : design.spacing.hero; + final sectionGap = compactHeight ? design.spacing.huge : 56.0; + final bottom = + design.spacing.xxxl + MediaQuery.paddingOf(context).bottom; + + return ScrollConfiguration( + behavior: const _LoginScrollBehavior(), + child: SingleChildScrollView( + key: const ValueKey('loftify-login-scroll'), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB(horizontal, top, horizontal, bottom), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: (constraints.maxHeight - top - bottom).clamp( + 0.0, + double.infinity, + ), + ), + child: IntrinsicHeight( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...formChildren, + SizedBox(height: design.spacing.huge), + Align( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: primaryAction, + ), + ), + const Spacer(), + SizedBox(height: sectionGap), + _AlternativeMethods( + title: alternativeTitle, + methods: alternativeMethods, + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _AlternativeMethods extends StatelessWidget { + const _AlternativeMethods({required this.title, required this.methods}); + + final String title; + final List methods; + + @override + Widget build(BuildContext context) { + final design = context.design; + return Column( + key: const ValueKey('loftify-login-alternatives'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Row( + key: const ValueKey('loftify-login-alternative-heading'), + children: [ + Expanded(child: Divider(color: design.colors.outline)), + Padding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.md), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + title, + textAlign: TextAlign.center, + style: design.typography.label.copyWith( + color: design.colors.textMuted, + ), + ), + ), + ), + Expanded(child: Divider(color: design.colors.outline)), + ], + ), + ), + ), + SizedBox(height: design.spacing.xl), + Wrap( + key: const ValueKey('loftify-login-alternative-methods'), + alignment: WrapAlignment.center, + spacing: design.spacing.xxl, + runSpacing: design.spacing.md, + children: [ + for (final method in methods) + Semantics( + button: true, + label: method.label, + child: Tooltip( + message: method.label, + child: IconButton.outlined( + onPressed: method.onPressed, + icon: Icon(method.icon, size: design.icons.regular), + ), + ), + ), + ], + ), + ], + ); + } +} + +class _LoginScrollBehavior extends MaterialScrollBehavior { + const _LoginScrollBehavior(); + + @override + ScrollPhysics getScrollPhysics(BuildContext context) => + const ClampingScrollPhysics(); +} diff --git a/lib/Widgets/Navigation/loftify_glass_navigation_bar.dart b/lib/Widgets/Navigation/loftify_glass_navigation_bar.dart new file mode 100644 index 00000000..a7204fb2 --- /dev/null +++ b/lib/Widgets/Navigation/loftify_glass_navigation_bar.dart @@ -0,0 +1,547 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../Utils/enums.dart'; +import '../../Utils/lottie_files.dart'; + +@immutable +class LoftifyNavigationDestination { + const LoftifyNavigationDestination({ + required this.icon, + required this.label, + this.lottieAsset, + this.badgeCount = 0, + }); + + final IconData icon; + final String label; + final String? lottieAsset; + final int badgeCount; +} + +/// A floating bottom navigation surface that keeps content visible beneath it. +/// +/// Blur is automatically disabled for web, high-contrast, reduced-motion and +/// accessible-navigation environments. [enableBlur] provides an explicit +/// solid fallback for devices where transparency is undesirable or expensive. +class LoftifyGlassNavigationBar extends StatelessWidget { + const LoftifyGlassNavigationBar({ + super.key, + required this.destinations, + required this.currentIndex, + required this.onSelect, + this.onDoubleTap, + this.enableBlur = true, + this.displayStyle = NavigationBarDisplayStyle.iconOnly, + }) : assert(destinations.length >= 2), + assert(currentIndex == -1 || + (currentIndex >= 0 && currentIndex < destinations.length)); + + final List destinations; + final int currentIndex; + final ValueChanged onSelect; + final ValueChanged? onDoubleTap; + final bool enableBlur; + final NavigationBarDisplayStyle displayStyle; + + static const double barHeight = 64; + static const double horizontalMargin = 10; + static const double blurSigma = 18; + static const Duration standardPageTransitionDuration = Duration( + milliseconds: 220, + ); + + static bool shouldShowForKeyboard(MediaQueryData mediaQuery) { + return mediaQuery.viewInsets.bottom <= 0; + } + + static bool shouldReduceMotion( + MediaQueryData mediaQuery, { + bool? platformReduceMotion, + }) { + final reduceMotion = platformReduceMotion ?? + WidgetsBinding + .instance.platformDispatcher.accessibilityFeatures.reduceMotion; + return reduceMotion || + mediaQuery.disableAnimations || + mediaQuery.accessibleNavigation; + } + + static Duration pageTransitionDuration( + MediaQueryData mediaQuery, { + bool? platformReduceMotion, + }) { + return shouldReduceMotion( + mediaQuery, + platformReduceMotion: platformReduceMotion, + ) + ? Duration.zero + : standardPageTransitionDuration; + } + + static bool shouldUseBlur( + MediaQueryData mediaQuery, { + required bool enabled, + bool isWeb = kIsWeb, + bool? platformReduceMotion, + }) { + return enabled && + !isWeb && + !shouldReduceMotion( + mediaQuery, + platformReduceMotion: platformReduceMotion, + ) && + !mediaQuery.highContrast; + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final useBlur = shouldUseBlur(mediaQuery, enabled: enableBlur); + final bottomInset = mediaQuery.viewPadding.bottom; + final surfaceColor = theme.colorScheme.surface; + final backgroundColor = useBlur + ? surfaceColor.withValues(alpha: isDark ? 0.78 : 0.72) + : surfaceColor; + final borderColor = theme.dividerColor.withValues( + alpha: isDark ? 0.78 : 0.9, + ); + final radius = BorderRadius.circular(24); + final content = DecoratedBox( + key: const ValueKey('loftify-glass-navigation-surface'), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: radius, + border: Border.all(color: borderColor, width: 0.8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.22 : 0.08), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + child: SizedBox( + height: barHeight, + child: Row( + children: List.generate(destinations.length, (index) { + return Expanded( + child: _LoftifyNavigationItem( + destination: destinations[index], + selected: currentIndex == index, + displayStyle: displayStyle, + onTap: () => onSelect(index), + onDoubleTap: + onDoubleTap == null ? null : () => onDoubleTap!(index), + ), + ); + }), + ), + ), + ); + + return RepaintBoundary( + child: Semantics( + container: true, + explicitChildNodes: true, + child: Padding( + padding: EdgeInsets.fromLTRB( + horizontalMargin, + 6, + horizontalMargin, + bottomInset + 6, + ), + child: ClipRRect( + borderRadius: radius, + child: useBlur + ? BackdropFilter( + filter: ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: content, + ) + : content, + ), + ), + ), + ); + } +} + +class _LoftifyNavigationItem extends StatefulWidget { + const _LoftifyNavigationItem({ + required this.destination, + required this.selected, + required this.displayStyle, + required this.onTap, + this.onDoubleTap, + }); + + final LoftifyNavigationDestination destination; + final bool selected; + final NavigationBarDisplayStyle displayStyle; + final VoidCallback onTap; + final VoidCallback? onDoubleTap; + + @override + State<_LoftifyNavigationItem> createState() => _LoftifyNavigationItemState(); +} + +class _LoftifyNavigationItemState extends State<_LoftifyNavigationItem> { + bool _pressed = false; + Timer? _doubleTapWindow; + + @override + void dispose() { + _doubleTapWindow?.cancel(); + super.dispose(); + } + + void _setPressed(bool value) { + if (_pressed == value) return; + setState(() => _pressed = value); + } + + void _handleTap() { + widget.onTap(); + if (widget.onDoubleTap == null) return; + if (_doubleTapWindow?.isActive == true) { + _doubleTapWindow?.cancel(); + _doubleTapWindow = null; + widget.onDoubleTap!(); + return; + } + _doubleTapWindow = Timer( + const Duration(milliseconds: 300), + () => _doubleTapWindow = null, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final selectedColor = theme.colorScheme.primary; + final unselectedColor = theme.colorScheme.onSurfaceVariant; + final foregroundColor = widget.selected ? selectedColor : unselectedColor; + final duration = + reduceMotion ? Duration.zero : const Duration(milliseconds: 180); + final showIcon = widget.displayStyle != NavigationBarDisplayStyle.textOnly; + final showLabel = widget.displayStyle != NavigationBarDisplayStyle.iconOnly; + final selectionHeight = showIcon && showLabel ? 52.0 : 42.0; + + final semanticLabel = widget.destination.badgeCount > 0 + ? '${widget.destination.label}, ${widget.destination.badgeCount}' + : widget.destination.label; + return Semantics( + button: true, + selected: widget.selected, + label: semanticLabel, + excludeSemantics: true, + onTap: widget.onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: _handleTap, + onTapDown: (_) => _setPressed(true), + onTapUp: (_) => _setPressed(false), + onTapCancel: () => _setPressed(false), + child: AnimatedScale( + scale: _pressed ? 0.94 : 1, + duration: + reduceMotion ? Duration.zero : const Duration(milliseconds: 90), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + key: ValueKey( + 'loftify-navigation-selection-${widget.destination.label}', + ), + duration: duration, + curve: Curves.easeOutCubic, + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + constraints: BoxConstraints(minHeight: selectionHeight), + decoration: BoxDecoration( + color: widget.selected + ? selectedColor.withValues(alpha: 0.11) + : Colors.transparent, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: widget.selected + ? selectedColor.withValues(alpha: 0.18) + : Colors.transparent, + width: 0.8, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (showIcon) + _NavigationIcon( + icon: widget.destination.icon, + lottieAsset: widget.destination.lottieAsset, + selected: widget.selected, + badgeCount: widget.destination.badgeCount, + color: foregroundColor, + ), + if (showIcon && showLabel) const SizedBox(height: 2), + if (showLabel) + _NavigationLabel( + label: widget.destination.label, + badgeCount: showIcon ? 0 : widget.destination.badgeCount, + selected: widget.selected, + color: foregroundColor, + fontSize: showIcon ? 10.5 : 12.5, + duration: duration, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _NavigationLabel extends StatelessWidget { + const _NavigationLabel({ + required this.label, + required this.badgeCount, + required this.selected, + required this.color, + required this.fontSize, + required this.duration, + }); + + final String label; + final int badgeCount; + final bool selected; + final Color color; + final double fontSize; + final Duration duration; + + @override + Widget build(BuildContext context) { + final text = MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.4, + child: AnimatedDefaultTextStyle( + duration: duration, + curve: Curves.easeOutCubic, + style: (Theme.of(context).textTheme.labelSmall ?? const TextStyle()) + .copyWith( + color: color, + fontSize: fontSize, + height: 1, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + if (badgeCount <= 0) return text; + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible(child: text), + const SizedBox(width: 4), + _NavigationBadge(count: badgeCount), + ], + ); + } +} + +class _NavigationIcon extends StatelessWidget { + const _NavigationIcon({ + required this.icon, + required this.lottieAsset, + required this.selected, + required this.badgeCount, + required this.color, + }); + + final IconData icon; + final String? lottieAsset; + final bool selected; + final int badgeCount; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 23, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + if (lottieAsset == null) + ChewieIcon(icon, size: 22, color: color) + else + LoftifyNavigationLottieIcon( + asset: lottieAsset!, + selected: selected, + color: color, + size: 22, + ), + if (badgeCount > 0) + Positioned( + top: -3, + right: -7, + child: _NavigationBadge(count: badgeCount), + ), + ], + ), + ); + } +} + +class _NavigationBadge extends StatelessWidget { + const _NavigationBadge({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return Semantics( + label: '$count', + child: Container( + constraints: const BoxConstraints(minWidth: 15), + height: 15, + padding: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 1.2, + ), + ), + alignment: Alignment.center, + child: Text( + count > 99 ? '99+' : '$count', + maxLines: 1, + style: TextStyle( + color: Theme.of(context).colorScheme.onError, + fontSize: 8, + height: 1, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +class LoftifyNavigationLottieIcon extends StatefulWidget { + const LoftifyNavigationLottieIcon({ + super.key, + required this.asset, + required this.selected, + required this.color, + this.size = 22, + }); + + final String asset; + final bool selected; + final Color color; + final double size; + + @override + State createState() => + _LoftifyNavigationLottieIconState(); +} + +class _LoftifyNavigationLottieIconState + extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + bool _loaded = false; + bool _animateWhenLoaded = false; + + // The loading mark starts and ends on a clear glyph. Play its drawing motion + // once on selection, then restore the opening frame as its idle state. + bool get _usesLoadingMark => widget.asset == LottieFiles.navHome; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this) + ..value = _usesLoadingMark ? 0 : (widget.selected ? 1 : 0); + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed && _usesLoadingMark && mounted) { + _controller.value = 0; + } + }); + } + + @override + void didUpdateWidget(covariant LoftifyNavigationLottieIcon oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.asset != widget.asset) { + _loaded = false; + _animateWhenLoaded = widget.selected; + _controller.value = _usesLoadingMark ? 0 : (widget.selected ? 1 : 0); + return; + } + if (oldWidget.selected == widget.selected) return; + if (!widget.selected) { + _animateWhenLoaded = false; + _controller.value = 0; + return; + } + if (LoftifyGlassNavigationBar.shouldReduceMotion(MediaQuery.of(context))) { + _controller.value = 1; + } else if (_loaded) { + _controller.forward(from: 0); + } else { + _animateWhenLoaded = true; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LottieFiles.buildAnimation( + widget.asset, + key: ValueKey(widget.asset), + size: widget.size, + controller: _controller, + tint: widget.color, + strokeWidth: _usesLoadingMark && widget.selected ? 6.8 : null, + onLoaded: () { + _loaded = true; + if (_usesLoadingMark) { + _controller.duration = const Duration(milliseconds: 800); + } + if (!_animateWhenLoaded || !widget.selected) return; + _animateWhenLoaded = false; + if (LoftifyGlassNavigationBar.shouldReduceMotion( + MediaQuery.of(context), + )) { + _controller.value = 1; + } else { + _controller.forward(from: 0); + } + }, + ); + } +} diff --git a/lib/Widgets/PostDetail/comment_item.dart b/lib/Widgets/PostDetail/comment_item.dart new file mode 100644 index 00000000..15508e2c --- /dev/null +++ b/lib/Widgets/PostDetail/comment_item.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +class CommentItem extends StatelessWidget { + const CommentItem({ + super.key, + required this.avatar, + required this.header, + required this.content, + required this.metadata, + this.trailing, + this.replies = const [], + this.footer, + this.onTap, + this.nested = false, + this.margin, + }); + + final Widget avatar; + final Widget header; + final Widget content; + final Widget metadata; + final Widget? trailing; + final List replies; + final Widget? footer; + final VoidCallback? onTap; + final bool nested; + final EdgeInsetsGeometry? margin; + + @override + Widget build(BuildContext context) { + return Padding( + padding: margin ?? + (nested + ? const EdgeInsets.only(top: 10) + : const EdgeInsets.symmetric(horizontal: 16, vertical: 10)), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + avatar, + SizedBox(width: nested ? 8 : 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints(minHeight: nested ? 28 : 38), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: header, + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 10), + trailing!, + ], + ], + ), + ), + const SizedBox(height: 4), + content, + const SizedBox(height: 5), + metadata, + if (replies.isNotEmpty) ...[ + const SizedBox(height: 2), + ...replies, + ], + if (footer != null) ...[ + const SizedBox(height: 8), + footer!, + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Widgets/PostDetail/detail_bottom_bar.dart b/lib/Widgets/PostDetail/detail_bottom_bar.dart new file mode 100644 index 00000000..dd9845f0 --- /dev/null +++ b/lib/Widgets/PostDetail/detail_bottom_bar.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; + +import 'package:awesome_chewie/awesome_chewie.dart'; + +import '../../Theme/loftify_design_theme.dart'; + +class DetailBottomBar extends StatelessWidget { + const DetailBottomBar({ + super.key, + required this.children, + this.horizontalPadding = 10, + this.spacing = 4, + }); + + final List children; + final double horizontalPadding; + final double spacing; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: SafeArea( + top: false, + minimum: const EdgeInsets.only(bottom: 4), + child: Container( + height: 64, + padding: + EdgeInsets.fromLTRB(horizontalPadding, 4, horizontalPadding, 4), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.45), + width: 0.6, + ), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < children.length; index++) ...[ + Expanded(child: children[index]), + if (index != children.length - 1) SizedBox(width: spacing), + ], + ], + ), + ), + ), + ); + } +} + +class DetailActionSlot extends StatelessWidget { + const DetailActionSlot({ + super.key, + required this.child, + required this.semanticLabel, + this.onTap, + }); + + final Widget child; + final String semanticLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final design = context.design; + return Semantics( + button: onTap != null, + label: semanticLabel, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(design.radii.control), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(design.radii.control), + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.pressed) + ? design.colors.accent.withValues( + alpha: design.icons.pressedOpacity, + ) + : Colors.transparent, + ), + child: Center(child: child), + ), + ), + ); + } +} + +class DetailActionButton extends StatelessWidget { + const DetailActionButton({ + super.key, + required this.icon, + required this.label, + this.onTap, + this.foregroundColor, + }); + + final Widget icon; + final String label; + final VoidCallback? onTap; + final Color? foregroundColor; + + @override + Widget build(BuildContext context) { + final design = context.design; + final color = foregroundColor ?? design.colors.textPrimary; + return DetailActionSlot( + semanticLabel: label, + onTap: onTap, + child: Container( + margin: EdgeInsets.symmetric(horizontal: design.spacing.xs), + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xxs, + vertical: design.spacing.xxs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconTheme( + data: IconTheme.of(context).copyWith(color: color, size: 22), + child: icon, + ), + SizedBox(height: design.spacing.xxs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: ChewieTheme.labelMedium, + ), + ], + ), + ), + ); + } +} + +/// Token-driven vertical action rail used by reading and image detail pages. +class DetailFloatingActionRail extends StatelessWidget { + const DetailFloatingActionRail({ + super.key, + required this.children, + this.slotWidth = 48, + this.slotHeight = 48, + }); + + final List children; + final double slotWidth; + final double slotHeight; + + @override + Widget build(BuildContext context) { + final design = context.design; + return Semantics( + container: true, + explicitChildNodes: true, + child: DecoratedBox( + key: const ValueKey('detail-floating-action-rail'), + decoration: BoxDecoration( + color: design.colors.surfaceRaised, + borderRadius: BorderRadius.circular(design.radii.panel), + border: Border.all( + color: design.colors.outline, + width: design.borders.hairline, + ), + boxShadow: design.shadows.floating, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(design.radii.panel), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xs, + vertical: design.spacing.sm, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final child in children) + SizedBox( + width: slotWidth, + height: slotHeight, + child: child, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/Widgets/PostDetail/lazy_comment_jump.dart b/lib/Widgets/PostDetail/lazy_comment_jump.dart new file mode 100644 index 00000000..b52c6c14 --- /dev/null +++ b/lib/Widgets/PostDetail/lazy_comment_jump.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +/// Reveals an anchor inside lazy post content from either side of it. +/// [contentExtent] is the end of the post-content sliver (before recommendations). +Future revealLazyComment({ + required ScrollController controller, + required GlobalKey anchorKey, + required bool Function() isActive, + double? contentExtent, +}) async { + Future revealIfMounted() async { + final anchorContext = anchorKey.currentContext; + if (anchorContext == null) return false; + await Scrollable.ensureVisible( + anchorContext, + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + ); + return true; + } + + if (!isActive() || !controller.hasClients) return; + if (await revealIfMounted()) return; + + if (contentExtent != null) { + final position = controller.position; + final approach = (contentExtent - position.viewportDimension * 0.9) + .clamp(position.minScrollExtent, position.maxScrollExtent); + if ((position.pixels - approach).abs() > + position.viewportDimension * 0.25) { + await controller.animateTo( + approach, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + if (!isActive()) return; + await WidgetsBinding.instance.endOfFrame; + } + } + + // The anchor is before the recommendation sliver. Once near that boundary, + // move backward by less than one viewport so the lazy child can mount. + for (var step = 0; step < 80 && isActive(); step++) { + if (await revealIfMounted()) return; + if (!controller.hasClients) return; + final position = controller.position; + final direction = + contentExtent != null || position.pixels > position.maxScrollExtent / 2 + ? -1.0 + : 1.0; + final target = + (position.pixels + direction * position.viewportDimension * 0.8) + .clamp(position.minScrollExtent, position.maxScrollExtent); + if ((target - position.pixels).abs() < 0.5) return; + await controller.animateTo( + target, + duration: const Duration(milliseconds: 45), + curve: Curves.linear, + ); + if (!isActive()) return; + await WidgetsBinding.instance.endOfFrame; + } +} diff --git a/lib/Widgets/PostDetail/post_content_section.dart b/lib/Widgets/PostDetail/post_content_section.dart new file mode 100644 index 00000000..c3c308ed --- /dev/null +++ b/lib/Widgets/PostDetail/post_content_section.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../Design/loftify_reading.dart'; +import '../Design/loftify_state_view.dart'; + +class PostContentSection extends StatefulWidget { + const PostContentSection({ + super.key, + required this.title, + required this.content, + this.url, + this.style, + this.onDownloadSuccess, + this.textExtractor, + }); + + final String title; + final String content; + final String? url; + final TextStyle? style; + final VoidCallback? onDownloadSuccess; + final String Function(String content)? textExtractor; + + @override + State createState() => _PostContentSectionState(); +} + +class _PostContentSectionState extends State { + int _revision = 0; + Widget? _renderedContent; + Object? _renderError; + StackTrace? _renderStackTrace; + bool _contentIsEmpty = false; + + @override + void initState() { + super.initState(); + _prepareContent(); + } + + @override + void didUpdateWidget(covariant PostContentSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.title != widget.title || + oldWidget.content != widget.content || + oldWidget.url != widget.url || + oldWidget.style != widget.style || + oldWidget.onDownloadSuccess != widget.onDownloadSuccess || + oldWidget.textExtractor != widget.textExtractor) { + _prepareContent(); + } + } + + void _prepareContent() { + try { + final extractor = widget.textExtractor ?? HtmlUtil.extractTextFromHtml; + final plainTitle = widget.title.trim(); + final plainContent = extractor(widget.content).trim(); + if (plainTitle.isEmpty && plainContent.isEmpty) { + _contentIsEmpty = true; + _renderedContent = const SizedBox.shrink(); + } else { + _contentIsEmpty = false; + final escapedTitle = const HtmlEscape().convert(plainTitle); + final htmlTitle = escapedTitle.isEmpty + ? '' + : "

$escapedTitle

"; + _renderedContent = RepaintBoundary( + key: ValueKey(_revision), + child: CustomHtmlWidget( + url: widget.url, + content: '$htmlTitle${widget.content}', + style: widget.style, + heightDelta: 0, + letterSpacingDelta: 0, + onDownloadSuccess: widget.onDownloadSuccess, + ), + ); + } + _renderError = null; + _renderStackTrace = null; + } catch (error, stackTrace) { + _contentIsEmpty = false; + _renderedContent = null; + _renderError = error; + _renderStackTrace = stackTrace; + ILogger.error('Failed to render post content', error, stackTrace); + } + } + + @override + Widget build(BuildContext context) { + final design = context.design; + if (_contentIsEmpty) return const SizedBox.shrink(); + if (_renderedContent != null) { + return LoftifyReadingFrame( + topPadding: design.spacing.xl, + bottomPadding: design.spacing.md, + child: _renderedContent!, + ); + } + assert(_renderError != null && _renderStackTrace != null); + return LoftifyReadingFrame( + topPadding: design.spacing.xl, + bottomPadding: design.spacing.md, + child: LoftifyStateView( + visual: LoftifyStateVisual.error, + title: chewieLocalizations.loadFailed, + actionLabel: chewieLocalizations.retry, + padding: EdgeInsets.all(design.spacing.xl), + onAction: () { + setState(() { + _revision++; + _prepareContent(); + }); + }, + ), + ); + } +} diff --git a/lib/Widgets/PostDetail/post_download_action_icon.dart b/lib/Widgets/PostDetail/post_download_action_icon.dart new file mode 100644 index 00000000..4bf572d0 --- /dev/null +++ b/lib/Widgets/PostDetail/post_download_action_icon.dart @@ -0,0 +1,103 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../loftify_icons.dart'; + +/// Compact, determinate download feedback for a post AppBar action. +/// +/// Loading intentionally uses a progress ring instead of the application's +/// generic Lottie loading animation: this state represents transferred bytes, +/// not an indeterminate page load. +class PostDownloadActionIcon extends StatelessWidget { + const PostDownloadActionIcon({ + super.key, + required this.state, + required this.semanticLabel, + this.progress = 0, + }); + + static const double visualSize = 20; + static const double iconSize = 18; + static const double progressSize = 18; + static const double progressStrokeWidth = 2; + + final DownloadState state; + final String semanticLabel; + final double progress; + + @override + Widget build(BuildContext context) { + final design = context.design; + final normalizedProgress = progress.clamp(0.0, 1.0).toDouble(); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final semanticsValue = state == DownloadState.loading + ? '${(normalizedProgress * 100).round()}%' + : null; + + return Semantics( + key: const ValueKey('post-download-semantics'), + label: semanticLabel, + value: semanticsValue, + liveRegion: + state == DownloadState.succeed || state == DownloadState.failed, + child: ExcludeSemantics( + child: SizedBox.square( + dimension: visualSize, + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 160), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: switch (state) { + DownloadState.none => ChewieIcon( + LoftifyIcons.download, + key: const ValueKey('post-download-idle'), + size: iconSize, + color: design.colors.textPrimary, + ), + DownloadState.loading => TweenAnimationBuilder( + key: const ValueKey('post-download-progress'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + tween: Tween(begin: 0, end: normalizedProgress), + builder: (context, value, child) { + return Center( + child: SizedBox.square( + dimension: progressSize, + child: CircularProgressIndicator( + value: value, + strokeWidth: progressStrokeWidth, + strokeCap: StrokeCap.round, + color: design.colors.accentForeground, + backgroundColor: design.colors.outline.withValues( + alpha: + MediaQuery.highContrastOf(context) ? 0.72 : 0.4, + ), + ), + ), + ); + }, + ), + DownloadState.succeed => ChewieIcon( + LoftifyIcons.check, + key: const ValueKey('post-download-success'), + size: iconSize, + color: design.colors.success, + ), + DownloadState.failed => ChewieIcon( + LoftifyIcons.warning, + key: const ValueKey('post-download-failed'), + size: iconSize, + color: design.colors.danger, + ), + }, + ), + ), + ), + ); + } +} diff --git a/lib/Widgets/PostDetail/post_swipe_gesture_detector.dart b/lib/Widgets/PostDetail/post_swipe_gesture_detector.dart new file mode 100644 index 00000000..26d5cedc --- /dev/null +++ b/lib/Widgets/PostDetail/post_swipe_gesture_detector.dart @@ -0,0 +1,170 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +/// Observes a deliberate horizontal pointer movement without entering the +/// gesture arena. HTML's SelectionArea otherwise wins the arena before the +/// outer post swipe recognizer, making swipes on article text ineffective. +/// Interactive horizontal children can opt out through [excludedRegions]. +class PostSwipeGestureDetector extends StatefulWidget { + const PostSwipeGestureDetector({ + super.key, + required this.child, + this.excludedRegions = const [], + this.behavior = HitTestBehavior.translucent, + this.edgeActivationWidth = 28, + this.onHorizontalDragStart, + this.onHorizontalDragUpdate, + this.onHorizontalDragEnd, + this.onHorizontalDragCancel, + }); + + final Widget child; + final List excludedRegions; + final HitTestBehavior behavior; + final double edgeActivationWidth; + final GestureDragStartCallback? onHorizontalDragStart; + final GestureDragUpdateCallback? onHorizontalDragUpdate; + final GestureDragEndCallback? onHorizontalDragEnd; + final GestureDragCancelCallback? onHorizontalDragCancel; + + @override + State createState() => + _PostSwipeGestureDetectorState(); +} + +class _PostSwipeGestureDetectorState extends State { + int? _pointer; + Offset? _start; + Offset? _last; + VelocityTracker? _velocity; + bool _dragging = false; + + bool _canStartAt(Offset globalPosition) { + if (_isEdge(globalPosition)) return true; + for (final key in widget.excludedRegions) { + final region = key.currentContext?.findRenderObject(); + if (region is! RenderBox || !region.attached || !region.hasSize) continue; + final local = region.globalToLocal(globalPosition); + if ((Offset.zero & region.size).contains(local)) return false; + } + return true; + } + + bool _isEdge(Offset globalPosition) { + final renderObject = context.findRenderObject(); + if (widget.edgeActivationWidth > 0 && + renderObject is RenderBox && + renderObject.attached && + renderObject.hasSize) { + final local = renderObject.globalToLocal(globalPosition); + if (local.dx <= widget.edgeActivationWidth || + local.dx >= renderObject.size.width - widget.edgeActivationWidth) { + return true; + } + } + return false; + } + + void _onDown(PointerDownEvent event) { + if (_pointer != null || !_canStartAt(event.position)) return; + _pointer = event.pointer; + _start = event.position; + _last = event.position; + _velocity = VelocityTracker.withKind(event.kind) + ..addPosition(event.timeStamp, event.position); + } + + void _onMove(PointerMoveEvent event) { + if (event.pointer != _pointer) return; + final start = _start!; + final previous = _last!; + _last = event.position; + _velocity?.addPosition(event.timeStamp, event.position); + var delta = event.position - previous; + if (!_dragging) { + final movement = event.position - start; + if (movement.dx.abs() < 12 || + movement.dx.abs() <= movement.dy.abs() * 1.5) { + return; + } + _dragging = true; + widget.onHorizontalDragStart?.call(DragStartDetails( + globalPosition: start, + )); + // Include movement accumulated before the horizontal intent threshold, + // otherwise the content visibly lags behind the first accepted frame. + delta = movement; + } + widget.onHorizontalDragUpdate?.call(DragUpdateDetails( + globalPosition: event.position, + delta: Offset(delta.dx, 0), + primaryDelta: delta.dx, + )); + } + + void _onUp(PointerUpEvent event) { + if (event.pointer != _pointer) return; + _velocity?.addPosition(event.timeStamp, event.position); + if (_dragging) { + final horizontalVelocity = + _velocity?.getVelocity().pixelsPerSecond.dx ?? 0.0; + widget.onHorizontalDragEnd?.call(DragEndDetails( + velocity: Velocity(pixelsPerSecond: Offset(horizontalVelocity, 0)), + primaryVelocity: horizontalVelocity, + )); + } + _clear(); + } + + void _onCancel(PointerCancelEvent event) { + if (event.pointer != _pointer) return; + if (_dragging) widget.onHorizontalDragCancel?.call(); + _clear(); + } + + void _clear() { + _pointer = null; + _start = null; + _last = null; + _velocity = null; + _dragging = false; + } + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: widget.behavior, + gestures: { + _EdgePostSwipeRecognizer: + GestureRecognizerFactoryWithHandlers<_EdgePostSwipeRecognizer>( + _EdgePostSwipeRecognizer.new, + (recognizer) => recognizer + ..isEdge = _isEdge + ..onStart = (_) {}, + ), + }, + child: Listener( + behavior: widget.behavior, + onPointerDown: _onDown, + onPointerMove: _onMove, + onPointerUp: _onUp, + onPointerCancel: _onCancel, + child: widget.child, + ), + ); + } +} + +class _EdgePostSwipeRecognizer extends HorizontalDragGestureRecognizer { + bool Function(Offset position)? isEdge; + + @override + bool isPointerAllowed(PointerEvent event) => + (isEdge?.call(event.position) ?? false) && super.isPointerAllowed(event); + + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + resolve(GestureDisposition.accepted); + } +} diff --git a/lib/Widgets/PostItem/common_info_post_item_builder.dart b/lib/Widgets/PostItem/common_info_post_item_builder.dart index 3abbb534..5b368161 100644 --- a/lib/Widgets/PostItem/common_info_post_item_builder.dart +++ b/lib/Widgets/PostItem/common_info_post_item_builder.dart @@ -6,7 +6,16 @@ import '../../Utils/utils.dart'; import '../PostItem/general_post_item_builder.dart'; class CommonInfoItemBuilder { - static GeneralPostItem getGeneralPostItem(PostDetailData item) { + static GeneralPostItem getGeneralPostItem( + PostDetailData item, { + void Function(bool liked)? onLikeChanged, + }) { + if (isInvalid(item)) { + return _invalidPostItem( + item.post?.id ?? item.postData?.id ?? 0, + onLikeChanged: onLikeChanged, + ); + } List photoLinks = []; switch (getPostType(item)) { case PostType.video: @@ -40,7 +49,7 @@ class CommonInfoItemBuilder { postId: item.post!.id, permalink: item.post!.permalink, collectionId: item.post!.postCollection?.id ?? 0, - liked: item.liked!, + liked: item.liked ?? false, blogName: item.post!.blogInfo!.blogName, blogNickName: item.post!.blogInfo!.blogNickName, title: item.post!.title, @@ -51,23 +60,54 @@ class CommonInfoItemBuilder { likeCount: item.post!.postCount?.favoriteCount ?? 0, tags: item.post!.tagList, bigAvaImg: item.post!.blogInfo!.bigAvaImg, + onLikeChanged: onLikeChanged, + ); + } + + static GeneralPostItem _invalidPostItem( + int postId, { + void Function(bool liked)? onLikeChanged, + }) { + return GeneralPostItem( + type: PostType.invalid, + photoLinks: const [], + blogId: 0, + postId: postId, + permalink: '', + collectionId: 0, + liked: false, + blogName: '', + blogNickName: '', + title: '', + digest: '', + content: '', + firstImageUrl: '', + duration: 0, + likeCount: 0, + tags: const [], + bigAvaImg: '', + onLikeChanged: onLikeChanged, ); } static Widget buildWaterfallFlowPostItem( - BuildContext context, PostDetailData item) { + BuildContext context, + PostDetailData item, { + Key? key, + void Function(bool liked)? onLikeChanged, + }) { return WaterfallFlowPostItemWidget( - key: ValueKey(item.post!.id), - item: getGeneralPostItem(item), + key: key ?? ValueKey(item.post?.id ?? item.postData?.id ?? 0), + item: getGeneralPostItem(item, onLikeChanged: onLikeChanged), ); } static bool hasImage(PostDetailData item) { - return item.post!.photoLinks.isNotEmpty; + return item.post?.photoLinks.isNotEmpty == true; } static bool isVideo(PostDetailData item) { - return item.post!.type == 4; + return item.post?.type == 4; } static bool isInvalid(PostDetailData item) { @@ -93,13 +133,16 @@ class CommonInfoItemBuilder { static Widget buildNineGridPostItem( BuildContext context, PostDetailData item, { + Key? key, double wh = 100, int? activePostId, + void Function(bool liked)? onLikeChanged, }) { return GridPostItemWidget( + key: key ?? ValueKey(item.post?.id ?? item.postData?.id ?? 0), wh: wh, activePostId: activePostId, - item: getGeneralPostItem(item), + item: getGeneralPostItem(item, onLikeChanged: onLikeChanged), ); } } diff --git a/lib/Widgets/PostItem/general_post_item.dart b/lib/Widgets/PostItem/general_post_item.dart new file mode 100644 index 00000000..5b9e9072 --- /dev/null +++ b/lib/Widgets/PostItem/general_post_item.dart @@ -0,0 +1,108 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; + +import '../../Models/grain_response.dart'; +import '../../Models/post_detail_response.dart'; +import '../../Utils/enums.dart'; +import '../../Utils/post_sequence_source.dart'; + +class GeneralPostItem { + GeneralPostItem({ + this.showLikeButton = true, + this.showArticle = true, + this.showVideo = true, + this.tagPrefix, + this.photoCount, + this.shareInfo, + this.followed, + this.shared = false, + this.shareCount = 0, + this.commentCount = 0, + required this.type, + required this.photoLinks, + required this.blogId, + required this.postId, + required this.permalink, + required this.collectionId, + required this.liked, + required this.blogName, + required this.blogNickName, + required this.title, + required this.digest, + required this.content, + required this.firstImageUrl, + required this.duration, + required this.likeCount, + required this.tags, + required this.bigAvaImg, + this.excludeTag, + this.publishTime = 0, + this.opTime = 0, + this.showMoreButton = false, + this.onShieldTag, + this.onShieldContent, + this.onShieldUser, + this.onLikeChanged, + this.sequenceSource, + }); + + PostType type; + List photoLinks; + int blogId; + int publishTime; + int opTime; + int postId; + String permalink; + int collectionId; + bool liked; + bool shared; + bool? followed; + String blogName; + String blogNickName; + String title; + String digest; + String content; + String firstImageUrl; + int duration; + int likeCount; + int shareCount; + int commentCount; + List tags; + String bigAvaImg; + int? photoCount; + String? tagPrefix; + bool? showVideo; + bool? showArticle; + bool? showLikeButton; + String? excludeTag; + bool showMoreButton; + ShareInfo? shareInfo; + final Function(String tag)? onShieldTag; + final Function()? onShieldContent; + final Function()? onShieldUser; + final void Function(bool liked)? onLikeChanged; + final PostSequenceSource? sequenceSource; + + bool get hasTitleOrContent { + final normalizedTitle = StringUtil.clearBlank(title); + final normalizedContent = + StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(content)); + final normalizedDigest = + StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(digest)); + return StringUtil.isNotEmpty(normalizedTitle) || + StringUtil.isNotEmpty(normalizedContent) || + StringUtil.isNotEmpty(normalizedDigest); + } + + String get processedTitle { + final normalizedTitle = StringUtil.clearBlank(title); + final normalizedDigest = + StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(digest)); + final normalizedContent = + StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(content)); + return StringUtil.isNotEmpty(normalizedTitle) + ? normalizedTitle + : StringUtil.isNotEmpty(normalizedDigest) + ? normalizedDigest + : normalizedContent; + } +} diff --git a/lib/Widgets/PostItem/general_post_item_builder.dart b/lib/Widgets/PostItem/general_post_item_builder.dart index b3e0d1f2..e33e26d9 100644 --- a/lib/Widgets/PostItem/general_post_item_builder.dart +++ b/lib/Widgets/PostItem/general_post_item_builder.dart @@ -1,19 +1,18 @@ -import 'dart:math'; - import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:like_button/like_button.dart'; -import 'package:loftify/Screens/Post/video_detail_screen.dart'; import 'package:loftify/Widgets/BottomSheet/shield_bottom_sheet.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item.dart'; import '../../Api/post_api.dart'; import '../../Api/user_api.dart'; -import '../../Models/grain_response.dart'; import '../../Models/illust.dart'; import '../../Models/post_detail_response.dart'; import '../../Screens/Info/user_detail_screen.dart'; import '../../Screens/Post/post_detail_screen.dart'; +import '../../Screens/Post/video_detail_screen.dart'; +import '../../Theme/loftify_design_theme.dart'; import '../../Utils/enums.dart'; import '../../Utils/hive_util.dart'; import '../../Utils/uri_util.dart'; @@ -21,103 +20,92 @@ import '../../Utils/utils.dart'; import '../../l10n/l10n.dart'; import '../Item/item_builder.dart'; import '../Item/loftify_item_builder.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; +import '../loftify_reaction_icon.dart'; import 'image_grid.dart'; -class GeneralPostItem { - PostType type; - List photoLinks; - int blogId; - int publishTime; - int opTime; - int postId; - String permalink; - int collectionId; - bool liked; - bool shared; - bool? followed; - String blogName; - String blogNickName; - String title; - String digest; - String content; - String firstImageUrl; - int duration; - int likeCount; - int shareCount; - int commentCount; - List tags; - String bigAvaImg; - int? photoCount; - String? tagPrefix; - bool? showVideo; - bool? showArticle; - bool? showLikeButton; - String? excludeTag; - bool showMoreButton; - ShareInfo? shareInfo; - final Function(String tag)? onShieldTag; - final Function()? onShieldContent; - final Function()? onShieldUser; +export 'package:loftify/Widgets/PostItem/general_post_item.dart'; - GeneralPostItem({ - this.showLikeButton = true, - this.showArticle = true, - this.showVideo = true, - this.tagPrefix, - this.photoCount, - this.shareInfo, - this.followed, - this.shared = false, - this.shareCount = 0, - this.commentCount = 0, - required this.type, - required this.photoLinks, - required this.blogId, - required this.postId, - required this.permalink, - required this.collectionId, - required this.liked, - required this.blogName, - required this.blogNickName, - required this.title, - required this.digest, - required this.content, - required this.firstImageUrl, - required this.duration, - required this.likeCount, - required this.tags, - required this.bigAvaImg, - this.excludeTag, - this.publishTime = 0, - this.opTime = 0, - this.showMoreButton = false, - this.onShieldTag, - this.onShieldContent, - this.onShieldUser, - }); +const double _postCardRadius = 12; - bool get hasTitleOrContent { - var item = this; - String title = StringUtil.clearBlank(item.title); - String content = StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(item.content)); - String digest = StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(item.digest)); - return (StringUtil.isNotEmpty(title) || - StringUtil.isNotEmpty(content) || - StringUtil.isNotEmpty(digest)); - } +double _safePhotoAspectRatio(PhotoLink photo) { + if (photo.ow <= 0 || photo.oh <= 0) return 1; + final ratio = photo.ow / photo.oh; + return ratio.isFinite && ratio > 0 ? ratio : 1; +} - String get processedTitle { - var item = this; - String title = StringUtil.clearBlank(item.title); - String digest = StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(item.digest)); - String content = StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(item.content)); - String shownTitle = StringUtil.isNotEmpty(title) - ? title - : StringUtil.isNotEmpty(digest) - ? digest - : content; - return shownTitle; - } +Widget _buildInvalidPostCard( + BuildContext context, { + double? width, + double? height, + double minHeight = 96, + double radius = _postCardRadius, +}) { + return ContainerItem( + backgroundColor: ChewieTheme.canvasColor, + radius: radius, + roundTop: true, + roundBottom: true, + border: Border.all(color: Theme.of(context).dividerColor, width: 0.8), + child: SizedBox( + width: width, + height: height, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: Center( + child: Padding( + padding: const EdgeInsets.all(12), + child: LayoutBuilder(builder: (context, constraints) { + final label = appLocalizations.invalidContent; + final style = Theme.of(context) + .textTheme + .labelSmall + ?.apply(fontWeightDelta: 1); + final metrics = TextPainter( + text: TextSpan(text: 'Ag', style: style), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final lineHeight = metrics.height; + metrics.dispose(); + final showIcon = !constraints.hasBoundedHeight || + constraints.maxHeight >= 29 + lineHeight * 2; + final availableHeight = + constraints.maxHeight - (showIcon ? 29 : 0); + final lines = constraints.hasBoundedHeight && lineHeight > 0 + ? (availableHeight / lineHeight).floor().clamp(1, 4) + : 4; + return Tooltip( + message: label, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showIcon) ...[ + ChewieIcon( + LoftifyIcons.invalidContent, + color: Theme.of(context).colorScheme.onSurfaceVariant, + size: 24, + ), + const SizedBox(height: 5), + ], + Text( + label, + semanticsLabel: label, + maxLines: lines, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: style, + ), + ], + ), + ); + }), + ), + ), + ), + ), + ); } class WaterfallFlowPostItemWidget extends StatefulWidget { @@ -143,74 +131,113 @@ class WaterfallFlowPostItemWidgetState item = widget.item; } + @override + void didUpdateWidget(covariant WaterfallFlowPostItemWidget oldWidget) { + super.didUpdateWidget(oldWidget); + item = widget.item; + } + @override Widget build(BuildContext context) { - return buildWaterfallFlowPostItem(); + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.sizeOf(context).width; + return buildWaterfallFlowPostItem(width: availableWidth); + }, + ); } - Widget buildWaterfallFlowPostItem() { - double width = (MediaQuery.sizeOf(context).width - 24) / 2; + Widget buildWaterfallFlowPostItem({required double width}) { + final radius = context.design.radii.card; PostType type = item.type; late Widget main; switch (type) { case PostType.image: - main = buildWaterfallFlowImageItem(width: width); + main = item.photoLinks.isNotEmpty + ? buildWaterfallFlowImageItem(width: width) + : item.hasTitleOrContent + ? buildWaterfallFlowArticleItem(width: width) + : _buildInvalidPostCard( + context, + width: width, + minHeight: 120, + radius: radius, + ); case PostType.article: main = item.showArticle ?? true ? buildWaterfallFlowArticleItem(width: width) : emptyWidget; case PostType.video: main = item.showVideo ?? true - ? buildWaterfallFlowVideoItem(width: width) + ? item.photoLinks.isNotEmpty + ? buildWaterfallFlowVideoItem(width: width) + : item.hasTitleOrContent + ? buildWaterfallFlowArticleItem(width: width) + : _buildInvalidPostCard( + context, + width: width, + minHeight: 120, + radius: radius, + ) : emptyWidget; case PostType.grain: main = emptyWidget; case PostType.invalid: - main = emptyWidget; + main = _buildInvalidPostCard( + context, + width: width, + minHeight: 120, + radius: radius, + ); } return GestureDetector( - onTap: () { - GeneralPostItemBuilder.onTapItem(context, item); - }, + behavior: HitTestBehavior.opaque, + onTap: () => GeneralPostItemBuilder.onTapItem(context, item), onLongPress: item.showMoreButton ? () { HapticFeedback.mediumImpact(); GeneralPostItemBuilder.showMoreSheet(context, item); } : null, - child: ClickableWrapper(child:main), + child: ClickableWrapper(child: main), ); } Widget buildWaterfallFlowArticleItem({ required double width, }) { + final design = context.design; return Column( children: [ - ContainerItem( - backgroundColor: Theme.of(context).cardColor, - child: Container( - padding: const EdgeInsets.all(15), + LoftifyCard( + variant: LoftifyCardVariant.muted, + padding: EdgeInsets.all(design.spacing.lg), + child: SizedBox( width: width, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.title, - style: Theme.of(context).textTheme.titleMedium?.apply( - fontWeightDelta: 2, - fontSizeDelta: -1, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - Text( - HtmlUtil.extractTextFromHtml(item.digest), - maxLines: 6, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), + if (StringUtil.isNotEmpty(item.title)) + Text( + item.title, + style: design.typography.cardTitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + if (StringUtil.isNotEmpty(item.title) && + StringUtil.isNotEmpty(item.digest)) + SizedBox(height: design.spacing.lg), + if (StringUtil.isNotEmpty(item.digest)) + Text( + HtmlUtil.extractTextFromHtml(item.digest), + maxLines: 6, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + ), ], ), ), @@ -224,38 +251,29 @@ class WaterfallFlowPostItemWidgetState Widget buildWaterfallFlowImageItem({ required double width, - double maxHeight = 300, + double? maxHeight, double minHeight = 120, }) { + final design = context.design; + final radius = design.radii.card; + final effectiveMaxHeight = maxHeight ?? design.grid.maximumDenseCardExtent; return Column( children: [ Stack( children: [ - Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, width: 0.3), - borderRadius: BorderRadius.circular(10), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - height: max( - min(item.photoLinks[0].oh * (width / item.photoLinks[0].ow), - maxHeight), - minHeight, - ), - width: width, - child: ChewieItemBuilder.buildCachedImage( - context: context, - fit: BoxFit.cover, - showLoading: false, - imageUrl: Utils.getUrlByQuality( - item.photoLinks[0].middle, - HiveUtil.getImageQuality( - HiveUtil.waterfallFlowImageQualityKey)), - ), - ), + _buildWaterfallMediaSurface( + width: width, + height: (width / _safePhotoAspectRatio(item.photoLinks[0])) + .clamp(minHeight, effectiveMaxHeight), + radius: radius, + child: ChewieItemBuilder.buildCachedImage( + context: context, + fit: BoxFit.cover, + showLoading: false, + imageUrl: Utils.getUrlByQuality( + item.photoLinks[0].middle, + HiveUtil.getImageQuality( + HiveUtil.waterfallFlowImageQualityKey)), ), ), if (Utils.isGIF(item.firstImageUrl)) @@ -286,38 +304,30 @@ class WaterfallFlowPostItemWidgetState Widget buildWaterfallFlowVideoItem({ required double width, - double maxHeight = 300, + double? maxHeight, double minHeight = 120, }) { - var height = max( - min(item.photoLinks[0].oh * (width / item.photoLinks[0].ow), maxHeight), - minHeight, - ); + final design = context.design; + final radius = design.radii.card; + final effectiveMaxHeight = maxHeight ?? design.grid.maximumDenseCardExtent; + final height = (width / _safePhotoAspectRatio(item.photoLinks[0])) + .clamp(minHeight, effectiveMaxHeight); return Column( children: [ Stack( children: [ - Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, width: 0.3), - borderRadius: BorderRadius.circular(10), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - height: height.isNaN ? maxHeight : height, - width: width, - child: ChewieItemBuilder.buildCachedImage( - context: context, - fit: BoxFit.cover, - showLoading: false, - imageUrl: Utils.getUrlByQuality( - item.photoLinks[0].orign, - HiveUtil.getImageQuality( - HiveUtil.waterfallFlowImageQualityKey)), - ), - ), + _buildWaterfallMediaSurface( + width: width, + height: height, + radius: radius, + child: ChewieItemBuilder.buildCachedImage( + context: context, + fit: BoxFit.cover, + showLoading: false, + imageUrl: Utils.getUrlByQuality( + item.photoLinks[0].orign, + HiveUtil.getImageQuality( + HiveUtil.waterfallFlowImageQualityKey)), ), ), Positioned( @@ -341,9 +351,44 @@ class WaterfallFlowPostItemWidgetState ); } + Widget _buildWaterfallMediaSurface({ + required double width, + required double height, + required double radius, + required Widget child, + }) { + final design = context.design; + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox( + key: ValueKey('waterfall-post-media-${item.postId}'), + width: width, + height: height, + child: Stack( + fit: StackFit.expand, + children: [ + child, + IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: design.colors.outline, + width: design.borders.hairline, + ), + borderRadius: BorderRadius.circular(radius), + ), + ), + ), + ], + ), + ), + ); + } + Widget buildWaterfallFlowPostItemMeta({ bool showTitle = true, }) { + final design = context.design; String tag = ""; if (item.tags.isNotEmpty) { tag = item.tags[0]; @@ -356,7 +401,10 @@ class WaterfallFlowPostItemWidgetState String shownTitle = item.processedTitle; bool hasTitle = item.hasTitleOrContent; return Container( - padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 6), + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xs, + vertical: design.spacing.sm, + ), child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -369,26 +417,24 @@ class WaterfallFlowPostItemWidgetState showTitle && hasTitle ? Container( width: double.infinity, - margin: const EdgeInsets.only(bottom: 3), + margin: EdgeInsets.only( + bottom: design.spacing.xxs, + ), child: Text( shownTitle, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.start, - style: Theme.of(context) - .textTheme - .titleMedium - ?.apply( - fontSizeDelta: -2, - fontWeightDelta: 2, - ), + style: design.typography.cardTitle.copyWith( + color: design.colors.textPrimary, + ), ), ) - : const SizedBox(height: 3), + : SizedBox(height: design.spacing.xxs), if (tag.isNotEmpty) Container( width: double.infinity, - margin: const EdgeInsets.only(bottom: 6), + margin: EdgeInsets.only(bottom: design.spacing.sm), alignment: Alignment.centerLeft, child: ItemBuilder.buildSmallTagItem(context, tag), ), @@ -396,22 +442,34 @@ class WaterfallFlowPostItemWidgetState ), ), if (item.showMoreButton) - GestureDetector( - onTap: () { - GeneralPostItemBuilder.showMoreSheet(context, item); - }, - child: Container( - margin: EdgeInsets.only(top: showTitle && hasTitle ? 3 : 5), - child: const Icon( - Icons.more_vert_rounded, - size: 16, + Semantics( + button: true, + label: MaterialLocalizations.of(context).showMenuTooltip, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + GeneralPostItemBuilder.showMoreSheet(context, item); + }, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: design.spacing.md, + top: showTitle && hasTitle + ? design.spacing.xxs + : design.spacing.xs, + bottom: design.spacing.md, + ), + child: ChewieIcon( + LoftifyIcons.moreVertical, + size: design.icons.small, + color: design.colors.textSecondary, + ), ), ), ), ], ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), + padding: EdgeInsets.symmetric(horizontal: design.spacing.xxs), child: Row( children: [ GestureDetector( @@ -425,7 +483,7 @@ class WaterfallFlowPostItemWidgetState ); }, child: Container( - margin: const EdgeInsets.only(right: 5), + margin: EdgeInsets.only(right: design.spacing.sm), child: ItemBuilder.buildAvatar( context: context, imageUrl: item.bigAvaImg, @@ -447,12 +505,14 @@ class WaterfallFlowPostItemWidgetState }, child: Text( item.blogNickName, - style: Theme.of(context).textTheme.bodySmall, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), overflow: TextOverflow.ellipsis, ), ), ), - const SizedBox(width: 5), + SizedBox(width: design.spacing.sm), if (item.showLikeButton == true) LoftifyItemBuilder.buildLikedButton( context, @@ -463,8 +523,10 @@ class WaterfallFlowPostItemWidgetState size: 16, iconSize: 16, likeCountPadding: const EdgeInsets.only(left: 3), - defaultColor: Theme.of(context).textTheme.bodySmall?.color, - countStyle: Theme.of(context).textTheme.bodySmall, + defaultColor: design.colors.textSecondary, + countStyle: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), onTap: (_) async { HapticFeedback.mediumImpact(); int status = await PostApi.likeOrUnLike( @@ -472,6 +534,7 @@ class WaterfallFlowPostItemWidgetState postId: item.postId, blogId: item.blogId, ).then((value) { + if (!mounted) return value['meta']['status']; setState(() { if (value['meta']['status'] != 200) { IToast.showTop( @@ -481,10 +544,12 @@ class WaterfallFlowPostItemWidgetState item.likeCount += item.liked ? 1 : -1; item.likeCount = item.likeCount.clamp(0, 100000000000000000); + item.onLikeChanged?.call(item.liked); } }); return value['meta']['status']; }); + if (!mounted) return item.liked; if (status == 4071) { Utils.validSlideCaptcha(context); } @@ -526,6 +591,12 @@ class GridPostItemWidgetState extends State { item = widget.item; } + @override + void didUpdateWidget(covariant GridPostItemWidget oldWidget) { + super.didUpdateWidget(oldWidget); + item = widget.item; + } + @override Widget build(BuildContext context) { return buildGridPostItem( @@ -545,21 +616,48 @@ class GridPostItemWidgetState extends State { late Widget main; switch (item.type) { case PostType.image: - main = buildNineGridImageItem(wh: wh, activePostId: activePostId); + main = item.photoLinks.isNotEmpty + ? buildNineGridImageItem(wh: wh, activePostId: activePostId) + : item.hasTitleOrContent + ? buildNineGridArticleItem( + wh: wh, + activePostId: activePostId, + ) + : buildInvalidItem(wh: wh); case PostType.article: main = buildNineGridArticleItem(wh: wh, activePostId: activePostId); case PostType.video: - main = buildNineGridVideoItem(wh: wh, activePostId: activePostId); + main = item.photoLinks.isNotEmpty + ? buildNineGridVideoItem(wh: wh, activePostId: activePostId) + : item.hasTitleOrContent + ? buildNineGridArticleItem( + wh: wh, + activePostId: activePostId, + ) + : buildInvalidItem(wh: wh); case PostType.grain: main = emptyWidget; case PostType.invalid: main = buildInvalidItem(wh: wh); } - return GestureDetector( - onTap: () { - GeneralPostItemBuilder.onTapItem(context, item); - }, - child: ClickableWrapper(child:main), + return Material( + color: item.type == PostType.article + ? Theme.of(context).cardColor + : item.type == PostType.invalid + ? ChewieTheme.canvasColor + : Colors.transparent, + borderRadius: BorderRadius.circular(_postCardRadius), + child: InkWell( + borderRadius: BorderRadius.circular(_postCardRadius), + onTap: () => GeneralPostItemBuilder.onTapItem(context, item), + onLongPress: item.showMoreButton + ? () { + HapticFeedback.mediumImpact(); + GeneralPostItemBuilder.showMoreSheet(context, item); + } + : null, + child: ClickableWrapper(child: main), + ), ); } @@ -579,10 +677,10 @@ class GridPostItemWidgetState extends State { color: Theme.of(context).primaryColor, width: 1.6) : Border.all( color: Theme.of(context).dividerColor, width: 0.8), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_postCardRadius), ), child: ClipRRect( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(_postCardRadius), child: SizedBox( height: wh, width: wh, @@ -617,8 +715,10 @@ class GridPostItemWidgetState extends State { required double wh, }) { return ContainerItem( - backgroundColor: Theme.of(context).cardColor, - radius: 12, + backgroundColor: Colors.transparent, + radius: _postCardRadius, + roundTop: true, + roundBottom: true, border: activePostId == item.postId ? Border.all(color: Theme.of(context).primaryColor, width: 1.6) : Border.all(color: Theme.of(context).dividerColor, width: 0.8), @@ -641,7 +741,8 @@ class GridPostItemWidgetState extends State { SizedBox(height: item.title.isNotEmpty ? 5 : 5), Expanded( child: Text( - StringUtil.clearBlank(HtmlUtil.extractTextFromHtml(item.digest)), + StringUtil.clearBlank( + HtmlUtil.extractTextFromHtml(item.digest)), maxLines: item.title.isNotEmpty ? 6 : 8, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.apply( @@ -671,10 +772,10 @@ class GridPostItemWidgetState extends State { color: Theme.of(context).primaryColor, width: 1.6) : Border.all( color: Theme.of(context).dividerColor, width: 0.8), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_postCardRadius), ), child: ClipRRect( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(_postCardRadius), child: SizedBox( height: wh, width: wh, @@ -709,34 +810,11 @@ class GridPostItemWidgetState extends State { } Widget buildInvalidItem({required double wh}) { - return ContainerItem( - backgroundColor: ChewieTheme.canvasColor, - border: Border.all(color: Theme.of(context).dividerColor, width: 0.8), - child: Container( - padding: const EdgeInsets.all(5), - width: wh, - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline_rounded, - color: Theme.of(context).textTheme.labelSmall?.color, - size: 24, - ), - const SizedBox(height: 5), - Text( - appLocalizations.invalidContent, - maxLines: 4, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .labelSmall - ?.apply(fontWeightDelta: 1), - ), - ], - ), - ), + return _buildInvalidPostCard( + context, + width: wh, + height: wh, + minHeight: wh, ); } } @@ -754,7 +832,7 @@ class TilePostItemWidget extends StatefulWidget { } class TilePostItemWidgetState extends State - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with TickerProviderStateMixin { late GeneralPostItem item; @override @@ -763,33 +841,57 @@ class TilePostItemWidgetState extends State item = widget.item; } + @override + void didUpdateWidget(covariant TilePostItemWidget oldWidget) { + super.didUpdateWidget(oldWidget); + item = widget.item; + } + @override Widget build(BuildContext context) { - super.build(context); return buildTilePostItem(); } Widget buildTilePostItem() { - double width = (MediaQuery.sizeOf(context).width - 24) / 2; PostType type = item.type; late Widget main; switch (type) { case PostType.image: - main = buildTileImageItem(); + main = item.photoLinks.isNotEmpty + ? buildTileImageItem() + : item.hasTitleOrContent + ? buildTileArticleItem() + : _buildInvalidPostCard( + context, + width: double.infinity, + ); case PostType.article: main = item.showArticle ?? true ? buildTileArticleItem() : emptyWidget; case PostType.video: - main = item.showVideo ?? true ? buildTileVideoItem() : emptyWidget; + main = item.showVideo ?? true + ? item.photoLinks.isNotEmpty + ? buildTileVideoItem() + : item.hasTitleOrContent + ? buildTileArticleItem() + : _buildInvalidPostCard( + context, + width: double.infinity, + ) + : emptyWidget; case PostType.grain: - case PostType.invalid: main = emptyWidget; + case PostType.invalid: + main = _buildInvalidPostCard( + context, + width: double.infinity, + ); } - var res = Material( + final isLandscape = ResponsiveUtil.isLandscapeLayout(); + return Material( color: Colors.transparent, child: InkWell( - onTap: () { - GeneralPostItemBuilder.onTapItem(context, item); - }, + borderRadius: BorderRadius.circular(_postCardRadius), + onTap: () => GeneralPostItemBuilder.onTapItem(context, item), onLongPress: item.showMoreButton ? () { HapticFeedback.mediumImpact(); @@ -797,38 +899,26 @@ class TilePostItemWidgetState extends State } : null, child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - width: 0.3, - ), - ), + padding: EdgeInsets.only( + left: 12, + right: 12, + top: !isLandscape && widget.isFirst ? 0 : 12, + bottom: isLandscape ? 12 : 0, ), + decoration: isLandscape + ? BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 0.3, + ), + ), + ) + : null, child: main, ), ), ); - return ResponsiveUtil.isLandscapeLayout() - ? res - : GestureDetector( - onTap: () { - GeneralPostItemBuilder.onTapItem(context, item); - }, - onLongPress: item.showMoreButton - ? () { - HapticFeedback.mediumImpact(); - GeneralPostItemBuilder.showMoreSheet(context, item); - } - : null, - child: Container( - color: Colors.transparent, - padding: EdgeInsets.only( - left: 12, right: 12, top: widget.isFirst ? 0 : 12), - child: main, - ), - ); } Widget buildTileShareRow() { @@ -851,8 +941,8 @@ class TilePostItemWidgetState extends State padding: const EdgeInsets.only(bottom: 8, left: 8), child: Row( children: [ - Icon( - Icons.thumb_up_alt, + ChewieIcon( + LoftifyIcons.recommend, color: Theme.of(context).textTheme.bodySmall?.color, size: 16, ), @@ -943,7 +1033,10 @@ class TilePostItemWidgetState extends State ), if (item.followed != true) const SizedBox(width: 8), CircleIconButton( - icon: const Icon(Icons.more_vert_rounded, size: 20), + icon: const ChewieIcon( + LoftifyIcons.moreVertical, + size: 20, + ), onTap: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); @@ -955,12 +1048,12 @@ class TilePostItemWidgetState extends State ); } - _buildMoreButtons() { + FlutterContextMenu _buildMoreButtons() { return FlutterContextMenu( entries: [ FlutterContextMenuItem( appLocalizations.copyLink, - iconData: Icons.copy_rounded, + iconData: LoftifyIcons.copy, onPressed: () { Utils.copy( context, @@ -973,7 +1066,7 @@ class TilePostItemWidgetState extends State }, ), FlutterContextMenuItem(appLocalizations.visitOriginalPost, - iconData: Icons.view_carousel_outlined, onPressed: () { + iconData: LoftifyIcons.originalPost, onPressed: () { UriUtil.openInternal( context, LoftifyUriUtil.getPostUrlById( @@ -985,7 +1078,7 @@ class TilePostItemWidgetState extends State ); }), FlutterContextMenuItem(appLocalizations.openWithBrowser, - iconData: Icons.open_in_browser_rounded, onPressed: () { + iconData: LoftifyIcons.openExternal, onPressed: () { UriUtil.openExternal( LoftifyUriUtil.getPostUrlById( item.blogName, @@ -995,7 +1088,7 @@ class TilePostItemWidgetState extends State ); }), FlutterContextMenuItem(appLocalizations.shareToOtherApps, - iconData: Icons.share_rounded, onPressed: () { + iconData: LoftifyIcons.share, onPressed: () { UriUtil.share( LoftifyUriUtil.getPostUrlById( item.blogName, @@ -1023,6 +1116,7 @@ class TilePostItemWidgetState extends State if (content.isNotEmpty) CustomHtmlWidget( content: content, + selectable: false, style: Theme.of(context).textTheme.bodyMedium, // linkBold: false, // selectable: false, @@ -1060,7 +1154,7 @@ class TilePostItemWidgetState extends State Widget buildTileImageItem() { var grid = ImageGrid( - ratios: item.photoLinks.map((e) => e.ow / e.oh).toList(), + ratios: item.photoLinks.map(_safePhotoAspectRatio).toList(), itemCount: item.photoLinks.length, itemBuilder: (BuildContext context, int index, BorderRadius radius) { radius = @@ -1081,8 +1175,11 @@ class TilePostItemWidgetState extends State showClose: false, fullScreen: true, useFade: true, + opaque: false, HeroPhotoViewScreen( - imageUrls: _getImageIllusts(), + imageUrls: _getImageIllusts() + .map((illust) => illust.url) + .toList(), initIndex: index, tagPrefix: "TilePost", useMainColor: true, @@ -1115,7 +1212,7 @@ class TilePostItemWidgetState extends State ], ), ); - double ratio = item.photoLinks[index].ow / item.photoLinks[index].oh; + double ratio = _safePhotoAspectRatio(item.photoLinks[index]); ratio = ratio.clamp(0.8, 1.6); bool isSingle = item.photoLinks.length == 1; return Container( @@ -1165,7 +1262,7 @@ class TilePostItemWidgetState extends State double maxHeight = 300, double minHeight = 120, }) { - double ratio = item.photoLinks[0].ow / item.photoLinks[0].oh; + double ratio = _safePhotoAspectRatio(item.photoLinks[0]); ratio = ratio.clamp(0.8, 1.6); return Column( children: [ @@ -1177,10 +1274,10 @@ class TilePostItemWidgetState extends State decoration: BoxDecoration( border: Border.all( color: Theme.of(context).dividerColor, width: 0.3), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(_postCardRadius), ), child: ClipRRect( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(_postCardRadius), child: AspectRatio( aspectRatio: ratio, child: ChewieItemBuilder.buildCachedImage( @@ -1259,16 +1356,12 @@ class TilePostItemWidgetState extends State context, text: StringUtil.formatCount(item.likeCount), spacing: 4, - icon: !item.liked - ? const Icon( - Icons.favorite_border_rounded, - size: 20, - ) - : const Icon( - Icons.favorite_rounded, - color: ChewieColors.likeButtonColor, - size: 20, - ), + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.like, + selected: item.liked, + color: item.liked ? LoftifyReactionColors.like : null, + size: 20, + ), onTap: () { _handleLike(); }, @@ -1278,16 +1371,12 @@ class TilePostItemWidgetState extends State context, text: StringUtil.formatCount(item.shareCount), spacing: 4, - icon: !item.shared - ? const Icon( - Icons.thumb_up_outlined, - size: 18, - ) - : const Icon( - Icons.thumb_up, - color: ChewieColors.shareButtonColor, - size: 18, - ), + icon: LoftifyReactionIcon( + kind: LoftifyReactionKind.recommend, + selected: item.shared, + color: item.shared ? LoftifyReactionColors.recommend : null, + size: 18, + ), onTap: () { _handleRecommend(); }, @@ -1296,7 +1385,7 @@ class TilePostItemWidgetState extends State ItemBuilder.buildIconTextButton( context, text: appLocalizations.comment, - icon: const Icon(Icons.mode_comment_outlined, size: 18), + icon: const ChewieIcon(LoftifyIcons.comment, size: 18), spacing: 4, onTap: () { RouteUtil.pushPanelCupertinoRoute( @@ -1304,6 +1393,7 @@ class TilePostItemWidgetState extends State PostDetailScreen( generalPostItem: item, isArticle: item.type == PostType.article, + sequenceSource: item.sequenceSource, ), ); }, @@ -1326,66 +1416,64 @@ class TilePostItemWidgetState extends State ); } - _handleLike() async { + Future _handleLike() async { HapticFeedback.mediumImpact(); - await PostApi.likeOrUnLike( + final value = await PostApi.likeOrUnLike( isLike: !item.liked, postId: item.postId, blogId: item.blogId, - ).then((value) { - if (value['meta']['status'] != 200) { - if (StringUtil.isNotEmpty(value['meta']['desc']) && - StringUtil.isNotEmpty(value['meta']['msg'])) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } - if (value['meta']['status'] == 4071) { - Utils.validSlideCaptcha(context); - } - } else { - item.liked = !item.liked; - if (item.liked != true) { - IToast.showTop(appLocalizations.unlike); - } - item.likeCount += item.liked ? 1 : -1; - item.likeCount = item.likeCount.clamp(0, 100000000000000000); + ); + if (!mounted) return; + if (value['meta']['status'] != 200) { + if (StringUtil.isNotEmpty(value['meta']['desc']) && + StringUtil.isNotEmpty(value['meta']['msg'])) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } + if (value['meta']['status'] == 4071) { + Utils.validSlideCaptcha(context); + } + } else { + item.liked = !item.liked; + if (item.liked != true) { + IToast.showTop(appLocalizations.unlike); } - setState(() {}); - }); + item.likeCount += item.liked ? 1 : -1; + item.likeCount = item.likeCount.clamp(0, 100000000000000000); + item.onLikeChanged?.call(item.liked); + } + setState(() {}); } - _handleRecommend() async { + Future _handleRecommend() async { HapticFeedback.mediumImpact(); - await PostApi.shareOrUnShare( + final value = await PostApi.shareOrUnShare( isShare: !item.shared, postId: item.postId, blogId: item.blogId, - ).then((value) { - if (value['meta']['status'] != 200) { - if (StringUtil.isNotEmpty(value['meta']['desc']) && - StringUtil.isNotEmpty(value['meta']['msg'])) { - IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); - } - if (value['meta']['status'] == 4071) { - Utils.validSlideCaptcha(context); - } - } else { - item.shared = !item.shared; - if (item.shared) { - IToast.showTop(appLocalizations.unrecommend); - } - item.shareCount += item.shared ? 1 : -1; - item.shareCount = item.shareCount.clamp(0, 100000000000000000); + ); + if (!mounted) return; + if (value['meta']['status'] != 200) { + if (StringUtil.isNotEmpty(value['meta']['desc']) && + StringUtil.isNotEmpty(value['meta']['msg'])) { + IToast.showTop(value['meta']['desc'] ?? value['meta']['msg']); + } + if (value['meta']['status'] == 4071) { + Utils.validSlideCaptcha(context); + } + } else { + item.shared = !item.shared; + if (item.shared) { + IToast.showTop(appLocalizations.unrecommend); } - setState(() {}); - }); + item.shareCount += item.shared ? 1 : -1; + item.shareCount = item.shareCount.clamp(0, 100000000000000000); + } + setState(() {}); } - - @override - bool get wantKeepAlive => true; } class GeneralPostItemBuilder { - static onTapItem(BuildContext context, GeneralPostItem item) { + static void onTapItem(BuildContext context, GeneralPostItem item) { if (item.type == PostType.invalid) { IToast.showTop(appLocalizations.invalidContent); } else if (item.type == PostType.video) { @@ -1403,34 +1491,33 @@ class GeneralPostItemBuilder { PostDetailScreen( generalPostItem: item, isArticle: item.type == PostType.article, + sequenceSource: item.sequenceSource, ), ); } } - static showMoreSheet(BuildContext context, GeneralPostItem item) { - showDialog( - context: context, - builder: (context) { - return BottomSheetWrapperWidget( - preferMinWidth: 400, - child: ShieldBottomSheet( - tags: item.tags, - onShieldContent: () { - item.onShieldContent?.call(); - Navigator.pop(context); - }, - onShieldUser: () { - item.onShieldUser?.call(); - Navigator.pop(context); - }, - onShieldTag: (tag) { - item.onShieldTag?.call(tag); - Navigator.pop(context); - }, - ), - ); - }, + static void showMoreSheet(BuildContext context, GeneralPostItem item) { + BottomSheetBuilder.showBottomSheet( + context, + (sheetContext) => ShieldBottomSheet( + tags: item.tags, + onShieldContent: () { + item.onShieldContent?.call(); + Navigator.pop(sheetContext); + }, + onShieldUser: () { + item.onShieldUser?.call(); + Navigator.pop(sheetContext); + }, + onShieldTag: (tag) { + item.onShieldTag?.call(tag); + Navigator.pop(sheetContext); + }, + ), + responsive: true, + preferMinWidth: 400, + topRadius: const Radius.circular(24), ); } } diff --git a/lib/Widgets/PostItem/grain_post_item_builder.dart b/lib/Widgets/PostItem/grain_post_item_builder.dart index c286ea76..57c1ddd9 100644 --- a/lib/Widgets/PostItem/grain_post_item_builder.dart +++ b/lib/Widgets/PostItem/grain_post_item_builder.dart @@ -1,13 +1,18 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:loftify/Models/grain_response.dart'; import '../../Models/post_detail_response.dart'; import '../../Utils/enums.dart'; +import '../../Utils/post_sequence_source.dart'; import '../../Utils/utils.dart'; import 'general_post_item_builder.dart'; class GrainPostItemBuilder { - static GeneralPostItem getGeneralPostItem(GrainPostItem item) { + static GeneralPostItem getGeneralPostItem( + GrainPostItem item, { + PostSequenceSource? sequenceSource, + }) { List photoLinks = []; switch (getPostType(item)) { case PostType.video: @@ -83,6 +88,7 @@ class GrainPostItemBuilder { shareCount: item.postData.postCountView.shareCount, commentCount: item.postData.postCountView.responseCount, shared: item.shared, + sequenceSource: sequenceSource, ); } @@ -144,11 +150,12 @@ class GrainPostItemBuilder { GrainPostItem item, { double wh = 100, int? activePostId, + PostSequenceSource? sequenceSource, }) { return GridPostItemWidget( wh: wh, activePostId: activePostId, - item: getGeneralPostItem(item), + item: getGeneralPostItem(item, sequenceSource: sequenceSource), ); } } diff --git a/lib/Widgets/PostItem/image_grid.dart b/lib/Widgets/PostItem/image_grid.dart index ba5d917e..10c0911b 100644 --- a/lib/Widgets/PostItem/image_grid.dart +++ b/lib/Widgets/PostItem/image_grid.dart @@ -13,8 +13,8 @@ * If not, see . */ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; -import 'package:loftify/Utils/constant.dart'; class ImageGrid extends StatefulWidget { final Function(BuildContext, int, BorderRadius radius) itemBuilder; @@ -32,11 +32,7 @@ class ImageGrid extends StatefulWidget { ImageGridState createState() => ImageGridState(); } -class ImageGridState extends State - with AutomaticKeepAliveClientMixin { - @override - bool get wantKeepAlive => true; - +class ImageGridState extends State { late List ratios; double calculatedAspectRatio = 1; double maxAspectRatio = 1.6; @@ -52,24 +48,33 @@ class ImageGridState extends State @override void initState() { super.initState(); - ratios = widget.ratios; - if (ratios.length != widget.itemCount) { - ratios.addAll( - List.generate(widget.itemCount - ratios.length, (index) => 1)); - } + _syncRatios(); + } + + @override + void didUpdateWidget(covariant ImageGrid oldWidget) { + super.didUpdateWidget(oldWidget); + _syncRatios(); + } + + void _syncRatios() { + final count = widget.itemCount.clamp(0, 9); + ratios = List.generate(count, (index) { + if (index >= widget.ratios.length) return 1; + final ratio = widget.ratios[index]; + return ratio.isFinite && ratio > 0 ? ratio : 1; + }); calculatedAspectRatio = ratios.fold(0, (previousValue, element) { - if (element == 0) { - return previousValue; - } return previousValue + element; }); - setState(() {}); + if (calculatedAspectRatio <= 0 || !calculatedAspectRatio.isFinite) { + calculatedAspectRatio = 1; + } } @override Widget build(BuildContext context) { - super.build(context); - int count = widget.itemCount; + final count = widget.itemCount.clamp(0, 9); Widget res; if (count == 0) { res = emptyWidget; @@ -92,7 +97,13 @@ class ImageGridState extends State } else { res = _buildNineImages(radius); } - return Container(constraints: constraints, child: res); + return Container( + constraints: constraints, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: res, + ), + ); } Widget _buildSingleImage(double radius) { @@ -301,117 +312,131 @@ class ImageGridState extends State } Widget _buildSevenImages(double radius) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - AspectRatio( - aspectRatio: 1, - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: 4, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 2, - mainAxisSpacing: 2, + return AspectRatio( + aspectRatio: 12 / 11, + child: Column( + children: [ + Expanded( + flex: 8, + child: Row( + children: [ + Expanded( + flex: 2, + child: widget.itemBuilder( + context, + 0, + BorderRadius.only(topLeft: Radius.circular(radius)), + ), + ), + const SizedBox(width: 2), + Expanded( + child: Column( + children: [ + Expanded( + child: widget.itemBuilder( + context, + 1, + BorderRadius.only( + topRight: Radius.circular(radius), + ), + ), + ), + const SizedBox(height: 2), + Expanded( + child: widget.itemBuilder( + context, + 2, + BorderRadius.zero, + ), + ), + ], + ), + ), + ], ), - itemBuilder: (context, index) { - var borderRadius = BorderRadius.zero; - switch (index) { - case 0: - borderRadius = - BorderRadius.only(topLeft: Radius.circular(radius)); - break; - case 1: - borderRadius = - BorderRadius.only(topRight: Radius.circular(radius)); - break; - } - return widget.itemBuilder(context, index, borderRadius); - }, ), - ), - const SizedBox(height: 2), - AspectRatio( - aspectRatio: 3, - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: 3, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 2, - mainAxisSpacing: 2, + const SizedBox(height: 2), + Expanded( + flex: 3, + child: Row( + children: List.generate(4, (offset) { + final index = offset + 3; + final borderRadius = switch (index) { + 3 => BorderRadius.only( + bottomLeft: Radius.circular(radius), + ), + 6 => BorderRadius.only( + bottomRight: Radius.circular(radius), + ), + _ => BorderRadius.zero, + }; + return Expanded( + child: Padding( + padding: EdgeInsets.only(left: offset == 0 ? 0 : 2), + child: widget.itemBuilder(context, index, borderRadius), + ), + ); + }), ), - itemBuilder: (context, index) { - var borderRadius = BorderRadius.zero; - switch (index) { - case 0: - borderRadius = - BorderRadius.only(bottomLeft: Radius.circular(radius)); - break; - case 2: - borderRadius = - BorderRadius.only(bottomRight: Radius.circular(radius)); - break; - } - return widget.itemBuilder(context, index + 4, borderRadius); - }, ), - ), - ], + ], + ), ); } Widget _buildEightImages(double radius) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - AspectRatio( - aspectRatio: 3 / 2, - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: 6, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 2, - mainAxisSpacing: 2, + return AspectRatio( + aspectRatio: 1, + child: Column( + children: [ + Expanded( + flex: 2, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: 6, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 2, + mainAxisSpacing: 2, + ), + itemBuilder: (context, index) { + final borderRadius = switch (index) { + 0 => BorderRadius.only( + topLeft: Radius.circular(radius), + ), + 2 => BorderRadius.only( + topRight: Radius.circular(radius), + ), + _ => BorderRadius.zero, + }; + return widget.itemBuilder(context, index, borderRadius); + }, ), - itemBuilder: (context, index) { - var borderRadius = BorderRadius.zero; - switch (index) { - case 0: - borderRadius = - BorderRadius.only(topLeft: Radius.circular(radius)); - break; - case 2: - borderRadius = - BorderRadius.only(topRight: Radius.circular(radius)); - break; - } - return widget.itemBuilder(context, index, borderRadius); - }, ), - ), - const SizedBox(height: 2), - AspectRatio( - aspectRatio: 2, - child: Row( - children: [ - Expanded( - child: widget.itemBuilder(context, 6, - BorderRadius.only(bottomLeft: Radius.circular(radius))), - ), - const SizedBox(width: 2), - Expanded( - child: widget.itemBuilder(context, 7, - BorderRadius.only(bottomRight: Radius.circular(radius))), - ), - ], + const SizedBox(height: 2), + Expanded( + child: Row( + children: [ + Expanded( + child: widget.itemBuilder( + context, + 6, + BorderRadius.only(bottomLeft: Radius.circular(radius)), + ), + ), + const SizedBox(width: 2), + Expanded( + child: widget.itemBuilder( + context, + 7, + BorderRadius.only(bottomRight: Radius.circular(radius)), + ), + ), + ], + ), ), - ), - ], + ], + ), ); } diff --git a/lib/Widgets/PostItem/loftify_post_archive_grid.dart b/lib/Widgets/PostItem/loftify_post_archive_grid.dart new file mode 100644 index 00000000..e32dfea5 --- /dev/null +++ b/lib/Widgets/PostItem/loftify_post_archive_grid.dart @@ -0,0 +1,150 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +typedef LoftifyArchiveGridItemBuilder = Widget Function( + BuildContext context, + int index, + double tileExtent, +); + +/// A square archive grid whose media extent always matches the real cell. +/// +/// Phone layouts retain the product's familiar three-column archive. Wider +/// viewports add columns around a 160 px target instead of stretching covers. +class LoftifyPostArchiveGrid extends StatelessWidget { + const LoftifyPostArchiveGrid({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.padding = const EdgeInsets.only(top: 12), + this.spacing = 6, + }); + + final int itemCount; + final LoftifyArchiveGridItemBuilder itemBuilder; + final EdgeInsetsGeometry padding; + final double spacing; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final resolvedPadding = padding.resolve(Directionality.of(context)); + final contentWidth = max( + 1.0, + constraints.maxWidth - resolvedPadding.horizontal, + ); + final geometry = LoftifyArchiveGridGeometry.calculate( + contentWidth, + spacing: spacing, + ); + return GridView.builder( + padding: padding, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: geometry.columnCount, + mainAxisSpacing: geometry.spacing, + crossAxisSpacing: geometry.spacing, + ), + itemBuilder: (context, index) => itemBuilder( + context, + index, + geometry.tileExtent, + ), + ); + }, + ); + } +} + +/// Sliver counterpart of [LoftifyPostArchiveGrid] for grouped archive pages. +class LoftifyPostArchiveSliverGrid extends StatelessWidget { + const LoftifyPostArchiveSliverGrid({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.padding = const EdgeInsets.only(top: 12), + this.spacing = 6, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + }); + + final int itemCount; + final LoftifyArchiveGridItemBuilder itemBuilder; + final EdgeInsetsGeometry padding; + final double spacing; + final bool addAutomaticKeepAlives; + final bool addRepaintBoundaries; + final bool addSemanticIndexes; + + @override + Widget build(BuildContext context) { + return SliverLayoutBuilder( + builder: (context, constraints) { + final resolvedPadding = padding.resolve(Directionality.of(context)); + final contentWidth = max( + 1.0, + constraints.crossAxisExtent - resolvedPadding.horizontal, + ); + final geometry = LoftifyArchiveGridGeometry.calculate( + contentWidth, + spacing: spacing, + ); + return SliverPadding( + padding: padding, + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: geometry.columnCount, + mainAxisSpacing: geometry.spacing, + crossAxisSpacing: geometry.spacing, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => itemBuilder( + context, + index, + geometry.tileExtent, + ), + childCount: itemCount, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + ), + ), + ); + }, + ); + } +} + +@immutable +class LoftifyArchiveGridGeometry { + const LoftifyArchiveGridGeometry._({ + required this.columnCount, + required this.tileExtent, + required this.spacing, + }); + + factory LoftifyArchiveGridGeometry.calculate( + double contentWidth, { + double spacing = 6, + }) { + final safeWidth = contentWidth.isFinite + ? contentWidth.clamp(1.0, double.infinity).toDouble() + : 1.0; + final columns = + safeWidth < 600 ? 3 : (safeWidth / 160).floor().clamp(3, 8).toInt(); + return LoftifyArchiveGridGeometry._( + columnCount: columns, + spacing: spacing, + tileExtent: (safeWidth - spacing * (columns - 1)) / columns, + ); + } + + final int columnCount; + final double tileExtent; + final double spacing; +} diff --git a/lib/Widgets/PostItem/recommend_flow_item_builder.dart b/lib/Widgets/PostItem/recommend_flow_item_builder.dart index f3e0e32d..c369bb8a 100644 --- a/lib/Widgets/PostItem/recommend_flow_item_builder.dart +++ b/lib/Widgets/PostItem/recommend_flow_item_builder.dart @@ -1,11 +1,10 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import '../../Models/post_detail_response.dart'; import '../../Models/recommend_response.dart'; -import '../../Utils/constant.dart'; import '../../Utils/enums.dart'; import '../../Utils/hive_util.dart'; -import '../../Utils/utils.dart'; import 'general_post_item_builder.dart'; class RecommendFlowItemBuilder { @@ -17,40 +16,47 @@ class RecommendFlowItemBuilder { final Function()? onShieldUser, bool showMoreButton = false, }) { + final postData = item.postData; + if (postData == null) { + return _invalidPostItem(item.itemId); + } + final postView = postData.postView; + final blogInfo = item.blogInfo; List photoLinks = []; PostType type = getPostType(item); switch (type) { case PostType.video: - String cover = ""; - if (StringUtil.isNotEmpty(item.postData!.postView.previewUrl)) { - cover = item.postData!.postView.previewUrl!; - } else { - cover = item.postData!.postView.firstImage!.orign; + final firstImage = postView.firstImage; + final cover = StringUtil.isNotEmpty(postView.previewUrl) + ? postView.previewUrl! + : firstImage?.orign ?? ''; + if (StringUtil.isNotEmpty(cover)) { + photoLinks = [ + PhotoLink( + orign: cover, + raw: cover, + small: cover, + middle: cover, + rw: firstImage?.ow ?? 0, + rh: firstImage?.oh ?? 0, + ow: firstImage?.ow ?? 0, + oh: firstImage?.oh ?? 0, + ) + ]; } - photoLinks = [ - PhotoLink( - orign: cover, - raw: cover, - small: cover, - middle: cover, - rw: item.postData!.postView.firstImage!.ow, - rh: item.postData!.postView.firstImage!.oh, - ow: item.postData!.postView.firstImage!.ow, - oh: item.postData!.postView.firstImage!.oh, - ) - ]; break; case PostType.image: + final firstImage = postView.firstImage!; photoLinks = [ PhotoLink( - orign: item.postData!.postView.firstImage!.orign, - raw: item.postData!.postView.firstImage!.orign, - small: item.postData!.postView.firstImage!.orign, - middle: item.postData!.postView.firstImage!.orign, - rw: item.postData!.postView.firstImage!.ow, - rh: item.postData!.postView.firstImage!.oh, - ow: item.postData!.postView.firstImage!.ow, - oh: item.postData!.postView.firstImage!.oh, + orign: firstImage.orign, + raw: firstImage.orign, + small: firstImage.orign, + middle: firstImage.orign, + rw: firstImage.ow, + rh: firstImage.oh, + ow: firstImage.ow, + oh: firstImage.oh, ) ]; break; @@ -60,22 +66,22 @@ class RecommendFlowItemBuilder { return GeneralPostItem( type: type, photoLinks: photoLinks, - blogId: item.postData!.postView.blogId, - postId: item.postData!.postView.id, - permalink: item.postData!.postView.permalink, - collectionId: item.postData!.postCollection?.id ?? 0, + blogId: postView.blogId, + postId: postView.id, + permalink: postView.permalink, + collectionId: postData.postCollection?.id ?? 0, liked: item.favorite, - blogName: item.blogInfo!.blogName, - blogNickName: item.blogInfo!.blogNickName, - title: item.postData!.postView.title, - digest: item.postData!.postView.digest, - content: item.postData!.postView.digest, - firstImageUrl: item.postData!.postView.firstImage?.orign ?? "", - duration: item.postData!.postView.videoPostView?.videoInfo.duration ?? 0, - likeCount: item.postData!.postCount!.favoriteCount, - photoCount: item.postData!.postView.photoCount, - tags: item.postData!.postView.tagList, - bigAvaImg: item.blogInfo!.bigAvaImg, + blogName: blogInfo?.blogName ?? '', + blogNickName: blogInfo?.blogNickName ?? '', + title: postView.title, + digest: postView.digest, + content: postView.digest, + firstImageUrl: postView.firstImage?.orign ?? '', + duration: postView.videoPostView?.videoInfo.duration ?? 0, + likeCount: postData.postCount?.favoriteCount ?? 0, + photoCount: postView.photoCount, + tags: postView.tagList, + bigAvaImg: blogInfo?.bigAvaImg ?? '', showArticle: ChewieHiveUtil.getBool(HiveUtil.showRecommendArticleKey), showVideo: ChewieHiveUtil.getBool(HiveUtil.showRecommendVideoKey), excludeTag: excludeTag, @@ -86,6 +92,28 @@ class RecommendFlowItemBuilder { ); } + static GeneralPostItem _invalidPostItem(int postId) { + return GeneralPostItem( + type: PostType.invalid, + photoLinks: const [], + blogId: 0, + postId: postId, + permalink: '', + collectionId: 0, + liked: false, + blogName: '', + blogNickName: '', + title: '', + digest: '', + content: '', + firstImageUrl: '', + duration: 0, + likeCount: 0, + tags: const [], + bigAvaImg: '', + ); + } + static Widget buildWaterfallFlowPostItem( BuildContext context, PostListItem item, { @@ -95,9 +123,8 @@ class RecommendFlowItemBuilder { String? excludeTag, bool showMoreButton = false, }) { - if (item.postData == null) return emptyWidget; return WaterfallFlowPostItemWidget( - key: ValueKey(item.postData!.postView.id), + key: ValueKey(item.postData?.postView.id ?? item.itemId), item: getGeneralPostItem( item, excludeTag: excludeTag, @@ -115,9 +142,8 @@ class RecommendFlowItemBuilder { double wh = 100, int? activePostId, }) { - if (item.postData == null) return emptyWidget; return GridPostItemWidget( - key: ValueKey(item.postData!.postView.id), + key: ValueKey(item.postData?.postView.id ?? item.itemId), wh: wh, activePostId: activePostId, item: getGeneralPostItem( diff --git a/lib/Widgets/PostItem/search_post_flow_item_builder.dart b/lib/Widgets/PostItem/search_post_flow_item_builder.dart index f51d73a5..7acad3c0 100644 --- a/lib/Widgets/PostItem/search_post_flow_item_builder.dart +++ b/lib/Widgets/PostItem/search_post_flow_item_builder.dart @@ -1,3 +1,4 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import '../../Models/post_detail_response.dart'; diff --git a/lib/Widgets/Profile/profile_header_components.dart b/lib/Widgets/Profile/profile_header_components.dart new file mode 100644 index 00000000..5eaecac6 --- /dev/null +++ b/lib/Widgets/Profile/profile_header_components.dart @@ -0,0 +1,367 @@ +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; + +const _coverTextShadows = [ + Shadow( + color: Color(0x99000000), + blurRadius: 5, + offset: Offset(0, 1), + ), +]; + +/// Content-width and reflow contract for the complete profile heading. +class LoftifyProfileHeaderLayout extends StatelessWidget { + const LoftifyProfileHeaderLayout({ + super.key, + required this.summary, + this.showcase, + this.maxContentWidth = 1180, + }); + + final Widget summary; + final Widget? showcase; + final double maxContentWidth; + + static bool usesSideBySide( + BuildContext context, { + required bool hasShowcase, + }) { + final scale = MediaQuery.textScalerOf(context).scale(14) / 14; + return hasShowcase && + MediaQuery.sizeOf(context).width >= 820 && + scale <= 1.35; + } + + @override + Widget build(BuildContext context) { + final design = context.design; + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxContentWidth), + child: LayoutBuilder( + builder: (context, constraints) { + final sideBySide = showcase != null && + constraints.maxWidth >= 820 && + MediaQuery.textScalerOf(context).scale(14) / 14 <= 1.35; + if (sideBySide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: KeyedSubtree( + key: const ValueKey('loftify-profile-summary'), + child: summary, + ), + ), + SizedBox(width: design.spacing.xl), + Expanded( + flex: 2, + child: KeyedSubtree( + key: const ValueKey('loftify-profile-showcase'), + child: showcase!, + ), + ), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + KeyedSubtree( + key: const ValueKey('loftify-profile-summary'), + child: summary, + ), + if (showcase != null) ...[ + SizedBox(height: design.spacing.lg), + KeyedSubtree( + key: const ValueKey('loftify-profile-showcase'), + child: showcase!, + ), + ], + ], + ); + }, + ), + ), + ); + } +} + +/// Responsive identity block used over the author's cover image. +/// +/// The block intentionally owns no fixed height. Long identifiers, localized +/// metadata and accessibility text scaling can grow without colliding with the +/// avatar or the trailing action. +class LoftifyProfileIdentity extends StatelessWidget { + const LoftifyProfileIdentity({ + super.key, + required this.avatar, + required this.displayName, + required this.idLabel, + required this.metadata, + this.onDisplayNameLongPress, + this.onIdLongPress, + this.descriptionLabel, + this.onDescriptionPressed, + this.trailing, + this.foregroundColor = Colors.white, + }); + + final Widget avatar; + final String displayName; + final String idLabel; + final String metadata; + final VoidCallback? onDisplayNameLongPress; + final VoidCallback? onIdLongPress; + final String? descriptionLabel; + final VoidCallback? onDescriptionPressed; + final Widget? trailing; + final Color foregroundColor; + + @override + Widget build(BuildContext context) { + final design = context.design; + return LayoutBuilder( + builder: (context, constraints) { + final scale = MediaQuery.textScalerOf(context) + .scale(design.typography.label.fontSize ?? 13) / + (design.typography.label.fontSize ?? 13); + final moveTrailingBelow = + trailing != null && (constraints.maxWidth < 380 || scale > 1.35); + final identity = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + avatar, + SizedBox(width: design.spacing.lg), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: onDisplayNameLongPress, + child: Text( + displayName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: design.typography.pageTitle.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + shadows: _coverTextShadows, + ), + ), + ), + SizedBox(height: design.spacing.xs), + GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: onIdLongPress, + child: Text( + idLabel, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: foregroundColor.withValues(alpha: 0.88), + shadows: _coverTextShadows, + ), + ), + ), + SizedBox(height: design.spacing.xs), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: design.spacing.sm, + runSpacing: design.spacing.xs, + children: [ + Text( + metadata, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: foregroundColor.withValues(alpha: 0.88), + shadows: _coverTextShadows, + ), + ), + if (descriptionLabel != null && + onDescriptionPressed != null) + _DescriptionAction( + label: descriptionLabel!, + onPressed: onDescriptionPressed!, + foregroundColor: foregroundColor, + ), + ], + ), + ], + ), + ), + if (trailing != null && !moveTrailingBelow) ...[ + SizedBox(width: design.spacing.sm), + trailing!, + ], + ], + ); + + return Semantics( + label: '$displayName, $idLabel, $metadata', + child: moveTrailingBelow + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + identity, + SizedBox(height: design.spacing.sm), + Align(alignment: Alignment.centerRight, child: trailing), + ], + ) + : identity, + ); + }, + ); + } +} + +class _DescriptionAction extends StatelessWidget { + const _DescriptionAction({ + required this.label, + required this.onPressed, + required this.foregroundColor, + }); + + final String label; + final VoidCallback onPressed; + final Color foregroundColor; + + @override + Widget build(BuildContext context) { + final design = context.design; + return Semantics( + button: true, + label: label, + child: Material( + color: foregroundColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(design.radii.full), + child: InkWell( + onTap: onPressed, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return foregroundColor.withValues(alpha: 0.12); + } + return Colors.transparent; + }), + borderRadius: BorderRadius.circular(design.radii.full), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(width: design.spacing.xs), + Icon( + LoftifyIcons.expand, + size: design.icons.small, + color: foregroundColor, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Translucent profile-header action that remains legible over arbitrary +/// cover artwork while keeping its state visually quieter than content. +class LoftifyProfileAction extends StatelessWidget { + const LoftifyProfileAction({ + super.key, + required this.label, + required this.icon, + required this.onPressed, + this.emphasized = false, + this.danger = false, + }); + + final String label; + final IconData icon; + final VoidCallback onPressed; + final bool emphasized; + final bool danger; + + @override + Widget build(BuildContext context) { + final design = context.design; + final emphasizedForeground = LoftifyTheme.readableForegroundColor( + design.colors.accent, + backgrounds: const [Colors.white], + brightness: Brightness.light, + ); + final dangerForeground = LoftifyTheme.readableForegroundColor( + design.colors.danger, + backgrounds: const [Colors.white], + brightness: Brightness.light, + ); + final background = danger || emphasized + ? Colors.white.withValues(alpha: 0.94) + : Colors.black.withValues(alpha: 0.22); + final foreground = danger + ? dangerForeground + : emphasized + ? emphasizedForeground + : Colors.white; + return LoftifyCard( + key: const ValueKey('loftify-profile-action'), + onTap: onPressed, + backgroundColor: background, + variant: danger ? LoftifyCardVariant.outlined : LoftifyCardVariant.flat, + radius: design.radii.control, + semanticLabel: label, + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xl, + vertical: design.spacing.md, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: 48 - design.spacing.md * 2, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: design.icons.regular, color: foreground), + SizedBox(width: design.spacing.md), + Flexible( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: design.typography.label.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Widgets/Profile/profile_more_action_sheet.dart b/lib/Widgets/Profile/profile_more_action_sheet.dart new file mode 100644 index 00000000..98602ae5 --- /dev/null +++ b/lib/Widgets/Profile/profile_more_action_sheet.dart @@ -0,0 +1,229 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; + +/// Compact, scrollable profile actions. The caller owns navigation and the +/// original action callbacks so opening this sheet never changes their meaning. +class LoftifyProfileMoreActionSheet extends StatelessWidget { + const LoftifyProfileMoreActionSheet({ + super.key, + required this.privacyTitle, + required this.cancelLabel, + required this.actions, + required this.privacyActions, + required this.onSelected, + required this.onCancel, + }); + + final String privacyTitle; + final String cancelLabel; + final List actions; + final List privacyActions; + final ValueChanged onSelected; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + final design = context.design; + final screen = MediaQuery.sizeOf(context); + final radius = Radius.circular(design.radii.dialog + 4); + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: screen.height * 0.76), + child: Material( + color: design.colors.surfaceRaised, + borderRadius: BorderRadius.vertical(top: radius), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 30, + child: Center( + child: DecoratedBox( + key: const ValueKey('profile-more-handle'), + decoration: BoxDecoration( + color: design.colors.outlineStrong.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(design.radii.full), + ), + child: const SizedBox(width: 32, height: 4), + ), + ), + ), + Flexible( + fit: FlexFit.loose, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB( + design.spacing.lg, + design.spacing.sm, + design.spacing.lg, + design.spacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _actionGrid(context, actions), + if (privacyActions.isNotEmpty) ...[ + Padding( + padding: EdgeInsets.fromLTRB( + design.spacing.sm, + design.spacing.sm, + design.spacing.sm, + design.spacing.md, + ), + child: Row( + children: [ + Text( + privacyTitle, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: design.spacing.lg), + Expanded( + child: Divider(color: design.colors.outline), + ), + ], + ), + ), + _actionGrid(context, privacyActions), + ], + ], + ), + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: design.colors.outline, + width: design.borders.hairline, + ), + ), + ), + child: InkWell( + key: const ValueKey('profile-more-cancel'), + splashFactory: NoSplash.splashFactory, + onTap: onCancel, + child: SizedBox( + height: 56, + width: double.infinity, + child: Center( + child: Text( + cancelLabel, + style: design.typography.body.copyWith( + color: design.colors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _actionGrid( + BuildContext context, + List entries, + ) { + final design = context.design; + return LayoutBuilder(builder: (context, constraints) { + final columns = constraints.maxWidth < 336 ? 3 : 4; + final gap = design.spacing.xs; + final width = (constraints.maxWidth - (columns - 1) * gap) / columns; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final entry in entries) + SizedBox( + width: width, + child: _ProfileActionTile( + action: entry, + onTap: () => onSelected(entry), + ), + ), + ], + ); + }); + } +} + +class _ProfileActionTile extends StatelessWidget { + const _ProfileActionTile({required this.action, required this.onTap}); + + final FlutterContextMenuItem action; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final design = context.design; + final danger = action.status == MenuItemStatus.error; + final foreground = + danger ? design.colors.danger : design.colors.textPrimary; + return Semantics( + button: true, + label: action.label, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(design.radii.control), + child: InkWell( + key: ValueKey('profile-more-${action.label}'), + borderRadius: BorderRadius.circular(design.radii.control), + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return foreground.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.focused)) { + return foreground.withValues(alpha: 0.1); + } + return Colors.transparent; + }), + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xs, + vertical: design.spacing.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 48, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration( + color: danger + ? design.colors.danger.withValues(alpha: 0.1) + : design.colors.surfaceMuted, + borderRadius: BorderRadius.circular(design.radii.control), + ), + child: Icon(action.iconData, size: 22, color: foreground), + ), + SizedBox(height: design.spacing.xs), + Text( + action.label, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: design.typography.metadata.copyWith( + color: foreground, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/Widgets/Profile/profile_overview_card.dart b/lib/Widgets/Profile/profile_overview_card.dart new file mode 100644 index 00000000..fd219319 --- /dev/null +++ b/lib/Widgets/Profile/profile_overview_card.dart @@ -0,0 +1,185 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../Design/loftify_surfaces.dart'; + +class ProfileStatisticData { + const ProfileStatisticData({ + required this.title, + required this.count, + this.onTap, + }); + + final String title; + final int? count; + final VoidCallback? onTap; +} + +class ProfileOverviewCard extends StatelessWidget { + const ProfileOverviewCard({ + super.key, + required this.statistics, + this.backgroundColor, + this.foregroundColor, + this.radius = 14, + }); + + final List statistics; + final Color? backgroundColor; + final Color? foregroundColor; + final double radius; + + @override + Widget build(BuildContext context) { + final effectiveForeground = + foregroundColor ?? Theme.of(context).colorScheme.onSurface; + return LoftifyCard( + backgroundColor: backgroundColor, + radius: radius, + child: LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale( + Theme.of(context).textTheme.labelSmall?.fontSize ?? 11) / + (Theme.of(context).textTheme.labelSmall?.fontSize ?? 11); + final useTwoRows = statistics.length > 2 && + (constraints.maxWidth < 280 || textScale > 1.45); + if (useTwoRows) { + final itemWidth = constraints.maxWidth / 2; + return Wrap( + children: [ + for (final statistic in statistics) + SizedBox( + width: itemWidth, + child: _ProfileStatisticItem( + data: statistic, + foregroundColor: effectiveForeground, + radius: radius, + ), + ), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + for (var index = 0; index < statistics.length; index++) ...[ + Expanded( + child: _ProfileStatisticItem( + data: statistics[index], + foregroundColor: effectiveForeground, + radius: radius, + ), + ), + if (index != statistics.length - 1) + SizedBox( + height: 42, + child: VerticalDivider( + width: 1, + thickness: 0.6, + color: effectiveForeground.withValues(alpha: 0.22), + ), + ), + ], + ], + ); + }, + ), + ); + } +} + +class _ProfileStatisticItem extends StatelessWidget { + const _ProfileStatisticItem({ + required this.data, + required this.foregroundColor, + required this.radius, + }); + + final ProfileStatisticData data; + final Color foregroundColor; + final double radius; + + @override + Widget build(BuildContext context) { + final formatted = _formatCount(context, data.count ?? 0); + final count = formatted['count']?.toString() ?? '0'; + final scale = formatted['scale']?.toString(); + return Semantics( + button: data.onTap != null, + label: '${data.title} ${data.count ?? 0}', + child: InkWell( + onTap: data.onTap, + borderRadius: BorderRadius.circular(radius), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 24, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: data.count == null ? '-' : count), + if (data.count != null && scale != null) + TextSpan( + text: ' $scale', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: foregroundColor), + ), + ], + ), + maxLines: 1, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 3), + Text( + data.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foregroundColor.withValues(alpha: 0.82), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } + + Map _formatCount(BuildContext context, int count) { + final languageCode = Localizations.localeOf(context).languageCode; + if (const {'zh', 'ja', 'ko'}.contains(languageCode)) { + return NumberUtil.formatCountToMap(count).map( + (key, value) => MapEntry(key, value.toString()), + ); + } + + const units = <({int threshold, String suffix})>[ + (threshold: 1000000000, suffix: 'B'), + (threshold: 1000000, suffix: 'M'), + (threshold: 1000, suffix: 'K'), + ]; + for (final unit in units) { + if (count >= unit.threshold) { + final value = count / unit.threshold; + final digits = value >= 100 ? 0 : 1; + final compact = value.toStringAsFixed(digits).replaceFirst('.0', ''); + return {'count': compact, 'scale': unit.suffix}; + } + } + return {'count': count.toString()}; + } +} diff --git a/lib/Widgets/Profile/supporter_list_item.dart b/lib/Widgets/Profile/supporter_list_item.dart new file mode 100644 index 00000000..c1ec63d9 --- /dev/null +++ b/lib/Widgets/Profile/supporter_list_item.dart @@ -0,0 +1,167 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../../Utils/enums.dart'; +import '../Item/item_builder.dart'; +import '../loftify_icons.dart'; + +/// A supporter keeps the same quiet, full-width rhythm as the relationship +/// lists while giving the contribution score its own compact visual anchor. +class LoftifySupporterListItem extends StatelessWidget { + const LoftifySupporterListItem({ + super.key, + required this.blogId, + required this.avatarUrl, + required this.name, + required this.blogName, + required this.intro, + required this.score, + required this.onTap, + }); + + final int blogId; + final String avatarUrl; + final String name; + final String blogName; + final String? intro; + final int score; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final design = context.design; + final summary = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ItemBuilder.buildAvatar( + context: context, + size: 48, + imageUrl: avatarUrl, + tagPrefix: 'supporter-$blogId', + showDetailMode: ShowDetailMode.not, + ), + SizedBox(width: design.spacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: design.typography.cardTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: design.spacing.xs), + Text( + 'ID: $blogName', + style: design.typography.metadata.copyWith( + color: design.colors.textMuted, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (intro?.trim().isNotEmpty == true) ...[ + SizedBox(height: design.spacing.xs), + Text( + intro!.trim(), + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ); + final scoreBadge = DecoratedBox( + decoration: BoxDecoration( + color: design.colors.accentContainer, + borderRadius: BorderRadius.circular(design.radii.full), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.sm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ChewieIcon( + LoftifyIcons.premium, + size: 16, + color: design.colors.accent, + ), + SizedBox(width: design.spacing.xs), + Text( + score.toString(), + style: design.typography.metadata.copyWith( + color: design.colors.accent, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + + return LayoutBuilder(builder: (context, constraints) { + final scale = MediaQuery.textScalerOf(context).scale(14) / 14; + final stacked = constraints.maxWidth < 320 || + (constraints.maxWidth < 520 && scale > 1.4); + return Semantics( + key: ValueKey('loftify-supporter-row-$blogId'), + button: true, + label: name, + explicitChildNodes: true, + child: Material( + color: design.colors.page, + child: InkWell( + onTap: onTap, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return design.colors.textPrimary.withValues(alpha: 0.045); + } + if (states.contains(WidgetState.focused)) { + return design.colors.accent.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.hovered)) { + return design.colors.textPrimary.withValues(alpha: 0.025); + } + return Colors.transparent; + }), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.md, + ), + child: stacked + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + summary, + SizedBox(height: design.spacing.sm), + Align( + alignment: Alignment.centerRight, + child: scoreBadge, + ), + ], + ) + : Row( + children: [ + Expanded(child: summary), + SizedBox(width: design.spacing.md), + scoreBadge, + ], + ), + ), + ), + ), + ); + }); + } +} diff --git a/lib/Widgets/Suit/dress_preview_card.dart b/lib/Widgets/Suit/dress_preview_card.dart new file mode 100644 index 00000000..619b9459 --- /dev/null +++ b/lib/Widgets/Suit/dress_preview_card.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; + +class DressPreviewCard extends StatelessWidget { + const DressPreviewCard({ + super.key, + required this.title, + required this.subtitle, + required this.preview, + this.onTap, + this.badge, + this.previewHeight = 210, + this.previewPadding = const EdgeInsets.all(16), + this.footer, + }); + + final String title; + final String subtitle; + final String? badge; + final Widget preview; + final VoidCallback? onTap; + final double previewHeight; + final EdgeInsets previewPadding; + final Widget? footer; + + @override + Widget build(BuildContext context) { + final design = context.design; + return LoftifyCard( + variant: LoftifyCardVariant.outlined, + onTap: onTap, + semanticLabel: '$title, $subtitle', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: previewHeight, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: design.colors.surfaceMuted), + Padding(padding: previewPadding, child: preview), + if (badge?.isNotEmpty == true) + PositionedDirectional( + start: design.spacing.lg, + top: design.spacing.lg, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.sm, + ), + decoration: BoxDecoration( + color: design.colors.surfaceRaised, + borderRadius: BorderRadius.circular( + design.radii.control, + ), + border: Border.all( + color: design.colors.outline, + width: design.borders.hairline, + ), + ), + child: Text( + badge!, + style: design.typography.metadata, + ), + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + design.spacing.xl, + design.spacing.xl, + design.spacing.lg, + design.spacing.xl, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: design.typography.cardTitle, + ), + if (subtitle.isNotEmpty) ...[ + SizedBox(height: design.spacing.sm), + Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + ), + ], + ], + ), + ), + if (onTap != null) ...[ + SizedBox(width: design.spacing.lg), + Icon( + LoftifyIcons.next, + size: design.icons.small, + color: design.colors.accentForeground, + ), + ], + ], + ), + ), + if (footer != null) + Padding( + padding: EdgeInsets.fromLTRB( + design.spacing.xl, + 0, + design.spacing.xl, + design.spacing.xl, + ), + child: footer!, + ), + ], + ), + ); + } +} diff --git a/lib/Widgets/Tag/tag_detail_components.dart b/lib/Widgets/Tag/tag_detail_components.dart new file mode 100644 index 00000000..ee88e77e --- /dev/null +++ b/lib/Widgets/Tag/tag_detail_components.dart @@ -0,0 +1,282 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../Design/loftify_controls.dart'; +import '../Design/loftify_surfaces.dart'; +import '../loftify_icons.dart'; + +@immutable +class LoftifyTagMetric { + const LoftifyTagMetric(this.label, {this.emphasized = false}); + + final String label; + final bool emphasized; +} + +/// Content-first tag heading shared by the tag-detail page across phone and +/// wide layouts. It deliberately avoids a full card background so the tag and +/// its works remain the visual focus. +class LoftifyTagHero extends StatelessWidget { + const LoftifyTagHero({ + super.key, + required this.tag, + required this.metrics, + required this.subscribed, + required this.subscribeLabel, + required this.subscribedLabel, + required this.onSubscriptionPressed, + this.trailing = const [], + }); + + final String tag; + final List metrics; + final bool subscribed; + final String subscribeLabel; + final String subscribedLabel; + final VoidCallback onSubscriptionPressed; + final List trailing; + + @override + Widget build(BuildContext context) { + final design = context.design; + return Padding( + key: const ValueKey('loftify-tag-hero'), + padding: EdgeInsets.only( + top: design.spacing.sectionTop, + bottom: design.spacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context) + .scale(design.typography.label.fontSize ?? 13) / + (design.typography.label.fontSize ?? 13); + final stackActions = constraints.maxWidth < 360 || + textScale > 1.3 || + trailing.length > 1; + if (stackActions) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTitle(context), + SizedBox(height: design.spacing.md), + Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: design.spacing.sm, + runSpacing: design.spacing.sm, + children: [_buildSubscriptionButton(), ...trailing], + ), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: _buildTitle(context)), + SizedBox(width: design.spacing.lg), + _buildSubscriptionButton(), + if (trailing.isNotEmpty) ...[ + SizedBox(width: design.spacing.sm), + ...trailing, + ], + ], + ); + }, + ), + if (metrics.isNotEmpty) ...[ + SizedBox(height: design.spacing.lg), + Wrap( + spacing: design.spacing.md, + runSpacing: design.spacing.sm, + children: [ + for (final metric in metrics) _TagMetricChip(metric: metric), + ], + ), + ], + ], + ), + ); + } + + Widget _buildTitle(BuildContext context) { + final design = context.design; + return Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: design.colors.accentContainer, + borderRadius: BorderRadius.circular(design.radii.control), + ), + alignment: Alignment.center, + child: Icon( + LoftifyIcons.tag, + size: design.icons.regular, + color: design.colors.onAccentContainer, + ), + ), + SizedBox(width: design.spacing.lg), + Expanded( + child: Text( + tag, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: design.typography.pageTitle, + ), + ), + ], + ); + } + + Widget _buildSubscriptionButton() { + return LoftifyCompactToggleButton( + label: subscribed ? subscribedLabel : subscribeLabel, + selected: subscribed, + onPressed: onSubscriptionPressed, + ); + } +} + +class _TagMetricChip extends StatelessWidget { + const _TagMetricChip({required this.metric}); + + final LoftifyTagMetric metric; + + @override + Widget build(BuildContext context) { + final design = context.design; + final foreground = metric.emphasized + ? design.colors.onAccentContainer + : design.colors.textSecondary; + return DecoratedBox( + decoration: BoxDecoration( + color: metric.emphasized + ? design.colors.accentContainer + : design.colors.surface, + borderRadius: BorderRadius.circular(design.radii.full), + border: Border.all( + color: metric.emphasized + ? design.colors.accent.withValues(alpha: 0.35) + : design.colors.outline, + width: design.borders.hairline, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.sm, + ), + child: Text( + metric.label, + style: design.typography.metadata.copyWith(color: foreground), + ), + ), + ); + } +} + +/// A compact discovery entrance for collections, related tags and dress. +/// The supplied illustration remains decorative and never competes with text. +class LoftifyTagDiscoveryCard extends StatelessWidget { + const LoftifyTagDiscoveryCard({ + super.key, + required this.title, + required this.description, + required this.illustration, + required this.onTap, + }); + + final String title; + final String description; + final Widget illustration; + final VoidCallback onTap; + + static const double illustrationAspectRatio = 170 / 65; + static const double minimumHeight = 70; + static const double maximumTextScale = 1.5; + + static double preferredHeight(BuildContext context) { + final design = context.design; + final scaler = MediaQuery.textScalerOf(context); + final metadataSize = design.typography.metadata.fontSize ?? 12; + final cardTitleSize = design.typography.cardTitle.fontSize ?? 15; + final scale = math.min( + maximumTextScale, + scaler.scale(metadataSize) / metadataSize, + ); + final titleHeight = + metadataSize * scale * (design.typography.metadata.height ?? 1); + final descriptionHeight = + cardTitleSize * scale * (design.typography.cardTitle.height ?? 1); + return math.max( + minimumHeight, + design.spacing.md * 2 + + titleHeight + + design.spacing.xs + + descriptionHeight + + 2, + ); + } + + static double preferredWidth(BuildContext context) => + preferredHeight(context) * illustrationAspectRatio; + + @override + Widget build(BuildContext context) { + final design = context.design; + final height = preferredHeight(context); + return SizedBox( + key: ValueKey('loftify-tag-discovery-card-$title'), + width: preferredWidth(context), + height: height, + child: LoftifyCard( + variant: LoftifyCardVariant.flat, + backgroundColor: Colors.transparent, + radius: design.radii.control, + onTap: onTap, + child: Stack( + fit: StackFit.expand, + children: [ + illustration, + Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.md, + ), + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: maximumTextScale, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.metadata.copyWith( + color: design.colors.textSecondary, + ), + ), + SizedBox(height: design.spacing.xs), + Text( + description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: design.typography.cardTitle, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Widgets/loftify_icons.dart b/lib/Widgets/loftify_icons.dart new file mode 100644 index 00000000..68287f97 --- /dev/null +++ b/lib/Widgets/loftify_icons.dart @@ -0,0 +1,121 @@ +import 'package:flutter/widgets.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +/// Product-level icon semantics. Pages should use these names instead of +/// importing an icon font directly. +/// +/// Names describe product meaning, never visual variants such as `filled`, +/// `outlined`, `active` or `selected`. A persistent state keeps the same glyph +/// and is expressed by the shared icon component. A different glyph is only +/// valid when the action itself changes, for example play/pause. +abstract final class LoftifyIcons { + static const IconData home = LucideIcons.compass; + static const IconData search = LucideIcons.search; + static const IconData activity = LucideIcons.heart; + static const IconData profile = LucideIcons.userRound; + static const IconData logout = LucideIcons.logOut; + static const IconData dress = LucideIcons.paintbrush; + static const IconData notifications = LucideIcons.bell; + static const IconData settings = LucideIcons.settings; + static const IconData flag = LucideIcons.flag; + static const IconData copyright = LucideIcons.copyright; + static const IconData block = LucideIcons.ban; + static const IconData hash = LucideIcons.hash; + static const IconData tag = LucideIcons.tag; + static const IconData shield = LucideIcons.shield; + static const IconData previous = LucideIcons.chevronLeft; + static const IconData next = LucideIcons.chevronRight; + static const IconData previousPost = LucideIcons.chevronsLeft; + static const IconData nextPost = LucideIcons.chevronsRight; + static const IconData expand = LucideIcons.chevronDown; + static const IconData sortDirection = LucideIcons.arrowDownUp; + static const IconData favorite = LucideIcons.heart; + static const IconData recommend = LucideIcons.thumbsUp; + static const IconData hot = LucideIcons.flame; + static const IconData egg = LucideIcons.egg; + static const IconData magic = LucideIcons.wandSparkles; + static const IconData select = LucideIcons.circle; + static const IconData more = LucideIcons.ellipsis; + static const IconData moreVertical = LucideIcons.ellipsisVertical; + static const IconData slide = LucideIcons.chevronsRight; + static const IconData edit = LucideIcons.pencil; + static const IconData history = LucideIcons.history; + static const IconData premium = LucideIcons.crown; + static const IconData shop = LucideIcons.shoppingBag; + static const IconData avatarFrame = LucideIcons.frame; + static const IconData copy = LucideIcons.copy; + static const IconData follow = LucideIcons.userPlus; + static const IconData specialFollow = LucideIcons.star; + static const IconData unfollow = LucideIcons.userMinus; + static const IconData bookmark = LucideIcons.bookmark; + static const IconData comment = LucideIcons.messageCircle; + static const IconData article = LucideIcons.scrollText; + static const IconData invalidContent = LucideIcons.circleAlert; + static const IconData originalPost = LucideIcons.fileText; + static const IconData quote = LucideIcons.quote; + static const IconData reblog = LucideIcons.repeat2; + static const IconData collection = LucideIcons.libraryBig; + static const IconData grain = LucideIcons.wheat; + static const IconData filter = LucideIcons.listFilter; + static const IconData listLayout = LucideIcons.layoutList; + static const IconData gridLayout = LucideIcons.layoutGrid; + static const IconData scrollTop = LucideIcons.arrowUp; + static const IconData trendUp = LucideIcons.trendingUp; + static const IconData trendDown = LucideIcons.trendingDown; + + static const IconData refresh = LucideIcons.refreshCw; + static const IconData save = LucideIcons.save; + static const IconData add = LucideIcons.plus; + static const IconData check = LucideIcons.check; + static const IconData empty = LucideIcons.inbox; + static const IconData error = LucideIcons.circleX; + static const IconData warning = LucideIcons.triangleAlert; + static const IconData clear = LucideIcons.x; + static const IconData visible = LucideIcons.eye; + static const IconData hidden = LucideIcons.eyeOff; + static const IconData reset = LucideIcons.rotateCcw; + static const IconData openExternal = LucideIcons.externalLink; + + static const IconData merge = LucideIcons.gitMerge; + static const IconData bug = LucideIcons.bug; + static const IconData commit = LucideIcons.gitCommitHorizontal; + static const IconData review = LucideIcons.messageSquareText; + static const IconData share = LucideIcons.share2; + static const IconData support = LucideIcons.circleHelp; + static const IconData contact = LucideIcons.atSign; + static const IconData language = LucideIcons.languages; + static const IconData group = LucideIcons.users; + static const IconData send = LucideIcons.send; + static const IconData phone = LucideIcons.smartphone; + static const IconData verification = LucideIcons.shieldCheck; + static const IconData password = LucideIcons.keyRound; + static const IconData lofterId = LucideIcons.idCard; + static const IconData email = LucideIcons.mail; + + static const IconData generalSettings = LucideIcons.settings2; + static const IconData appearance = LucideIcons.paintbrushVertical; + static const IconData image = LucideIcons.image; + static const IconData basicSettings = settings; + static const IconData experiment = LucideIcons.flaskConical; + static const IconData info = LucideIcons.info; + static const IconData about = info; + + static const IconData download = LucideIcons.download; + static const IconData batchDownload = LucideIcons.folderDown; + static const IconData file = LucideIcons.file; + static const IconData video = LucideIcons.video; + static const IconData videoUnavailable = LucideIcons.videoOff; + static const IconData videoSettings = LucideIcons.slidersHorizontal; + static const IconData continuousPlayback = LucideIcons.listVideo; + static const IconData danmaku = LucideIcons.captions; + static const IconData back = LucideIcons.arrowLeft; + static const IconData sound = LucideIcons.volume2; + static const IconData mute = LucideIcons.volumeX; + static const IconData enterFullscreen = LucideIcons.maximize2; + static const IconData exitFullscreen = LucideIcons.minimize2; + static const IconData pause = LucideIcons.pause; + static const IconData play = LucideIcons.play; + static const IconData retry = LucideIcons.rotateCcw; + static const IconData close = LucideIcons.x; + static const IconData delete = LucideIcons.trash2; +} diff --git a/lib/Widgets/loftify_reaction_icon.dart b/lib/Widgets/loftify_reaction_icon.dart new file mode 100644 index 00000000..9a48dd1d --- /dev/null +++ b/lib/Widgets/loftify_reaction_icon.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; + +import 'loftify_icons.dart'; + +enum LoftifyReactionKind { like, recommend, bookmark } + +/// Stable action colors, independent of the user's theme accent. +abstract final class LoftifyReactionColors { + static const like = Color(0xFFE45B68); + static const recommend = Color(0xFF4B8FE2); + static const bookmark = Color(0xFFD99525); + + static Color forKind(LoftifyReactionKind kind) => switch (kind) { + LoftifyReactionKind.like => like, + LoftifyReactionKind.recommend => recommend, + LoftifyReactionKind.bookmark => bookmark, + }; +} + +/// Keeps Lucide outlines for the idle state and uses matching, tintable +/// vector silhouettes for persistent selected states. +class LoftifyReactionIcon extends StatelessWidget { + const LoftifyReactionIcon({ + super.key, + required this.kind, + required this.selected, + this.size = 24, + this.color, + }); + + final LoftifyReactionKind kind; + final bool selected; + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + final resolvedColor = color ?? + (selected + ? LoftifyReactionColors.forKind(kind) + : IconTheme.of(context).color ?? Colors.black); + if (!selected) { + final icon = switch (kind) { + LoftifyReactionKind.like => LoftifyIcons.favorite, + LoftifyReactionKind.recommend => LoftifyIcons.recommend, + LoftifyReactionKind.bookmark => LoftifyIcons.bookmark, + }; + return Icon(icon, size: size, color: resolvedColor); + } + return SizedBox.square( + dimension: size, + child: CustomPaint( + painter: LoftifyReactionIconPainter(kind, resolvedColor), + ), + ); + } +} + +@visibleForTesting +class LoftifyReactionIconPainter extends CustomPainter { + const LoftifyReactionIconPainter(this.kind, this.color); + + final LoftifyReactionKind kind; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.scale(size.width / 24, size.height / 24); + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill + ..isAntiAlias = true; + switch (kind) { + case LoftifyReactionKind.like: + canvas.drawPath(_heart(), paint); + case LoftifyReactionKind.recommend: + canvas.drawPath(_thumb(), paint); + case LoftifyReactionKind.bookmark: + canvas.drawPath(_bookmark(), paint); + } + canvas.restore(); + } + + Path _heart() => Path() + ..moveTo(12, 21.3) + ..cubicTo(10.5, 20, 5.1, 15.3, 3.4, 13.2) + ..cubicTo(0.9, 10.3, 1.4, 6.7, 3.7, 4.5) + ..cubicTo(5.9, 2.3, 9.1, 2.5, 11.3, 4.5) + ..lineTo(12, 5.2) + ..lineTo(12.7, 4.5) + ..cubicTo(14.9, 2.5, 18.1, 2.3, 20.3, 4.5) + ..cubicTo(22.6, 6.7, 23.1, 10.3, 20.6, 13.2) + ..cubicTo(18.9, 15.3, 13.5, 20, 12, 21.3) + ..close(); + + Path _bookmark() => Path() + ..moveTo(6, 2.5) + ..lineTo(18, 2.5) + ..quadraticBezierTo(20, 2.5, 20, 4.5) + ..lineTo(20, 21) + ..quadraticBezierTo(20, 22.1, 19, 21.5) + ..lineTo(12, 17.5) + ..lineTo(5, 21.5) + ..quadraticBezierTo(4, 22.1, 4, 21) + ..lineTo(4, 4.5) + ..quadraticBezierTo(4, 2.5, 6, 2.5) + ..close(); + + Path _thumb() => Path() + ..moveTo(5, 10) + ..lineTo(8.1, 10) + ..lineTo(9.6, 8.8) + ..lineTo(12.1, 3.1) + ..quadraticBezierTo(12.7, 1.6, 14, 2.2) + ..quadraticBezierTo(16.7, 3.4, 15.8, 6.4) + ..lineTo(14.9, 9.6) + ..lineTo(19.1, 9.6) + ..quadraticBezierTo(22.4, 9.6, 21.5, 12.9) + ..lineTo(19.4, 20.1) + ..quadraticBezierTo(18.9, 21.8, 17.1, 21.8) + ..lineTo(5, 21.8) + ..quadraticBezierTo(2.6, 21.8, 2.6, 19.4) + ..lineTo(2.6, 12.4) + ..quadraticBezierTo(2.6, 10, 5, 10) + ..close(); + + @override + bool shouldRepaint(covariant LoftifyReactionIconPainter oldDelegate) => + oldDelegate.kind != kind || oldDelegate.color != color; +} diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index c311ca3c..a09591a3 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,719 +1,771 @@ { "@@locale": "en", "appName": "Loftify", - "all": "全部", - "save": "儲存", - "saveSuccess": "保存成功", - "reset": "重置", - "resetSuccess": "重置成功", - "confirm": "確認", - "complete": "完成", - "cancel": "取消", - "copy": "複製", - "copySuccess": "複製成功", - "cut": "剪下", - "paste": "貼上", - "selectAll": "全選", - "select": "選擇", - "delete": "刪除", - "deleting": "刪除中...", - "deleteSuccess": "刪除成功", - "deleteFailed": "刪除失敗", - "search": "搜尋", - "share": "分享", - "input": "輸入", - "custom": "自訂", - "refresh": "刷新", - "enable": "啟用", - "copyLink": "複製連結", - "setSuccess": "設定成功", - "edit": "修改", - "editSuccess": "修改成功", - "editFailed": "修改失敗", - "jumpFailed": "跳躍失敗", - "openWithBrowser": "在瀏覽器開啟", - "shareToOtherApps": "分享到其他應用程式", - "jumpToBrowserDownload": "即將跳到瀏覽器下載", - "loadFailed": "載入失敗", - "reload": "重新載入", - "loadErrorType": "錯誤類型:{type}", - "loadUnkownError": "未知錯誤", - "home": "首頁", - "dynamic": "動態", - "explore": "探索", - "layoutSetting": "佈局", - "imageSetting": "圖片", - "operationSetting": "操作", - "privacySetting": "隱私", - "lofterSetting": "LOFTER設定", - "lofterBasicSetting": "LOFTER設定", - "blacklistSetting": "黑名單管理", - "tagShieldSetting": "屏蔽標籤管理", - "userDynamicShieldSetting": "不看TA的動態管理", - "experimentSetting": "實驗室", - "mine": "我的", - "setting": "設定", - "basicSetting": "基本設定", - "generalSetting": "通用", - "language": "語言", - "chooseLanguage": "選擇語言", - "followSystem": "跟隨系統", - "checkUpdates": "檢查更新", - "checkingUpdates": "檢查更新中...", - "checkUpdatesFailed": "檢查更新失敗", - "checkUpdatesFailedTip": "檢查更新失敗,請重試", - "autoCheckUpdates": "自動檢查更新", - "newVersion": "新版本:{version}", - "getNewVersion": "發現新版本{version}", - "doesImmediateUpdate": "是否立即更新?", - "changelogAsFollow": "更新日誌如下:
{log}", - "goToUpdate": "前往更新", - "goToBrowserUpdate": "前往瀏覽器更新", - "updateLater": "暫不更新", - "immediatelyInstall": "立即安裝", - "installPortableTip": "您目前正在使用隨身版,請手動解壓縮安裝包並覆蓋原有檔案", - "installing": "安裝中...", - "installCanceled": "安裝已取消", - "installFileNotFound": "安裝包{filepath}不存在", - "alreadyDownloadProgress": "已下載{progress}%", - "downloadSuccess": "下載成功", - "alreadyDownload": "已下載", - "download": "下載", - "downloading": "下載中...", - "downloadComplete": "下載完成", - "downloadFailed": "下載失敗", - "downloadFailedAndRetry": "下載失敗,請重試", - "downloadFailedAndRetryTip": "新版安裝包下載失敗,請重試", - "downloadingNewVersionPackage": "正在下載新版本安裝包...", - "downloadSuccessClickToInstall": "新版本安裝包已下載完成,點選立即安裝", - "currentVersion": "目前版本", - "alreadyLatestVersion": "已經是最新版本", - "alreadyLatestVersionTip": "目前版本為{version},已經是最新版本", - "immediatelyDownload": "立即下載", - "appearanceSetting": "外觀", - "themeSetting": "主題設定", - "themeMode": "主題模式", - "chooseThemeMode": "選擇主題模式", - "lightTheme": "淺色模式", - "darkTheme": "深色模式", - "selectTheme": "選擇主題", - "newTheme": "新主題", - "pureWhite": "極簡白", - "pureBlack": "極簡黑", - "freshGreen": "清新綠色", - "blueIron": "藍鐵", - "primaryColor": "主色調", - "fontFamily": "字體", + "all": "All", + "save": "Save", + "saveSuccess": "Saved successfully", + "reset": "Reset", + "resetSuccess": "Reset successfully", + "confirm": "Confirm", + "complete": "Complete", + "cancel": "Cancel", + "copy": "Copy", + "copySuccess": "Copied to clipboard", + "cut": "Cut", + "paste": "Paste", + "selectAll": "Select All", + "select": "Select", + "delete": "Delete", + "deleting": "Deleting...", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Delete failed", + "search": "Search", + "share": "Share", + "input": "Input", + "custom": "Custom", + "refresh": "Refresh", + "enable": "Enable", + "copyLink": "Copy Link", + "setSuccess": "Updated successfully", + "edit": "Edit", + "editSuccess": "Edited successfully", + "editFailed": "Edit failed", + "jumpFailed": "Navigation failed", + "openWithBrowser": "Open in Browser", + "shareToOtherApps": "Share to Other Apps", + "jumpToBrowserDownload": "Redirecting to Browser Download", + "loadFailed": "Load Failed", + "reload": "Reload", + "loadErrorType": "Error Type: {type}", + "loadUnkownError": "Unknown Error", + "home": "Home", + "dynamicTab": "Feed", + "explore": "Explore", + "layoutSetting": "Layout", + "imageSetting": "Images", + "operationSetting": "Behavior", + "privacySetting": "Privacy", + "lofterSetting": "LOFTER Settings", + "lofterBasicSetting": "LOFTER Settings", + "blacklistSetting": "Blacklist", + "tagShieldSetting": "Blocked Tags", + "userDynamicShieldSetting": "Muted User Activity", + "experimentSetting": "Labs", + "mine": "Me", + "setting": "Settings", + "basicSetting": "Basic", + "generalSetting": "General", + "language": "Language", + "chooseLanguage": "Choose Language", + "followSystem": "Follow System", + "checkUpdates": "Check for Updates", + "checkingUpdates": "Checking for Updates...", + "checkUpdatesFailed": "Update Check Failed", + "checkUpdatesFailedTip": "Failed to check for updates, please try again", + "autoCheckUpdates": "Auto Check for Updates", + "newVersion": "New Version: {version}", + "getNewVersion": "New Version {version} Found", + "doesImmediateUpdate": "Update Now?", + "changelogAsFollow": "Changelog: {log}", + "goToUpdate": "Go to Update", + "goToBrowserUpdate": "Go to browser update", + "updateLater": "Update Later", + "immediatelyInstall": "Install Now", + "installPortableTip": "You are currently using the portable version. Please manually extract the installation package and overwrite the existing files.", + "installing": "Installing...", + "installCanceled": "Installation Canceled", + "installFileNotFound": "Installation Package {filepath} Not Found", + "alreadyDownloadProgress": "Downloaded {progress}%", + "downloadSuccess": "Downloaded successfully", + "alreadyDownload": "Already Downloaded", + "download": "Download", + "downloading": "Downloading...", + "downloadComplete": "Download Complete", + "downloadFailed": "Download Failed", + "downloadManagement": "Download Manager", + "downloadTaskDetails": "Download Details", + "downloadSourceSummary": "Source", + "downloadResourceList": "Resources", + "downloadOverallProgress": "Overall Progress", + "viewOriginalContent": "View Original Content", + "downloadSourceUnavailable": "The original content is no longer available", + "pauseAllDownloads": "Pause All", + "resumeAllDownloads": "Resume All", + "cancelAllDownloads": "Cancel All", + "retryFailedDownloads": "Retry Failed", + "downloadBatchCounts": "{requested} requested · {skipped} skipped · {unavailable} unavailable", + "downloadSourcePostAll": "All post resources", + "downloadSourceFavoriteFolder": "Favorite folder", + "downloadSourceOther": "Content batch", + "downloadPartiallyFailed": "Partially completed", + "downloadGroupProgress": "{completed} / {total} resources · {percent}%", + "batchDownload": "Batch Download", + "batchDownloadRule": "Duplicate and completed resources are skipped. Failed, cancelled, or paused tasks are requeued.", + "batchDownloadSelected": "Selected {selected} / {total} posts", + "batchDownloadResolving": "Preparing {current} / {total}", + "batchDownloadSummary": "Queued {queued}, skipped {skipped}, unavailable {failed} posts", + "batchDownloadNoSelection": "Select at least one post", + "noDownloadableResources": "No downloadable resources were found", + "loadAllPostsForBatch": "Selecting all first loads every post in this collection", + "noDownloadTasks": "No download tasks yet", + "activeDownloads": "Active Downloads", + "downloadHistory": "Download History", + "waitingForDownload": "Waiting", + "downloadPaused": "Paused", + "downloadCancelled": "Cancelled", + "clearFinishedDownloads": "Clear finished tasks", + "downloadUnknownSize": "Size unknown", + "downloadFailedAndRetry": "Download failed. Try again", + "downloadFailedAndRetryTip": "The new version could not be downloaded. Please try again.", + "downloadingNewVersionPackage": "Downloading New Version Installation Package...", + "downloadSuccessClickToInstall": "New version installation package downloaded. Click to install now.", + "currentVersion": "Current Version", + "alreadyLatestVersion": "Up to Date", + "alreadyLatestVersionTip": "Current version is {version}, which is the latest version.", + "immediatelyDownload": "Download Now", + "appearanceSetting": "Appearance", + "themeSetting": "Theme Settings", + "themeMode": "Theme Mode", + "chooseThemeMode": "Choose Theme Mode", + "lightTheme": "Light Mode", + "darkTheme": "Dark Mode", + "selectTheme": "Select Theme", + "newTheme": "Create New Theme", + "pureWhite": "Pure White", + "pureBlack": "Pure Black", + "freshGreen": "Fresh Green", + "blueIron": "Blue Iron", + "primaryColor": "Primary Color", + "fontFamily": "Font", "fontItemCaptionLatin": "AaBbCc", "fontItemCaptionNonLatin": "你好世界", - "chooseFontFamily": "選擇字體", - "defaultFontFamily": "內建字體", - "customFontFamily": "自訂字體", - "loadFontFamily": "導入字體", - "fontFamlyLoadSuccess": "字體載入成功,重啟後切換", - "fontFamlyLoadFailed": "字體載入失敗", - "fontFileLoading": "字體檔案載入中...", - "fontFileNotExist": "字型檔案不存在,請嘗試重新下載或匯入", - "lxgw": "霞鷸文楷", - "lxgwGB": "霞鷚文楷-GB", - "lxgwLite": "霞鷸文楷-Lite", - "lxgwScreen": "霞鷸文楷-Screen", + "chooseFontFamily": "Choose Font", + "defaultFontFamily": "Built-in Font", + "customFontFamily": "Custom Font", + "loadFontFamily": "Import Font", + "fontFamlyLoadSuccess": "Font imported. Restart to apply.", + "fontFamlyLoadFailed": "Font Load Failed", + "fontFileLoading": "Importing font...", + "fontFileNotExist": "Font file not found. Download or import it again", + "lxgw": "LXGW WenKai", + "lxgwGB": "LXGW WenKai-GB", + "lxgwLite": "LXGW WenKai-Lite", + "lxgwScreen": "LXGW WenKai-Screen", "miSans": "MiSans", - "smileySans": "得意黑", + "smileySans": "Smiley Sans", "harmonyOSSans": "HarmonyOS Sans", - "deleteFont": "刪除字體{fontFamily}", - "deleteFontMessage": "是否刪除字體{fontFamily}?刪除後該字體檔案無法找回", - "enableFrostedGlassEffect": "啟用毛玻璃效果", - "hideAppbarWhenScrolling": "滾動時隱藏標題列", - "hideBottombarWhenScrolling": "滾動時隱藏底欄", - "safeSetting": "安全", - "gestureLock": "手勢密碼", - "enableGestureLock": "啟用手勢密碼", - "enableGestureLockTip": "啟用手勢密碼,保護你的Loftify", - "setGestureLock": "設定手勢密碼", - "haveToSetGestureLockTip": "設定手勢密碼後才能使用鎖定功能", - "changeGestureLock": "更改手勢密碼", - "noGestureLock": "尚未設定手勢密碼", - "noGestureLockTip": "尚未設定手勢密碼,是否前往設定", - "goToSetGestureLock": "前往設定", - "enableGestureLockSuccess": "手勢密碼啟用成功", - "disableGestureLockSuccess": "手勢密碼關閉成功", - "atLeast4Points": "連線數不能小於4個,請重新設定", - "drawGestureLockAgain": "請再畫出手勢密碼", - "gestureLockNotMatch": "與上一次繪製不一致, 請重新繪製", - "setGestureLockSuccess": "手勢密碼設定成功", - "gestureLockWrong": "密碼錯誤, 請重新繪製", - "verifyGestureLock": "驗證手勢密碼", - "enableBiometricSuccess": "生物辨識開啟成功", - "biometric": "生物辨識", - "biometricUnlock": "使用生物辨識解鎖", - "biometricUnlockTip": "僅透過生物辨識進行驗證;僅支援Android、IOS、Windows裝置;Windows裝置上僅支援PIN碼驗證", - "biometricErrorHwUnavailable": "目前裝置的生物辨識硬體不可用", - "biometricErrorNoBiometricEnrolled": "目前裝置未輸入生物辨識", - "biometricErrorNoHardware": "目前裝置不支援生物辨識", - "biometricErrorPasscodeNotSet": "目前裝置未設定鎖定畫面密碼", - "biometricErrorUnkown": "未知錯誤", - "biometricErrorUnsupported": "目前平台不支援生物辨識", - "biometricVerifyPin": "驗證PIN", - "biometricVerifySuccess": "驗證成功", - "biometricReason": "進行指紋驗證以使用{appName}", - "biometricReasonWindows": "驗證PIN以使用{appName}", - "biometricCancelButton": "取消", - "biometricGoToSettingsButton": "去設定", - "biometricNotRecognized": "指紋辨識失敗", - "biometricGoToSettingsDescription": "請設定指紋", + "deleteFont": "Delete Font {fontFamily}", + "deleteFontMessage": "Are you sure you want to delete font {fontFamily}? Once deleted, the font file cannot be recovered.", + "enableFrostedGlassEffect": "Enable Frosted Glass Effect", + "hideAppbarWhenScrolling": "Hide App Bar When Scrolling", + "hideBottombarWhenScrolling": "Hide Bottom Bar When Scrolling", + "safeSetting": "Security", + "gestureLock": "Gesture Lock", + "enableGestureLock": "Enable Gesture Lock", + "enableGestureLockTip": "Custom database encryption password will disable gesture lock", + "setGestureLock": "Set Gesture Lock", + "haveToSetGestureLockTip": "Set gesture lock to use the lock function", + "changeGestureLock": "Change Gesture Lock", + "noGestureLock": "No Gesture Lock Set", + "noGestureLockTip": "No gesture lock has been set, would you like to set it now?", + "goToSetGestureLock": "Set Now", + "enableGestureLockSuccess": "Gesture lock enabled successfully", + "disableGestureLockSuccess": "Gesture lock disabled successfully", + "atLeast4Points": "Connect at least 4 dots", + "drawGestureLockAgain": "Please draw the gesture lock again", + "gestureLockNotMatch": "Does not match the previous drawing, please try again", + "setGestureLockSuccess": "Gesture lock set successfully", + "gestureLockWrong": "Incorrect pattern, try again", + "verifyGestureLock": "Verify Gesture Lock", + "enableBiometricSuccess": "Biometric enabled successfully", + "biometric": "Biometrics", + "biometricUnlock": "Unlock using Biometrics", + "biometricUnlockTip": "Use biometrics for authentication; supported on Android, iOS, and Windows devices (only PIN verification on Windows)", + "biometricErrorHwUnavailable": "Biometric hardware is currently unavailable", + "biometricErrorNoBiometricEnrolled": "No biometric data enrolled on the device", + "biometricErrorNoHardware": "Biometrics not supported on this device", + "biometricErrorPasscodeNotSet": "No passcode set on this device", + "biometricErrorUnkown": "Unknown error", + "biometricErrorUnsupported": "Biometrics not supported on this platform", + "biometricVerifyPin": "Verify PIN", + "biometricVerifySuccess": "Verification successful", + "biometricReason": "Verify fingerprint to use {appName}", + "biometricReasonWindows": "Verify PIN to use {appName}", + "biometricCancelButton": "Cancel", + "biometricGoToSettingsButton": "Go to Settings", + "biometricNotRecognized": "Fingerprint recognition failed", + "biometricGoToSettingsDescription": "Please set up fingerprints", "biometricHint": "", - "biometricUserCanceled": "使用者取消操作", - "biometricTimeout": "操作逾時", - "biometricUnknown": "驗證失敗,可能是嘗試次數過多", - "biometricError": "驗證失敗", - "biometricSuccess": "指紋辨識成功", - "biometricSignInTitle": "指紋驗證", - "biometricDeviceCredentialsRequiredTitle": "請先錄入指紋!", - "biometricNotAvailable": "您的裝置不支援生物辨識", - "biometricNotEnrolled": "您的裝置未輸入生物辨識", - "biometricLockout": "生物辨識功能已被鎖定,請稍後再試", - "biometricLockoutPermanent": "生物辨識功能已永久鎖定,請使用其他方式解鎖", - "biometricOtherReason": "未知原因:{reason}", - "drawOldGestureLock": "請繪製舊手勢密碼", - "drawNewGestureLock": "繪製新手勢密碼", - "autoLock": "處於背景自動鎖定", - "autoLockTip": "在Windows、Linux、MacOS裝置中,視窗最小化或最小化至托盤時即表示處於背景", - "autoLockDelay": "自動鎖定時機", - "chooseAutoLockDelay": "選擇自動鎖定時機", - "immediatelyLock": "立即鎖定", - "after30SecondsLock": "處於後台30秒後鎖定", - "after1MinuteLock": "處於後台1分鐘後鎖定", - "after3MinutesLock": "處於後台3分鐘後鎖定", - "after5MinutesLock": "處於後台5分鐘後鎖定", - "after10MinutesLock": "處於後台10分鐘後鎖定", - "safeMode": "安全模式", - "safeModeTip": "僅支援Android、IOS裝置;當軟體進入最近任務清單頁面,隱藏頁面內容;同時停用應用程式內截圖", - "clearCache": "清空快取", - "platformSetting": "平台適配設定", - "desktopSetting": "桌面端設定", - "closeWindowOption": "關閉主介面時", - "chooseCloseWindowOption": "選擇關閉主介面時", - "minimizeToTray": "最小化至系統托盤", - "showTray": "顯示系統托盤", - "exitApp": "退出Loftify", - "displayAppTray": "顯示主視窗", - "lockAppTray": "鎖定", - "officialWebsiteTray": "官網", + "biometricUserCanceled": "User canceled", + "biometricTimeout": "Operation timed out", + "biometricUnknown": "Verification failed, possibly due to too many attempts", + "biometricError": "Verification failed", + "biometricSuccess": "Fingerprint recognized successfully", + "biometricSignInTitle": "Fingerprint Verification", + "biometricDeviceCredentialsRequiredTitle": "Please enroll fingerprints!", + "biometricNotAvailable": "Your device does not support biometrics", + "biometricNotEnrolled": "No biometric data enrolled on your device", + "biometricLockout": "Biometrics locked, please try again later", + "biometricLockoutPermanent": "Biometrics permanently locked, please use another method to unlock", + "biometricOtherReason": "Unknown reason: {reason}", + "drawOldGestureLock": "Please draw the old gesture lock", + "drawNewGestureLock": "Draw a new gesture lock", + "autoLock": "Auto-lock when in the background", + "autoLockTip": "Requires a gesture lock or custom database password. On desktop, minimizing the app or sending it to the tray counts as background activity", + "autoLockDelay": "Auto-lock timing", + "chooseAutoLockDelay": "Choose when to auto-lock", + "immediatelyLock": "Lock immediately", + "after30SecondsLock": "Lock after 30 seconds in the background", + "after1MinuteLock": "Lock after 1 minute in the background", + "after3MinutesLock": "Lock after 3 minutes in the background", + "after5MinutesLock": "Lock after 5 minutes in the background", + "after10MinutesLock": "Lock after 10 minutes in the background", + "safeMode": "Safe Mode", + "safeModeTip": "Supported on Android and iOS devices; hides app content when in the recent apps list and disables in-app screenshots", + "clearCache": "Clear Cache", + "platformSetting": "Platform", + "desktopSetting": "Desktop", + "closeWindowOption": "When Closing the Main Window", + "chooseCloseWindowOption": "Choose what happens when closing the main window", + "minimizeToTray": "Minimize to system tray", + "showTray": "Show system tray", + "exitApp": "Exit Loftify", + "displayAppTray": "Show main window", + "lockAppTray": "Lock", + "officialWebsiteTray": "Official Website", "repoTray": "GitHub", - "exitAppTray": "退出", - "launchAtStartup": "開機自啟動", - "autoMemoryWindowPositionAndSize": "記憶視窗位置與大小", - "autoMemoryWindowPositionAndSizeTip": "關閉後,每次開啟Loftify都會居中顯示且具有預設視窗大小", - "mobileSetting": "行動端設定", - "useDesktopLayoutWhenLandscape": "橫屏時啟用桌面端佈局", - "haveToRestartWhenChange": "更改後需要重新啟動", - "inAppBrowser": "內建瀏覽器", - "clearingCache": "清空快取中...", - "clearCacheSuccess": "清空快取成功", - "clearingLog": "清空日誌中...", - "clearLogSuccess": "清空日誌成功", - "clearLogFailed": "清空日誌失敗", - "exportLog": "匯出日誌", - "noLog": "暫無日誌可匯出", - "exportLogHint": "當您在使用軟體的過程中遇到問題時,匯出日誌提供給開發者以方便溯源", - "exportPathCannotInLogDir": "匯出路徑不能在日誌目錄中", - "clearLog": "清空日誌", - "exporting": "匯出中...", - "exportSuccess": "匯出成功", - "exportFailed": "匯出失敗", - "clearLogTitle": "確認清空日誌", - "clearLogHint": "是否清空日誌?當您在使用軟體的過程中遇到問題時,我們建議您匯出日誌後再清空日誌", - "about": "關於", - "changelog": "更新日誌", - "failedToGetChangelog": "取得更新日誌失敗", - "bugReport": "回報BUG", - "githubRepo": "GitHub倉庫", - "privacyPolicy": "隱私權政策", - "serviceTerm": "服務條款", - "rate": "評分數吧", - "rateTitle": "為Loftify評個分吧", - "pleaseRate": "請評分", - "rateSuccess": "感謝您的評分", - "pleaseClickToRate": "請點選評分", - "rateLater": "暫時不分", - "submitRate": "提交評分", - "rate1Star": "革命仍需努力", - "rate2Star": "期待您的回饋與建議", - "rate3Star": "我會繼續進步的!", - "rate4Star": "收下你的認可啦", - "rate5Star": "啾咪~~", - "shareApp": "分享APP", - "shareAppText": "Loftify - 簡潔的雙重認證器\\n{officialWebsite}", - "feedbackSubject": "Loftify回饋", - "contact": "聯絡我們", - "officialWebsite": "官方網站", - "qqGroup": "QQ群聊", - "telegramGroup": "Telegram頻道", - "eggEssay": "  恭喜你發現了我藏在Loftify中的小彩蛋!", - "noEmailClient": "尚未安裝郵箱程序,已複製Email位址到剪貼簿", - "shareSuccess": "分享成功", - "shareFailed": "分享失敗", - "cancelShare": "取消分享", - "loading": "載入中...", - "notSupportedUri": "不支援的URI:{uri}", - "pleaseGrantFilePermission": "請授予檔案儲存權限", - "hasRejectedFilePermission": "已拒絕檔案儲存權限,將跳到瀏覽器下載", - "shortcut": "快速鍵", - "shortcutHelp": "快捷鍵幫助", - "back": "返回", - "backToHome": "返回首頁", - "escape": "退出", - "lock": "鎖定軟體", - "dayAgo": "{day}天前", - "hourAgo": "{hour}小時前", - "minuteAgo": "{minute}分鐘前", - "secondAgo": "{second}秒前", - "rightnow": "剛剛", - "changeDayNightMode": "切換深淺色模式", - "errorQrCode": "二維碼建置失敗", - "showArticleInRecommendFlow": "建議串流顯示文章", - "showVideoInRecommendFlow": "推薦串流顯示影片", - "showPostDetailFloatingOperationBar": "浮動操作欄", - "showPostDetailFloatingOperationBarDescription": "取消勾選將不顯示貼文詳情頁面的浮動操作欄", - "showPostDetailFloatingOperationBarOnlyInArticle": "僅在文章中顯示浮動操作欄", - "searchResultPage": "搜尋結果頁面", - "recordSearchHistory": "保存搜尋紀錄", - "recordSearchHistoryDescription": "取消勾選將清空並不再記錄搜尋記錄", - "guessYouLike": "猜你想搜", - "guessYouLikeDescription": "搜尋結果頁顯示猜你想搜", - "externalLinkCards": "外鏈卡", - "externalLinkCardsDescription": "搜尋結果頁顯示外鏈卡片", - "hotRank": "熱門名單", - "hotRankDescription": "搜尋結果頁顯示熱門清單", - "postDetailPage": "文章詳情頁", - "showCollectionPreNext": "集合上下篇", - "showCollectionPreNextDescription": "取消勾選將不顯示上一篇、下一篇等入口", - "showDownloadButton": "下載按鈕", - "showDownloadButtonDescription": "取消勾選將不顯示下載全部圖片按鈕", - "loadBlacklistFailed": "黑名單載入失敗", - "unlockBlacklist": "解除黑名單", - "unlockBlacklistMessage": "確認解除「{user}」的黑名單? ", - "unlock": "解除", - "eggMessage": "  恭喜你發現了我藏在Loftify中的小彩蛋!
  相信發現這個彩蛋的你已經很熟悉Loftify了,那麼我先做個自我介紹吧。下重新拾起了Flutter開發Loftify,並適配了平板裝置和Windows系統。身份驗證器受到我室友的青睞,甚至他的同事還詢問有沒有IOS版本的,這是我第一次體會到自己的作品被他人認可的那種奇妙的感覺。在閒暇的時候,我已經用Flutter全面重構了CloudOTP,並支援了更多新功能。在未來我也會將自己的作品呈現給更多喜歡它的人們。
  靈感呢,來自Android 14系統,是我設計過的彩蛋中唯一差強人意的一個,以此獻給使用Loftify的你,希望你喜歡這個彩蛋,也希望你能喜歡Loftify💕💕。", - "overrideCloudControl": "覆蓋雲控", - "overrideCloudControlDescription": "覆蓋雲控值,使Loftify的功能更加豐富", - "refreshRate": "刷新率", - "chooseRefreshRate": "選擇刷新率", - "refreshRateDescription": "意在解決部分機型高刷失效的問題,如無問題,請不要修改\n如果您的設備支援LTPO,可能會設定失敗\n已選模式: {selected}\n首選模式: {preferred}\n活動模式: {active}", - "setRefreshRateSuccess": "更新率設定成功", - "setRefreshRateFailed": "刷新率設定失敗", - "setRefreshRateFailedWithError": "刷新率設定失敗: {error}", - "setRefreshRateSuccessWithDisplayModeNotChanged": "更新率設定成功,但目前顯示模式未改變", - "filenameFormat": "檔案命名格式", - "inputFilenameFormat": "輸入檔案命名格式", - "availableFields": "可用欄位: ", - "field": "字段", - "description": "描述", - "example": "範例", - "fieldOriginalNameDescription": "圖片原檔案名稱", + "exitAppTray": "Exit", + "launchAtStartup": "Launch at startup", + "autoMemoryWindowPositionAndSize": "Remember window position and size", + "autoMemoryWindowPositionAndSizeTip": "If disabled, Loftify opens centered at its default window size", + "mobileSetting": "Mobile", + "navigationBarDisplayStyle": "Navigation bar style", + "navigationBarDisplayStyleDescription": "Choose whether the bottom navigation shows icons, labels, or both", + "chooseNavigationBarDisplayStyle": "Choose a navigation bar style", + "navigationBarIconAndText": "Icons and labels", + "navigationBarIconOnly": "Icons only", + "navigationBarTextOnly": "Labels only", + "reduceTransparency": "Reduce transparency effects", + "reduceTransparencyDescription": "Use a solid navigation surface for clearer contrast and smoother performance on lower-end devices", + "useDesktopLayoutWhenLandscape": "Enable desktop layout in landscape mode", + "haveToRestartWhenChange": "A restart is required after changes", + "inAppBrowser": "In-app Browser", + "clearingCache": "Clearing cache...", + "clearCacheSuccess": "Cache cleared successfully", + "clearingLog": "Clearing logs...", + "clearLogSuccess": "Logs cleared successfully", + "clearLogFailed": "Failed to clear logs", + "exportLog": "Export Logs", + "noLog": "No logs available for export", + "exportLogHint": "When you encounter issues while using the software, export logs to provide them to the developer for troubleshooting", + "exportPathCannotInLogDir": "The export path cannot be inside the log directory", + "clearLog": "Clear Logs", + "exporting": "Exporting...", + "exportSuccess": "Export successful", + "exportFailed": "Export failed", + "clearLogTitle": "Clear Logs?", + "clearLogHint": "Clear all logs? If you're troubleshooting a problem, export them first.", + "about": "About", + "changelog": "Changelog", + "failedToGetChangelog": "Failed to get changelog", + "bugReport": "Report a Bug", + "githubRepo": "GitHub Repository", + "privacyPolicy": "Privacy Policy", + "serviceTerm": "Terms of Service", + "rate": "Rate", + "rateTitle": "Rate Loftify", + "pleaseRate": "Please rate", + "rateSuccess": "Thank you for your rating", + "pleaseClickToRate": "Choose a rating", + "rateLater": "Not Now", + "submitRate": "Submit Rating", + "rate1Star": "Still room for improvement", + "rate2Star": "Send us your feedback and suggestions", + "rate3Star": "I'll keep improving!", + "rate4Star": "Thanks for your support!", + "rate5Star": "Love it! ~~", + "shareApp": "Share the App", + "shareAppText": "Loftify - A clean, simple third-party LOFTER client\\n{officialWebsite}", + "feedbackSubject": "Loftify Feedback", + "contact": "Contact Us", + "officialWebsite": "Official Website", + "qqGroup": "QQ group chat", + "telegramGroup": "Telegram Channel", + "eggEssay": "  Congratulations on discovering the easter egg hidden in Loftify!", + "noEmailClient": "No email client installed, the email address has been copied to the clipboard", + "shareSuccess": "Share successful", + "shareFailed": "Share failed", + "cancelShare": "Cancel share", + "loading": "Loading...", + "notSupportedUri": "Unsupported URI: {uri}", + "pleaseGrantFilePermission": "Please grant file storage permission", + "hasRejectedFilePermission": "File storage permission denied, redirecting to browser for download", + "shortcut": "Shortcut", + "shortcutHelp": "Shortcut help", + "back": "Back", + "backToHome": "Back to Home", + "escape": "Exit", + "lock": "Lock App", + "dayAgo": "{day} days ago", + "hourAgo": "{hour} hours ago", + "minuteAgo": "{minute} minutes ago", + "secondAgo": "{second} seconds ago", + "rightnow": "Just now", + "changeDayNightMode": "Switch between light and dark mode", + "errorQrCode": "QR code generation failed", + "showArticleInRecommendFlow": "Show Articles in Recommendation Feed", + "showVideoInRecommendFlow": "Show Videos in Recommendation Feed", + "hideHomeAppBarOnScroll": "Hide home top bar while scrolling", + "hideSearchNavigation": "Hide search from bottom navigation", + "hideSearchNavigationDescription": "The search button will appear on the right side of the home top bar", + "showPostDetailFloatingOperationBar": "Floating Action Bar", + "showPostDetailFloatingOperationBarDescription": "Turn this off to hide the floating action bar on post detail pages", + "showPostDetailFloatingOperationBarOnlyInArticle": "Show Floating Action Bar Only for Articles", + "searchResultPage": "Search results page", + "recordSearchHistory": "Save search history", + "recordSearchHistoryDescription": "Turn this off to clear your search history and stop recording searches", + "guessYouLike": "Suggested Searches", + "guessYouLikeDescription": "Show suggested searches on the search results page", + "externalLinkCards": "External Link Cards", + "externalLinkCardsDescription": "Show external link cards on the search results page", + "hotRank": "Trending", + "hotRankDescription": "Show trending list on search results page", + "postDetailPage": "Post details page", + "showCollectionPreNext": "Collection Prev & Next", + "showCollectionPreNextDescription": "Turn this off to hide previous and next post shortcuts", + "showDownloadButton": "Download button", + "showDownloadButtonDescription": "Turn this off to hide the Download All Images button", + "loadBlacklistFailed": "Blacklist failed to load", + "unlockBlacklist": "Remove from Blacklist", + "unlockBlacklistMessage": "Remove \"{user}\" from the blacklist?", + "unlock": "Remove", + "eggMessage": "  Congratulations! You found the little easter egg hidden in Loftify.
  If you've found this, you probably know Loftify pretty well, so let me introduce myself. I returned to Flutter development to build Loftify and adapt it for tablets and Windows. My authenticator app was well received by my roommate, and even his colleagues asked about an iOS version. It was the first time I felt the joy of seeing my work recognized by others. In my spare time, I rebuilt CloudOTP in Flutter and added many new features. I hope to share more of my work with people who enjoy it in the future.
  Inspired by Android 14, this easter egg is dedicated to everyone who uses Loftify. I hope you enjoy it - and Loftify too. 💕💕", + "overrideCloudControl": "Override Cloud Settings", + "overrideCloudControlDescription": "Override cloud values to make Loftify more feature-rich", + "refreshRate": "Refresh Rate", + "chooseRefreshRate": "Select refresh rate", + "refreshRateDescription": "This option addresses high refresh rate issues on some devices. Do not change it unless needed.\nConfiguration may fail if your device supports LTPO.\nSelected mode: {selected}\nPreferred mode: {preferred}\nActive mode: {active}", + "setRefreshRateSuccess": "Refresh rate set successfully", + "setRefreshRateFailed": "Failed to set refresh rate", + "setRefreshRateFailedWithError": "Failed to set refresh rate: {error}", + "setRefreshRateSuccessWithDisplayModeNotChanged": "Refresh rate set successfully, but the current display mode is unchanged", + "filenameFormat": "File Naming Format", + "inputFilenameFormat": "Input file naming format", + "availableFields": "Available fields:", + "field": "Field", + "description": "Description", + "example": "Example", + "fieldOriginalNameDescription": "Original image file name", "fieldOriginalNameExample": "6JKDN49SDDVMG", - "fieldBlogIdDescription": "作者ID", + "fieldBlogIdDescription": "Author ID", "fieldBlogIdExample": "12345678", - "fieldBlogLofterIdDescription": "作者Lofter ID", + "fieldBlogLofterIdDescription": "Author LOFTER ID", "fieldBlogLofterIdExample": "loftifyofficial", - "fieldBlogNickNameDescription": "作者暱稱", - "fieldBlogNickNameExample": "Loftify官方", - "fieldPostIdDescription": "帖子ID", + "fieldBlogNickNameDescription": "Author nickname", + "fieldBlogNickNameExample": "Loftify Official", + "fieldPostIdDescription": "Post ID", "fieldPostIdExample": "12345678", - "fieldPostTitleDescription": "貼文標題,若沒有標題則設定為「無標題」", - "fieldPostTitleExample": "正式發布啦", - "fieldPostTagsDescription": "貼文標籤,若沒有標籤則設定為「無標籤」", - "fieldPostTagsExample": "第三方,Loftify", - "fieldPostPublishTimeDescription": "貼文發佈時間字串", + "fieldPostTitleDescription": "The title of the post, or \"no title\" if there is no title", + "fieldPostTitleExample": "It's officially released", + "fieldPostTagsDescription": "Post tags, set to 'no tags' if no tags", + "fieldPostTagsExample": "Third Party, Loftify", + "fieldPostPublishTimeDescription": "Post Publish Time", "fieldPostPublishTimeExample": "2024-11-03_12-34-56", - "fieldPartDescription": "目前圖片在所有圖片中的序號", + "fieldPartDescription": "The serial number of the current image in all images", "fieldPartExample": "0", - "fieldTimestampDescription": "當前時間戳記", + "fieldTimestampDescription": "Current timestamp", "fieldTimestampExample": "1234567890", - "fieldCurrentTimeDescription": "當前時間字串", + "fieldCurrentTimeDescription": "Current Time String", "fieldCurrentTimeExample": "2024-11-03_12-34-56", - "fieldUnderlineDescription": "底線", + "fieldUnderlineDescription": "Underline", "fieldUnderlineExample": "_", - "fieldSlackDescription": "路徑分隔符,用於建立資料夾", + "fieldSlackDescription": "Path separator for creating folders", "fieldSlackExample": "/", - "doubleTapInDetailPage": "詳情頁雙擊頁面", - "chooseDoubleTapInDetailPage": "選擇詳情頁雙擊頁面動作", - "afterDownloadSuccess": "下載成功後", - "chooseAfterDownloadSuccess": "選擇下載成功後操作", - "afterDownloadSuccessDescription": "下載成功後(點擊詳情頁的下載全部按鈕、雙擊詳情頁面下載、點擊圖片詳情頁的下載或下載全部按鈕)執行的操作", - "downloadImageSetting": "下載設定", - "downloadImagePath": "圖片/影片下載路徑", - "chooseDownloadImagePath": "選擇圖片/影片下載路徑", - "imageQuality": "圖片品質", - "waterfallFlowImageQuality": "瀑布流圖片品質", - "chooseWaterfallFlowImageQuality": "選擇瀑布流圖片品質", - "postDetailImageQuality": "貼文詳情圖片品質", - "choosePostDetailImageQuality": "選擇貼文詳情圖片品質", - "bigImageQuality": "看大圖時圖片品質", - "chooseBigImageQuality": "選擇看大圖時圖片品質", - "bigImageSetting": "大圖設定", - "backgroundColorFollowMainColor": "背景色跟隨圖片主色調", - "tapLinkButton": "點選連結按鈕", - "tapLinkButtonDescription": "點選連結按鈕時複製的圖片連結品質", - "chooseTapLinkButton": "選擇點選連結按鈕時複製的圖片連結品質", - "longPressLinkButton": "長按連結按鈕", - "longPressLinkButtonDescription": "長按連結按鈕時複製的圖片連結品質", - "chooseLongPressLinkButton": "選擇長按連結按鈕時複製的圖片連結品質", - "loadGiftSettingFailed": "禮物設定載入失敗", - "copyrightWatermarkTitle": "版權浮水印關閉提示", - "copyrightWatermarkMessage": "由於一些歷史遺留問題,你需要先在電腦上關閉版權水印功能(主頁設定-版權-版權水印)", - "goToEdit": "前往更改", - "editLater": "暫不更改", - "personalizedService": "個人化服務", - "personalizedServiceDescription": "取消勾選後,將不會使用你的個人資訊為你提供個人化服務", - "copyrightProtection": "版權保護", - "copyrightWatermark": "版權水印簽名", - "copyrightWatermarkDescription": "勾選後,所有使用者保存你的圖片到手機,會顯示你的簽名水印,保護原創", - "workProtection": "作品保護", - "workProtectionDescription": "勾選後,發佈的圖片日誌將禁止被儲存到本機", - "shareVideoProtection": "產生分享影片保護", - "shareVideoProtectionDescription": "勾選後,你的作品將禁止生成分享影片", - "giftSetting": "禮物設定", - "acceptGift": "接受禮物", - "imageBlurPreview": "支援圖片模糊預覽", - "addShieldTag": "新增封鎖標籤", - "unblockShieldTag": "解除屏蔽", - "unblockShieldTagMessage": "確認解除屏蔽「{tag}」? ", - "resumeView": "恢復檢視", - "resumeViewDynamic": "恢復檢視動態", - "resumeViewDynamicMessage": "確認恢復檢視「{blogNickName}」的動態? ", - "noBgAvatar": "暫無壁紙頭像", - "noDress": "暫無裝扮", - "noCollection": "暫無收藏", - "noSubscribedCollection": "暫時沒有訂閱的收藏", - "noGrain": "暫無糧單", - "noSubscribedGrain": "暫時沒有訂閱的糧單", - "noTag": "暫無標籤", - "noSubscribedTag": "暫無訂閱的標籤", - "noContent": "暫無內容", - "noDynamic": "暫無動態", - "noLike": "暫無喜歡", - "noRecommend": "暫無推薦", - "noHistory": "暫無足跡", - "noFavorite": "暫無收藏", - "noFavoriteFolder": "暫無收藏夾", - "noArticle": "暫無文章", - "noUser": "暫無使用者", - "noEmotePackage": "暫無表情包", - "noSearchResult": "暫無搜尋結果", - "singleSuit": "單套", - "singleImage": "單張", - "cardPool": "卡池", - "pendantCount": "{count}個掛件", - "emoteCount": "{count}個永久表情", - "viewDetail": "查看詳情", - "dressSuccess": "配戴成功", - "unDressSuccess": "取消配戴成功", - "dressImmediately": "立即配戴", - "dressingCurrently": "正在佩戴中", - "avatarBox": "頭像方塊", - "commentBubble": "評論氣泡", - "dressDetail": "裝扮詳情", - "emotePackage": "表情符號", - "emotePackageDetail": "表情包詳情", - "emote": "表情", - "relatedDress": "#{tag}#的相關裝扮", - "dressList": "裝扮清單", - "dressTheme": "裝扮主題", - "bgAvatar": "壁紙頭像", - "dress": "裝扮", - "shop": "TA的商品", - "feedbackWelcome": "歡迎回饋", - "feedbackWelcomeMessage": "加入QQ群,回饋BUG、建議和想法,歡迎你的加入!", - "goToQQ": "跳轉至QQ", - "joinLater": "暫不加入", - "viewPersonalHomepage": "查看個人首頁", - "logout": "退出登入", - "goToLogin": "前往登入", - "follow": "關注", - "followed": "已關注", - "unfollow": "取消追蹤", - "specialFollow": "特別注意", - "specialFollowed": "已特別關注", - "unSpecialFollow": "取消特別追蹤", - "blacklisted": "已封鎖", - "tag": "標籤", - "collection": "收藏", - "grain": "糧單", - "updateRecently": "最近更新", - "visitFrequently": "最常訪問", - "viewRecently": "最近看過", - "searchRecently": "最近搜尋", - "hotCount": "熱度", - "subscribe": "訂閱", - "subscribed": "已訂閱", - "viewCount": "瀏覽", - "circleWorks": "圈圈作品", - "updateCount": "{count}篇更新", - "continueRead": "繼續閱讀", - "startRead": "開始閱讀", - "chapter": "篇", - "unsubscribe": "取消訂閱", - "myFollowingWithCount": "我的關注({count})", - "myFollowerWithCount": "我的粉絲({count})", - "contentCenter": "內容中心", - "myLikes": "我的喜歡", - "myRecommends": "我的推薦", - "myFavorites": "我的收藏", - "myHistory": "我的足跡", - "myCreative": "我的創作", - "myPosts": "我的作品", - "myCollections": "我的收藏", - "myGrains": "我的糧單", - "login": "登入", - "loginToGetPersonalizedService": "登入以取得個人化服務", - "haveCopiedNickName": "已複製暱稱", - "haveCopiedLofterID": "已複製LofterID", - "haveCopiedRemark": "已複製備註", - "remarkSuffix": " | 備註: {remark}", - "ipSuffix": " | IP屬地: ", - "descriptionTitle": "{nickName}的個人介紹", - "moreInfo": "更多資訊", - "cannotViewFollowingList": "無法查看關注清單", - "cannotViewFollowerList": "無法查看粉絲清單", - "cannotViewSupporterList": "無法查看支持者清單", - "gender": "性別", - "male": "男", - "female": "女", - "confidential": "保密", + "doubleTapInDetailPage": "Double-tap on Detail Page", + "chooseDoubleTapInDetailPage": "Choose Double-tap Action", + "afterDownloadSuccess": "After successful download", + "chooseAfterDownloadSuccess": "Choose what happens when the download is successful", + "afterDownloadSuccessDescription": "Applied after downloading from the post detail page or image viewer", + "downloadImageSetting": "Download Settings", + "downloadImagePath": "Image/Video Download Path", + "chooseDownloadImagePath": "Choose an image/video download path", + "imageQuality": "Image quality", + "waterfallFlowImageQuality": "Masonry Image Quality", + "chooseWaterfallFlowImageQuality": "Select Masonry Image Quality", + "postDetailImageQuality": "Post Details Image Quality", + "choosePostDetailImageQuality": "Select post details image quality", + "bigImageQuality": "Image quality when viewing large images", + "chooseBigImageQuality": "Select image quality when viewing large images", + "bigImageSetting": "Large Image Settings", + "backgroundColorFollowMainColor": "The background color follows the main tone of the image", + "tapLinkButton": "Tap the link button", + "tapLinkButtonDescription": "Image quality used when copying a link by tapping the link button", + "chooseTapLinkButton": "Choose the image quality used for links copied by tapping the button", + "longPressLinkButton": "Press and hold the link button", + "longPressLinkButtonDescription": "Image quality used when copying a link by long-pressing the link button", + "chooseLongPressLinkButton": "Choose the image quality used for links copied by long-pressing the button", + "loadGiftSettingFailed": "Gift settings failed to load", + "copyrightWatermarkTitle": "Disable Copyright Watermark", + "copyrightWatermarkMessage": "Before continuing, disable the copyright watermark on the LOFTER website under Homepage Settings - Copyright - Copyright Watermark", + "goToEdit": "Go to Edit", + "editLater": "Not Now", + "personalizedService": "Personalized Services", + "personalizedServiceDescription": "Turn this off to prevent your personal information from being used for personalized services", + "copyrightProtection": "Copyright Protection", + "copyrightWatermark": "Copyright Watermark Signature", + "copyrightWatermarkDescription": "Add your signature watermark when other users save your images", + "workProtection": "Work Protection", + "workProtectionDescription": "Prevent published image posts from being saved locally", + "shareVideoProtection": "Generate Shared Video Protection", + "shareVideoProtectionDescription": "Prevent shared videos from being generated from your posts", + "giftSetting": "Gift settings", + "acceptGift": "Accept Gifts", + "imageBlurPreview": "Enable Blurred Image Previews", + "addShieldTag": "Add Blocked Tag", + "unblockShieldTag": "Unblock", + "unblockShieldTagMessage": "Are you sure you want to unblock \"{tag}\"?", + "resumeView": "Resume Viewing", + "resumeViewDynamic": "Resume Viewing Activity", + "resumeViewDynamicMessage": "Are you sure you want to resume viewing {blogNickName}?", + "noBgAvatar": "No Profile Backgrounds Yet", + "noDress": "No Outfits Yet", + "noCollection": "No Favorites Yet", + "noSubscribedCollection": "No Subscribed Collections", + "noGrain": "No Grain Lists Yet", + "noSubscribedGrain": "No Subscribed Grain Lists", + "noTag": "No tags", + "noSubscribedTag": "No subscribed tags", + "noContent": "Nothing to see", + "noDynamic": "No activity", + "noLike": "No likes yet", + "noRecommend": "No Recommendations Yet", + "noHistory": "No Browsing History", + "noFavorite": "No favorites yet", + "noFavoriteFolder": "No Favorite Folders", + "noArticle": "No posts yet", + "noUser": "There are no users yet", + "noEmotePackage": "No emoticons yet", + "noSearchResult": "No results", + "singleSuit": "Single set", + "singleImage": "Single Image", + "cardPool": "Card Pool", + "pendantCount": "{count} widgets", + "emoteCount": "{count} Permanent Emotes", + "viewDetail": "View details", + "dressSuccess": "Worn successfully", + "unDressSuccess": "Removed successfully", + "dressImmediately": "Wear it now", + "dressingCurrently": "Wearing", + "avatarBox": "Avatar Frame", + "commentBubble": "Comment Bubble", + "dressDetail": "Outfit Details", + "emotePackage": "Emoji", + "emotePackageDetail": "Emote Pack Details", + "emote": "Emote", + "relatedDress": "Related Outfits: #{tag}", + "dressList": "Dress List", + "dressTheme": "Dress-up Theme", + "bgAvatar": "Profile Background", + "dress": "Outfits", + "shop": "Their Items", + "feedbackWelcome": "We'd Love Your Feedback", + "feedbackWelcomeMessage": "Join our QQ group to report bugs and share suggestions or ideas", + "goToQQ": "Open QQ", + "joinLater": "Maybe Later", + "viewPersonalHomepage": "View Profile", + "logout": "Log out", + "goToLogin": "Go to login", + "follow": "Follow", + "followed": "Followed", + "unfollow": "Unfollow", + "specialFollow": "Special Follow", + "specialFollowed": "Special Follow", + "unSpecialFollow": "Remove Special Follow", + "blacklisted": "Blocked", + "tag": "Tag", + "collection": "Favorites", + "grain": "Grain Lists", + "updateRecently": "Recent updates", + "visitFrequently": "Most visited", + "viewRecently": "Recently viewed", + "searchRecently": "Recent searches", + "hotCount": "Popularity", + "subscribe": "Subscribe", + "subscribed": "Subscribed", + "viewCount": "Browse", + "circleWorks": "Circle Posts", + "updateCount": "{count} updates", + "continueRead": "Continue reading", + "startRead": "Start reading", + "chapter": "Posts", + "unsubscribe": "Unsubscribe", + "myFollowingWithCount": "Following ({count})", + "myFollowerWithCount": "Followers ({count})", + "contentCenter": "Content Center", + "myLikes": "My Likes", + "myRecommends": "My Recommendations", + "myFavorites": "My Favorites", + "myHistory": "My Browsing History", + "myCreative": "My Creations", + "myPosts": "My Posts", + "myCollections": "My Favorites", + "myGrains": "My Grain Lists", + "login": "Log in", + "loginToGetPersonalizedService": "Log in to get personalized service", + "haveCopiedNickName": "Copied nickname", + "haveCopiedLofterID": "LOFTER ID copied", + "haveCopiedRemark": "Note copied", + "remarkSuffix": "| Note: {remark}", + "ipSuffix": "| IP location:", + "descriptionTitle": "{nickName}'s Bio", + "moreInfo": "More information", + "cannotViewFollowingList": "Cannot View Following", + "cannotViewFollowerList": "Cannot View Followers", + "cannotViewSupporterList": "Cannot View Supporters", + "gender": "Gender", + "male": "Male", + "female": "Female", + "confidential": "Private", "lofterId": "ID: {id}", - "userMeta": "{postCount}篇文章 · {collectionCount}個合集", - "follower": "粉絲", - "following": "關注", - "clearSearchHistory": "清空搜尋紀錄", - "clearSearchHistoryMessage": "清空搜尋紀錄後無法恢復,請謹慎操作", - "guessYouSearch": "猜你想搜", - "unSupportVideoInDesktop": "桌面端不支援播放影片", - "searchingCount": "{count}人在搜尋", - "searchHint": "搜尋標籤、收藏、文章、討論、糧單、使用者", - "getLinkFailed": "取得連結失敗", - "collectionDetail": "合集詳情", - "grainDetail": "糧單詳情", - "noDescription": "暫無簡介", - "order": "正序", - "reverseOrder": "倒序", - "subscribeCollection": "訂閱合集", - "subscribeGrain": "訂閱糧單", - "noPostInCollection": "合集中暫無文章", - "noPostInGrain": "糧單中暫無文章", - "collectionCover": "合輯封面", - "grainCover": "糧單封面", - "updateAt": "更新於", - "postCount": "文章數", - "subscribeCount": "訂閱數", - "totalHotCount": "總熱度", - "viewCountLong": "瀏覽量", - "coCreatorCount": "共創數", - "postDetail": "貼文詳情", - "descriptionWithPostCount": "{description}({postCount}篇)", - "catelog": "目錄", - "prePost": "上一篇", - "nextPost": "下一篇", - "atFirstPost": "已在首篇", - "haveAtFirstPost": "已經是第一篇了", - "atLastPost": "已在末篇", - "haveAtLastPost": "已經是最後一篇了", - "eraseBlur": "擦拭圖圖", - "reblogFrom": "授權轉載自{user}", - "noneCopyright": "不聲明", - "byCopyright": "署名", - "byNdCopyright": "署名-禁止演繹", - "byNcCopyright": "署名-非商業性使用", - "bySaCopyright": "署名-相同方式共享", - "byNcNdCopyright": "署名-非商業性使用-禁止演繹", - "byNcSaCopyright": "署名-非商業性使用-相同方式共享", - "favorite": "收藏", - "favorited": "已收藏", - "moreRecommend": "更多推薦", - "hotRecommend": "熱門推薦", - "hotCollectionRank": "熱門合輯榜", - "hotGrainRank": "熱門糧單榜", - "visitOriginalPost": "訪問原文", - "relatedTag": "相關標籤", - "relatedPost": "相關文章", - "viewAll": "看全部", - "comprehensive": "綜合", - "user": "使用者", - "article": "文章", - "hottest": "最熱", - "newest": "最新", - "participateCount": "參與", - "collectionGrain": "合貨單", - "collectionGrainDetail": "熱門「{title}」", - "relatedDressShort": "相關裝扮", - "relatedDressShortDetail": "已取得{slotCount}次", - "releaseRecently": "最新發布", - "commentRecently": "最新評論", - "filter": "篩選", - "dayRank": "日榜", - "weekRank": "周榜", - "monthRank": "月榜", - "allAreSearching": "大家都在搜", - "multipleSearchKeySeparatedBySpaces": "多個搜尋字詞以空格隔開", - "searchInTag": "在#{tag}#內搜尋", - "tagRelatedTags": "#{tag}#的相關標籤", - "getCaptcha": "取得驗證碼", - "phoneCannotBeEmpty": "手機號碼不能為空", - "imageCaptchaCannotBeEmpty": "圖片驗證碼不能為空", - "resendAfterSeconds": "{seconds}s後重新發送", - "sendCaptchaSuccess": "驗證碼發送成功", - "phoneOrCodeCaptchaCannotBeEmpty": "手機號碼或簡訊驗證碼不能為空", - "loginSuccess": "登陸成功", - "loginByCaptcha": "驗證碼登入", - "loginByEmail": "信箱登入", - "loginByLofterID": "Lofter ID登入", - "loginByPassword": "密碼登入", - "inputPhone": "輸入手機號碼", - "inputLofterID": "輸入Lofter ID", - "inputEmail": "輸入信箱", - "inputImageCaptcha": "輸入圖片驗證碼", - "inputCodeCaptcha": "輸入簡訊驗證碼", - "inputPassword": "輸入密碼", - "otherLoginMethods": "其他登入方式", - "lofterIDOrPasswordCannotBeEmpty": "LofterID或密碼不能為空", - "emailOrPasswordCannotBeEmpty": "郵件信箱或密碼不能為空白", - "phoneOrPasswordCannotBeEmpty": "手機號碼或密碼不能為空白", - "emailNotExist": "信箱不存在", - "retryLoginLater": "您登入密碼錯誤次數過多,請稍後再試", - "retryLoginTomorrow": "您登入密碼錯誤次數太多,請明天再試", - "retryLoginLaterWithIP": "您的IP登入密碼錯誤次數過多,請稍後再試", - "accountOrPasswordWrong": "帳號或密碼錯誤", - "passwordWrong": "密碼錯誤", - "retryLaterWithFrequency": "您登入密碼錯誤次數過多,請稍後再試", - "invalidContent": "無效內容", - "favoriteFolderDetail": "收藏夾詳情", - "haveCopiedFolderName": "已複製收藏夾名稱", - "haveCopiedFolderID": "已複製收藏夾ID", + "userMeta": "{postCount} posts · {collectionCount} collections", + "follower": "Followers", + "following": "Following", + "clearSearchHistory": "Clear search history", + "clearSearchHistoryMessage": "Clear your search history? This cannot be undone.", + "guessYouSearch": "Guess you want to search", + "unSupportVideoInDesktop": "Video is not supported on desktop", + "searchingCount": "{count} people are searching", + "searchHint": "Search tags, favorites, articles, discussions, grain lists, or users", + "getLinkFailed": "Failed to get link", + "collectionDetail": "Collection Details", + "grainDetail": "Grain List Details", + "noDescription": "No profile yet", + "order": "Ascending", + "reverseOrder": "Descending", + "subscribeCollection": "Subscribe to Collection", + "subscribeGrain": "Subscribe to Grain List", + "noPostInCollection": "No posts in the collection", + "noPostInGrain": "No posts in the grain list", + "collectionCover": "Collection Cover", + "grainCover": "Grain List Cover", + "updateAt": "Updated at", + "postCount": "Posts", + "subscribeCount": "Subscribers", + "totalHotCount": "Total Popularity", + "viewCountLong": "Views", + "coCreatorCount": "Co-creators", + "postDetail": "Post Details", + "descriptionWithPostCount": "{description} ({postCount})", + "catelog": "Table of Contents", + "prePost": "Previous", + "nextPost": "Next", + "continueSwipeToPost": "Keep swiping for {post}", + "releaseToSwitchPost": "Release to open {post}", + "adjacentPostLoadFailed": "Couldn't load the adjacent post. Try again.", + "atFirstPost": "This is the first post", + "haveAtFirstPost": "You're already at the first post", + "atLastPost": "This is the last post", + "haveAtLastPost": "You're already at the last post", + "eraseBlur": "Reveal Image", + "reblogFrom": "Reposted with permission from {user}", + "noneCopyright": "No License Specified", + "byCopyright": "Attribution", + "byNdCopyright": "Attribution-NoDerivatives", + "byNcCopyright": "Attribution-NonCommercial", + "bySaCopyright": "Attribution-ShareAlike", + "byNcNdCopyright": "Attribution-NonCommercial-NoDerivatives", + "byNcSaCopyright": "Attribution-NonCommercial-ShareAlike", + "favorite": "Favorites", + "favorited": "Saved", + "moreRecommend": "More Recommendations", + "hotRecommend": "Top Recommendations", + "hotCollectionRank": "Trending Collections", + "hotGrainRank": "Trending Grain Lists", + "visitOriginalPost": "View Original Post", + "relatedTag": "Related tags", + "relatedPost": "Related articles", + "viewAll": "See all", + "comprehensive": "All", + "user": "Users", + "article": "Article", + "hottest": "Hottest", + "newest": "Latest", + "participateCount": "Participants", + "collectionGrain": "Collections & Grain Lists", + "collectionGrainDetail": "Popular \"{title}\"", + "relatedDressShort": "Related outfits", + "relatedDressShortDetail": "Fetched {slotCount} times", + "releaseRecently": "Recently Published", + "commentRecently": "Recent Comments", + "filter": "Filter", + "dayRank": "Daily", + "weekRank": "Weekly", + "monthRank": "Monthly", + "allAreSearching": "Everybody's searching.", + "multipleSearchKeySeparatedBySpaces": "Multiple search terms separated by spaces", + "searchInTag": "Search within # {tag} #", + "tagRelatedTags": "Related tags for # {tag} #", + "getCaptcha": "Get Code", + "phoneCannotBeEmpty": "Mobile number cannot be empty", + "imageCaptchaCannotBeEmpty": "Image verification code cannot be empty", + "resendAfterSeconds": "Resend in {seconds} s", + "sendCaptchaSuccess": "Verification code sent successfully", + "phoneOrCodeCaptchaCannotBeEmpty": "Mobile number or SMS verification code cannot be empty", + "loginSuccess": "Login successful", + "loginByCaptcha": "SMS Code Login", + "loginByEmail": "Email Login", + "loginByLofterID": "LOFTER ID Login", + "loginByPassword": "Password Login", + "inputPhone": "Enter mobile number", + "inputLofterID": "Enter LOFTER ID", + "inputEmail": "Enter email", + "inputImageCaptcha": "Enter image CAPTCHA", + "inputCodeCaptcha": "Enter SMS code", + "inputPassword": "Enter password", + "otherLoginMethods": "Other login methods", + "lofterIDOrPasswordCannotBeEmpty": "LOFTER ID or password cannot be empty", + "emailOrPasswordCannotBeEmpty": "Email or password cannot be empty", + "phoneOrPasswordCannotBeEmpty": "Mobile number or password cannot be empty", + "emailNotExist": "Email address not found", + "retryLoginLater": "Too many failed login attempts. Please try again later", + "retryLoginTomorrow": "Too many failed login attempts. Please try again tomorrow", + "retryLoginLaterWithIP": "Too many failed login attempts from this IP. Please try again later", + "accountOrPasswordWrong": "Incorrect username or password", + "passwordWrong": "Incorrect password", + "retryLaterWithFrequency": "Too many failed login attempts. Please try again later", + "invalidContent": "Invalid content", + "favoriteFolderDetail": "Favorite Folder Details", + "haveCopiedFolderName": "Favorite folder name copied", + "haveCopiedFolderID": "Favorite folder ID copied", "folderId": "ID: {id}", - "editFolderTitle": "編輯收藏夾名稱", - "inputFolderTitle": "輸入收藏夾名稱", - "newFolder": "新建收藏夾", - "deleteFolder": "刪除收藏夾", - "deleteFolderMessage": "確定刪除收藏夾「{title}」?刪除後,其中的文章也將取消收藏", - "createSuccess": "創建成功", - "followerList": "粉絲表", - "followingList": "關注清單", - "clearMyHistory": "清空我的足跡", - "clearMyHistoryMessage": "確認清空我的足跡?清空後無法恢復", - "clearInvalidContent": "清空無效內容", - "closeMyHistory": "關閉我的足跡", - "closeMyHistoryMessage": "確認關閉我的足跡?關閉後將清空足跡且無法恢復", - "openMyHistory": "打開我的足跡", - "clearSuccess": "清空成功", - "closeSuccess": "關閉成功", - "openSuccess": "開啟成功", - "supporterList": "支持者表", - "like": "喜歡", - "gift": "禮物", - "recommend": "推薦", - "atMe": "@我", - "other": "其他", - "notice": "通知", - "noNotice": "暂无通知", - "personalHomepage": "個人首頁", - "viewThemeBg": "查看主題背景", - "themeBg": "主題背景", - "viewShop": "查看TA的商品", - "dressAvatarBox": "戴上頭像框", - "undressAvatarBox": "取消配戴頭像框", - "setRemark": "設定備註", - "setRemarkSuccess": "設定備註成功", - "setRemarkMessage": "設定「{blogNickName}」的備註", - "blockBlacklist": "加入黑名單", - "blockBlacklistSuccess": "封鎖成功", - "unblockBlacklistSuccess": "解除封鎖成功", - "recoverViewRecommend": "恢復檢視TA建議的內容", - "shieldViewRecommend": "不看TA推薦的內容", - "recoverViewDynamic": "恢復檢視TA的動態", - "shieldViewDynamic": "不看TA的動態", - "copyHomepageLink": "複製主頁連結", - "personalAvatar": "個人頭像", - "postLikes": "文章獲得喜歡", - "postRecommends": "累計獲得推薦", - "postFavorites": "累計獲得收藏", - "commentLikes": "討論獲得喜歡", - "comeOn": "加油哦", - "supporter": "支持者", - "editProfile": "編輯資料", - "masterpiece": "TA的代表作", - "selectFolder": "選擇收藏夾", - "newOp": "新建", - "getSlideCaptchaFailed": "滑桿驗證碼取得失敗", - "pleaseFinishSlideCaptcha": "請完成滑桿驗證:", - "validSuccess": "驗證成功", - "validFailed": "驗證失敗", - "slideToComplete": "向右拖曳滑桿填滿拼圖", - "sendValidationFailed": "發送驗證失敗", - "reduceRecommend": "減少標籤下內容推薦", - "uninterestedInContent": "🙈 內容不感興趣", - "uninterestedInUser": "💔 筆者不感興趣", - "contentRange": "內容範圍", - "noLimit": "不限", - "followingUser": "關注的人", - "haveNotVisitRecentSevenDays": "近7日未看", - "contentType": "內容形式", - "words": "文字", - "images": "圖片", - "releaseTime": "發佈時間", - "inOneDay": "一天之內", - "inOneWeek": "一週內", - "inOneMonth": "一個月內", - "tagProtection": "標籤保護", - "noComment": "暫無評論", - "latestComment": "最新評論", - "hotComment": "熱門評論", - "viewAllComments": "查看全部評論", - "yearAndMonth": "{year}年{month}月", - "pin": "置頂", - "unlocked": "已解鎖", - "preview": "預覽", - "unlockCount": "{count}次解鎖", - "unlockGift": "解鎖回禮", - "unlockSuccess": "解鎖成功", - "haveCopiedImageLink": "已複製圖片連結", - "easterEgg": "彩蛋", - "coinCount": "個樂乎幣", - "liangpiaoCount": "張糧票", - "liangpiao": "糧票", - "presentToUnlock": "支付{thing}解鎖回禮", - "presentToUnlockMessage": "你目前擁有{bag},是否確認付款以解鎖回禮?", - "notEnoughLiangpiao": "餐票不足", - "includedIn": "收錄至", - "wordCount": "字", - "imageCount": "圖", - "or": "或", - "animatedGif": "動圖", - "fromMyRecommend": "來自我的推薦", - "fromOtherRecommend": "來自好友 {user} 推薦", - "video": "影片", - "comment": "討論", - "unlike": "取消按讚", - "unrecommend": "取消推薦", - "unsupportDownloadCurrentImageinArticle": "文章不支援下載目前圖片", - "noImageToDownload": "沒有圖片可以下載", - "lowImageQuality": "低品質", - "middleImageQuality": "中等品質", - "rawImageQuality": "高品質", - "originImageQuality": "原圖", - "noOperation": "無操作", - "downloadCurrentImage": "下載當前圖片", - "downloadAllImages": "下載所有圖片", - "copyPostLink": "複製貼文連結", - "licenseDetail": "根據{license}許可證開源", - "moreComments": "—— 更多{length}條回覆", - "author": "作者", - "haveCopiedComment": "已複製{user}的評論", - "followEach": "相互關注", - "joinCount": "{count}人參與", - "enter": "進入", - "searchInApp": "應用程式內搜尋", - "loadingArticle": "文章加载中...", - "retry": "重試", - "tenThousand": "萬" -} \ No newline at end of file + "editFolderTitle": "Edit Favorite Folder", + "inputFolderTitle": "Enter Favorite Folder Name", + "newFolder": "New Favorite Folder", + "deleteFolder": "Delete Favorite Folder", + "deleteFolderMessage": "Delete favorite folder \"{title}\"? Its posts will also be removed from favorites.", + "createSuccess": "Created successfully", + "followerList": "Followers", + "followingList": "Following", + "clearMyHistory": "Clear Browsing History", + "clearMyHistoryMessage": "Clear your browsing history? This cannot be undone.", + "clearInvalidContent": "Remove Unavailable Items", + "closeMyHistory": "Turn Off Browsing History", + "closeMyHistoryMessage": "Turn off browsing history? Existing history will be cleared and cannot be recovered.", + "openMyHistory": "Turn On Browsing History", + "clearSuccess": "Cleared successfully", + "closeSuccess": "Closed successfully", + "openSuccess": "Opened successfully", + "supporterList": "Supporters", + "like": "Like", + "gift": "Gifts", + "recommend": "Recommend", + "atMe": "Mentions", + "other": "Other", + "notice": "Notifications", + "noNotice": "No notifications yet", + "personalHomepage": "Profile", + "viewThemeBg": "View Theme Background", + "themeBg": "Theme Background", + "viewShop": "View Their Items", + "dressAvatarBox": "Wear an avatar frame", + "undressAvatarBox": "Remove Avatar Frame", + "setRemark": "Set Remark", + "setRemarkSuccess": "Remark saved", + "setRemarkMessage": "Set a remark for {blogNickName}", + "blockBlacklist": "Block User", + "blockBlacklistSuccess": "User blocked", + "unblockBlacklistSuccess": "User unblocked", + "recoverViewRecommend": "Resume Viewing Recommendations", + "shieldViewRecommend": "Mute This User's Recommendations", + "recoverViewDynamic": "Resume Viewing Activity", + "shieldViewDynamic": "Mute This User's Activity", + "copyHomepageLink": "Copy homepage link", + "personalAvatar": "Profile Picture", + "postLikes": "Posts Liked", + "postRecommends": "Recommendations Received", + "postFavorites": "Favorites Received", + "commentLikes": "Comment Likes", + "comeOn": "Come on!", + "supporter": "Supporter", + "editProfile": "Edit your information", + "masterpiece": "Featured Posts", + "selectFolder": "Select Favorite Folder", + "newOp": "New", + "getSlideCaptchaFailed": "Failed to load the slider CAPTCHA", + "pleaseFinishSlideCaptcha": "Please complete the slider verification:", + "validSuccess": "Verification successful", + "validFailed": "Verification failed", + "slideToComplete": "Drag the slider to the right to fill the puzzle", + "sendValidationFailed": "Failed to send verification", + "reduceRecommend": "See Fewer Posts for This Tag", + "uninterestedInContent": "Not Interested in This Content 🙈", + "uninterestedInUser": "Not Interested in This Author 💔", + "contentRange": "Content Scope", + "noLimit": "No Limit", + "followingUser": "Following", + "haveNotVisitRecentSevenDays": "Not Viewed in the Last 7 Days", + "contentType": "Content Format", + "words": "Text", + "images": "Images", + "releaseTime": "Publish Time", + "inOneDay": "Past Day", + "inOneWeek": "Past Week", + "inOneMonth": "Past Month", + "tagProtection": "Tag Protection", + "noComment": "No Comments", + "latestComment": "Latest Comments", + "hotComment": "Top Comments", + "viewAllComments": "View All Comments", + "yearAndMonth": "{year}-{month}", + "pin": "Pin", + "unlocked": "Unlocked", + "preview": "Preview", + "unlockCount": "{count} Unlocks", + "unlockGift": "Unlock Reward", + "unlockSuccess": "Unlocked successfully", + "haveCopiedImageLink": "Image link copied", + "easterEgg": "Easter Egg", + "coinCount": "Happy Coins", + "liangpiaoCount": " Grain Tickets", + "liangpiao": "Grain Tickets", + "presentToUnlock": "Spend {thing} to Unlock", + "presentToUnlockMessage": "You have {bag}. Spend it to unlock this post?", + "notEnoughLiangpiao": "Not Enough Liangpiao", + "includedIn": "Included in", + "wordCount": "Words", + "imageCount": "Images", + "or": "or", + "animatedGif": "GIF", + "fromMyRecommend": "Recommended by Me", + "fromOtherRecommend": "Recommended by {user}", + "video": "Video", + "videoPlaybackOptions": "Playback options", + "continuousPlayback": "Auto play next", + "playbackSpeed": "Playback speed", + "danmaku": "Danmaku", + "comment": "Comments", + "unlike": "Unlike", + "unrecommend": "Remove Recommendation", + "unsupportDownloadCurrentImageinArticle": "The article does not support downloading the current image", + "noImageToDownload": "No images to download", + "lowImageQuality": "Low", + "middleImageQuality": "Medium", + "rawImageQuality": "High", + "originImageQuality": "Original", + "noOperation": "No action", + "downloadCurrentImage": "Download current image", + "downloadAllImages": "Download all images", + "copyPostLink": "Copy post link", + "licenseDetail": "Licensed under {license}", + "moreComments": "—— {length} more replies", + "replyTo": "Reply to {user}", + "author": "Author", + "haveCopiedComment": "Copied {user}'s comment", + "followEach": "Mutuals", + "joinCount": "{count} participants", + "enter": "Enter", + "searchInApp": "In-app search", + "loadingArticle": "Loading posts...", + "retry": "Retry", + "tenThousand": "10K" +} diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index b31fdd6d..ccf16f47 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -39,7 +39,7 @@ "loadErrorType": "错误类型:{type}", "loadUnkownError": "未知错误", "home": "首页", - "dynamic": "动态", + "dynamicTab": "动态", "explore": "探索", "layoutSetting": "布局", "imageSetting": "图片", @@ -82,6 +82,39 @@ "downloading": "下载中...", "downloadComplete": "下载完成", "downloadFailed": "下载失败", + "downloadManagement": "下载管理", + "downloadTaskDetails": "下载任务详情", + "downloadSourceSummary": "内容来源", + "downloadResourceList": "资源列表", + "downloadOverallProgress": "整体进度", + "viewOriginalContent": "查看原内容", + "downloadSourceUnavailable": "原内容已失效或来源信息不完整", + "pauseAllDownloads": "全部暂停", + "resumeAllDownloads": "全部继续", + "cancelAllDownloads": "全部取消", + "retryFailedDownloads": "重试失败项", + "downloadBatchCounts": "请求 {requested} 项 · 跳过 {skipped} 项 · 不可用 {unavailable} 项", + "downloadSourcePostAll": "帖子全部资源", + "downloadSourceFavoriteFolder": "收藏夹", + "downloadSourceOther": "内容批次", + "downloadPartiallyFailed": "部分完成", + "downloadGroupProgress": "{completed} / {total} 项资源 · {percent}%", + "batchDownload": "批量下载", + "batchDownloadRule": "重复和已下载资源将自动跳过;失败、取消或暂停的任务会重新加入队列。", + "batchDownloadSelected": "已选择 {selected} / {total} 篇", + "batchDownloadResolving": "正在解析 {current} / {total}", + "batchDownloadSummary": "已加入 {queued} 项,跳过 {skipped} 项,无法下载 {failed} 篇", + "batchDownloadNoSelection": "请至少选择一篇内容", + "noDownloadableResources": "所选内容中没有可下载资源", + "loadAllPostsForBatch": "选择全部时会先加载集合内的全部内容", + "noDownloadTasks": "暂无下载任务", + "activeDownloads": "进行中的下载", + "downloadHistory": "下载记录", + "waitingForDownload": "等待中", + "downloadPaused": "已暂停", + "downloadCancelled": "已取消", + "clearFinishedDownloads": "清理已结束任务", + "downloadUnknownSize": "大小未知", "downloadFailedAndRetry": "下载失败,请重试", "downloadFailedAndRetryTip": "新版本安装包下载失败,请重试", "downloadingNewVersionPackage": "正在下载新版本安装包...", @@ -206,6 +239,14 @@ "autoMemoryWindowPositionAndSize": "记忆窗口位置和大小", "autoMemoryWindowPositionAndSizeTip": "关闭后,每次打开Loftify都会居中显示且具有默认窗口大小", "mobileSetting": "移动端设置", + "navigationBarDisplayStyle": "导航栏展示风格", + "navigationBarDisplayStyleDescription": "选择底部导航栏展示图标、文字或同时展示两者", + "chooseNavigationBarDisplayStyle": "选择导航栏展示风格", + "navigationBarIconAndText": "图文", + "navigationBarIconOnly": "仅图标", + "navigationBarTextOnly": "仅文字", + "reduceTransparency": "降低透明效果", + "reduceTransparencyDescription": "使用实色导航栏以提升对比度,并改善低性能设备上的流畅度", "useDesktopLayoutWhenLandscape": "横屏时启用桌面端布局", "haveToRestartWhenChange": "更改后需要重启", "inAppBrowser": "内置浏览器", @@ -274,6 +315,9 @@ "errorQrCode": "二维码构建失败", "showArticleInRecommendFlow": "推荐流显示文章", "showVideoInRecommendFlow": "推荐流显示视频", + "hideHomeAppBarOnScroll": "滚动时隐藏首页顶栏", + "hideSearchNavigation": "隐藏导航栏中的搜索入口", + "hideSearchNavigationDescription": "隐藏后,搜索入口会显示在首页顶栏右侧", "showPostDetailFloatingOperationBar": "悬浮操作栏", "showPostDetailFloatingOperationBarDescription": "取消勾选将不显示帖子详情页的悬浮操作栏", "showPostDetailFloatingOperationBarOnlyInArticle": "仅在文章中显示悬浮操作栏", @@ -516,6 +560,9 @@ "catelog": "目录", "prePost": "上一篇", "nextPost": "下一篇", + "continueSwipeToPost": "继续滑动查看{post}", + "releaseToSwitchPost": "松开切换到{post}", + "adjacentPostLoadFailed": "相邻帖子加载失败,请重试", "atFirstPost": "已在首篇", "haveAtFirstPost": "已经是第一篇了", "atLastPost": "已在末篇", @@ -692,6 +739,10 @@ "fromMyRecommend": "来自我的推荐", "fromOtherRecommend": "来自好友 {user} 推荐", "video": "视频", + "videoPlaybackOptions": "播放设置", + "continuousPlayback": "自动连播", + "playbackSpeed": "播放速度", + "danmaku": "弹幕", "comment": "评论", "unlike": "取消点赞", "unrecommend": "取消推荐", @@ -707,6 +758,7 @@ "copyPostLink": "复制帖子链接", "licenseDetail": "根据{license}许可证开源", "moreComments": "—— 更多{length}条回复", + "replyTo": "回复 {user}", "author": "作者", "haveCopiedComment": "已复制{user}的评论", "followEach": "相互关注", @@ -716,4 +768,4 @@ "loadingArticle": "文章加载中...", "retry": "重试", "tenThousand": "万" -} \ No newline at end of file +} diff --git a/lib/l10n/intl_zh_CN.arb b/lib/l10n/intl_zh_CN.arb index a3e754d8..088f2b22 100644 --- a/lib/l10n/intl_zh_CN.arb +++ b/lib/l10n/intl_zh_CN.arb @@ -39,7 +39,7 @@ "loadErrorType": "错误类型:{type}", "loadUnkownError": "未知错误", "home": "首页", - "dynamic": "动态", + "dynamicTab": "动态", "explore": "探索", "layoutSetting": "布局", "imageSetting": "图片", @@ -82,6 +82,39 @@ "downloading": "下载中...", "downloadComplete": "下载完成", "downloadFailed": "下载失败", + "downloadManagement": "下载管理", + "downloadTaskDetails": "下载任务详情", + "downloadSourceSummary": "内容来源", + "downloadResourceList": "资源列表", + "downloadOverallProgress": "整体进度", + "viewOriginalContent": "查看原内容", + "downloadSourceUnavailable": "原内容已失效或来源信息不完整", + "pauseAllDownloads": "全部暂停", + "resumeAllDownloads": "全部继续", + "cancelAllDownloads": "全部取消", + "retryFailedDownloads": "重试失败项", + "downloadBatchCounts": "请求 {requested} 项 · 跳过 {skipped} 项 · 不可用 {unavailable} 项", + "downloadSourcePostAll": "帖子全部资源", + "downloadSourceFavoriteFolder": "收藏夹", + "downloadSourceOther": "内容批次", + "downloadPartiallyFailed": "部分完成", + "downloadGroupProgress": "{completed} / {total} 项资源 · {percent}%", + "batchDownload": "批量下载", + "batchDownloadRule": "重复和已下载资源将自动跳过;失败、取消或暂停的任务会重新加入队列。", + "batchDownloadSelected": "已选择 {selected} / {total} 篇", + "batchDownloadResolving": "正在解析 {current} / {total}", + "batchDownloadSummary": "已加入 {queued} 项,跳过 {skipped} 项,无法下载 {failed} 篇", + "batchDownloadNoSelection": "请至少选择一篇内容", + "noDownloadableResources": "所选内容中没有可下载资源", + "loadAllPostsForBatch": "选择全部时会先加载集合内的全部内容", + "noDownloadTasks": "暂无下载任务", + "activeDownloads": "进行中的下载", + "downloadHistory": "下载记录", + "waitingForDownload": "等待中", + "downloadPaused": "已暂停", + "downloadCancelled": "已取消", + "clearFinishedDownloads": "清理已结束任务", + "downloadUnknownSize": "大小未知", "downloadFailedAndRetry": "下载失败,请重试", "downloadFailedAndRetryTip": "新版本安装包下载失败,请重试", "downloadingNewVersionPackage": "正在下载新版本安装包...", @@ -206,6 +239,14 @@ "autoMemoryWindowPositionAndSize": "记忆窗口位置和大小", "autoMemoryWindowPositionAndSizeTip": "关闭后,每次打开Loftify都会居中显示且具有默认窗口大小", "mobileSetting": "移动端设置", + "navigationBarDisplayStyle": "导航栏展示风格", + "navigationBarDisplayStyleDescription": "选择底部导航栏展示图标、文字或同时展示两者", + "chooseNavigationBarDisplayStyle": "选择导航栏展示风格", + "navigationBarIconAndText": "图文", + "navigationBarIconOnly": "仅图标", + "navigationBarTextOnly": "仅文字", + "reduceTransparency": "降低透明效果", + "reduceTransparencyDescription": "使用实色导航栏以提升对比度,并改善低性能设备上的流畅度", "useDesktopLayoutWhenLandscape": "横屏时启用桌面端布局", "haveToRestartWhenChange": "更改后需要重启", "inAppBrowser": "内置浏览器", @@ -274,6 +315,9 @@ "errorQrCode": "二维码构建失败", "showArticleInRecommendFlow": "推荐流显示文章", "showVideoInRecommendFlow": "推荐流显示视频", + "hideHomeAppBarOnScroll": "滚动时隐藏首页顶栏", + "hideSearchNavigation": "隐藏导航栏中的搜索入口", + "hideSearchNavigationDescription": "隐藏后,搜索入口会显示在首页顶栏右侧", "showPostDetailFloatingOperationBar": "悬浮操作栏", "showPostDetailFloatingOperationBarDescription": "取消勾选将不显示帖子详情页的悬浮操作栏", "showPostDetailFloatingOperationBarOnlyInArticle": "仅在文章中显示悬浮操作栏", @@ -516,6 +560,9 @@ "catelog": "目录", "prePost": "上一篇", "nextPost": "下一篇", + "continueSwipeToPost": "继续滑动查看{post}", + "releaseToSwitchPost": "松开切换到{post}", + "adjacentPostLoadFailed": "相邻帖子加载失败,请重试", "atFirstPost": "已在首篇", "haveAtFirstPost": "已经是第一篇了", "atLastPost": "已在末篇", @@ -692,6 +739,10 @@ "fromMyRecommend": "来自我的推荐", "fromOtherRecommend": "来自好友 {user} 推荐", "video": "视频", + "videoPlaybackOptions": "播放设置", + "continuousPlayback": "自动连播", + "playbackSpeed": "播放速度", + "danmaku": "弹幕", "comment": "评论", "unlike": "取消点赞", "unrecommend": "取消推荐", @@ -707,6 +758,7 @@ "copyPostLink": "复制帖子链接", "licenseDetail": "根据{license}许可证开源", "moreComments": "—— 更多{length}条回复", + "replyTo": "回复 {user}", "author": "作者", "haveCopiedComment": "已复制{user}的评论", "followEach": "相互关注", @@ -716,4 +768,4 @@ "loadingArticle": "文章加载中...", "retry": "重试", "tenThousand": "万" -} \ No newline at end of file +} diff --git a/lib/l10n/intl_zh_TW.arb b/lib/l10n/intl_zh_TW.arb index cf2130ba..0470f5b8 100644 --- a/lib/l10n/intl_zh_TW.arb +++ b/lib/l10n/intl_zh_TW.arb @@ -39,7 +39,7 @@ "loadErrorType": "錯誤類型:{type}", "loadUnkownError": "未知錯誤", "home": "首頁", - "dynamic": "動態", + "dynamicTab": "動態", "explore": "探索", "layoutSetting": "佈局", "imageSetting": "圖片", @@ -82,6 +82,39 @@ "downloading": "下載中...", "downloadComplete": "下載完成", "downloadFailed": "下載失敗", + "downloadManagement": "下載管理", + "downloadTaskDetails": "下載任務詳情", + "downloadSourceSummary": "內容來源", + "downloadResourceList": "資源列表", + "downloadOverallProgress": "整體進度", + "viewOriginalContent": "查看原內容", + "downloadSourceUnavailable": "原內容已失效或來源資訊不完整", + "pauseAllDownloads": "全部暫停", + "resumeAllDownloads": "全部繼續", + "cancelAllDownloads": "全部取消", + "retryFailedDownloads": "重試失敗項", + "downloadBatchCounts": "請求 {requested} 項 · 跳過 {skipped} 項 · 不可用 {unavailable} 項", + "downloadSourcePostAll": "貼文全部資源", + "downloadSourceFavoriteFolder": "收藏夾", + "downloadSourceOther": "內容批次", + "downloadPartiallyFailed": "部分完成", + "downloadGroupProgress": "{completed} / {total} 項資源 · {percent}%", + "batchDownload": "批次下載", + "batchDownloadRule": "重複和已下載資源將自動略過;失敗、取消或暫停的任務會重新加入佇列。", + "batchDownloadSelected": "已選擇 {selected} / {total} 篇", + "batchDownloadResolving": "正在解析 {current} / {total}", + "batchDownloadSummary": "已加入 {queued} 項,略過 {skipped} 項,無法下載 {failed} 篇", + "batchDownloadNoSelection": "請至少選擇一篇內容", + "noDownloadableResources": "所選內容中沒有可下載資源", + "loadAllPostsForBatch": "選擇全部時會先載入集合內的全部內容", + "noDownloadTasks": "暫無下載任務", + "activeDownloads": "進行中的下載", + "downloadHistory": "下載記錄", + "waitingForDownload": "等待中", + "downloadPaused": "已暫停", + "downloadCancelled": "已取消", + "clearFinishedDownloads": "清理已結束任務", + "downloadUnknownSize": "大小未知", "downloadFailedAndRetry": "下載失敗,請重試", "downloadFailedAndRetryTip": "新版安裝包下載失敗,請重試", "downloadingNewVersionPackage": "正在下載新版本安裝包...", @@ -206,6 +239,14 @@ "autoMemoryWindowPositionAndSize": "記憶視窗位置與大小", "autoMemoryWindowPositionAndSizeTip": "關閉後,每次開啟Loftify都會居中顯示且具有預設視窗大小", "mobileSetting": "行動端設定", + "navigationBarDisplayStyle": "導覽列顯示樣式", + "navigationBarDisplayStyleDescription": "選擇底部導覽列顯示圖示、文字或同時顯示兩者", + "chooseNavigationBarDisplayStyle": "選擇導覽列顯示樣式", + "navigationBarIconAndText": "圖文", + "navigationBarIconOnly": "僅圖示", + "navigationBarTextOnly": "僅文字", + "reduceTransparency": "降低透明效果", + "reduceTransparencyDescription": "使用實色導覽列以提升對比度,並改善低效能裝置上的流暢度", "useDesktopLayoutWhenLandscape": "橫屏時啟用桌面端佈局", "haveToRestartWhenChange": "更改後需要重新啟動", "inAppBrowser": "內建瀏覽器", @@ -274,12 +315,15 @@ "errorQrCode": "二維碼建置失敗", "showArticleInRecommendFlow": "建議串流顯示文章", "showVideoInRecommendFlow": "推薦串流顯示影片", + "hideHomeAppBarOnScroll": "捲動時隱藏首頁頂欄", + "hideSearchNavigation": "隱藏導覽列中的搜尋入口", + "hideSearchNavigationDescription": "隱藏後,搜尋入口會顯示在首頁頂欄右側", "showPostDetailFloatingOperationBar": "浮動操作欄", "showPostDetailFloatingOperationBarDescription": "取消勾選將不顯示貼文詳情頁面的浮動操作欄", "showPostDetailFloatingOperationBarOnlyInArticle": "僅在文章中顯示浮動操作欄", "searchResultPage": "搜尋結果頁面", "recordSearchHistory": "保存搜尋紀錄", - "recordSearchHistoryDe​​scription": "取消勾選將清空並不再記錄搜尋記錄", + "recordSearchHistoryDescription": "取消勾選將清空並不再記錄搜尋記錄", "guessYouLike": "猜你想搜", "guessYouLikeDescription": "搜尋結果頁顯示猜你想搜", "externalLinkCards": "外鏈卡", @@ -354,12 +398,12 @@ "chooseBigImageQuality": "選擇看大圖時圖片品質", "bigImageSetting": "大圖設定", "backgroundColorFollowMainColor": "背景色跟隨圖片主色調", - "tapLinkBut​​ton": "點選連結按鈕", - "tapLinkBut​​tonDescription": "點選連結按鈕時複製的圖片連結品質", - "chooseTapLinkBut​​ton": "選擇點選連結按鈕時複製的圖片連結品質", - "longPressLinkBut​​ton": "長按連結按鈕", - "longPressLinkBut​​tonDescription": "長按連結按鈕時複製的圖片連結品質", - "chooseLongPressLinkBut​​ton": "選擇長按連結按鈕時複製的圖片連結品質", + "tapLinkButton": "點選連結按鈕", + "tapLinkButtonDescription": "點選連結按鈕時複製的圖片連結品質", + "chooseTapLinkButton": "選擇點選連結按鈕時複製的圖片連結品質", + "longPressLinkButton": "長按連結按鈕", + "longPressLinkButtonDescription": "長按連結按鈕時複製的圖片連結品質", + "chooseLongPressLinkButton": "選擇長按連結按鈕時複製的圖片連結品質", "loadGiftSettingFailed": "禮物設定載入失敗", "copyrightWatermarkTitle": "版權浮水印關閉提示", "copyrightWatermarkMessage": "由於一些歷史遺留問題,你需要先在電腦上關閉版權水印功能(主頁設定-版權-版權水印)", @@ -515,6 +559,9 @@ "catelog": "目錄", "prePost": "上一篇", "nextPost": "下一篇", + "continueSwipeToPost": "繼續滑動查看{post}", + "releaseToSwitchPost": "放開切換到{post}", + "adjacentPostLoadFailed": "相鄰貼文載入失敗,請重試", "atFirstPost": "已在首篇", "haveAtFirstPost": "已經是第一篇了", "atLastPost": "已在末篇", @@ -615,7 +662,7 @@ "atMe": "@我", "other": "其他", "notice": "通知", - "noNotice": "暂无通知", + "noNotice": "暫無通知", "personalHomepage": "個人首頁", "viewThemeBg": "查看主題背景", "themeBg": "主題背景", @@ -692,6 +739,10 @@ "fromMyRecommend": "來自我的推薦", "fromOtherRecommend": "來自好友 {user} 推薦", "video": "影片", + "videoPlaybackOptions": "播放設定", + "continuousPlayback": "自動連播", + "playbackSpeed": "播放速度", + "danmaku": "彈幕", "comment": "討論", "unlike": "取消按讚", "unrecommend": "取消推薦", @@ -707,13 +758,14 @@ "copyPostLink": "複製貼文連結", "licenseDetail": "根據{license}許可證開源", "moreComments": "—— 更多{length}條回覆", + "replyTo": "回覆 {user}", "author": "作者", "haveCopiedComment": "已複製{user}的評論", "followEach": "相互關注", "joinCount": "{count}人參與", "enter": "進入", "searchInApp": "應用程式內搜尋", - "loadingArticle": "文章加载中...", + "loadingArticle": "文章載入中...", "retry": "重試", "tenThousand": "萬" -} \ No newline at end of file +} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 0a8bd0a5..f33cf2ec 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -14,9 +14,37 @@ */ import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/widgets.dart'; import '../generated/app_localizations.dart'; +export '../generated/app_localizations.dart'; + AppLocalizations get appLocalizations { return AppLocalizations.of(chewieProvider.rootContext)!; } + +Locale resolveAppLocale(Locale locale) { + for (final supported in AppLocalizations.supportedLocales) { + if (supported.languageCode == locale.languageCode && + supported.countryCode == locale.countryCode) { + return supported; + } + } + for (final supported in AppLocalizations.supportedLocales) { + if (supported.languageCode == locale.languageCode) return supported; + } + return AppLocalizations.supportedLocales.first; +} + +Locale resolveSystemAppLocale() { + return resolveAppLocale( + WidgetsBinding.instance.platformDispatcher.locale, + ); +} + +String formatLocalizedYearMonth(int timestamp) { + if (timestamp < 10000000000) timestamp *= 1000; + final date = DateTime.fromMillisecondsSinceEpoch(timestamp); + return appLocalizations.yearAndMonth(date.month, date.year); +} diff --git a/lib/main.dart b/lib/main.dart index f8c2a863..6316f41c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; @@ -12,7 +13,11 @@ import 'package:local_notifier/local_notifier.dart'; import 'package:loftify/Database/database_manager.dart'; import 'package:loftify/Utils/app_provider.dart'; import 'package:loftify/Utils/cloud_control_provider.dart'; +import 'package:loftify/Utils/display_mode_util.dart'; +import 'package:loftify/Utils/download_task_manager.dart'; import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/loftify_file_util.dart'; +import 'package:loftify/Utils/lottie_files.dart'; import 'package:loftify/Utils/request_header_util.dart'; import 'package:loftify/Utils/request_util.dart'; import 'package:loftify/Utils/uri_util.dart'; @@ -20,14 +25,12 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart'; import 'package:protocol_handler/protocol_handler.dart'; import 'package:provider/provider.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; import 'package:window_manager/window_manager.dart'; import 'Screens/Lock/pin_verify_screen.dart'; import 'Screens/main_screen.dart'; -import 'Utils/constant.dart'; -import 'Widgets/Item/item_builder.dart'; -import 'generated/app_localizations.dart'; -import 'generated/l10n.dart'; +import 'l10n/l10n.dart'; const List kWindowsSchemes = ["lofter"]; @@ -37,7 +40,9 @@ Future main(List args) async { Future runMyApp(List args) async { await initApp(); + chewieProvider.loadingWidgetBuilder = LottieFiles.buildLoadingAnimation; if (ResponsiveUtil.isAndroid()) { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); await initDisplayMode(); await RequestHeaderUtil.initAndroidInfo(); } @@ -86,6 +91,8 @@ Future initApp() async { // Hive.defaultDirectory = await FileUtil.getApplicationDir(); await HiveUtil.initBox(); await ResponsiveUtil.init(); + LoftifyFileUtil.configureDownloadDelegates(); + await DownloadTaskManager.instance.initialize(); await RequestUtil.init(); UriUtil.processUrl = LoftifyUriUtil.processUrl; } @@ -95,7 +102,7 @@ Future initWindow() async { Offset position = ChewieHiveUtil.getWindowPosition(); WindowOptions windowOptions = WindowOptions( size: ChewieHiveUtil.getWindowSize(), - minimumSize: minimumSize, + minimumSize: ChewieProvider.minimumWindowSize, center: position == Offset.zero, backgroundColor: Colors.transparent, skipTaskbar: false, @@ -110,20 +117,30 @@ Future initWindow() async { Future initDisplayMode() async { try { - var modes = await FlutterDisplayMode.supported; + final modes = await FlutterDisplayMode.supported; ILogger.info("Supported display modes: $modes"); + final activeMode = await FlutterDisplayMode.active; + final preferredMode = await FlutterDisplayMode.preferred; ILogger.info( - "Current active display mode: ${await FlutterDisplayMode.active}\nCurrent preferred display mode: ${await FlutterDisplayMode.preferred}"); - int refreshRate = + "Current active display mode: $activeMode\nCurrent preferred display mode: $preferredMode"); + final legacyIndex = ChewieHiveUtil.getInt(HiveUtil.refreshRateKey, defaultValue: -1); - if (refreshRate == -1) { - await FlutterDisplayMode.setHighRefreshRate(); - ILogger.info("Config display mode: high refresh rate"); - } else { - DisplayMode configMode = modes[refreshRate.clamp(0, modes.length - 1)]; - await FlutterDisplayMode.setPreferredMode(configMode); - ILogger.info("Config display mode: ${configMode.toString()}"); + final encodedMode = ChewieHiveUtil.getString(HiveUtil.refreshRateModeKey); + final configMode = DisplayModePreference.resolve( + modes: modes, + activeMode: activeMode, + encodedMode: encodedMode, + legacyIndex: legacyIndex, + ); + if ((encodedMode == null || encodedMode.isEmpty) && legacyIndex >= 0) { + await ChewieHiveUtil.put( + HiveUtil.refreshRateModeKey, + DisplayModePreference.encode(configMode), + ); + await ChewieHiveUtil.delete(HiveUtil.refreshRateKey); } + await DisplayModeController.setPreferredMode(configMode); + ILogger.info("Config display mode: $configMode"); ILogger.info( "Current active display mode after config: ${await FlutterDisplayMode.active}\nCurrent preferred display mode after config: ${await FlutterDisplayMode.preferred}"); } catch (e, t) { @@ -146,7 +163,7 @@ Future onError(FlutterErrorDetails details) async { } } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { final Widget home; final String title; @@ -156,6 +173,34 @@ class MyApp extends StatelessWidget { this.title = 'Loftify', }); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + appProvider.refreshSystemLocale(); + } + + @override + void didChangeLocales(List? locales) { + appProvider.refreshSystemLocale(); + } + + @override + void didChangePlatformBrightness() { + appProvider.refreshSystemTheme(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + @override Widget build(BuildContext context) { return MultiProvider( @@ -168,10 +213,10 @@ class MyApp extends StatelessWidget { builder: (context, globalProvider, child) => MaterialApp( navigatorKey: chewieProvider.globalNavigatorKey, navigatorObservers: [chewieProvider.routeObserver], - title: title, + title: widget.title, themeMode: appProvider.themeMode.themeMode, - theme: appProvider.lightTheme.toThemeData(), - darkTheme: appProvider.darkTheme.toThemeData(), + theme: LoftifyTheme.build(appProvider.lightTheme), + darkTheme: LoftifyTheme.build(appProvider.darkTheme), debugShowCheckedModeBanner: false, localizationsDelegates: const [ AppLocalizations.delegate, @@ -180,40 +225,53 @@ class MyApp extends StatelessWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - locale: context.watch().locale, + locale: globalProvider.locale ?? resolveSystemAppLocale(), supportedLocales: AppLocalizations.supportedLocales, localeResolutionCallback: (locale, supportedLocales) { try { if (globalProvider.locale != null) { return globalProvider.locale; - } else if (locale != null && supportedLocales.contains(locale)) { - return locale; - } else { - return Localizations.localeOf(context); } + return resolveAppLocale( + WidgetsBinding.instance.platformDispatcher.locale, + ); } catch (e, t) { ILogger.error("Failed to load locale", e, t); return const Locale("zh", "CN"); } }, - home: CustomMouseRegion(child: home), + home: CustomMouseRegion(child: widget.home), builder: (context, widget) { - return Overlay( - initialEntries: [ - if (widget != null) ...[ - OverlayEntry( - builder: (context) => Listener( - onPointerDown: (_) { - if (!ResponsiveUtil.isDesktop() && - searchScreenState?.hasSearchFocus == true) { - FocusManager.instance.primaryFocus?.unfocus(); - } + final systemUiOverlayStyle = + AppBarWrapper.systemUiOverlayStyleForBrightness( + Theme.of(context).brightness, + includeNavigationBar: true, + ); + return AnnotatedRegion( + value: systemUiOverlayStyle, + child: Overlay( + initialEntries: [ + if (widget != null) ...[ + OverlayEntry( + builder: (overlayContext) { + chewieProvider.setRootContext( + chewieProvider.globalNavigatorState?.context ?? + overlayContext, + ); + return Listener( + onPointerDown: (_) { + if (!ResponsiveUtil.isDesktop() && + searchScreenState?.hasSearchFocus == true) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + child: widget, + ); }, - child: widget, ), - ), + ], ], - ], + ), ); }, ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 0158b605..cf61485c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) local_notifier_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin"); local_notifier_plugin_register_with_registrar(local_notifier_registrar); + g_autoptr(FlPluginRegistrar) restart_app_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RestartAppPlugin"); + restart_app_plugin_register_with_registrar(restart_app_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index fca641a9..a48fb6c5 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST gtk hotkey_manager_linux local_notifier + restart_app screen_retriever_linux tray_manager url_launcher_linux @@ -13,6 +14,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5cf53d6c..12942a28 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import app_links import audio_session +import connectivity_plus import device_info_plus import file_picker import flutter_inappwebview_macos @@ -16,20 +17,22 @@ import just_audio import local_auth_darwin import local_notifier import package_info_plus -import path_provider_foundation import protocol_handler_macos +import restart_app import screen_retriever_macos import share_plus -import sqflite +import sqflite_darwin import tray_manager import url_launcher_macos import video_player_avfoundation import wakelock_plus +import webview_flutter_wkwebview import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) @@ -39,14 +42,15 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ProtocolHandlerMacosPlugin.register(with: registry.registrar(forPlugin: "ProtocolHandlerMacosPlugin")) + RestartAppPlugin.register(with: registry.registrar(forPlugin: "RestartAppPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 281de23f..385a84cb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,39 +5,34 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.flutter-io.cn" source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.flutter-io.cn" source: hosted - version: "6.11.0" + version: "10.0.1" animations: dependency: "direct main" description: name: animations - sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + sha256: "9cb469212ea51be27097f23b519d594c01171721347b55df9334fff653659e7f" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.11" + version: "2.2.0" ansicolor: dependency: transitive description: name: ansicolor - sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.2" + version: "2.0.3" app_links: dependency: "direct main" description: @@ -82,34 +77,34 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.0" + version: "2.7.0" asn1lib: dependency: transitive description: name: asn1lib - sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" url: "https://pub.flutter-io.cn" source: hosted - version: "1.5.3" + version: "1.6.5" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.flutter-io.cn" source: hosted - version: "2.11.0" + version: "2.13.1" audio_session: dependency: transitive description: name: audio_session - sha256: "4012d798e25a0ebfd4ad7203fefe7e386fdfedd2243afc52fa700b8bde6a3a4f" + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.20" + version: "0.1.25" auto_size_text: dependency: "direct main" description: @@ -125,70 +120,62 @@ packages: relative: true source: path version: "0.2.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: bdd41f116998233182cc4ec04cb6afac534067d640ddebb791ce44593f574b87 + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.5.8" blur: dependency: "direct main" description: name: blur - sha256: fd23f1247faee4a7d1a3efb6b7c3cea134f3b939d72e5f8d45233deb0776259f + sha256: "1318cf79c735784eda8dffc77b1085b757373d233c1c8d94dc0cfd81530a3574" url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.0" + version: "4.0.2" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.1" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.1" + version: "1.3.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.2" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.4" + version: "4.1.5" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.flutter-io.cn" - source: hosted - version: "8.0.0" + version: "2.15.1" built_collection: dependency: transitive description: @@ -201,10 +188,10 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" url: "https://pub.flutter-io.cn" source: hosted - version: "8.9.2" + version: "8.12.7" cached_network_image: dependency: "direct main" description: @@ -241,58 +228,74 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0" + version: "1.4.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.3" + version: "2.0.4" chewie: dependency: transitive description: name: chewie - sha256: e53da939709efb9aad0f3d72a69a8d05f889168b7a138af60ce78bab5c94b135 + sha256: "53dadd2c5b6748742d7744072b38a417ad22691ca55715850300ee793dc7cb27" url: "https://pub.flutter-io.cn" source: hosted - version: "1.8.1" + version: "1.13.1" cli_util: dependency: transitive description: name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + sha256: "89b8801ba49f8b593fbf2d3e59efd0f566c46cd4ba6837ae2610c72b52a0f899" url: "https://pub.flutter-io.cn" source: hosted - version: "0.4.1" + version: "0.6.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.1" - code_builder: + version: "1.1.2" + code_assets: dependency: transitive description: - name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.flutter-io.cn" source: hosted - version: "4.10.0" + version: "1.2.1" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.3.1" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.flutter-io.cn" source: hosted - version: "1.19.0" + version: "2.1.0" console: dependency: transitive description: @@ -305,50 +308,50 @@ packages: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.1" + version: "3.1.2" cookie_jar: dependency: transitive description: name: cookie_jar - sha256: a6ac027d3ed6ed756bfce8f3ff60cb479e266f3b0fdabd6242b804b6765e52de + sha256: "963da02c1ef64cb5ac20de948c9e5940aa351f1e34a12b1d327c83d85b7e8fff" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.8" + version: "4.0.9" cross_file: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.4+2" + version: "0.3.5+4" crypto: dependency: "direct main" description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.3" + version: "3.0.7" csslib: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.0" + version: "1.0.2" cupertino_icons: dependency: transitive description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.8" + version: "1.0.9" dart_sm: dependency: "direct main" description: @@ -361,26 +364,26 @@ packages: dependency: transitive description: name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.1" + version: "3.1.7" dbus: dependency: transitive description: name: dbus - sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.10" + version: "0.7.15" decimal: dependency: transitive description: name: decimal - sha256: fc706a5618b81e5b367b01dd62621def37abc096f2b46a9bd9068b64c1fa36d0 + sha256: "2c3c8b74f2948066d3f42585477aec9cfc48fefd7a723a4d4274a6c71a5c0df7" url: "https://pub.flutter-io.cn" source: hosted - version: "3.2.4" + version: "3.2.6" device_info_plus: dependency: "direct main" description: @@ -393,42 +396,42 @@ packages: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f url: "https://pub.flutter-io.cn" source: hosted - version: "7.0.2" + version: "7.0.3" dio: dependency: "direct main" description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" url: "https://pub.flutter-io.cn" source: hosted - version: "5.9.0" + version: "5.11.0" dio_cache_interceptor: dependency: "direct main" description: name: dio_cache_interceptor - sha256: fb7905c0d12075d8786a6b63bffd64ae062d053f682cfaf28d145a2686507308 + sha256: "9472f9f3dcaa384ae68e60606c1d04e6314dea5eab39f18d94ff9b0a300dbbe4" url: "https://pub.flutter-io.cn" source: hosted - version: "3.5.0" + version: "4.0.7" dio_cookie_manager: dependency: "direct main" description: name: dio_cookie_manager - sha256: e79498b0f632897ff0c28d6e8178b4bc6e9087412401f618c31fa0904ace050d + sha256: "4ed4669cacb11931517c1158876a2189f19386674b9dab498abcca063dbe4c61" url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.1" + version: "3.5.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.1" + version: "2.2.1" encrypt: dependency: "direct main" description: @@ -441,10 +444,10 @@ packages: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.7" + version: "2.1.0" extended_nested_scroll_view: dependency: "direct main" description: @@ -457,42 +460,42 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.2.0" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.flutter-io.cn" source: hosted - version: "7.0.0" + version: "7.0.1" file_picker: dependency: "direct main" description: name: file_picker - sha256: c9943dd7d702ab4199d199bc151a2d79c86db031a02ad84566dab58c494d2adc + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 url: "https://pub.flutter-io.cn" source: hosted - version: "8.3.1" + version: "8.3.7" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.0" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -510,18 +513,18 @@ packages: dependency: "direct main" description: name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" url: "https://pub.flutter-io.cn" source: hosted - version: "3.4.1" + version: "3.4.2" flutter_displaymode: dependency: "direct main" description: name: flutter_displaymode - sha256: "42c5e9abd13d28ed74f701b60529d7f8416947e58256e6659c5550db719c57ef" + sha256: ecd44b1e902b0073b42ff5b55bf283f38e088270724cdbb7f7065ccf54aa60a8 url: "https://pub.flutter-io.cn" source: hosted - version: "0.6.0" + version: "0.7.0" flutter_highlight: dependency: transitive description: @@ -550,10 +553,10 @@ packages: dependency: transitive description: name: flutter_inappwebview_internal_annotations - sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.1" + version: "1.3.0" flutter_inappwebview_ios: dependency: transitive description: @@ -598,64 +601,71 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.0" + version: "6.0.0" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications - sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3 + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" url: "https://pub.flutter-io.cn" source: hosted - version: "17.2.1+2" + version: "22.3.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.1" + version: "8.0.1" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" url: "https://pub.flutter-io.cn" source: hosted - version: "7.2.0" + version: "3.1.1" flutter_localizations: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_math_fork: - dependency: transitive - description: - path: "." - ref: main - resolved-ref: bd30a5faca93fe1279f1d39d347f9dbe8472877b - url: "https://github.com/dakshatsuraasa/flutter_math.git" - source: git - version: "0.7.4" flutter_native_splash: dependency: "direct main" description: name: flutter_native_splash - sha256: aa06fec78de2190f3db4319dd60fdc8d12b2626e93ef9828633928c2dcaea840 + sha256: "7062602e0dbd29141fb8eb19220b5871ca650be5197ab9c1f193a28b17537bc7" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.1" + version: "2.4.4" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.20" + version: "2.0.35" flutter_shaders: dependency: transitive description: @@ -668,10 +678,10 @@ packages: dependency: transitive description: name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0" + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -686,18 +696,18 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html - sha256: f3967a5b42896662efdd420b5adaf8a7d3692b0f44462a07c80e3b4c173b1a02 + sha256: "4fa9a0d34df0a40ac8dd20765dc83bbd8f36ceecfffa2a72a7ab178548804a04" url: "https://pub.flutter-io.cn" source: hosted - version: "0.15.3" + version: "0.17.2" flutter_widget_from_html_core: dependency: transitive description: name: flutter_widget_from_html_core - sha256: b1048fd119a14762e2361bd057da608148a895477846d6149109b2151d2f7abf + sha256: "7ff010b116f6abc16429923e616fbc727f3f65ef4cee12ffdb280aeecbc21e7f" url: "https://pub.flutter-io.cn" source: hosted - version: "0.15.2" + version: "0.17.2" flutter_windowmanager: dependency: "direct main" description: @@ -709,98 +719,90 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "8.2.12" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + sha256: "90778fe0497fe3a09166e8cf2e0867310ff434b794526589e77ec03cf08ba8e8" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.0" + version: "8.2.14" fwfh_cached_network_image: dependency: transitive description: name: fwfh_cached_network_image - sha256: "8e44226801bfba27930673953afce8af44da7e92573be93f60385d9865a089dd" + sha256: "484cb5f8047f02cfac0654fca5832bfa91bb715fd7fc651c04eb7454187c4af8" url: "https://pub.flutter-io.cn" source: hosted - version: "0.14.3" + version: "0.16.1" fwfh_chewie: dependency: transitive description: name: fwfh_chewie - sha256: "37bde9cedfb6dc5546176f7f0c56af1e814966cb33ec58f16c9565ed93ccb704" + sha256: ae74fc26798b0e74f3983f7b851e74c63b9eeb2d3015ecd4b829096b2c3f8818 url: "https://pub.flutter-io.cn" source: hosted - version: "0.14.8" + version: "0.16.1" fwfh_just_audio: dependency: transitive description: name: fwfh_just_audio - sha256: "38dc2c55803bd3cef33042c473e0c40b891ad4548078424641a32032f6a1245f" + sha256: dfd622a0dfe049ac647423a2a8afa7f057d9b2b93d92710b624e3d370b1ac69a url: "https://pub.flutter-io.cn" source: hosted - version: "0.15.2" + version: "0.17.0" fwfh_svg: dependency: transitive description: name: fwfh_svg - sha256: "550b1014d12b5528d8bdb6e3b44b58721f3fb1f65d7a852d1623a817008bdfc4" + sha256: "2e6bb241179eeeb1a7941e05c8c923b05d332d36a9085233e7bf110ea7deb915" url: "https://pub.flutter-io.cn" source: hosted - version: "0.8.3" + version: "0.16.1" fwfh_url_launcher: dependency: transitive description: name: fwfh_url_launcher - sha256: b9f5d55a5ae2c2c07243ba33f7ba49ac9544bdb2f4c16d8139df9ccbebe3449c + sha256: c38aa8fb373fda3a89b951fa260b539f623f6edb45eee7874cb8b492471af881 url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.1" + version: "0.16.1" fwfh_webview: dependency: transitive description: name: fwfh_webview - sha256: c0a8b664b642f40f4c252a0ab4e72c22dcd97c7fb3a7e50a6b4bdb6f63afca19 + sha256: "30de1ce10ee789cbd23732558a4b837d9cfd9d6b6352acf686a0113969fb2f85" url: "https://pub.flutter-io.cn" source: hosted - version: "0.15.3" + version: "0.15.7" get_it: dependency: transitive description: name: get_it - sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" url: "https://pub.flutter-io.cn" source: hosted - version: "7.7.0" + version: "9.2.1" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.1.3" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.flutter-io.cn" source: hosted - version: "2.3.1" + version: "2.3.2" gtk: dependency: transitive description: name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.0" + version: "2.2.0" highlight: dependency: transitive description: @@ -817,6 +819,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" hotkey_manager: dependency: "direct main" description: @@ -877,34 +887,42 @@ packages: dependency: "direct main" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0" + version: "1.6.0" + http_cache_core: + dependency: transitive + description: + name: http_cache_core + sha256: d6de4ca6680e70f4eacd79e073d6a73734eb1da86305409e495adfabf404eddf + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.4" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.flutter-io.cn" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.2" + version: "4.1.2" image: dependency: transitive description: name: image - sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.flutter-io.cn" source: hosted - version: "4.2.0" + version: "4.3.0" image_gallery_saver: dependency: "direct main" description: @@ -924,74 +942,98 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.flutter-io.cn" source: hosted - version: "0.19.0" + version: "0.20.2" intl_utils: dependency: "direct main" description: name: intl_utils - sha256: e0fa279731c3122c542e92262f11d2ff9365460701f0227971c149dcdff2f6da + sha256: "07469a81dcb011ab3708ed4a324403f0f556f2e3663b769006f92c9e62a0066a" url: "https://pub.flutter-io.cn" source: hosted - version: "2.8.9" + version: "2.8.14" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.4" + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" js: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.flutter-io.cn" source: hosted - version: "0.6.7" + version: "0.7.2" json_annotation: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.flutter-io.cn" source: hosted - version: "4.9.0" + version: "4.12.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: b0a98230538fe5d0b60a22fb6bf1b6cb03471b53e3324ff6069c591679dd59c9 + sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d url: "https://pub.flutter-io.cn" source: hosted - version: "6.9.3" + version: "6.14.1" just_audio: dependency: transitive description: name: just_audio - sha256: ee50602364ba83fa6308f5512dd560c713ec3e1f2bc75f0db43618f0d82ef71a + sha256: e60aa97b233ddea025e13dfac59195207f528fa5e9e4a4a49a8c0766701e5fe6 url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.39" + version: "0.10.6" just_audio_platform_interface: dependency: transitive description: name: just_audio_platform_interface - sha256: "0243828cce503c8366cc2090cefb2b3c871aa8ed2f520670d76fd47aa1ab2790" + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" url: "https://pub.flutter-io.cn" source: hosted - version: "4.3.0" + version: "4.6.0" just_audio_web: dependency: transitive description: name: just_audio_web - sha256: "9a98035b8b24b40749507687520ec5ab404e291d2b0937823ff45d92cb18d448" + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" url: "https://pub.flutter-io.cn" source: hosted - version: "0.4.13" + version: "0.4.16" launch_at_startup: dependency: "direct main" description: @@ -1004,42 +1046,42 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.flutter-io.cn" source: hosted - version: "10.0.7" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.8" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.1" + version: "3.0.2" like_button: dependency: "direct main" description: name: like_button - sha256: "08e6a45b78888412df5d351786c550205ad3a677e72a0820d5bbc0b063c8a463" + sha256: "8b349521182ea6252b7fe1eaaad5932a9f55f94c3e87849376cfc25c78bac53a" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.5" + version: "2.1.0" lints: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.0" + version: "6.1.0" local_auth: dependency: "direct main" description: @@ -1052,34 +1094,34 @@ packages: dependency: "direct main" description: name: local_auth_android - sha256: "316503f6772dea9c0c038bb7aac4f68ab00112d707d258c770f7fc3c250a2d88" + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.51" + version: "1.0.56" local_auth_darwin: dependency: transitive description: name: local_auth_darwin - sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055" + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" url: "https://pub.flutter-io.cn" source: hosted - version: "1.6.0" + version: "1.6.1" local_auth_platform_interface: dependency: transitive description: name: local_auth_platform_interface - sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.10" + version: "1.1.0" local_auth_windows: dependency: transitive description: name: local_auth_windows - sha256: "505ba3367ca781efb1c50d3132e44a2446bccc4163427bc203b9b4d8994d97ea" + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.10" + version: "1.0.11" local_notifier: dependency: "direct main" description: @@ -1092,18 +1134,18 @@ packages: dependency: "direct main" description: name: logger - sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999" + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" url: "https://pub.flutter-io.cn" source: hosted - version: "2.6.0" + version: "2.7.0" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0" + version: "1.3.0" lottie: dependency: "direct main" description: @@ -1119,22 +1161,14 @@ packages: relative: true source: path version: "0.257.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.1.3-main.0" markdown: dependency: transitive description: name: markdown - sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 url: "https://pub.flutter-io.cn" source: hosted - version: "7.3.0" + version: "7.3.1" markdown_widget: dependency: transitive description: @@ -1146,18 +1180,18 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.16+1" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.flutter-io.cn" source: hosted - version: "0.11.1" + version: "0.13.0" menu_base: dependency: transitive description: @@ -1170,10 +1204,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.flutter-io.cn" source: hosted - version: "1.15.0" + version: "1.17.0" metadata_fetch: dependency: transitive description: @@ -1186,10 +1220,10 @@ packages: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.5" + version: "2.0.0" modal_bottom_sheet: dependency: "direct main" description: @@ -1202,10 +1236,10 @@ packages: dependency: "direct dev" description: name: msix - sha256: "519b183d15dc9f9c594f247e2d2339d855cf0eaacc30e19b128e14f3ecc62047" + sha256: "61415c352e8aea084332b8a5514e6408c961c8cdeca202804fff1040eb555ac7" url: "https://pub.flutter-io.cn" source: hosted - version: "3.16.7" + version: "3.18.0" nested: dependency: transitive description: @@ -1214,6 +1248,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.5.0" octo_image: dependency: transitive description: @@ -1226,10 +1276,10 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.0" + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -1250,18 +1300,18 @@ packages: dependency: "direct main" description: name: palette_generator - sha256: d50fbcd69abb80c5baec66d700033b1a320108b1aa17a5961866a12c0abb7c0c + sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.3+4" + version: "0.3.3+7" path: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.flutter-io.cn" source: hosted - version: "1.9.0" + version: "1.9.1" path_drawing: dependency: "direct main" description: @@ -1274,50 +1324,50 @@ packages: dependency: transitive description: name: path_parsing - sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.1" + version: "1.1.0" path_provider: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "30c5aa827a6ae95ce2853cdc5fe3971daaac00f6f081c419c013f7f57bff2f5e" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.7" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.0" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -1330,58 +1380,58 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" url: "https://pub.flutter-io.cn" source: hosted - version: "11.3.1" + version: "11.4.0" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: b29a799ca03be9f999aa6c39f7de5209482d638e6f857f6b93b0875c618b7e54 + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc url: "https://pub.flutter-io.cn" source: hosted - version: "12.0.7" + version: "12.1.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 url: "https://pub.flutter-io.cn" source: hosted - version: "9.4.5" + version: "9.6.1" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.1" + version: "0.1.4+1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 url: "https://pub.flutter-io.cn" source: hosted - version: "4.2.1" + version: "4.4.0" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.1" + version: "0.2.2" petitparser: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.flutter-io.cn" source: hosted - version: "6.0.2" + version: "7.0.2" pinyin: dependency: transitive description: @@ -1394,10 +1444,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.5" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -1418,10 +1468,10 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.flutter-io.cn" source: hosted - version: "1.5.1" + version: "1.5.2" process_run: dependency: "direct main" description: @@ -1490,18 +1540,18 @@ packages: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0" + version: "1.5.0" rational: dependency: transitive description: @@ -1510,6 +1560,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.3" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" responsive_builder: dependency: "direct main" description: @@ -1522,66 +1580,66 @@ packages: dependency: "direct main" description: name: restart_app - sha256: "00d5ec3e9de871cedbe552fc41e615b042b5ec654385e090e0983f6d02f655ed" + sha256: "3ea6b790dd88d7d927ed4792f3c6dd68e2fdffcbb1bf4df6d19313702e2d6387" url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.2" + version: "1.9.1" rxdart: dependency: transitive description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.flutter-io.cn" source: hosted - version: "0.27.7" + version: "0.28.0" screen_protector: dependency: "direct main" description: name: screen_protector - sha256: "305fd157f6f0b210afe216e790022bfe469c3b1d1f2e0d3dcc5906cc9c49c3e9" + sha256: "66f2ffbfbe7acc26db49de893e0c49078c67b546c27a57958d9bd9901f77c38e" url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.2+1" + version: "1.5.3" screen_retriever: dependency: transitive description: name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_linux: dependency: transitive description: name: screen_retriever_linux - sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_macos: dependency: transitive description: name: screen_retriever_macos - sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_platform_interface: dependency: transitive description: name: screen_retriever_platform_interface - sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_windows: dependency: transitive description: name: screen_retriever_windows - sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.0" + version: "0.2.2" scroll_to_index: dependency: transitive description: @@ -1610,18 +1668,18 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.0" + version: "3.0.0" shell_executor: dependency: transitive description: @@ -1655,50 +1713,50 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.0" + version: "4.2.4" source_helper: dependency: transitive description: name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.5" + version: "1.3.13" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.flutter-io.cn" source: hosted - version: "1.10.0" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.flutter-io.cn" - source: hosted - version: "7.0.0" + version: "1.10.2" sqflite: dependency: "direct main" description: name: sqflite - sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" url: "https://pub.flutter-io.cn" source: hosted - version: "2.3.3+1" + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2+3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.4" + version: "2.5.8" sqflite_common_ffi: dependency: "direct dev" description: @@ -1707,46 +1765,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.3.3" - sqlite3: + sqflite_darwin: dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + sqlite3: + dependency: "direct overridden" description: name: sqlite3 - sha256: "35d3726fe18ab1463403a5cc8d97dbc81f2a0b08082e8173851363fcc97b6627" + sha256: dd806fff004a0aeb01e208b858dbc649bc72104670d425a81a6dd17698535f6e url: "https://pub.flutter-io.cn" source: hosted - version: "2.7.2" + version: "2.8.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.flutter-io.cn" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0" + version: "1.4.1" string_validator: dependency: transitive description: @@ -1759,50 +1833,42 @@ packages: dependency: transitive description: name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.0+1" + version: "3.4.0+1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.3" + version: "0.7.10" timezone: dependency: transitive description: name: timezone - sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.4" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.1" + version: "0.11.1" tray_manager: dependency: "direct main" description: name: tray_manager - sha256: c9a63fd88bd3546287a7eb8ccc978d707eef82c775397af17dda3a4f4c039e64 + sha256: "1a659b08baa6e9b91ef8ce16eda37740de398be1c4cf322b8a1ddfef25c68c5a" url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.3" + version: "0.5.3" tuple: dependency: "direct main" description: @@ -1815,10 +1881,10 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.2" + version: "1.4.0" uni_platform: dependency: transitive description: @@ -1831,50 +1897,50 @@ packages: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.2" + version: "2.3.1" url_launcher: dependency: "direct main" description: name: url_launcher - sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.flutter-io.cn" source: hosted - version: "6.3.0" + version: "6.3.2" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" url: "https://pub.flutter-io.cn" source: hosted - version: "6.3.3" + version: "6.3.30" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.flutter-io.cn" source: hosted - version: "6.3.1" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.flutter-io.cn" source: hosted - version: "3.2.0" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1887,98 +1953,98 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.0" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: transitive description: name: uuid - sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.flutter-io.cn" source: hosted - version: "4.4.2" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.19" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.11+1" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: ca81fdfaf62a5ab45d7296614aea108d2c7d0efca8393e96174bf4d51e6725b0 + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.18" + version: "1.3.0" vector_math: dependency: "direct main" description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.4" + version: "2.2.0" video_player: dependency: "direct main" description: name: video_player - sha256: e30df0d226c4ef82e2c150ebf6834b3522cf3f654d8e2f9419d376cdc071425d + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" url: "https://pub.flutter-io.cn" source: hosted - version: "2.9.1" + version: "2.11.1" video_player_android: dependency: transitive description: name: video_player_android - sha256: fdc0331ce9f808cc2714014cb8126bd6369943affefd54f8fdab0ea0bb617b7f + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.2" + version: "2.9.5" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c + sha256: c238f5f0a26845cd0bcc2956049b63065a4d3c40ddfc22c3414cd170bef7fff9 url: "https://pub.flutter-io.cn" source: hosted - version: "2.6.1" + version: "2.11.0" video_player_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: video_player_platform_interface - sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6" + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" url: "https://pub.flutter-io.cn" source: hosted - version: "6.2.2" + version: "6.9.0" video_player_web: dependency: transitive description: name: video_player_web - sha256: "881b375a934d8ebf868c7fb1423b2bfaa393a0a265fa3f733079a86536064a10" + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" url: "https://pub.flutter-io.cn" source: hosted - version: "2.3.3" + version: "2.4.0" visibility_detector: dependency: transitive description: @@ -1988,109 +2054,109 @@ packages: source: hosted version: "0.4.0+2" vm_service: - dependency: transitive + dependency: "direct dev" description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.flutter-io.cn" source: hosted - version: "14.3.0" + version: "15.3.0" wakelock_plus: dependency: transitive description: name: wakelock_plus - sha256: "36c88af0b930121941345306d259ec4cc4ecca3b151c02e3a9e71aede83c615e" + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.10" + version: "1.3.3" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a" + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.2" + version: "1.6.0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.0" + version: "1.2.1" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.2" + version: "3.0.3" webview_flutter: - dependency: transitive + dependency: "direct overridden" description: name: webview_flutter - sha256: "6869c8786d179f929144b4a1f86e09ac0eddfe475984951ea6c634774c16b522" + sha256: "889a0a678e7c793c308c68739996227c9661590605e70b1f6cf6b9a6634f7aec" url: "https://pub.flutter-io.cn" source: hosted - version: "4.8.0" + version: "4.10.0" webview_flutter_android: dependency: "direct overridden" description: name: webview_flutter_android - sha256: dad3313c9ead95517bb1cae5e1c9d20ba83729d5a59e5e83c0a2d66203f27f91 + sha256: d1ee28f44894cbabb1d94cc42f9980297f689ff844d067ec50ff88d86e27d63f url: "https://pub.flutter-io.cn" source: hosted - version: "3.16.1" + version: "4.3.0" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface - sha256: d937581d6e558908d7ae3dc1989c4f87b786891ab47bb9df7de548a151779d8d + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" url: "https://pub.flutter-io.cn" source: hosted - version: "2.10.0" + version: "2.15.1" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: "9c62cc46fa4f2d41e10ab81014c1de470a6c6f26051a2de32111b2ee55287feb" + sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2" url: "https://pub.flutter-io.cn" source: hosted - version: "3.14.0" + version: "3.25.1" win32: dependency: transitive description: name: win32 - sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.flutter-io.cn" source: hosted - version: "5.5.4" + version: "5.15.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.3" + version: "1.1.5" window_manager: dependency: "direct main" description: @@ -2103,26 +2169,26 @@ packages: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.4" + version: "1.1.0" xml: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.flutter-io.cn" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.6.0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5d032637..e3146225 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: loftify -version: 2.5.0+2500 +version: 3.0.0+3000 description: An third-party application for LOFTER publish_to: none @@ -16,7 +16,7 @@ dependencies: animations: ^2.0.11 # 动画 fluttertoast: ^8.2.6 # 吐司 auto_size_text: ^3.0.0 # 自适应文本 - flutter_widget_from_html: ^0.15.3 # 将HTML渲染成组件 + flutter_widget_from_html: ^0.17.2 # 将HTML渲染成组件 card_swiper: ^3.0.1 # 卡片滑动 modal_bottom_sheet: ^3.0.0 # 底部弹窗 video_player: ^2.9.1 # 视频播放 @@ -24,7 +24,7 @@ dependencies: # media_kit_libs_windows_video: ^1.0.9 like_button: ^2.0.5 # 点赞按钮 sliding_up_panel: ^2.0.0+1 # 滑动面板 - blur: ^3.1.0 # 模糊 + blur: ^4.0.2 # 模糊 path_drawing: ^1.0.1 awesome_chewie: path: third-party/chewie @@ -41,14 +41,14 @@ dependencies: share_plus: ^10.1.4 # 分享 url_launcher: ^6.3.0 # URL跳转 app_links: ^6.1.4 # 应用URI - flutter_displaymode: ^0.6.0 # 设置刷新率 + flutter_displaymode: ^0.7.0 # 设置刷新率 image_gallery_saver: path: third-party/tool/image_gallery_saver # 保存图片到相册 lucide_icons: path: third-party/widget/lucide_icons permission_handler: ^11.3.1 # 权限管理 install_plugin: ^2.1.0 # 安装APP - flutter_local_notifications: ^17.2.1+2 # 通知 + flutter_local_notifications: ^22.3.0 # 通知 hotkey_manager: ^0.2.3 # 热键 # 存储/网络 path: @@ -60,7 +60,7 @@ dependencies: sqflite: ^2.3.3+1 # SQLite数据库 file_picker: ^8.3.1 # 文件选择 pointycastle: 3.9.1 - dio_cache_interceptor: ^3.5.0 # dio缓存 + dio_cache_interceptor: ^4.0.7 # dio缓存 dio_cookie_manager: ^3.1.1 # dio cookie管理 cached_network_image: ^3.2.1 # 网络图片缓存 flutter_cache_manager: ^3.3.1 # 缓存管理 @@ -79,12 +79,14 @@ dependencies: screen_protector: ^1.4.2+1 # 平台适配 responsive_builder: ^0.7.1 # 响应式布局 - tray_manager: ^0.2.3 # 托盘图标 + tray_manager: ^0.5.3 # 托盘图标 window_manager: ^0.4.3 # 窗口管理 restart_app: ^1.2.1 # 重启APP launch_at_startup: ^0.3.1 # 开机启动 local_notifier: ^0.1.6 # 本地通知 protocol_handler: ^0.2.0 # 协议处理 + background_downloader: 9.5.8 + connectivity_plus: 7.3.1 # desktop_multi_window: # 多窗口 # path: third-party/desktop_multi_window # window_size: # 窗口大小 @@ -95,16 +97,20 @@ dependencies: # texture_rgba_renderer: ^0.0.16 # 纹理渲染 dependency_overrides: - webview_flutter_android: 3.16.1 + sqlite3: 2.8.0 + webview_flutter: 4.10.0 + webview_flutter_android: 4.3.0 dev_dependencies: flutter_test: sdk: flutter + video_player_platform_interface: ^6.9.0 build_runner: ^2.4.15 - flutter_lints: ^4.0.0 + flutter_lints: ^6.0.0 json_serializable: ^6.7.1 msix: ^3.16.7 - sqflite_common_ffi: ^2.3.3 + sqflite_common_ffi: 2.3.3 + vm_service: ^15.3.0 msix_config: display_name: Loftify @@ -138,7 +144,6 @@ flutter: uses-material-design: true assets: - assets/lottie/ - - assets/icon/ - assets/illust/ - assets/mess/ - assets/avatar.png @@ -146,4 +151,4 @@ flutter: - assets/logo.svg - assets/logo-transparent.png - assets/logo-transparent-big.png - - assets/logo-transparent-big.ico \ No newline at end of file + - assets/logo-transparent-big.ico diff --git a/test/display_mode_util_test.dart b/test/display_mode_util_test.dart new file mode 100644 index 00000000..838cdcbe --- /dev/null +++ b/test/display_mode_util_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_displaymode/flutter_displaymode.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/display_mode_util.dart'; + +void main() { + const automatic = DisplayMode.auto; + const mode120 = DisplayMode( + id: 1, + width: 1200, + height: 2670, + refreshRate: 120, + ); + const mode60 = DisplayMode( + id: 2, + width: 1200, + height: 2670, + refreshRate: 60, + ); + const mode90 = DisplayMode( + id: 3, + width: 1200, + height: 2670, + refreshRate: 90, + ); + const tablet144 = DisplayMode( + id: 4, + width: 2560, + height: 1600, + refreshRate: 144, + ); + + test('stable signature restores the same mode after list reordering', () { + final encoded = DisplayModePreference.encode(mode90); + final restored = DisplayModePreference.resolve( + modes: const [automatic, mode60, mode120, mode90], + activeMode: mode60, + encodedMode: encoded, + ); + + expect(restored, mode90); + }); + + test('legacy index is resolved against the original plugin order', () { + final restored = DisplayModePreference.resolve( + modes: const [automatic, mode120, mode60, mode90], + activeMode: mode60, + legacyIndex: 3, + ); + + expect(restored, mode90); + }); + + test('default selects highest refresh rate at the active resolution', () { + final restored = DisplayModePreference.resolve( + modes: const [automatic, tablet144, mode60, mode90, mode120], + activeMode: mode60, + ); + + expect(restored, mode120); + }); + + test('unsupported saved mode safely falls back to the current resolution', + () { + final restored = DisplayModePreference.resolve( + modes: const [automatic, mode60, mode90], + activeMode: mode60, + encodedMode: '1080x2400@165.000', + ); + + expect(restored, mode90); + }); + + test('nearby fractional rate restores the saved physical mode', () { + const fractional60 = DisplayMode( + id: 5, + width: 1200, + height: 2670, + refreshRate: 59.94, + ); + final restored = DisplayModePreference.resolve( + modes: const [automatic, mode120, mode90, fractional60], + activeMode: mode120, + encodedMode: '1200x2670@60.000', + ); + + expect(restored, fractional60); + }); + + test('distant unsupported rate still falls back to highest refresh rate', () { + final restored = DisplayModePreference.resolve( + modes: const [automatic, mode60, mode90, mode120], + activeMode: mode60, + encodedMode: '1200x2670@75.000', + ); + + expect(restored, mode120); + }); + + test('mode menu is deduplicated and ordered by refresh rate', () { + final ordered = DisplayModePreference.ordered( + const [mode60, mode120, automatic, mode90, mode120], + ); + + expect(ordered, const [automatic, mode120, mode90, mode60]); + expect( + DisplayModePreference.label( + ordered.first, + automaticLabel: 'Follow system', + ), + 'Follow system', + ); + expect( + DisplayModePreference.label(mode120, automaticLabel: 'Follow system'), + '1200 × 2670 · 120 Hz', + ); + }); + + test('surface refresh-rate hint preserves exact rate and clears for auto', + () { + const fractionalMode = DisplayMode( + id: 6, + width: 1080, + height: 2400, + refreshRate: 119.88, + ); + + expect(DisplayModeController.surfaceRefreshRate(fractionalMode), 119.88); + expect(DisplayModeController.surfaceRefreshRate(automatic), 0); + }); +} diff --git a/test/download_indicator_progress_test.dart b/test/download_indicator_progress_test.dart new file mode 100644 index 00000000..59c392c4 --- /dev/null +++ b/test/download_indicator_progress_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Models/download_task.dart'; + +void main() { + for (final status in DownloadTaskStatus.values) { + for (final total in [0, 100]) { + test('indicator follows transfer state $status total=$total', () { + final task = DownloadTask( + id: 'test', + url: 'https://example.com/image.jpg', + fileName: 'image.jpg', + mediaType: DownloadMediaType.image, + status: status, + progress: 0.4, + receivedBytes: 40, + totalBytes: total, + createdAt: DateTime(2026), + updatedAt: DateTime(2026)); + final expected = status == DownloadTaskStatus.completed + ? 1.0 + : status == DownloadTaskStatus.downloading && total == 0 + ? null + : 0.4; + expect(task.indicatorProgress, expected); + expect(task.progress, 0.4); + }); + } + } +} diff --git a/test/download_source_router_test.dart b/test/download_source_router_test.dart new file mode 100644 index 00000000..d1fb3b31 --- /dev/null +++ b/test/download_source_router_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Models/download_task.dart'; +import 'package:loftify/Screens/Info/favorite_folder_detail_screen.dart'; +import 'package:loftify/Screens/Info/like_screen.dart'; +import 'package:loftify/Screens/Info/share_screen.dart'; +import 'package:loftify/Screens/Post/collection_detail_screen.dart'; +import 'package:loftify/Screens/Post/grain_detail_screen.dart'; +import 'package:loftify/Screens/Post/post_detail_screen.dart'; +import 'package:loftify/Utils/download_source_router.dart'; + +void main() { + test('post source resolves decimal persisted ids to post meta hex ids', () { + final destination = DownloadSourceRouter.destinationFor( + const DownloadSourceDescriptor( + type: DownloadSourceType.postAll, + sourceId: '16', + title: 'Post', + metadata: { + 'postId': '16', + 'blogId': '32', + 'blogName': 'creator', + }, + ), + ); + + expect(destination, isA()); + final screen = destination! as PostDetailScreen; + expect(screen.meta, { + 'postId': '10', + 'blogId': '20', + 'blogName': 'creator', + }); + }); + + test('collection grain and favorite sources keep their business ids', () { + final collection = DownloadSourceRouter.destinationFor( + const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Collection', + metadata: { + 'postId': '11', + 'blogId': '12', + 'blogName': 'creator', + }, + ), + )! as CollectionDetailScreen; + final grain = DownloadSourceRouter.destinationFor( + const DownloadSourceDescriptor( + type: DownloadSourceType.grain, + sourceId: '88', + title: 'Grain', + metadata: {'blogId': '99'}, + ), + )! as GrainDetailScreen; + final favorite = DownloadSourceRouter.destinationFor( + const DownloadSourceDescriptor( + type: DownloadSourceType.favoriteFolder, + sourceId: '66', + title: 'Folder', + ), + )! as FavoriteFolderDetailScreen; + + expect(collection.collectionId, 42); + expect(collection.postId, 11); + expect(collection.blogId, 12); + expect(collection.blogName, 'creator'); + expect(grain.grainId, 88); + expect(grain.blogId, 99); + expect(favorite.favoriteFolderId, 66); + }); + + test('self list sources resolve to the matching content lists', () { + final likes = DownloadSourceRouter.destinationFor( + const DownloadSourceDescriptor( + type: DownloadSourceType.likes, + sourceId: 'self', + title: 'Likes', + ), + ); + final recommendations = DownloadSourceRouter.destinationFor( + const DownloadSourceDescriptor( + type: DownloadSourceType.recommendations, + sourceId: 'self', + title: 'Recommendations', + ), + ); + + expect(likes, isA()); + expect(recommendations, isA()); + }); + + test('invalid or unsupported source returns a local navigation failure', () { + expect( + DownloadSourceRouter.destinationFor( + const DownloadSourceDescriptor( + type: DownloadSourceType.postAll, + sourceId: 'invalid', + title: 'Invalid post', + ), + ), + isNull, + ); + expect( + DownloadSourceRouter.destinationFor( + const DownloadSourceDescriptor( + type: DownloadSourceType.other, + sourceId: 'other', + title: 'Other', + ), + ), + isNull, + ); + }); +} diff --git a/test/download_task_manager_test.dart b/test/download_task_manager_test.dart new file mode 100644 index 00000000..0c081e36 --- /dev/null +++ b/test/download_task_manager_test.dart @@ -0,0 +1,685 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Models/download_task.dart'; +import 'package:loftify/Utils/download_task_manager.dart'; + +class _MemoryDownloadTaskStore implements DownloadTaskStore { + _MemoryDownloadTaskStore([ + List initial = const [], + List initialGroups = const [], + ]) : records = List.from(initial), + groupRecords = List.from(initialGroups); + + List records; + List groupRecords; + int writeCount = 0; + int groupWriteCount = 0; + + @override + Future> read() async => List.from(records); + + @override + Future write(List tasks) async { + writeCount++; + records = tasks + .map((task) => DownloadTask.fromJson(task.toJson())) + .toList(growable: false); + } + + @override + Future> readGroups() async => + List.from(groupRecords); + + @override + Future writeGroups(List groups) async { + groupWriteCount++; + groupRecords = groups + .map((group) => DownloadGroup.fromJson(group.toJson())) + .toList(growable: false); + } +} + +class _ControlledDownloadTaskExecutor extends DownloadTaskExecutor { + final Map> _completers = {}; + final Map _progressCallbacks = {}; + final List starts = []; + final List cleaned = []; + final List paused = []; + final List cancelled = []; + int initializeCount = 0; + int networkAvailableCount = 0; + + @override + Future initialize() async { + initializeCount++; + } + + @override + Future run( + DownloadTask task, { + required CancelToken cancelToken, + required DownloadProgressCallback onProgress, + }) { + starts.add(task.id); + _progressCallbacks[task.id] = onProgress; + final completer = Completer(); + _completers[task.id] = completer; + cancelToken.whenCancel.then((_) { + if (!completer.isCompleted) { + completer.completeError( + DioException.requestCancelled( + requestOptions: RequestOptions(path: task.url), + reason: cancelToken.cancelError, + ), + ); + } + }); + return completer.future; + } + + void emitProgress(String taskId, int received, int total) { + _progressCallbacks[taskId]?.call(received, total); + } + + void complete(String taskId) { + final completer = _completers[taskId]; + if (completer != null && !completer.isCompleted) { + completer.complete( + DownloadTaskResult(savedPath: '/downloads/$taskId'), + ); + } + } + + void fail(String taskId) { + final completer = _completers[taskId]; + if (completer != null && !completer.isCompleted) { + completer.completeError( + DioException.connectionError( + requestOptions: RequestOptions(path: taskId), + reason: 'offline', + ), + ); + } + } + + @override + Future pause(DownloadTask task) async { + paused.add(task.id); + return true; + } + + @override + Future cancel(DownloadTask task) async { + cancelled.add(task.id); + } + + @override + Future onNetworkAvailable() async { + networkAvailableCount++; + } + + @override + Future deleteTemporaryFiles(DownloadTask task) async { + cleaned.add(task.id); + } +} + +Future _settleManager() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('download task survives JSON persistence', () { + final now = DateTime(2026, 8, 24, 10, 30); + final source = DownloadTask( + id: 'task-1', + url: 'https://example.com/image.jpg', + fileName: 'image.jpg', + title: 'A post', + thumbnailUrl: 'https://example.com/thumb.jpg', + mediaType: DownloadMediaType.image, + status: DownloadTaskStatus.paused, + failureKind: DownloadFailureKind.network, + progress: 0.45, + receivedBytes: 45, + totalBytes: 100, + createdAt: now, + updatedAt: now.add(const Duration(seconds: 2)), + ); + + final restored = DownloadTask.fromJson(source.toJson()); + + expect(restored.id, source.id); + expect(restored.mediaType, DownloadMediaType.image); + expect(restored.status, DownloadTaskStatus.paused); + expect(restored.failureKind, DownloadFailureKind.network); + expect(restored.progress, 0.45); + expect(restored.receivedBytes, 45); + expect(restored.totalBytes, 100); + expect(restored.isActive, isTrue); + }); + + test('download group preserves source type metadata and aggregate status', + () { + final now = DateTime(2026, 8, 29, 10, 30); + final source = DownloadGroup( + id: 'group-1', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Collection title', + metadata: {'collectionId': '42'}, + ), + taskIds: const ['task-1', 'task-2'], + requestedCount: 2, + createdAt: now, + updatedAt: now, + ); + final restored = DownloadGroup.fromJson(source.toJson()); + final snapshot = restored.snapshot([ + DownloadTask( + id: 'task-1', + url: 'https://example.com/1.jpg', + fileName: '1.jpg', + mediaType: DownloadMediaType.image, + status: DownloadTaskStatus.completed, + progress: 1, + createdAt: now, + updatedAt: now, + ), + DownloadTask( + id: 'task-2', + url: 'https://example.com/2.jpg', + fileName: '2.jpg', + mediaType: DownloadMediaType.image, + status: DownloadTaskStatus.failed, + progress: 0.5, + createdAt: now, + updatedAt: now, + ), + ]); + + expect(restored.source.stableKey, 'collection:42'); + expect(restored.source.metadata['collectionId'], '42'); + expect(snapshot.status, DownloadGroupStatus.partiallyFailed); + expect(snapshot.progress, 0.75); + }); + + test('manager queues, pauses, resumes and completes a task', () async { + final store = _MemoryDownloadTaskStore(); + final executor = _ControlledDownloadTaskExecutor(); + final manager = DownloadTaskManager( + store: store, + executor: executor, + maxConcurrentTasks: 1, + ); + await manager.initialize(); + expect(executor.initializeCount, 1); + + final task = await manager.enqueue( + url: 'https://example.com/video.mp4', + fileName: 'video.mp4', + mediaType: DownloadMediaType.video, + ); + await _settleManager(); + expect(executor.starts, [task.id]); + expect(manager.tasks.single.status, DownloadTaskStatus.downloading); + + executor.emitProgress(task.id, 25, 100); + expect(manager.tasks.single.progress, 0.25); + expect(manager.tasks.single.receivedBytes, 25); + + await manager.pause(task.id); + await _settleManager(); + expect(manager.tasks.single.status, DownloadTaskStatus.paused); + expect(executor.paused, [task.id]); + + await manager.resume(task.id); + await _settleManager(); + expect(executor.starts, [task.id, task.id]); + expect(manager.tasks.single.status, DownloadTaskStatus.downloading); + + final completion = manager.waitForCompletion(task.id); + executor.complete(task.id); + expect(await completion, isTrue); + await _settleManager(); + expect(manager.tasks.single.status, DownloadTaskStatus.completed); + expect(manager.tasks.single.progress, 1); + expect(store.records.single.status, DownloadTaskStatus.completed); + }); + + test('manager limits concurrency and deduplicates completed resources', + () async { + final store = _MemoryDownloadTaskStore(); + final executor = _ControlledDownloadTaskExecutor(); + final manager = DownloadTaskManager( + store: store, + executor: executor, + maxConcurrentTasks: 2, + ); + await manager.initialize(); + + final first = await manager.enqueue( + url: 'https://example.com/1.jpg', + fileName: '1.jpg', + mediaType: DownloadMediaType.image, + ); + final second = await manager.enqueue( + url: 'https://example.com/2.jpg', + fileName: '2.jpg', + mediaType: DownloadMediaType.image, + ); + final third = await manager.enqueue( + url: 'https://example.com/3.jpg', + fileName: '3.jpg', + mediaType: DownloadMediaType.image, + ); + await _settleManager(); + expect(executor.starts, hasLength(2)); + expect(executor.starts, containsAll([first.id, second.id])); + + executor.complete(first.id); + await _settleManager(); + expect(executor.starts, contains(third.id)); + executor.complete(second.id); + executor.complete(third.id); + await _settleManager(); + + final duplicate = await manager.enqueue( + url: first.url, + fileName: 'renamed.jpg', + mediaType: DownloadMediaType.image, + ); + expect(duplicate.id, first.id); + expect(manager.tasks, hasLength(3)); + expect(executor.starts.where((id) => id == first.id), hasLength(1)); + }); + + test('running tasks recover after restart and finished records are cleaned', + () async { + final now = DateTime(2026, 8, 24); + final running = DownloadTask( + id: 'running', + url: 'https://example.com/running.jpg', + fileName: 'running.jpg', + mediaType: DownloadMediaType.image, + status: DownloadTaskStatus.downloading, + progress: 0.4, + receivedBytes: 40, + totalBytes: 100, + createdAt: now, + updatedAt: now, + ); + final completed = DownloadTask( + id: 'completed', + url: 'https://example.com/completed.jpg', + fileName: 'completed.jpg', + mediaType: DownloadMediaType.image, + status: DownloadTaskStatus.completed, + progress: 1, + receivedBytes: 100, + totalBytes: 100, + createdAt: now, + updatedAt: now, + ); + final store = _MemoryDownloadTaskStore([running, completed]); + final executor = _ControlledDownloadTaskExecutor(); + final manager = DownloadTaskManager(store: store, executor: executor); + + await manager.initialize(); + await _settleManager(); + expect(executor.starts, ['running']); + expect( + manager.tasks.firstWhere((task) => task.id == 'running').status, + DownloadTaskStatus.downloading, + ); + + await manager.clearFinished(); + expect(manager.tasks.map((task) => task.id), isNot(contains('completed'))); + expect(executor.cleaned, contains('completed')); + }); + + test('failed tasks remain retryable and cancelled tasks clean partial data', + () async { + final executor = _ControlledDownloadTaskExecutor(); + final manager = DownloadTaskManager( + store: _MemoryDownloadTaskStore(), + executor: executor, + errorLogger: (_, __, ___) {}, + maxConcurrentTasks: 1, + ); + await manager.initialize(); + + final failed = await manager.enqueue( + url: 'https://example.com/failure.jpg', + fileName: 'failure.jpg', + mediaType: DownloadMediaType.image, + ); + await _settleManager(); + executor.fail(failed.id); + await _settleManager(); + expect(manager.tasks.single.status, DownloadTaskStatus.failed); + expect(manager.tasks.single.errorMessage, isNotEmpty); + + await manager.retry(failed.id); + await _settleManager(); + expect(executor.starts.where((id) => id == failed.id), hasLength(2)); + await manager.cancel(failed.id); + await _settleManager(); + expect(manager.tasks.single.status, DownloadTaskStatus.cancelled); + expect(executor.cancelled, contains(failed.id)); + expect(executor.cleaned, contains(failed.id)); + }); + + test('network failures resume automatically after connectivity returns', + () async { + final changes = StreamController>.broadcast(); + final executor = _ControlledDownloadTaskExecutor(); + final manager = DownloadTaskManager( + store: _MemoryDownloadTaskStore(), + executor: executor, + errorLogger: (_, __, ___) {}, + monitorConnectivity: true, + connectivityCheck: () async => [ + ConnectivityResult.none, + ], + connectivityChanges: changes.stream, + ); + await manager.initialize(); + + final task = await manager.enqueue( + url: 'https://example.com/reconnect.jpg', + fileName: 'reconnect.jpg', + mediaType: DownloadMediaType.image, + ); + await _settleManager(); + executor.fail(task.id); + await _settleManager(); + expect(manager.tasks.single.status, DownloadTaskStatus.failed); + expect( + manager.tasks.single.failureKind, + DownloadFailureKind.network, + ); + + changes.add([ConnectivityResult.wifi]); + await _settleManager(); + expect(executor.networkAvailableCount, 1); + expect(executor.starts.where((id) => id == task.id), hasLength(2)); + expect(manager.tasks.single.failureKind, isNull); + + manager.dispose(); + await changes.close(); + }); + + test('unsafe and oversized file names are normalized before persistence', + () async { + final manager = DownloadTaskManager( + store: _MemoryDownloadTaskStore(), + executor: _ControlledDownloadTaskExecutor(), + ); + await manager.initialize(); + + final task = await manager.enqueue( + url: 'https://example.com/name.jpg', + fileName: '${'very-long:' * 30}image?.jpg', + mediaType: DownloadMediaType.image, + ); + + expect(task.fileName.length, lessThanOrEqualTo(180)); + expect(task.fileName, endsWith('.jpg')); + expect(task.fileName, isNot(contains(RegExp(r'[\\/:*?"<>|]')))); + }); + + test('batch enqueue deduplicates, validates and persists atomically', + () async { + final store = _MemoryDownloadTaskStore(); + final executor = _ControlledDownloadTaskExecutor(); + final manager = DownloadTaskManager( + store: store, + executor: executor, + maxConcurrentTasks: 1, + ); + await manager.initialize(); + final writesBeforeBatch = store.writeCount; + + final result = await manager.enqueueBatch(const [ + DownloadRequest( + url: 'https://example.com/a.jpg', + fileName: 'a.jpg', + mediaType: DownloadMediaType.image, + ), + DownloadRequest( + url: ' https://example.com/a.jpg ', + fileName: 'duplicate.jpg', + mediaType: DownloadMediaType.image, + ), + DownloadRequest( + url: '', + fileName: 'invalid.jpg', + mediaType: DownloadMediaType.image, + ), + DownloadRequest( + url: 'ftp://example.com/file.zip', + fileName: 'file.zip', + mediaType: DownloadMediaType.file, + ), + DownloadRequest( + url: 'https://example.com/b.mp4', + fileName: 'b.mp4', + mediaType: DownloadMediaType.video, + ), + ]); + + expect(result.requestedCount, 5); + expect(result.queuedCount, 2); + expect(result.newTaskCount, 2); + expect(result.skippedCount, 1); + expect(result.invalidCount, 2); + expect(manager.tasks, hasLength(2)); + expect(store.writeCount, writesBeforeBatch + 1); + }); + + test('batch enqueue creates and restores one typed parent group', () async { + final store = _MemoryDownloadTaskStore(); + final manager = DownloadTaskManager( + store: store, + executor: _ControlledDownloadTaskExecutor(), + maxConcurrentTasks: 1, + ); + await manager.initialize(); + + final result = await manager.enqueueBatch( + const [ + DownloadRequest( + url: 'https://example.com/group-a.jpg', + fileName: 'group-a.jpg', + mediaType: DownloadMediaType.image, + ), + DownloadRequest( + url: 'https://example.com/group-b.jpg', + fileName: 'group-b.jpg', + mediaType: DownloadMediaType.image, + ), + ], + source: const DownloadSourceDescriptor( + type: DownloadSourceType.grain, + sourceId: '88', + title: 'A grain', + metadata: {'grainId': '88'}, + ), + unavailableCount: 1, + ); + + expect(result.group, isNotNull); + expect(manager.groups, hasLength(1)); + expect(manager.groups.single.taskIds, hasLength(2)); + expect(manager.groups.single.unavailableCount, 1); + expect(store.groupRecords.single.source.type, DownloadSourceType.grain); + + final restored = DownloadTaskManager( + store: store, + executor: _ControlledDownloadTaskExecutor(), + maxConcurrentTasks: 1, + ); + await restored.initialize(); + expect(restored.groups.single.source.stableKey, 'grain:88'); + expect(restored.groups.single.taskIds, hasLength(2)); + }); + + test('group controls pause resume cancel and retry matching child tasks', + () async { + final now = DateTime(2026, 8, 29, 16); + DownloadTask task(String id, DownloadTaskStatus status) => DownloadTask( + id: id, + url: 'https://example.com/$id.jpg', + fileName: '$id.jpg', + mediaType: DownloadMediaType.image, + status: status, + createdAt: now, + updatedAt: now, + ); + + final store = _MemoryDownloadTaskStore( + [ + task('queued', DownloadTaskStatus.queued), + task('paused', DownloadTaskStatus.paused), + task('failed', DownloadTaskStatus.failed), + task('standalone', DownloadTaskStatus.completed), + ], + [ + DownloadGroup( + id: 'group-controls', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Group controls', + ), + taskIds: const ['paused', 'queued', 'failed'], + requestedCount: 3, + createdAt: now, + updatedAt: now, + ), + ], + ); + final executor = _ControlledDownloadTaskExecutor(); + final manager = DownloadTaskManager( + store: store, + executor: executor, + maxConcurrentTasks: 1, + ); + await manager.initialize(); + await _settleManager(); + + expect( + manager.tasksForGroup('group-controls').map((task) => task.id), + ['paused', 'queued', 'failed'], + ); + expect(manager.snapshotForGroup('missing'), isNull); + + await manager.pauseGroup('group-controls'); + await _settleManager(); + expect( + manager + .tasksForGroup('group-controls') + .take(2) + .map((task) => task.status), + everyElement(DownloadTaskStatus.paused), + ); + + await manager.resumeGroup('group-controls'); + await _settleManager(); + expect( + manager + .tasksForGroup('group-controls') + .take(2) + .every((task) => task.isActive), + isTrue, + ); + + await manager.cancelGroup('group-controls'); + await _settleManager(); + expect( + manager + .tasksForGroup('group-controls') + .take(2) + .map((task) => task.status), + everyElement(DownloadTaskStatus.cancelled), + ); + + await manager.retryFailedGroup('group-controls'); + await _settleManager(); + expect( + manager.tasksForGroup('group-controls').every((task) => task.isActive), + isTrue, + ); + expect( + manager.tasks.firstWhere((task) => task.id == 'standalone').status, + DownloadTaskStatus.completed, + ); + }); + + test('batch enqueue skips completed tasks and requeues failed tasks', + () async { + final now = DateTime(2026, 8, 24); + final completed = DownloadTask( + id: 'completed', + url: 'https://example.com/completed.jpg', + fileName: 'completed.jpg', + mediaType: DownloadMediaType.image, + status: DownloadTaskStatus.completed, + progress: 1, + createdAt: now, + updatedAt: now, + ); + final failed = DownloadTask( + id: 'failed', + url: 'https://example.com/failed.jpg', + fileName: 'failed.jpg', + mediaType: DownloadMediaType.image, + status: DownloadTaskStatus.failed, + progress: 0.6, + receivedBytes: 60, + totalBytes: 100, + errorMessage: 'offline', + failureKind: DownloadFailureKind.network, + createdAt: now, + updatedAt: now, + ); + final executor = _ControlledDownloadTaskExecutor(); + final manager = DownloadTaskManager( + store: _MemoryDownloadTaskStore([completed, failed]), + executor: executor, + maxConcurrentTasks: 1, + ); + await manager.initialize(); + + final result = await manager.enqueueBatch(const [ + DownloadRequest( + url: 'https://example.com/completed.jpg', + fileName: 'completed-again.jpg', + mediaType: DownloadMediaType.image, + ), + DownloadRequest( + url: 'https://example.com/failed.jpg', + fileName: 'failed-again.jpg', + mediaType: DownloadMediaType.image, + ), + ]); + + expect(result.queuedCount, 1); + expect(result.requeuedCount, 1); + expect(result.skippedCount, 1); + final retried = manager.tasks.firstWhere((task) => task.id == 'failed'); + expect(retried.progress, 0); + expect(retried.errorMessage, isNull); + expect(retried.failureKind, isNull); + await _settleManager(); + expect(executor.starts, contains('failed')); + }); +} diff --git a/test/grain_response_test.dart b/test/grain_response_test.dart new file mode 100644 index 00000000..93a810e1 --- /dev/null +++ b/test/grain_response_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Models/grain_response.dart'; + +void main() { + test('timeline item without post data is omitted', () { + expect(GrainPostItem.fromTimelineJson({'postData': null}), isNull); + expect(GrainPostItem.fromTimelineJson({}), isNull); + }); + + test('malformed non-null post data is still reported', () { + expect( + () => GrainPostItem.fromTimelineJson({'postData': 'invalid'}), + throwsA(isA()), + ); + }); +} diff --git a/test/l10n/localization_resources_test.dart b/test/l10n/localization_resources_test.dart new file mode 100644 index 00000000..4b8c0ee0 --- /dev/null +++ b/test/l10n/localization_resources_test.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/generated/app_localizations_en.dart'; +import 'package:loftify/generated/app_localizations_zh.dart'; + +Map _loadArb(String name) { + final file = File('lib/l10n/$name'); + return jsonDecode(file.readAsStringSync()) as Map; +} + +Iterable _messageKeys(Map arb) { + return arb.keys.where((key) => !key.startsWith('@')); +} + +List _placeholders(Object? value) { + return RegExp(r'\{([A-Za-z_][A-Za-z0-9_]*)\}') + .allMatches(value as String) + .map((match) => match.group(1)!) + .toList(); +} + +void main() { + final localeFiles = [ + 'intl_en.arb', + 'intl_zh.arb', + 'intl_zh_CN.arb', + 'intl_zh_TW.arb', + ]; + + test('all locale resources expose the same message keys', () { + final resources = localeFiles.map(_loadArb).toList(); + final expectedKeys = _messageKeys(resources.first).toSet(); + + expect(expectedKeys, hasLength(768)); + for (var index = 1; index < resources.length; index++) { + expect( + _messageKeys(resources[index]).toSet(), + expectedKeys, + reason: '${localeFiles[index]} must match intl_en.arb', + ); + } + }); + + test('translated messages preserve every placeholder and its order', () { + final english = _loadArb('intl_en.arb'); + final traditionalChinese = _loadArb('intl_zh_TW.arb'); + + for (final key in _messageKeys(english)) { + expect( + _placeholders(english[key]), + _placeholders(traditionalChinese[key]), + reason: 'Placeholder mismatch for $key', + ); + } + }); + + test('English resources contain real English UI copy', () { + final english = _loadArb('intl_en.arb'); + final messagesWithHanCharacters = _messageKeys(english) + .where( + (key) => RegExp(r'[\u3400-\u9fff]').hasMatch(english[key]! as String), + ) + .toList(); + + expect(messagesWithHanCharacters, ['fontItemCaptionNonLatin']); + + final localizations = AppLocalizationsEn(); + expect(localizations.home, 'Home'); + expect(localizations.dynamicTab, 'Feed'); + expect(localizations.loginByCaptcha, 'SMS Code Login'); + expect(localizations.grain, 'Grain Lists'); + expect(localizations.exitApp, 'Exit Loftify'); + }); + + test('Traditional Chinese page states do not fall back to simplified copy', + () { + final localizations = AppLocalizationsZhTw(); + + expect(localizations.noNotice, '暫無通知'); + expect(localizations.loadingArticle, '文章載入中...'); + expect(localizations.retry, '重試'); + }); +} diff --git a/test/like_archive_util_test.dart b/test/like_archive_util_test.dart new file mode 100644 index 00000000..ebdb5d37 --- /dev/null +++ b/test/like_archive_util_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Models/history_response.dart'; +import 'package:loftify/Utils/like_archive_util.dart'; + +void main() { + test('archive parser keeps repeated month counts at their real month', () { + final archives = buildLikeArchives( + [ + { + 'year': 2025, + 'monthCount': [1, 3, 1, 3], + }, + { + 'year': 2026, + 'monthCount': [0, 2], + }, + ], + descriptionBuilder: (month, year) => '$year-$month', + ); + + expect( + archives.map((archive) => '${archive.desc}:${archive.count}'), + ['2026-2:2', '2025-4:3', '2025-3:1', '2025-2:3', '2025-1:1'], + ); + }); + + test('batch removal updates counts and drops empty groups', () { + final archives = [ + ArchiveData(desc: 'new', count: 2, endTime: 0, startTime: 0), + ArchiveData(desc: 'old', count: 2, endTime: 0, startTime: 0), + ]; + + decrementLikeArchives(archives, [0, 1, 3]); + + expect(archives, hasLength(1)); + expect(archives.single.desc, 'old'); + expect(archives.single.count, 1); + }); +} diff --git a/test/loftify_file_util_test.dart b/test/loftify_file_util_test.dart new file mode 100644 index 00000000..40aa454d --- /dev/null +++ b/test/loftify_file_util_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/loftify_file_util.dart'; + +void main() { + test('download progress accumulator reserves progress for queued resources', + () { + final progress = DownloadProgressAccumulator(2); + + expect(progress.value, 0); + expect(progress.update(0, 50, 100), 0.25); + expect(progress.update(1, 25, 100), 0.375); + expect(progress.complete(0), 0.625); + expect(progress.update(1, 150, 100), 1); + }); + + test('download progress accumulator ignores invalid byte reports', () { + final progress = DownloadProgressAccumulator(2); + + expect(progress.update(0, 50, 0), 0); + expect(progress.update(-1, 50, 100), 0); + expect(progress.update(2, 50, 100), 0); + }); +} diff --git a/test/paged_data_controller_test.dart b/test/paged_data_controller_test.dart new file mode 100644 index 00000000..52c1e3da --- /dev/null +++ b/test/paged_data_controller_test.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/paged_data_controller.dart'; + +void main() { + group('PagedDataController', () { + test('refresh replaces items and adjacent pages are deduplicated', + () async { + final controller = PagedDataController( + initialCursor: 0, + keyOf: (item) => item, + loader: (cursor, refresh) async => PagedDataPage( + items: cursor == 0 ? const [1, 2] : const [2, 3], + nextCursor: cursor + 2, + hasMore: cursor == 0, + total: 3, + metadata: refresh ? 'latest' : null, + ), + ); + + expect(await controller.refresh(), IndicatorResult.success); + expect(await controller.load(), IndicatorResult.noMore); + expect(controller.items, [1, 2, 3]); + expect(controller.metadata, 'latest'); + + await controller.refresh(); + expect(controller.items, [1, 2]); + controller.dispose(); + }); + + test('failed page does not advance the committed cursor', () async { + var shouldFail = true; + final controller = PagedDataController( + initialCursor: 0, + keyOf: (item) => item, + loader: (cursor, refresh) async { + if (shouldFail) throw StateError('network'); + return PagedDataPage( + items: [cursor + 1], + nextCursor: cursor + 1, + hasMore: true, + ); + }, + ); + + expect(await controller.load(), IndicatorResult.fail); + expect(controller.cursor, 0); + expect(controller.lastError, isA()); + shouldFail = false; + expect(await controller.load(), IndicatorResult.success); + expect(controller.cursor, 1); + expect(controller.lastError, isNull); + controller.dispose(); + }); + + test('local removals keep total and no-more state in sync', () async { + final controller = PagedDataController( + initialCursor: 0, + keyOf: (item) => item, + loader: (cursor, refresh) async => const PagedDataPage( + items: [1, 2], + nextCursor: 2, + hasMore: false, + total: 2, + ), + ); + + await controller.refresh(); + expect( + controller.removeWhere( + (item) => item == 1, + updateCursor: (cursor, removedCount) => cursor - removedCount, + ), + 1, + ); + expect(controller.items, [2]); + expect(controller.total, 1); + expect(controller.cursor, 1); + expect(controller.noMore, isTrue); + controller.dispose(); + }); + + test('reset invalidates an in-flight page before starting a new source', + () async { + final firstPage = Completer>(); + var calls = 0; + final controller = PagedDataController( + initialCursor: 0, + keyOf: (item) => item, + loader: (cursor, refresh) { + calls++; + if (calls == 1) return firstPage.future; + return Future.value( + const PagedDataPage( + items: [2], + nextCursor: 1, + hasMore: false, + ), + ); + }, + ); + + final staleRequest = controller.refresh(); + controller.reset(); + expect(await controller.refresh(), IndicatorResult.success); + firstPage.complete( + const PagedDataPage( + items: [1], + nextCursor: 1, + hasMore: false, + ), + ); + expect(await staleRequest, IndicatorResult.none); + expect(controller.items, [2]); + controller.dispose(); + }); + + test('cancel pending keeps current items and ignores the stale page', + () async { + final stalePage = Completer>(); + var calls = 0; + final cursors = []; + final controller = PagedDataController( + initialCursor: 0, + keyOf: (item) => item, + loader: (cursor, refresh) { + cursors.add(cursor); + calls++; + if (calls == 1) { + return Future.value(const PagedDataPage( + items: [1, 2], + nextCursor: 2, + hasMore: true, + total: 4, + )); + } + if (calls == 2) return stalePage.future; + return Future.value(const PagedDataPage( + items: [3], + nextCursor: 2, + hasMore: false, + total: 2, + )); + }, + ); + + expect(await controller.refresh(), IndicatorResult.success); + final stale = controller.load(); + controller.cancelPending(); + expect(controller.items, [1, 2]); + expect(controller.loading, isFalse); + expect( + controller.removeWhere( + (item) => item == 1, + updateCursor: (cursor, removedCount) => cursor - removedCount, + ), + 1, + ); + expect(await controller.load(), IndicatorResult.noMore); + stalePage.complete(const PagedDataPage( + items: [99], + nextCursor: 3, + hasMore: false, + total: 4, + )); + expect(await stale, IndicatorResult.none); + expect(cursors, [0, 2, 1]); + expect(controller.items, [2, 3]); + controller.dispose(); + }); + + test('item parser isolates one malformed payload', () { + final errors = []; + final result = parsePagedDataItems( + [ + {'value': 1}, + {'bad': true}, + {'value': 2}, + ], + (json) => json['value'] as int, + onMalformed: (error, _) => errors.add(error), + ); + + expect(result, [1, 2]); + expect(errors, hasLength(1)); + }); + + test('item parser omits known unavailable entries without an error', () { + final errors = []; + final result = parsePagedDataItems( + [ + {'value': 1}, + {'value': null}, + {'value': 2}, + ], + (json) => json['value'] as int?, + onMalformed: (error, _) => errors.add(error), + ); + + expect(result, [1, 2]); + expect(errors, isEmpty); + }); + + test('metadata merger accumulates auxiliary page content', () async { + final controller = PagedDataController>( + initialCursor: 0, + keyOf: (item) => item, + loader: (cursor, refresh) async => PagedDataPage( + items: [cursor], + nextCursor: cursor + 1, + hasMore: cursor == 0, + metadata: [cursor + 10], + ), + metadataMerger: (current, incoming, refresh) => [ + ...?current, + ...?incoming, + ], + ); + + await controller.refresh(); + await controller.load(); + expect(controller.metadata, [10, 11]); + controller.dispose(); + }); + }); +} diff --git a/test/post_batch_download_resolver_test.dart b/test/post_batch_download_resolver_test.dart new file mode 100644 index 00000000..0379d610 --- /dev/null +++ b/test/post_batch_download_resolver_test.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Models/download_task.dart'; +import 'package:loftify/Models/post_detail_response.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Utils/post_batch_download_resolver.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item.dart'; + +GeneralPostItem _post({ + PostType type = PostType.image, + List photos = const [], + String content = '', + int postId = 7, +}) { + return GeneralPostItem( + type: type, + photoLinks: photos, + blogId: 3, + postId: postId, + permalink: '', + collectionId: 0, + liked: false, + blogName: 'author', + blogNickName: 'Author', + title: 'Post', + digest: '', + content: content, + firstImageUrl: '', + duration: 0, + likeCount: 0, + tags: const [], + bigAvaImg: '', + publishTime: 1, + ); +} + +PhotoLink _photo(String url) => PhotoLink( + orign: url, + raw: url, + small: url, + middle: url, + rw: 100, + rh: 100, + ow: 100, + oh: 100, + ); + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/batch_download_resolver', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + test('image cards resolve original URLs and remove duplicates', () async { + final resolver = PostBatchDownloadResolver( + detailLoader: ({required postId, required blogId, required blogName}) { + fail('Image cards with media must not fetch post details'); + }, + ); + + final resolution = await resolver.resolve(_post(photos: [ + _photo('//img.example.com/a.jpg?imageView&thumbnail=200x200'), + _photo('//img.example.com/a.jpg?imageView&thumbnail=200x200'), + _photo('https://img.example.com/b.png'), + ])); + + expect(resolution.failed, isFalse); + expect(resolution.requests, hasLength(2)); + expect(resolution.requests.first.url, startsWith('https://')); + expect(resolution.requests.map((request) => request.url).toSet(), + hasLength(2)); + }); + + test('article cards resolve inline images without a detail request', + () async { + final resolver = PostBatchDownloadResolver( + detailLoader: ({required postId, required blogId, required blogName}) { + fail('Complete article HTML must not fetch post details'); + }, + ); + + final resolution = await resolver.resolve(_post( + type: PostType.article, + content: '

Text

', + )); + + expect(resolution.failed, isFalse); + expect(resolution.requests, hasLength(1)); + expect(resolution.requests.single.fileName, endsWith('.webp')); + }); + + test('invalid cards are reported without creating queue requests', () async { + final resolver = PostBatchDownloadResolver(); + final resolution = await resolver.resolve(_post( + type: PostType.invalid, + postId: 0, + )); + + expect(resolution.failed, isTrue); + expect(resolution.requests, isEmpty); + }); + + test('video cards fetch the playable source before enqueueing', () async { + var detailRequests = 0; + final resolver = PostBatchDownloadResolver( + detailLoader: ( + {required postId, required blogId, required blogName}) async { + detailRequests++; + return { + 'meta': {'status': 200}, + 'response': { + 'posts': [ + { + 'post': { + 'id': postId, + 'blogId': blogId, + 'publisherUserId': 9, + 'type': 4, + 'valid': 1, + 'title': 'Video post', + 'embed': + '{"originUrl":"https://video.example.com/source.mp4"}', + }, + }, + ], + }, + }; + }, + ); + + final resolution = await resolver.resolve(_post(type: PostType.video)); + + expect(detailRequests, 1); + expect(resolution.failed, isFalse); + expect(resolution.requests, hasLength(1)); + expect(resolution.requests.single.mediaType, DownloadMediaType.video); + expect(resolution.requests.single.url, endsWith('source.mp4')); + }); +} diff --git a/test/post_sequence_source_test.dart b/test/post_sequence_source_test.dart new file mode 100644 index 00000000..8d2161a4 --- /dev/null +++ b/test/post_sequence_source_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Utils/post_sequence_source.dart'; + +PostSequenceEntry entry(int id, {PostType type = PostType.article}) => + PostSequenceEntry( + postId: id, + blogId: 10 + id, + blogName: 'blog-$id', + type: type, + ); + +void main() { + test('sequence keeps unique image and article posts in display order', () { + final source = PostSequenceSource(); + source.synchronize( + [ + entry(1), + entry(2, type: PostType.video), + entry(3, type: PostType.image), + entry(1), + entry(4, type: PostType.invalid), + ], + hasMore: false, + ); + + expect(source.entries.map((item) => item.postId), [1, 3]); + expect(source.canNavigateFrom(1, previous: true), isFalse); + expect(source.canNavigateFrom(1, previous: false), isTrue); + }); + + test('sequence resolves previous and next around the current post', () async { + final source = PostSequenceSource() + ..synchronize([entry(11), entry(12), entry(13)], hasMore: false); + + expect( + (await source.adjacentTo(12, previous: true))?.postId, + 11, + ); + expect( + (await source.adjacentTo(12, previous: false))?.postId, + 13, + ); + expect(await source.adjacentTo(13, previous: false), isNull); + }); + + test('concurrent next lookups share one pagination request', () async { + final release = Completer(); + var loadCount = 0; + late PostSequenceSource source; + source = PostSequenceSource( + loadMore: () async { + loadCount++; + await release.future; + source.synchronize( + [entry(21), entry(22)], + hasMore: false, + ); + }, + )..synchronize([entry(21)], hasMore: true); + + final first = source.adjacentTo(21, previous: false); + final second = source.adjacentTo(21, previous: false); + release.complete(); + + expect((await first)?.postId, 22); + expect((await second)?.postId, 22); + expect(loadCount, 1); + }); + + test('a pagination miss remains retryable', () async { + var loadCount = 0; + late PostSequenceSource source; + source = PostSequenceSource( + loadMore: () async { + loadCount++; + if (loadCount == 2) { + source.synchronize([entry(31), entry(32)], hasMore: false); + } + }, + )..synchronize([entry(31)], hasMore: true); + + expect(await source.adjacentTo(31, previous: false), isNull); + expect( + (await source.adjacentTo(31, previous: false))?.postId, + 32, + ); + expect(loadCount, 2); + }); +} diff --git a/test/post_swipe_gesture_test.dart b/test/post_swipe_gesture_test.dart new file mode 100644 index 00000000..0d4dafab --- /dev/null +++ b/test/post_swipe_gesture_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/post_swipe_gesture.dart'; + +void main() { + test('commit distance stays usable on narrow and wide layouts', () { + expect(PostSwipeGesturePolicy.commitDistance(240), 72); + expect(PostSwipeGesturePolicy.commitDistance(400), 88); + expect(PostSwipeGesturePolicy.commitDistance(1200), 128); + expect( + PostSwipeGesturePolicy.hasReachedCommitDistance( + rawOffset: 87.9, + viewportWidth: 400, + ), + isFalse, + ); + expect( + PostSwipeGesturePolicy.hasReachedCommitDistance( + rawOffset: -88, + viewportWidth: 400, + ), + isTrue, + ); + }); + + test('a deliberate drag commits only when the direction is available', () { + expect( + PostSwipeGesturePolicy.shouldCommit( + rawOffset: -88, + velocity: -100, + viewportWidth: 400, + available: true, + ), + isTrue, + ); + expect( + PostSwipeGesturePolicy.shouldCommit( + rawOffset: -120, + velocity: -100, + viewportWidth: 400, + available: false, + ), + isFalse, + ); + }); + + test('a fling needs minimum travel and matching velocity direction', () { + expect( + PostSwipeGesturePolicy.shouldCommit( + rawOffset: 40, + velocity: 1000, + viewportWidth: 400, + available: true, + ), + isTrue, + ); + expect( + PostSwipeGesturePolicy.shouldCommit( + rawOffset: 30, + velocity: 1200, + viewportWidth: 400, + available: true, + ), + isFalse, + ); + expect( + PostSwipeGesturePolicy.shouldCommit( + rawOffset: 40, + velocity: -1200, + viewportWidth: 400, + available: true, + ), + isFalse, + ); + }); + + test('first and last boundaries apply resistance instead of switching', () { + expect( + PostSwipeGesturePolicy.visualOffset( + rawOffset: 100, + viewportWidth: 400, + available: false, + ), + closeTo(22, 0.001), + ); + expect( + PostSwipeGesturePolicy.visualOffset( + rawOffset: -100, + viewportWidth: 400, + available: false, + ), + closeTo(-22, 0.001), + ); + }); + + test('edge hint appears only after a deliberate pull', () { + expect(PostSwipeGesturePolicy.hintRevealProgress(30), 0); + expect(PostSwipeGesturePolicy.hintRevealProgress(44), 0); + expect(PostSwipeGesturePolicy.hintRevealProgress(66), closeTo(0.5, 0.001)); + expect(PostSwipeGesturePolicy.hintRevealProgress(88), 1); + expect(PostSwipeGesturePolicy.hintRevealProgress(-120), 1); + }); + + test('first and last hints share the normal commit threshold', () { + expect( + PostSwipeGesturePolicy.boundaryHintRevealProgress( + rawOffset: 87.9, + viewportWidth: 400, + hasSequenceContext: true, + ), + 0, + ); + expect( + PostSwipeGesturePolicy.boundaryHintRevealProgress( + rawOffset: 88, + viewportWidth: 400, + hasSequenceContext: true, + ), + 1, + ); + expect( + PostSwipeGesturePolicy.boundaryHintRevealProgress( + rawOffset: 120, + viewportWidth: 400, + hasSequenceContext: false, + ), + 0, + ); + }); +} diff --git a/test/screens/info/favorite_folder_detail_loading_test.dart b/test/screens/info/favorite_folder_detail_loading_test.dart new file mode 100644 index 00000000..32619e07 --- /dev/null +++ b/test/screens/info/favorite_folder_detail_loading_test.dart @@ -0,0 +1,315 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/favorite_folder_detail_screen.dart'; +import 'package:loftify/Screens/Download/batch_download_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item_builder.dart'; +import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; +import 'package:loftify/l10n/l10n.dart'; + +class _Cookies extends Fake implements CookieManager {} + +void main() { + final pending = []; + final options = []; + setUpAll(() async { + final dir = Directory('build/test_hive/favorite_folder_detail_loading'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + RequestUtil.cookieManager = _Cookies(); + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + setUp(() { + appProvider.token = 'favorite-detail-test-account'; + pending.clear(); + options.clear(); + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }), + ); + }); + + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount(WidgetTester tester, + {Size size = const Size(390, 844), + Locale locale = const Locale('en'), + double textScale = 1}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: locale, + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const FavoriteFolderDetailScreen(favoriteFolderId: 42); + }), + )); + await frames(tester); + } + + void respond(int index, Map data) => pending[index] + .resolve(Response(requestOptions: options[index], data: data)); + + Map post(int id, int opTime) => { + 'liked': false, + 'opTime': opTime, + 'postData': { + 'postView': { + 'id': id, + 'blogId': 1, + 'publisherUserId': 1, + 'type': 1, + 'title': 'Story $id', + }, + 'postCountView': {}, + 'blogInfo': { + 'blogId': 1, + 'blogName': 'author', + 'blogNickName': 'Author', + 'bigAvaImg': '', + }, + }, + }; + + Map success(List> posts, + {int total = 3}) => + { + 'code': 0, + 'data': { + 'folder': { + 'id': 42, + 'name': 'Reading list', + 'postCount': total, + 'tags': [], + 'themes': [], + }, + 'posts': posts, + }, + }; + + for (final size in [const Size(280, 480), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + testWidgets('detail states fit $size $locale', (tester) async { + await mount(tester, size: size, locale: locale, textScale: 2); + expect(tester.takeException(), isNull); + respond(0, {'code': 503, 'msg': 'Unavailable'}); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + final retry = find.text(error.actionLabel!); + await tester.ensureVisible(retry); + await frames(tester); + await tester.tap(retry); + await frames(tester); + respond(1, success([post(1, 1704067200)], total: 1)); + await frames(tester); + expect(find.byType(GridPostItemWidget), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('detail retries timeout and keeps refresh widget', + (tester) async { + await mount(tester); + final refreshState = tester.state(find.byType(EasyRefresh)); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.loading); + pending[0].reject(DioException( + requestOptions: options[0], type: DioExceptionType.connectionTimeout)); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + await tester.tap(find.text(error.actionLabel!)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, success([], total: 0)); + await frames(tester); + expect(tester.state(find.byType(EasyRefresh)), same(refreshState)); + expect(find.byType(EmptyPlaceholder), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('detail keeps month labels aligned through paging and refresh', + (tester) async { + await mount(tester); + respond(0, success([post(1, 1704067200), post(2, 1706745600)])); + await frames(tester); + final january = formatLocalizedYearMonth(1704067200); + final february = formatLocalizedYearMonth(1706745600); + expect( + tester.getTopLeft(find.textContaining(january, findRichText: true)).dy, + lessThan(tester + .getTopLeft(find.textContaining(february, findRichText: true)) + .dy)); + expect(find.byType(GridPostItemWidget), findsNWidgets(2)); + final refresh = tester.widget(find.byType(EasyRefresh)); + final load = Future.sync(refresh.onLoad!); + await frames(tester); + expect(options[1].queryParameters['offset'], '2'); + respond(1, success([post(2, 1706745600), post(3, 1706745600)])); + await frames(tester); + expect(await load, IndicatorResult.noMore); + expect(find.byType(GridPostItemWidget), findsNWidgets(3)); + expect( + tester + .widgetList( + find.byType(LoftifyPostArchiveSliverGrid), + ) + .map((grid) => grid.itemCount), + [1, 2]); + final failedRefresh = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(2, {'code': 503, 'msg': 'Unavailable'}); + await frames(tester); + expect(await failedRefresh, IndicatorResult.fail); + expect(find.byType(GridPostItemWidget), findsNWidgets(3)); + final fresh = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(3, success([post(4, 1706745600)], total: 1)); + await frames(tester); + expect(await fresh, IndicatorResult.success); + expect(find.byType(GridPostItemWidget), findsOneWidget); + expect(find.textContaining(january, findRichText: true), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('detail ignores response after leaving', (tester) async { + await mount(tester); + await tester.pumpWidget(const SizedBox()); + respond(0, success([post(1, 1704067200)])); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + + testWidgets('detail discards old account response', (tester) async { + await mount(tester); + respond(0, success([post(1, 1704067200)], total: 1)); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final result = Future.sync(refresh.onRefresh!); + await frames(tester); + appProvider.token = 'other-account'; + respond(1, success([post(9, 1706745600)], total: 1)); + await frames(tester); + expect(await result, IndicatorResult.none); + expect(find.byType(GridPostItemWidget), findsNothing); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.error); + expect(tester.takeException(), isNull); + }); + + testWidgets('batch load fails visibly and retries remaining posts', + (tester) async { + await mount(tester); + respond(0, success([post(1, 1704067200)])); + await frames(tester); + tester + .widget( + find.byWidgetPredicate( + (widget) => + widget is ChewieIconButton && + widget.icon == LoftifyIcons.download, + ), + ) + .onPressed!(); + await frames(tester); + final batch = tester.widget( + find.byType(BatchDownloadScreen), + ); + expect(batch.initialItems, hasLength(1)); + final failedLoad = expectLater(batch.loadAllItems!(), throwsStateError); + await frames(tester); + expect(options[1].queryParameters['offset'], '1'); + respond(1, {'code': 503, 'msg': 'Unavailable'}); + await frames(tester); + await failedLoad; + final retry = batch.loadAllItems!(); + await frames(tester); + expect(options[2].queryParameters['offset'], '1'); + respond(2, success([post(2, 1706745600), post(3, 1706745600)])); + await frames(tester); + expect((await retry).map((item) => item.postId), [1, 2, 3]); + expect(tester.takeException(), isNull); + }); + + testWidgets('batch load rejects an incomplete server page', (tester) async { + await mount(tester); + respond(0, success([post(1, 1704067200)])); + await frames(tester); + tester + .widget( + find.byWidgetPredicate( + (widget) => + widget is ChewieIconButton && + widget.icon == LoftifyIcons.download, + ), + ) + .onPressed!(); + await frames(tester); + final batch = tester.widget( + find.byType(BatchDownloadScreen), + ); + final result = expectLater(batch.loadAllItems!(), throwsStateError); + await frames(tester); + respond(1, success([])); + await frames(tester); + await result; + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/info/favorite_folder_loading_test.dart b/test/screens/info/favorite_folder_loading_test.dart new file mode 100644 index 00000000..72352db4 --- /dev/null +++ b/test/screens/info/favorite_folder_loading_test.dart @@ -0,0 +1,333 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/favorite_folder_list_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; +import 'package:loftify/Widgets/Favorite/favorite_folder_card.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _Cookies extends Fake implements CookieManager {} + +void main() { + final pending = []; + final options = []; + + setUpAll(() async { + final dir = Directory('build/test_hive/favorite_folder_loading'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + RequestUtil.cookieManager = _Cookies(); + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + setUp(() { + appProvider.token = 'favorite-test-account'; + pending.clear(); + options.clear(); + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }), + ); + }); + + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount(WidgetTester tester, + {Size size = const Size(390, 844), + Locale locale = const Locale('en'), + bool dark = false, + double textScale = 1}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes + : ChewieThemeColorData.defaultLightThemes) + .first + .toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const FavoriteFolderListScreen(); + }), + )); + await frames(tester); + } + + void respond(int index, Map data) => pending[index] + .resolve(Response(requestOptions: options[index], data: data)); + + Map folder(int id) => { + 'id': id, + 'name': 'Folder $id', + 'coverUrl': '', + 'postCount': 1, + 'isDefault': 0, + 'tags': [], + 'themes': [], + }; + Map success(List ids, {int total = 3}) => { + 'code': 0, + 'data': { + 'createCount': total, + 'folders': ids.map(folder).toList(), + }, + }; + + for (final size in [const Size(280, 480), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + testWidgets('folder states fit $size $locale', (tester) async { + await mount(tester, size: size, locale: locale, textScale: 2); + expect(tester.takeException(), isNull); + respond(0, {'code': 503, 'msg': 'Unavailable'}); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + expect(tester.takeException(), isNull); + final retry = find.text(error.actionLabel!); + await tester.ensureVisible(retry); + await frames(tester); + await tester.tap(retry); + await frames(tester); + respond(1, success([1], total: 1)); + await frames(tester); + expect(find.byType(LoftifyFavoriteFolderCard), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('folder paging appends, deduplicates, and refresh replaces', + (tester) async { + await mount(tester); + expect(pending, hasLength(1)); + respond(0, success([1, 2])); + await frames(tester); + expect(find.byType(LoftifyFavoriteFolderCard), findsNWidgets(2)); + final refresh = tester.widget(find.byType(EasyRefresh)); + final load = Future.sync(refresh.onLoad!); + await frames(tester); + expect(pending, hasLength(2)); + expect(options[1].data, containsPair('offset', '2')); + respond(1, success([2, 3])); + await frames(tester); + expect(await load, IndicatorResult.noMore); + expect(find.byType(LoftifyFavoriteFolderCard), findsNWidgets(3)); + final failedRefresh = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(2, {'code': 503, 'msg': 'Unavailable'}); + await frames(tester); + expect(await failedRefresh, IndicatorResult.fail); + expect(find.byType(LoftifyFavoriteFolderCard), findsNWidgets(3)); + final fresh = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(3, success([4], total: 1)); + await frames(tester); + expect(await fresh, IndicatorResult.success); + expect(find.byType(LoftifyFavoriteFolderCard), findsOneWidget); + expect(find.text('Folder 4'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('folder initial failure retries in the same refresh widget', + (tester) async { + await mount(tester); + final original = tester.state(find.byType(EasyRefresh)); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.loading); + pending[0].reject(DioException( + requestOptions: options[0], type: DioExceptionType.connectionTimeout)); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + await tester.tap(find.text(error.actionLabel!)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, success([], total: 0)); + await frames(tester); + expect(tester.state(find.byType(EasyRefresh)), same(original)); + expect(find.byType(EmptyPlaceholder), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('folder ignores late response after leaving', (tester) async { + await mount(tester); + await tester.pumpWidget(const SizedBox()); + respond(0, success([1])); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + + testWidgets('folder discards old account response', (tester) async { + await mount(tester); + respond(0, success([1], total: 1)); + await frames(tester); + expect(find.byType(LoftifyFavoriteFolderCard), findsOneWidget); + final refresh = tester.widget(find.byType(EasyRefresh)); + final result = Future.sync(refresh.onRefresh!); + await frames(tester); + appProvider.token = 'other-account'; + respond(1, success([9], total: 1)); + await frames(tester); + expect(await result, IndicatorResult.none); + expect(find.byType(LoftifyFavoriteFolderCard), findsNothing); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.error); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed edit keeps the original folder name', (tester) async { + await mount(tester); + respond(0, success([1], total: 1)); + await frames(tester); + tester + .widget( + find.byType(LoftifyFavoriteFolderCard), + ) + .onEdit(); + await frames(tester); + final sheet = + tester.widget(find.byType(InputBottomSheet)); + sheet.onConfirm!('Renamed folder'); + await frames(tester); + expect(pending, hasLength(2)); + expect(find.text('Folder 1'), findsWidgets); + respond(1, {'code': 503, 'msg': 'Unavailable'}); + await frames(tester); + expect(find.text('Folder 1'), findsWidgets); + expect(find.text('Renamed folder'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('successful edit changes only the matching folder', + (tester) async { + await mount(tester); + respond(0, success([1, 2], total: 2)); + await frames(tester); + tester + .widgetList( + find.byType(LoftifyFavoriteFolderCard), + ) + .first + .onEdit(); + await frames(tester); + final sheet = + tester.widget(find.byType(InputBottomSheet)); + sheet.onConfirm!('Renamed folder'); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, {'code': 0, 'data': {}}); + await frames(tester); + expect(find.text('Renamed folder'), findsOneWidget); + expect(find.text('Folder 2'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('deletion removes the card after server success', (tester) async { + await mount(tester); + respond(0, success([1, 2], total: 2)); + await frames(tester); + tester + .widgetList( + find.byType(LoftifyFavoriteFolderCard), + ) + .first + .onDelete!(); + await frames(tester); + tester + .widget( + find.byType(CustomConfirmDialogWidget)) + .onTapConfirm(); + await frames(tester); + expect(pending, hasLength(2)); + expect(find.text('Folder 1'), findsWidgets); + respond(1, {'code': 0, 'data': {}}); + await frames(tester); + expect(find.text('Folder 1'), findsNothing); + expect(find.text('Folder 2'), findsOneWidget); + expect(pending, hasLength(3)); + respond(2, success([2], total: 1)); + await frames(tester); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed creation can be retried without duplicate requests', + (tester) async { + await mount(tester); + respond(0, success([], total: 0)); + await frames(tester); + final dynamic state = tester.state(find.byType(FavoriteFolderListScreen)); + state.handleAdd(); + await frames(tester); + final sheet = + tester.widget(find.byType(InputBottomSheet)); + sheet.onConfirm!('New folder'); + sheet.onConfirm!('New folder'); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, {'code': 503, 'msg': 'Unavailable'}); + await frames(tester); + expect(find.byType(EmptyPlaceholder), findsOneWidget); + sheet.onConfirm!('New folder'); + await frames(tester); + expect(pending, hasLength(3)); + respond(2, {'code': 0, 'data': {}}); + await frames(tester); + expect(pending, hasLength(4)); + respond(3, success([3], total: 1)); + await frames(tester); + expect(find.text('Folder 3'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/info/following_follower_screen_test.dart b/test/screens/info/following_follower_screen_test.dart new file mode 100644 index 00000000..442fb24c --- /dev/null +++ b/test/screens/info/following_follower_screen_test.dart @@ -0,0 +1,266 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/following_follower_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Widgets/Design/loftify_controls.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _Cookies extends Fake implements CookieManager {} + +void main() { + final pending = []; + final options = []; + + setUpAll(() async { + final directory = Directory('build/test_hive/following_follower_screen'); + await directory.create(recursive: true); + Hive.init(directory.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.userInfoKey, { + 'blogId': 1, + 'blogName': 'author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }); + RequestUtil.cookieManager = _Cookies(); + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + + setUp(() { + appProvider.token = 'relation-test-account'; + pending.clear(); + options.clear(); + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }), + ); + }); + + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount( + WidgetTester tester, { + FollowingMode mode = FollowingMode.following, + int total = 1, + Size size = const Size(390, 844), + Locale locale = const Locale('en'), + double textScale = 1, + bool dark = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(textScale), + ), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return FollowingFollowerScreen(total: total, followingMode: mode); + }), + )); + await frames(tester); + } + + void respond(int index, List> users) => + pending[index].resolve(Response(requestOptions: options[index], data: { + 'meta': {'status': 200}, + 'response': users, + })); + + Map user(int id) => { + 'blogId': id, + 'blogInfo': { + 'blogId': id, + 'blogName': 'author$id', + 'blogNickName': 'Creator $id', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }, + 'follower': true, + 'following': true, + }; + + testWidgets('timeout shows retry inside the same refresh container', + (tester) async { + await mount(tester); + final original = tester.state(find.byType(EasyRefresh)); + expect(options.single.path, contains('userfollowing.api')); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.loading, + ); + pending[0].reject(DioException( + requestOptions: options[0], + type: DioExceptionType.connectionTimeout, + )); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + await tester.tap(find.text(error.actionLabel!)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, [user(1)]); + await frames(tester); + expect(tester.state(find.byType(EasyRefresh)), same(original)); + expect(find.text('Creator 1'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('follower mode uses its endpoint and empty result is stable', + (tester) async { + await mount(tester, mode: FollowingMode.follower, total: 0); + expect(options.single.path, contains('blogfollower.api')); + respond(0, []); + await frames(tester); + expect(find.byType(LoftifyStateView), findsNothing); + expect(find.byType(EmptyPlaceholder), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('phone relation items keep the action beside user details', + (tester) async { + await mount(tester); + respond(0, [user(1)]); + await frames(tester); + final card = find.byKey(const ValueKey('loftify-relation-row-1')); + final action = find.byType(LoftifyCompactToggleButton); + expect(card, findsOneWidget); + expect(action, findsOneWidget); + expect(tester.getTopLeft(card).dx, 0); + expect(find.byType(Divider), findsNothing); + expect(tester.getSize(card).height, lessThan(150)); + expect(tester.getTopLeft(action).dx, + greaterThan(tester.getTopLeft(find.text('Creator 1')).dx)); + expect(tester.getTopLeft(action).dy, + lessThan(tester.getBottomLeft(card).dy - 40)); + final rowInk = tester.widget( + find.descendant(of: card, matching: find.byType(InkWell)).first, + ); + expect(rowInk.splashFactory, NoSplash.splashFactory); + final pressed = rowInk.overlayColor!.resolve({WidgetState.pressed}); + expect(pressed!.a, greaterThan(0)); + expect(pressed.a, lessThan(0.1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('duplicate-only page advances the raw server offset', + (tester) async { + await mount(tester, total: 4); + respond(0, [user(1)]); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final firstLoad = Future.sync(refresh.onLoad!); + await frames(tester); + expect(options[1].queryParameters['offset'], 1); + respond(1, [user(1)]); + await frames(tester); + expect(await firstLoad, IndicatorResult.success); + final secondLoad = Future.sync(refresh.onLoad!); + await frames(tester); + expect(options[2].queryParameters['offset'], 2); + respond(2, [user(2), user(3)]); + await frames(tester); + expect(await secondLoad, IndicatorResult.noMore); + expect(find.text('Creator 1'), findsOneWidget); + expect(find.text('Creator 2'), findsOneWidget); + expect(find.text('Creator 3'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'new refresh supersedes a pending page and keeps old cards on error', + (tester) async { + await mount(tester, total: 3); + respond(0, [user(1)]); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final load = Future.sync(refresh.onLoad!); + await frames(tester); + final newRefresh = Future.sync(refresh.onRefresh!); + await frames(tester); + expect(options[2].queryParameters['offset'], 0); + pending[2].reject(DioException( + requestOptions: options[2], + type: DioExceptionType.connectionTimeout, + )); + await frames(tester); + expect(await newRefresh, IndicatorResult.fail); + expect(find.text('Creator 1'), findsOneWidget); + respond(1, [user(2)]); + await frames(tester); + expect(await load, IndicatorResult.none); + expect(find.text('Creator 2'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('loading and empty states fit narrow dark large-text layout', + (tester) async { + await mount(tester, + size: const Size(280, 480), + locale: const Locale('zh', 'TW'), + textScale: 2, + dark: true); + expect(tester.takeException(), isNull); + respond(0, []); + await frames(tester); + expect(find.byType(EmptyPlaceholder), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/info/grain_screen_loading_test.dart b/test/screens/info/grain_screen_loading_test.dart new file mode 100644 index 00000000..88277f15 --- /dev/null +++ b/test/screens/info/grain_screen_loading_test.dart @@ -0,0 +1,451 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/grain_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _Cookies extends Fake implements CookieManager {} + +void main() { + final pending = []; + final options = []; + + setUpAll(() async { + final dir = Directory('build/test_hive/grain_screen_loading'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.userInfoKey, { + 'blogId': 1, + 'blogName': 'author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }); + RequestUtil.cookieManager = _Cookies(); + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + + setUp(() { + appProvider.token = 'grain-test-account'; + pending.clear(); + options.clear(); + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }), + ); + }); + + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount( + WidgetTester tester, { + Size size = const Size(390, 844), + Locale locale = const Locale('en'), + double textScale = 1, + bool dark = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(textScale), + ), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return GrainScreen(); + }), + )); + await frames(tester); + } + + void respond(int index, Map data) => pending[index] + .resolve(Response(requestOptions: options[index], data: data)); + + Map grain(int id) => { + 'addPostTime': 0, + 'coverUrl': '', + 'createTime': 0, + 'endTime': 0, + 'exposure': 0, + 'greatLevel': 0, + 'hotCount': 0, + 'hotPlanType': 0, + 'id': id, + 'joinCount': 0, + 'lastSubscribeTime': 0, + 'name': 'A very long collection of stories $id', + 'planStatus': 0, + 'postCount': 120, + 'status': 0, + 'subscribedCount': 0, + 'tags': ['long-tag-name', 'another-long-tag-name'], + 'type': 0, + 'updateTime': 0, + 'userId': 1, + 'viewCount': 0, + }; + + testWidgets('first load timeout shows retry without replacing refresh state', + (tester) async { + await mount(tester); + final original = tester.state(find.byType(EasyRefresh)); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.loading, + ); + pending[0].reject(DioException( + requestOptions: options[0], + type: DioExceptionType.connectionTimeout, + )); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + await tester.tap(find.text(error.actionLabel!)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(1)] + }, + }); + await frames(tester); + expect(tester.state(find.byType(EasyRefresh)), same(original)); + expect(find.text('A very long collection of stories 1'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('grain loading and retry fit narrow dark large-text layout', + (tester) async { + await mount(tester, + size: const Size(280, 480), + locale: const Locale('zh', 'TW'), + textScale: 2, + dark: true); + expect(tester.takeException(), isNull); + pending[0].reject(DioException( + requestOptions: options[0], + type: DioExceptionType.connectionTimeout, + )); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + expect(find.text(error.actionLabel!).hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed refresh keeps grain cards until a successful retry', + (tester) async { + await mount(tester); + respond(0, { + 'code': 0, + 'data': { + 'total': 2, + 'grains': [grain(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final failed = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(1, {'code': 503, 'msg': 'Unavailable'}); + await frames(tester); + expect(await failed, IndicatorResult.fail); + expect(find.text('A very long collection of stories 1'), findsOneWidget); + final retry = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(2, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(2)] + }, + }); + await frames(tester); + expect(await retry, IndicatorResult.success); + expect(find.text('A very long collection of stories 1'), findsNothing); + expect(find.text('A very long collection of stories 2'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('grain pagination advances raw offset and deduplicates IDs', + (tester) async { + await mount(tester); + respond(0, { + 'code': 0, + 'data': { + 'total': 3, + 'grains': [grain(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final load = Future.sync(refresh.onLoad!); + await frames(tester); + expect(options[1].queryParameters['offset'], 1); + respond(1, { + 'code': 0, + 'data': { + 'total': 3, + 'grains': [grain(1), grain(2)] + }, + }); + await frames(tester); + expect(await load, IndicatorResult.noMore); + expect(find.text('A very long collection of stories 1'), findsOneWidget); + expect(find.text('A very long collection of stories 2'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('duplicate-only grain page still advances to the next offset', + (tester) async { + await mount(tester); + respond(0, { + 'code': 0, + 'data': { + 'total': 4, + 'grains': [grain(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final duplicatePage = Future.sync(refresh.onLoad!); + await frames(tester); + respond(1, { + 'code': 0, + 'data': { + 'total': 4, + 'grains': [grain(1)] + }, + }); + await frames(tester); + expect(await duplicatePage, IndicatorResult.success); + final nextPage = Future.sync(refresh.onLoad!); + await frames(tester); + expect(options[2].queryParameters['offset'], 2); + respond(2, { + 'code': 0, + 'data': { + 'total': 4, + 'grains': [grain(2), grain(3)] + }, + }); + await frames(tester); + expect(await nextPage, IndicatorResult.noMore); + expect(find.text('A very long collection of stories 1'), findsOneWidget); + expect(find.text('A very long collection of stories 2'), findsOneWidget); + expect(find.text('A very long collection of stories 3'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('refresh supersedes an unfinished grain pagination request', + (tester) async { + await mount(tester); + respond(0, { + 'code': 0, + 'data': { + 'total': 3, + 'grains': [grain(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final stalePage = Future.sync(refresh.onLoad!); + await frames(tester); + final fresh = Future.sync(refresh.onRefresh!); + await frames(tester); + expect(pending, hasLength(3)); + expect(options[2].queryParameters['offset'], 0); + respond(2, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(9)] + }, + }); + await frames(tester); + expect(await fresh, IndicatorResult.success); + respond(1, { + 'code': 0, + 'data': { + 'total': 3, + 'grains': [grain(2)] + }, + }); + await frames(tester); + expect(await stalePage, IndicatorResult.none); + expect(find.text('A very long collection of stories 9'), findsOneWidget); + expect(find.text('A very long collection of stories 2'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('new account can load while the previous account is pending', + (tester) async { + await mount(tester); + respond(0, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final stale = Future.sync(refresh.onRefresh!); + await frames(tester); + appProvider.token = 'second-account'; + final current = Future.sync(refresh.onRefresh!); + await frames(tester); + expect(pending, hasLength(3)); + respond(2, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(9)] + }, + }); + await frames(tester); + expect(await current, IndicatorResult.success); + respond(1, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(2)] + }, + }); + await frames(tester); + expect(await stale, IndicatorResult.none); + expect(find.text('A very long collection of stories 1'), findsNothing); + expect(find.text('A very long collection of stories 9'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('grain response from old account is discarded', (tester) async { + await mount(tester); + respond(0, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final stale = Future.sync(refresh.onRefresh!); + await frames(tester); + appProvider.token = 'another-account'; + respond(1, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(9)] + }, + }); + await frames(tester); + expect(await stale, IndicatorResult.none); + expect(find.text('A very long collection of stories 9'), findsNothing); + expect(find.byType(LoftifyStateView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('large grain list builds only nearby rows', (tester) async { + await mount(tester); + respond(0, { + 'code': 0, + 'data': { + 'total': 300, + 'grains': [for (var id = 1; id <= 300; id++) grain(id)], + }, + }); + await frames(tester); + expect( + find + .textContaining('A very long collection of stories') + .evaluate() + .length, + lessThan(60), + ); + expect(tester.takeException(), isNull); + }); + + for (final size in [const Size(280, 480), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW'), + ]) { + for (final dark in [false, true]) { + testWidgets('grain card fits $size $locale dark=$dark at 2x text', + (tester) async { + await mount(tester, + size: size, locale: locale, textScale: 2, dark: dark); + respond(0, { + 'code': 0, + 'data': { + 'total': 1, + 'grains': [grain(1)] + }, + }); + await frames(tester); + expect( + find.text('A very long collection of stories 1'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + } +} diff --git a/test/screens/info/history_loading_test.dart b/test/screens/info/history_loading_test.dart new file mode 100644 index 00000000..ae18208e --- /dev/null +++ b/test/screens/info/history_loading_test.dart @@ -0,0 +1,519 @@ +import 'dart:io'; +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/history_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item_builder.dart'; +import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; + +class _Cookies extends Fake implements CookieManager {} + +void main() { + final pending = []; + final options = []; + setUpAll(() async { + final dir = Directory('build/test_hive/history_loading'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.userInfoKey, { + 'blogId': 1, + 'blogName': 'author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }); + RequestUtil.cookieManager = _Cookies(); + appProvider.token = 'test-account'; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + }); + setUp(() { + pending.clear(); + options.clear(); + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors + .add(InterceptorsWrapper(onRequest: (request, handler) { + options.add(request); + pending.add(handler); + })); + }); + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount( + WidgetTester tester, { + Size size = const Size(390, 844), + Locale locale = const Locale('en'), + bool dark = false, + double textScale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes + : ChewieThemeColorData.defaultLightThemes) + .first + .toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const HistoryScreen(); + }), + )); + await frames(tester); + } + + void respond(int index, Map data) => pending[index] + .resolve(Response(requestOptions: options[index], data: data)); + testWidgets('long history builds only nearby grid cells', (tester) async { + await mount(tester, size: const Size(280, 480), textScale: 2); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 300, + 'recordHistory': 1, + 'archiveData': [ + { + 'count': 300, + 'desc': 'September 2026 previously viewed creative works', + 'startTime': 0, + 'endTime': 1 + }, + ], + 'items': [ + for (var id = 1; id <= 300; id++) + { + 'post': { + 'id': id, + 'blogId': 1, + 'publisherUserId': 1, + 'type': 1, + 'title': 'Story $id', + 'blogInfo': { + 'blogId': 1, + 'blogName': 'author', + 'blogNickName': 'Author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }, + } + } + ], + }, + }); + await frames(tester); + final cells = find.byType(GridPostItemWidget); + expect(cells.evaluate().length, greaterThan(0)); + expect(cells.evaluate().length, lessThan(60)); + expect(tester.takeException(), isNull); + final last = find.byWidgetPredicate( + (widget) => widget is GridPostItemWidget && widget.item.postId == 300); + await tester.scrollUntilVisible(last, 800, + scrollable: find + .descendant( + of: find.byType(EasyRefresh), matching: find.byType(Scrollable)) + .first, + maxScrolls: 50); + await frames(tester); + expect(last, findsOneWidget); + expect(cells.evaluate().length, lessThan(60)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + for (final size in [const Size(280, 480), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final dark in [false, true]) { + testWidgets('history state layout $size $locale dark=$dark', + (tester) async { + await mount(tester, + size: size, locale: locale, dark: dark, textScale: 2); + expect(tester.takeException(), isNull); + expect(pending, hasLength(1)); + respond(0, { + 'meta': {'status': 503, 'msg': 'Unavailable'} + }); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + expect(tester.takeException(), isNull); + final retry = find.text(error.actionLabel!); + await tester.ensureVisible(retry); + await frames(tester); + await tester.tap(retry); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 0, + 'recordHistory': 1, + 'archiveData': [], + 'items': [], + } + }); + await frames(tester); + expect(find.byType(EmptyPlaceholder), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + } + } + } + for (final categoryCounts in [ + [], + [1], + [2, 1] + ]) { + testWidgets('history retains all rows with categories $categoryCounts', + (tester) async { + Map post(int id) => { + 'post': { + 'id': id, + 'blogId': 1, + 'publisherUserId': 1, + 'type': 1, + 'title': 'Story $id', + 'blogInfo': { + 'blogId': 1, + 'blogName': 'author', + 'blogNickName': 'Author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false + }, + } + }; + Map response(List ids, + {bool includeCategories = true}) => + { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'recordHistory': 1, + if (includeCategories) + 'archiveData': [ + for (var i = 0; i < categoryCounts.length; i++) + { + 'count': categoryCounts[i], + 'desc': 'Category $i', + 'startTime': 0, + 'endTime': 1 + } + ], + 'items': ids.map(post).toList(), + }, + }; + List visibleIds() => tester + .widgetList(find.byType(GridPostItemWidget)) + .map((item) => item.item.postId) + .toList(); + await mount(tester); + respond(0, response([1, 2])); + await frames(tester); + expect(visibleIds(), [1, 2]); + expect( + tester + .widgetList( + find.byType(LoftifyPostArchiveSliverGrid)) + .every((grid) => grid.itemCount > 0), + isTrue); + final refresh = tester.widget(find.byType(EasyRefresh)); + final load = Future.sync(refresh.onLoad!); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, response([3], includeCategories: false)); + await frames(tester); + expect(await load, IndicatorResult.noMore); + expect(visibleIds(), [1, 2, 3]); + final invalidRefresh = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(2, { + 'meta': {'status': 200}, + 'response': { + 'count': 9, + 'recordHistory': 1, + 'archiveData': [ + { + 'count': 9, + 'desc': 'Invalid replacement category', + 'startTime': 0, + 'endTime': 1 + } + ], + 'items': [post(8), 42], + } + }); + await frames(tester); + expect(await invalidRefresh, IndicatorResult.fail); + expect(visibleIds(), [1, 2, 3]); + expect( + find.textContaining('Invalid replacement category', + findRichText: true), + findsNothing); + final fresh = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(3, response([9], includeCategories: false)); + await frames(tester); + expect(await fresh, IndicatorResult.success); + expect(visibleIds(), [9]); + expect(find.textContaining('Category', findRichText: true), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + } + + for (final withCategories in [false, true]) { + testWidgets( + 'clearing invalid history preserves categories present=$withCategories', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 4, + 'recordHistory': 1, + 'archiveData': withCategories + ? [ + for (var i = 0; i < 2; i++) + { + 'count': 2, + 'desc': 'Category $i', + 'startTime': 0, + 'endTime': 1 + } + ] + : [], + 'items': [ + { + 'post': {'id': 1} + }, + { + 'post': {'id': 2} + }, + for (final id in [3, 4]) + { + 'post': { + 'id': id, + 'blogId': 1, + 'publisherUserId': 1, + 'title': 'Story $id', + 'blogInfo': { + 'blogId': 1, + 'blogName': 'author', + 'blogNickName': 'Author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false + }, + } + }, + ], + } + }); + await frames(tester); + final dynamic state = tester.state(find.byType(HistoryScreen)); + // Exercise local cleanup only; do not invoke the server's delete API. + state.clearInvalidHistory(); + await frames(tester); + expect( + tester + .widgetList(find.byType(GridPostItemWidget)) + .map((widget) => widget.item.postId), + [3, 4]); + if (withCategories) { + expect(find.textContaining('Category 0', findRichText: true), + findsNothing); + expect(find.textContaining('Category 1', findRichText: true), + findsOneWidget); + } + state.clearInvalidHistory(); + await frames(tester); + expect( + tester + .widgetList( + find.byType(LoftifyPostArchiveSliverGrid)) + .map((grid) => grid.itemCount), + [2]); + expect(pending, hasLength(1)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + } + + for (final timeout in [false, true]) { + testWidgets( + 'history retries without restarting initial refresh timeout=$timeout', + (tester) async { + await mount(tester); + final refreshState = tester.state(find.byType(EasyRefresh)); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.loading); + expect(pending, hasLength(1)); + if (timeout) { + pending.first.reject(DioException( + requestOptions: options.first, + type: DioExceptionType.connectionTimeout)); + } else { + respond(0, { + 'meta': {'status': 503, 'desc': 'Unavailable'} + }); + } + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + await tester.tap(find.text(error.actionLabel!)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 0, + 'recordHistory': 1, + 'archiveData': [], + 'items': [] + } + }); + await frames(tester); + expect(tester.state(find.byType(EasyRefresh)), same(refreshState)); + expect(pending, hasLength(2)); + expect(find.byType(EmptyPlaceholder), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + } + testWidgets('history ignores response after leaving', (tester) async { + await mount(tester); + await tester.pumpWidget(const SizedBox()); + respond(0, { + 'meta': {'status': 503, 'desc': 'Unavailable'} + }); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + for (final nextToken in ['', 'another-test-account']) { + testWidgets('history discards old account response token=$nextToken', + (tester) async { + appProvider.token = 'test-account'; + addTearDown(() => appProvider.token = 'test-account'); + await mount(tester); + appProvider.token = nextToken; + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 1, + 'recordHistory': 1, + 'archiveData': [ + { + 'count': 1, + 'desc': 'Previous account history', + 'startTime': 0, + 'endTime': 1 + } + ], + 'items': [ + { + 'post': {'id': 99} + } + ], + }, + }); + await frames(tester); + expect(find.byType(GridPostItemWidget), findsNothing); + expect( + find.textContaining('Previous account history', findRichText: true), + findsNothing); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.error); + final refresh = tester.widget(find.byType(EasyRefresh)); + final result = Future.sync(refresh.onRefresh!); + await frames(tester); + if (nextToken.isEmpty) { + expect(await result, IndicatorResult.fail); + expect(pending, hasLength(1)); + } else { + expect(pending, hasLength(2)); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 0, + 'recordHistory': 1, + 'archiveData': [], + 'items': [] + } + }); + await frames(tester); + expect(await result, IndicatorResult.success); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + } +} diff --git a/test/screens/info/like_batch_loading_test.dart b/test/screens/info/like_batch_loading_test.dart new file mode 100644 index 00000000..d686475e --- /dev/null +++ b/test/screens/info/like_batch_loading_test.dart @@ -0,0 +1,368 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Download/batch_download_screen.dart'; +import 'package:loftify/Screens/Info/like_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _Cookies extends Fake implements CookieManager {} + +void main() { + final pending = []; + final options = []; + + setUpAll(() async { + final dir = Directory('build/test_hive/like_batch_loading'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.userInfoKey, { + 'blogId': 1, + 'blogName': 'author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }); + RequestUtil.cookieManager = _Cookies(); + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + + setUp(() { + appProvider.token = 'like-batch-test-account'; + pending.clear(); + options.clear(); + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }), + ); + }); + + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount( + WidgetTester tester, { + Size size = const Size(390, 844), + Locale locale = const Locale('en'), + double textScale = 1, + InfoMode infoMode = InfoMode.me, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: locale, + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(textScale), + ), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return LikeScreen(infoMode: infoMode, blogName: 'author'); + }), + )); + await frames(tester); + } + + void respond(int index, Map data) => pending[index] + .resolve(Response(requestOptions: options[index], data: data)); + + Map post(int id) => { + 'post': { + 'id': id, + 'blogId': 1, + 'publisherUserId': 1, + 'type': 1, + 'title': 'Story $id', + 'blogInfo': { + 'blogId': 1, + 'blogName': 'author', + 'blogNickName': 'Author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }, + }, + }; + + Future openBatchDownload(WidgetTester tester) async { + tester.widget(find.byType(ShadowIconButton)).onTap!(); + await frames(tester); + final label = AppLocalizations.of( + tester.element(find.byType(LikeScreen)), + )! + .batchDownload; + await tester.tap(find.text(label)); + await frames(tester); + return tester.widget(find.byType(BatchDownloadScreen)); + } + + for (final size in [const Size(280, 480), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW'), + ]) { + testWidgets('like batch entry fits $size $locale at 2x text', + (tester) async { + await mount(tester, size: size, locale: locale, textScale: 2); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 1, + 'items': [post(1)] + }, + }); + await frames(tester); + expect(tester.takeException(), isNull); + final batch = await openBatchDownload(tester); + expect(batch.initialItems.map((item) => item.postId), [1]); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('failed like batch page retries without returning a partial set', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(1)] + }, + }); + await frames(tester); + final batch = await openBatchDownload(tester); + expect(batch.initialItems, hasLength(1)); + final failed = expectLater(batch.loadAllItems!(), throwsStateError); + await frames(tester); + expect(options[1].queryParameters['offset'], 1); + respond(1, { + 'meta': {'status': 503, 'msg': 'Unavailable'} + }); + await frames(tester); + await failed; + final retry = batch.loadAllItems!(); + await frames(tester); + expect(options[2].queryParameters['offset'], 1); + respond(2, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(2), post(3)] + }, + }); + await frames(tester); + expect((await retry).map((item) => item.postId), [1, 2, 3]); + expect(tester.takeException(), isNull); + }); + + testWidgets('early empty like page is not considered fully loaded', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(1)] + }, + }); + await frames(tester); + final batch = await openBatchDownload(tester); + final failed = expectLater(batch.loadAllItems!(), throwsStateError); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': {'count': 3, 'items': []}, + }); + await frames(tester); + await failed; + expect(tester.takeException(), isNull); + }); + + testWidgets('duplicate like page is not considered fully loaded', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(1)] + }, + }); + await frames(tester); + final batch = await openBatchDownload(tester); + final failed = expectLater(batch.loadAllItems!(), throwsStateError); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(1), post(2)] + }, + }); + await frames(tester); + await failed; + expect(tester.takeException(), isNull); + }); + + testWidgets('other authors likes do not offer current account cleanup', + (tester) async { + await mount(tester, infoMode: InfoMode.other); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 1, + 'items': [post(1)] + }, + }); + await frames(tester); + tester.widget(find.byType(ShadowIconButton)).onTap!(); + await frames(tester); + final labels = + AppLocalizations.of(tester.element(find.byType(LikeScreen)))!; + expect(find.text(labels.batchDownload), findsOneWidget); + expect(find.text(labels.clearInvalidContent), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('late cleanup response from old account does not mutate likes', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 1, + 'items': [{}], + }, + }); + await frames(tester); + expect( + tester + .widget( + find.byType(LoftifyPostArchiveSliverGrid), + ) + .itemCount, + 1, + ); + tester.widget(find.byType(ShadowIconButton)).onTap!(); + await frames(tester); + final clearLabel = AppLocalizations.of( + tester.element(find.byType(LikeScreen)), + )! + .clearInvalidContent; + await tester.tap(find.text(clearLabel)); + await frames(tester); + expect(options[1].path, '/v2.0/batchData.api'); + appProvider.token = 'another-account'; + respond(1, { + 'meta': {'status': 200} + }); + await frames(tester); + expect( + tester + .widget( + find.byType(LoftifyPostArchiveSliverGrid), + ) + .itemCount, + 1, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('cleanup does not restore items from an older page request', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [{}, post(2)], + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final stalePage = Future.sync(refresh.onLoad!); + await frames(tester); + expect(pending, hasLength(2)); + tester.widget(find.byType(ShadowIconButton)).onTap!(); + await frames(tester); + final clearLabel = AppLocalizations.of( + tester.element(find.byType(LikeScreen)), + )! + .clearInvalidContent; + await tester.tap(find.text(clearLabel)); + await frames(tester); + expect(options[2].path, '/v2.0/batchData.api'); + respond(2, { + 'meta': {'status': 200} + }); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(3)] + }, + }); + await frames(tester); + expect(await stalePage, IndicatorResult.none); + expect( + tester + .widget( + find.byType(LoftifyPostArchiveSliverGrid), + ) + .itemCount, + 1, + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/info/post_screen_loading_test.dart b/test/screens/info/post_screen_loading_test.dart new file mode 100644 index 00000000..a35270f2 --- /dev/null +++ b/test/screens/info/post_screen_loading_test.dart @@ -0,0 +1,464 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/post_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; +import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item_builder.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _Cookies extends Fake implements CookieManager {} + +void main() { + final pending = []; + final options = []; + + setUpAll(() async { + final dir = Directory('build/test_hive/post_screen_loading'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.userInfoKey, { + 'blogId': 1, + 'blogName': 'author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }); + RequestUtil.cookieManager = _Cookies(); + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + + setUp(() { + appProvider.token = 'post-test-account'; + pending.clear(); + options.clear(); + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }), + ); + }); + + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount( + WidgetTester tester, { + Size size = const Size(390, 844), + Locale locale = const Locale('en'), + double textScale = 1, + bool dark = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(textScale), + ), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return PostScreen(); + }), + )); + await frames(tester); + } + + Future mountNested(WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: ExtendedNestedScrollView( + onlyOneScrollInBody: true, + headerSliverBuilder: (context, _) => [ + const SliverAppBar( + expandedHeight: 350, + pinned: true, + bottom: PreferredSize( + preferredSize: Size.fromHeight(56), + child: SizedBox(height: 56, child: Text('Tabs')), + ), + ), + ], + body: PostScreen( + infoMode: InfoMode.other, + blogId: 1, + blogName: 'author', + nested: true, + ), + ), + ); + }), + )); + await frames(tester); + } + + void respond(int index, Map data) => pending[index] + .resolve(Response(requestOptions: options[index], data: data)); + + Map post(int id) => { + 'post': { + 'id': id, + 'blogId': 1, + 'publisherUserId': 1, + 'type': 1, + 'title': 'Story $id', + 'blogInfo': { + 'blogId': 1, + 'blogName': 'author', + 'blogNickName': 'Author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }, + }, + }; + + testWidgets('first post request timeout shows retry and can recover', + (tester) async { + await mount(tester); + expect(pending, hasLength(1)); + final refreshState = tester.state(find.byType(EasyRefresh)); + pending[0].reject(DioException( + requestOptions: options[0], + type: DioExceptionType.connectionTimeout, + )); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + await tester.tap(find.text(error.actionLabel!)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(1)] + }, + }); + await frames(tester); + expect(tester.state(find.byType(EasyRefresh)), same(refreshState)); + expect(find.byType(LoftifyPostArchiveSliverGrid), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('posts render when archive metadata is absent', (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(1)] + }, + }); + await frames(tester); + expect(find.byType(LoftifyPostArchiveSliverGrid), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('nested profile pull refreshes posts and ends after response', + (tester) async { + await mountNested(tester); + expect(pending, hasLength(1)); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(1)] + }, + }); + await frames(tester); + await tester.dragFrom(const Offset(200, 650), const Offset(0, 430)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(2)] + }, + }); + await frames(tester); + final inner = tester.state( + find.byType(Scrollable).last, + ); + expect(inner.position.pixels, closeTo(0, 0.1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('equal monthly post counts keep separate headings', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'archives': [ + { + 'year': 2024, + 'monthCount': [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + } + ], + 'posts': [post(1), post(2)] + }, + }); + await frames(tester); + final l10n = AppLocalizations.of(tester.element(find.byType(PostScreen)))!; + expect(find.textContaining(l10n.yearAndMonth(1, 2024)), findsOneWidget); + expect(find.textContaining(l10n.yearAndMonth(2, 2024)), findsOneWidget); + expect(find.byType(LoftifyPostArchiveSliverGrid), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); + + testWidgets('pinned post is not duplicated and does not shift page offset', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'topPost': post(1), + 'archives': [ + { + 'year': 2024, + 'monthCount': [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + } + ], + 'posts': [post(1), post(2)] + }, + }); + await frames(tester); + final grids = tester + .widgetList( + find.byType(LoftifyPostArchiveSliverGrid), + ) + .toList(); + expect(grids.map((grid) => grid.itemCount), [1, 1]); + expect(find.byType(GridPostItemWidget), findsNWidgets(2)); + final refresh = tester.widget(find.byType(EasyRefresh)); + final load = Future.sync(refresh.onLoad!); + await frames(tester); + expect(options[1].queryParameters['offset'], 2); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(3)] + }, + }); + await frames(tester); + expect(await load, IndicatorResult.success); + expect(find.byType(GridPostItemWidget), findsNWidgets(3)); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed post refresh preserves existing content', (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final failed = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(1, { + 'meta': {'status': 503, 'msg': 'Unavailable'} + }); + await frames(tester); + expect(await failed, IndicatorResult.fail); + expect(find.byType(GridPostItemWidget), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('post refresh supersedes unfinished pagination', (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final stale = Future.sync(refresh.onLoad!); + await frames(tester); + final fresh = Future.sync(refresh.onRefresh!); + await frames(tester); + expect(pending, hasLength(3)); + respond(2, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(9)] + }, + }); + await frames(tester); + expect(await fresh, IndicatorResult.success); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(2)] + }, + }); + await frames(tester); + expect(await stale, IndicatorResult.none); + expect(find.byType(GridPostItemWidget), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('old account post response cannot replace a new account', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final stale = Future.sync(refresh.onRefresh!); + await frames(tester); + appProvider.token = 'second-account'; + final current = Future.sync(refresh.onRefresh!); + await frames(tester); + expect(pending, hasLength(3)); + respond(2, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(9)] + }, + }); + await frames(tester); + expect(await current, IndicatorResult.success); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(2)] + }, + }); + await frames(tester); + expect(await stale, IndicatorResult.none); + expect(find.byKey(const ValueKey(9)), findsOneWidget); + expect(find.byKey(const ValueKey(1)), findsNothing); + expect(find.byKey(const ValueKey(2)), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('post error and retry fit narrow dark large-text layout', + (tester) async { + await mount(tester, + size: const Size(280, 480), + locale: const Locale('zh', 'TW'), + textScale: 2, + dark: true); + expect(tester.takeException(), isNull); + respond(0, { + 'meta': {'status': 503, 'msg': 'Unavailable'} + }); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + expect(find.text(error.actionLabel!).hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('large ungrouped post archive is lazily built', (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'posts': [for (var id = 1; id <= 300; id++) post(id)] + }, + }); + await frames(tester); + expect(find.byType(GridPostItemWidget).evaluate().length, lessThan(45)); + expect(tester.takeException(), isNull); + }); + + for (final size in [const Size(280, 480), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW'), + ]) { + for (final dark in [false, true]) { + testWidgets('post states fit $size $locale dark=$dark at 2x text', + (tester) async { + await mount(tester, + size: size, locale: locale, textScale: 2, dark: dark); + expect(tester.takeException(), isNull); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'posts': [post(1)] + }, + }); + await frames(tester); + expect(find.byType(LoftifyPostArchiveSliverGrid), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + } +} diff --git a/test/screens/info/profile_nested_refresh_test.dart b/test/screens/info/profile_nested_refresh_test.dart new file mode 100644 index 00000000..6aa8599f --- /dev/null +++ b/test/screens/info/profile_nested_refresh_test.dart @@ -0,0 +1,55 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Screens/Info/nested_mixin.dart'; + +void main() { + testWidgets('profile body pull reveals the refresh header', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var refreshes = 0; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ExtendedNestedScrollView( + onlyOneScrollInBody: true, + headerSliverBuilder: (context, _) => [ + const SliverAppBar( + expandedHeight: 500, + pinned: true, + bottom: PreferredSize( + preferredSize: Size.fromHeight(56), + child: SizedBox(height: 56, child: Text('Tabs')), + ), + ), + ], + body: EasyRefresh.builder( + header: buildNestedRefreshHeader(), + onRefresh: () async { + refreshes++; + }, + childBuilder: (context, physics) => CustomScrollView( + physics: physics, + slivers: [ + SliverList.builder( + itemCount: 20, + itemBuilder: (context, index) => SizedBox( + height: 100, + child: Text('Item $index'), + ), + ), + ], + ), + ), + ), + ), + )); + await tester.pump(); + await tester.drag(find.text('Item 0'), const Offset(0, 430)); + await tester.pumpAndSettle(); + expect(refreshes, 1); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/info/share_grouping_test.dart b/test/screens/info/share_grouping_test.dart new file mode 100644 index 00000000..134b68cf --- /dev/null +++ b/test/screens/info/share_grouping_test.dart @@ -0,0 +1,631 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/share_screen.dart'; +import 'package:loftify/Screens/Download/batch_download_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item_builder.dart'; +import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _Cookies extends Fake implements CookieManager {} + +void main() { + final pending = []; + final options = []; + setUpAll(() async { + final dir = Directory('build/test_hive/share_grouping'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.userInfoKey, { + 'blogId': 1, + 'blogName': 'author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }); + RequestUtil.cookieManager = _Cookies(); + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + setUp(() { + appProvider.token = 'share-test-account'; + pending.clear(); + options.clear(); + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }), + ); + }); + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount(WidgetTester tester, + {bool nested = false, + Size size = const Size(390, 844), + Locale locale = const Locale('en'), + double textScale = 1}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: locale, + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return ShareScreen(nested: nested); + }), + )); + await frames(tester); + } + + void respond(int index, Map data) => pending[index] + .resolve(Response(requestOptions: options[index], data: data)); + Map post(int id) => { + 'post': { + 'id': id, + 'blogId': 1, + 'publisherUserId': 1, + 'type': 1, + 'title': 'Story $id', + 'blogInfo': { + 'blogId': 1, + 'blogName': 'author', + 'blogNickName': 'Author', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }, + } + }; + + for (final size in [const Size(280, 480), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + testWidgets('recommend states fit $size $locale', (tester) async { + await mount(tester, size: size, locale: locale, textScale: 2); + expect(tester.takeException(), isNull); + respond(0, { + 'meta': {'status': 503, 'msg': 'Unavailable'} + }); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + expect(tester.takeException(), isNull); + final retry = find.text(error.actionLabel!); + await tester.ensureVisible(retry); + await frames(tester); + await tester.tap(retry); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 1, + 'items': [post(1)] + }, + }); + await frames(tester); + expect(find.byType(GridPostItemWidget), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('equal monthly counts keep distinct month headings', + (tester) async { + await mount(tester); + expect(pending, hasLength(1)); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 2, + 'archives': [ + { + 'year': 2024, + 'monthCount': [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + ], + 'items': [post(1), post(2)], + }, + }); + await frames(tester); + final localizations = + AppLocalizations.of(tester.element(find.byType(ShareScreen)))!; + expect( + find.textContaining(localizations.yearAndMonth(1, 2024), + findRichText: true), + findsOneWidget); + expect( + find.textContaining(localizations.yearAndMonth(2, 2024), + findRichText: true), + findsOneWidget); + expect(find.byType(GridPostItemWidget), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); + + testWidgets('missing archive metadata still shows returned posts', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 2, + 'items': [post(1), post(2)] + }, + }); + await frames(tester); + expect(find.byType(GridPostItemWidget), findsNWidgets(2)); + expect(find.byType(LoftifyPostArchiveSliverGrid), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('short archive metadata leaves later posts visible', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'archives': [ + { + 'year': 2024, + 'monthCount': [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + ], + 'items': [post(1), post(2)], + }, + }); + await frames(tester); + expect(find.byType(GridPostItemWidget), findsNWidgets(2)); + final refresh = tester.widget(find.byType(EasyRefresh)); + final load = Future.sync(refresh.onLoad!); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(3)] + }, + }); + await frames(tester); + expect(await load, IndicatorResult.noMore); + expect(find.byType(GridPostItemWidget), findsNWidgets(3)); + expect( + tester + .widgetList( + find.byType(LoftifyPostArchiveSliverGrid), + ) + .map((grid) => grid.itemCount), + [1, 2]); + expect(tester.takeException(), isNull); + }); + + testWidgets('large recommend group builds only nearby cells', (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 300, + 'archives': [ + { + 'year': 2024, + 'monthCount': [300, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + ], + 'items': [for (var id = 1; id <= 300; id++) post(id)], + }, + }); + await frames(tester); + expect(find.byType(GridPostItemWidget).evaluate().length, lessThan(60)); + expect( + tester + .widget( + find.byType(LoftifyPostArchiveSliverGrid), + ) + .itemCount, + 300); + expect(tester.takeException(), isNull); + }); + + testWidgets('recommend timeout retries in the same refresh container', + (tester) async { + await mount(tester); + final original = tester.state(find.byType(EasyRefresh)); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.loading); + pending[0].reject(DioException( + requestOptions: options[0], type: DioExceptionType.connectionTimeout)); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + await tester.tap(find.text(error.actionLabel!)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, { + 'meta': {'status': 200}, + 'response': {'count': 0, 'archives': [], 'items': []}, + }); + await frames(tester); + expect(tester.state(find.byType(EasyRefresh)), same(original)); + expect(find.byType(EmptyPlaceholder), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('malformed refresh preserves existing recommendations', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 2, + 'items': [post(1), post(2)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final retry = Future.sync(refresh.onRefresh!); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(3), 42] + }, + }); + await frames(tester); + expect(await retry, IndicatorResult.fail); + expect( + tester + .widgetList(find.byType(GridPostItemWidget)) + .map((item) => item.item.postId), + [1, 2]); + expect(tester.takeException(), isNull); + }); + + testWidgets('nested recommend view starts one request and retries', + (tester) async { + await mount(tester, nested: true); + expect(pending, hasLength(1)); + respond(0, { + 'meta': {'status': 503, 'msg': 'Unavailable'} + }); + await frames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + await tester.tap(find.text(error.actionLabel!)); + await frames(tester); + expect(pending, hasLength(2)); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 1, + 'items': [post(1)] + }, + }); + await frames(tester); + expect(find.byType(GridPostItemWidget), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('recommend response from old account is discarded', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 1, + 'items': [post(1)] + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final result = Future.sync(refresh.onRefresh!); + await frames(tester); + appProvider.token = 'other-account'; + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 1, + 'items': [post(9)] + }, + }); + await frames(tester); + expect(await result, IndicatorResult.none); + expect(find.byType(GridPostItemWidget), findsNothing); + expect( + tester.widget(find.byType(LoftifyStateView)).visual, + LoftifyStateVisual.error); + expect(tester.takeException(), isNull); + }); + + Future openBatchDownload(WidgetTester tester) async { + tester.widget(find.byType(ShadowIconButton)).onTap!(); + await frames(tester); + final label = AppLocalizations.of( + tester.element(find.byType(ShareScreen)), + )! + .batchDownload; + await tester.tap(find.text(label)); + await frames(tester); + return tester.widget(find.byType(BatchDownloadScreen)); + } + + testWidgets('batch load rejects failed page and retries from same offset', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(1)] + }, + }); + await frames(tester); + final batch = await openBatchDownload(tester); + expect(batch.initialItems, hasLength(1)); + final failed = expectLater(batch.loadAllItems!(), throwsStateError); + await frames(tester); + expect(options[1].queryParameters['offset'], 1); + respond(1, { + 'meta': {'status': 503, 'msg': 'Unavailable'} + }); + await frames(tester); + await failed; + final retry = batch.loadAllItems!(); + await frames(tester); + expect(options[2].queryParameters['offset'], 1); + respond(2, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(2), post(3)] + }, + }); + await frames(tester); + expect((await retry).map((item) => item.postId), [1, 2, 3]); + expect(tester.takeException(), isNull); + }); + + testWidgets('batch load rejects early empty page with missing posts', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(1)] + }, + }); + await frames(tester); + final batch = await openBatchDownload(tester); + final failed = expectLater(batch.loadAllItems!(), throwsStateError); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': {'count': 3, 'items': []}, + }); + await frames(tester); + await failed; + expect(tester.takeException(), isNull); + }); + + testWidgets('batch load rejects duplicate posts counted as complete', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(1)] + }, + }); + await frames(tester); + final batch = await openBatchDownload(tester); + final failed = expectLater(batch.loadAllItems!(), throwsStateError); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(1), post(2)] + }, + }); + await frames(tester); + await failed; + expect(tester.takeException(), isNull); + }); + + testWidgets('clearing invalid recommendations preserves month grouping', + (tester) async { + await mount(tester); + final localizations = + AppLocalizations.of(tester.element(find.byType(ShareScreen)))!; + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 2, + 'archives': [ + { + 'year': 2024, + 'monthCount': [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + } + ], + 'items': [ + { + 'post': {...post(1)['post'] as Map, 'blogId': 0} + }, + post(2), + ], + }, + }); + await frames(tester); + expect(find.textContaining(localizations.yearAndMonth(2, 2024)), + findsOneWidget); + tester.widget(find.byType(ShadowIconButton)).onTap!(); + await frames(tester); + await tester.tap(find.text(localizations.clearInvalidContent)); + await frames(tester); + expect(options[1].path, '/v2.0/batchData.api'); + respond(1, { + 'meta': {'status': 200} + }); + await frames(tester); + expect( + find.textContaining(localizations.yearAndMonth(2, 2024)), findsNothing); + expect(find.textContaining(localizations.yearAndMonth(1, 2024)), + findsOneWidget); + expect( + tester + .widget( + find.byType(LoftifyPostArchiveSliverGrid), + ) + .itemCount, + 1, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('clear invalid ignores an older pagination response', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'archives': [ + { + 'year': 2024, + 'monthCount': [2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + } + ], + 'items': [ + { + 'post': {...post(1)['post'] as Map, 'blogId': 0} + }, + post(2), + ], + }, + }); + await frames(tester); + final refresh = tester.widget(find.byType(EasyRefresh)); + final pagination = Future.sync(refresh.onLoad!); + await frames(tester); + expect(pending, hasLength(2)); + tester.widget(find.byType(ShadowIconButton)).onTap!(); + await frames(tester); + final clearLabel = AppLocalizations.of( + tester.element(find.byType(ShareScreen)), + )! + .clearInvalidContent; + await tester.tap(find.text(clearLabel)); + await frames(tester); + expect(options[2].path, '/v2.0/batchData.api'); + respond(2, { + 'meta': {'status': 200} + }); + await frames(tester); + respond(1, { + 'meta': {'status': 200}, + 'response': { + 'count': 3, + 'items': [post(3)] + }, + }); + await frames(tester); + expect(await pagination, IndicatorResult.none); + expect( + tester + .widgetList(find.byType(GridPostItemWidget)) + .map((item) => item.item.postId), + [2], + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('clear invalid timeout keeps the existing recommendations', + (tester) async { + await mount(tester); + respond(0, { + 'meta': {'status': 200}, + 'response': { + 'count': 2, + 'items': [ + { + 'post': {...post(1)['post'] as Map, 'blogId': 0} + }, + post(2), + ], + }, + }); + await frames(tester); + tester.widget(find.byType(ShadowIconButton)).onTap!(); + await frames(tester); + final clearLabel = AppLocalizations.of( + tester.element(find.byType(ShareScreen)), + )! + .clearInvalidContent; + await tester.tap(find.text(clearLabel)); + await frames(tester); + pending[1].reject(DioException( + requestOptions: options[1], + type: DioExceptionType.connectionTimeout, + )); + await frames(tester); + expect(find.byType(GridPostItemWidget), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/info/system_notice_screen_test.dart b/test/screens/info/system_notice_screen_test.dart new file mode 100644 index 00000000..1ed32ac7 --- /dev/null +++ b/test/screens/info/system_notice_screen_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/system_notice_screen.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/system_notice_screen', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + for (final size in [const Size(280, 480), const Size(720, 360)]) { + for (final scale in [1.0, 2.0]) { + for (final dark in [false, true]) { + for (final nickname in [ + 'Long creator name with several interests', + '很长的创作者昵称与兴趣描述', + '很長的創作者暱稱與興趣描述' + ]) { + testWidgets( + 'notification actions fit $size scale=$scale dark=$dark $nickname', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var profileTaps = 0; + var postTaps = 0; + await tester.pumpWidget(MaterialApp( + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: MediaQuery( + data: MediaQueryData( + size: size, textScaler: TextScaler.linear(scale)), + child: SingleChildScrollView( + child: SystemNoticeMessageTile( + nickname: nickname, + message: + '$nickname recommended your illustrated story / 推荐了你的作品 / 推薦了你的作品', + timestamp: 1724918400000, + avatarUrl: '', + thumbnailUrl: '', + onTap: () => postTaps++, + onAvatarTap: () => profileTaps++, + )), + )); + }), + )); + final action = find.byKey(const Key('system-notice-avatar-action')); + final semantics = tester.ensureSemantics(); + expect( + tester.getSemantics(action), + matchesSemantics( + label: nickname, + isButton: true, + hasTapAction: true, + )); + semantics.dispose(); + final actionRect = tester.getRect(action); + expect(actionRect.width, greaterThanOrEqualTo(48)); + expect(actionRect.height, greaterThanOrEqualTo(48)); + // The newly available bottom-right edge opens the author, not the post. + await tester.tapAt(actionRect.bottomRight - const Offset(1, 1)); + expect(profileTaps, 1); + expect(postTaps, 0); + final message = find.byKey(const Key('system-notice-message')); + expect( + tester.getRect(message).right, lessThanOrEqualTo(size.width)); + final thumbnail = find.byKey(const Key('system-notice-thumbnail')); + await tester.pumpAndSettle(); + await tester.ensureVisible(thumbnail); + await tester.pumpAndSettle(); + expect( + tester.getRect(thumbnail).right, lessThanOrEqualTo(size.width)); + await tester.tap(thumbnail); + expect(postTaps, 1); + expect(profileTaps, 1); + expect(tester.takeException(), isNull); + }); + } + } + } + } + + testWidgets('notification placeholder fills and remains scrollable', ( + tester, + ) async { + final previousBuilder = chewieProvider.stateWidgetBuilder; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + addTearDown(() { + chewieProvider.stateWidgetBuilder = previousBuilder; + }); + await tester.binding.setSurfaceSize(const Size(390, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return const Scaffold( + body: SystemNoticeTabPlaceholder(text: 'No notifications'), + ); + }, + ), + ), + ); + await tester.pump(); + + final scrollView = tester.widget( + find.byType(CustomScrollView), + ); + expect(scrollView.physics, isA()); + expect(tester.getSize(find.byType(CustomScrollView)).height, 568); + expect(find.text('No notifications'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('notification item reflows on narrow large-text screens', ( + tester, + ) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return const Scaffold( + body: MediaQuery( + data: MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: SingleChildScrollView( + child: SystemNoticeMessageTile( + nickname: 'A very long creator name', + message: + 'A very long creator name recommended your illustrated story', + timestamp: 1724918400000, + avatarUrl: '', + thumbnailUrl: '', + onTap: _noop, + onAvatarTap: _noop, + ), + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); + + expect( + tester.getRect(find.byKey(const Key('system-notice-message'))).right, + lessThanOrEqualTo(280), + ); + expect( + tester.getRect(find.byKey(const Key('system-notice-thumbnail'))).right, + lessThanOrEqualTo(280), + ); + expect(tester.takeException(), isNull); + }); +} + +void _noop() {} diff --git a/test/screens/lock/pin_screen_layout_test.dart b/test/screens/lock/pin_screen_layout_test.dart new file mode 100644 index 00000000..f276cacd --- /dev/null +++ b/test/screens/lock/pin_screen_layout_test.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Lock/pin_change_screen.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +Widget _host(Widget child) => MaterialApp( + locale: const Locale('en'), + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + navigatorKey: chewieProvider.globalNavigatorKey, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return MediaQuery( + data: const MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: child, + ); + }, + ), + ); + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/pin_screen_layout', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + setUp(() async { + await ChewieHiveUtil.put(HiveUtil.guesturePasswdKey, ''); + await ChewieHiveUtil.put(HiveUtil.enableBiometricKey, false); + }); + + testWidgets('gesture lock creation fits a narrow large-text screen', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(const PinChangeScreen())); + await tester.pump(); + + expect(find.byType(GestureUnlockView), findsOneWidget); + final gestureSize = tester.getSize(find.byType(GestureUnlockView)); + expect(gestureSize.width, greaterThanOrEqualTo(200)); + expect( + gestureSize.width, + moreOrLessEquals(gestureSize.height, epsilon: 0.5), + ); + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/navigation/dynamic_locale_test.dart b/test/screens/navigation/dynamic_locale_test.dart new file mode 100644 index 00000000..575b8924 --- /dev/null +++ b/test/screens/navigation/dynamic_locale_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Navigation/dynamic_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _UnusedCookieManager extends Fake implements CookieManager {} + +void main() { + var requests = 0; + setUpAll(() async { + final dir = Directory('build/test_hive/dynamic_locale'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.dynamicTabIdKey, 'grain'); + RequestUtil.cookieManager = _UnusedCookieManager(); + appProvider.token = 'test-account'; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors + .add(InterceptorsWrapper(onRequest: (options, handler) { + requests++; + handler.resolve( + Response(requestOptions: options, data: {'code': 0, 'data': {}})); + })); + }); + + Future mount(WidgetTester tester, Locale locale, bool dark) async { + await tester.pumpWidget(MaterialApp( + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const DynamicScreen(); + }), + )); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + for (final dark in [false, true]) { + testWidgets( + 'dynamic navigation relocalizes without recreating tabs or loading again dark=$dark', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await mount(tester, const Locale('en'), dark); + final state = tester.state(find.byType(DynamicScreen)); + final tabs = tester.widget(find.byType(TabBar)).controller!; + final loadedRequests = requests; + for (final locale in [ + const Locale('zh'), + const Locale('zh', 'TW'), + const Locale('en') + ]) { + await mount(tester, locale, dark); + final l10n = + AppLocalizations.of(tester.element(find.byType(DynamicScreen)))!; + for (final label in [ + l10n.follow, + l10n.tag, + l10n.collection, + l10n.grain + ]) { + expect(find.text(label), findsOneWidget); + } + expect(tester.state(find.byType(DynamicScreen)), same(state)); + expect( + tester.widget(find.byType(TabBar)).controller, same(tabs)); + expect(tabs.index, 3); + expect(requests, loadedRequests); + } + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('logged-out navigation tap does not refresh an unmounted tab', + (tester) async { + appProvider.token = ''; + addTearDown(() => appProvider.token = 'test-account'); + + await mount(tester, const Locale('zh'), false); + final state = tester.state(find.byType(DynamicScreen)); + final requestsBeforeTap = requests; + + expect(state.getCurrentCallRefresh(), isNull); + state.onTapBottomNavigation(); + state.scrollToTopOrRefresh(); + state.refresh(); + await tester.pump(); + + expect(requests, requestsBeforeTap); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/navigation/home_navigation_refresh_test.dart b/test/screens/navigation/home_navigation_refresh_test.dart new file mode 100644 index 00000000..4ee277cc --- /dev/null +++ b/test/screens/navigation/home_navigation_refresh_test.dart @@ -0,0 +1,208 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Navigation/home_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:provider/provider.dart'; + +class _UnusedCookieManager extends Fake implements CookieManager {} + +void main() { + setUpAll(() async { + final directory = Directory('build/test_hive/home_navigation_refresh'); + await directory.create(recursive: true); + Hive.init(directory.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + RequestUtil.cookieManager = _UnusedCookieManager(); + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + hapticFeedback: true, + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + }); + + for (final hideAppBar in [false, true]) { + for (final startDistance in [250.0, 600.0]) { + testWidgets( + 'home navigation from $startDistance px (hide app bar: $hideAppBar)', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + appProvider.hideHomeAppBarOnScroll = hideAppBar; + addTearDown(() => appProvider.hideHomeAppBarOnScroll = false); + + var requests = 0; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (options, handler) { + requests++; + handler.resolve(Response( + requestOptions: options, + data: { + 'code': 0, + 'data': { + 'offset': 0, + 'list': List.generate( + 30, + (index) => { + 'itemId': index + 1, + 'itemType': 1, + 'favorite': false, + 'following': false, + 'postData': { + 'postView': { + 'blogId': 1, + 'digest': 'Story content $index', + 'forbidShare': 0, + 'id': index + 1, + 'permalink': '', + 'photoCount': 0, + 'postPageUrl': '', + 'publishTime': 0, + 'tagList': [], + 'title': 'Story $index', + 'type': 1, + }, + }, + }), + }, + }, + )); + }), + ); + + final haptics = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + haptics.add(call.arguments.toString()); + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.pumpWidget(ChangeNotifierProvider.value( + value: appProvider, + child: MaterialApp( + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const HomeScreen(); + }), + ), + )); + for (var frame = 0; frame < 8; frame++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(requests, 1); + expect(haptics, isEmpty); + + final state = tester.state(find.byType(HomeScreen)); + if (hideAppBar) { + final appBar = tester.renderObject( + find.byKey(const ValueKey('home-floating-app-bar'), + skipOffstage: false), + ); + expect( + state.getScrollControllers().first.offset, lessThanOrEqualTo(1)); + expect(appBar.geometry!.paintExtent, greaterThan(0)); + expect( + tester + .getSize( + find.byKey(const ValueKey('refresh-indicator-viewport'))) + .height, + 0, + ); + } + final feedController = state.getScrollControllers().last; + expect(feedController.hasClients, isTrue); + if (hideAppBar) { + await tester.drag( + find.byType(CustomScrollView), Offset(0, -startDistance)); + for (var frame = 0; frame < 8; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(state.getScrollControllers().first.offset, greaterThan(1)); + } else { + feedController.jumpTo(startDistance); + await tester.pump(); + } + expect(feedController.offset, greaterThan(1)); + final outerController = state.getScrollControllers().first; + final distanceFromTop = + feedController.offset + (hideAppBar ? outerController.offset : 0.0); + if (startDistance < 422) { + expect(distanceFromTop, lessThan(422)); + } else { + expect(distanceFromTop, greaterThan(422)); + } + + final scrollToTop = state.onTapBottomNavigation(); + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + await scrollToTop; + expect(feedController.offset, lessThanOrEqualTo(1)); + expect(state.getScrollControllers().first.offset, lessThanOrEqualTo(1)); + if (hideAppBar) { + final appBar = tester.renderObject( + find.byKey(const ValueKey('home-floating-app-bar')), + ); + expect(appBar.geometry!.paintExtent, greaterThan(0)); + } + expect(requests, startDistance < 422 ? 2 : 1); + + if (startDistance > 422) { + final refresh = state.onTapBottomNavigation(); + for (var frame = 0; frame < 10; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + await refresh; + } + for (var frame = 0; frame < 16; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(requests, 2); + expect(haptics, ['HapticFeedbackType.mediumImpact']); + expect( + tester + .getSize(find.byKey(const ValueKey('refresh-indicator-viewport'))) + .height, + 0, + ); + if (hideAppBar) { + final appBar = tester.renderObject( + find.byKey(const ValueKey('home-floating-app-bar'), + skipOffstage: false), + ); + expect(appBar.geometry!.paintExtent, greaterThan(0)); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } + } +} diff --git a/test/screens/navigation/mine_loading_test.dart b/test/screens/navigation/mine_loading_test.dart new file mode 100644 index 00000000..82267ca8 --- /dev/null +++ b/test/screens/navigation/mine_loading_test.dart @@ -0,0 +1,396 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Navigation/mine_screen.dart'; +import 'package:loftify/Models/account_response.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/cloud_control_provider.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Widgets/Design/loftify_lottie.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:provider/provider.dart'; + +// RequestUtil requires a cookie interceptor at construction. These tests replace +// all interceptors before making requests, so no cookie storage is used. +class _UnusedCookieManager extends Fake implements CookieManager {} + +class _LongProfile extends Fake implements FullBlogInfo { + @override + String get blogNickName => + 'A very long author name with several creative interests'; + @override + String get blogName => 'a-very-long-author-id-with-many-characters'; + @override + String get bigAvaImg => ''; + @override + String get avatarBoxImage => ''; +} + +class _LargeCounts extends Fake implements MeInfoCount { + @override + int get hotCount => 987654321; +} + +class _ProfileStatistics extends Fake implements MeInfoBlogInfo { + @override + int get attentionCount => 123456789; + @override + int get followerCount => 987654321; + @override + int get postCount => 123456789; + @override + MeInfoCount get hot => _LargeCounts(); +} + +class _LoadedStatistics extends Fake implements MeInfoData { + @override + MeInfoBlogInfo get blogInfo => _ProfileStatistics(); + @override + int get collectionCount => 123456789; +} + +void main() { + late RequestInterceptorHandler pending; + late RequestOptions options; + late int requests; + + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/mine_loading', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + RequestUtil.cookieManager = _UnusedCookieManager(); + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + hapticFeedback: true, + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + + setUp(() { + appProvider.token = 'test-account'; + requests = 0; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (request, handler) { + requests++; + options = request; + pending = handler; + }), + ); + }); + + Future mount( + WidgetTester tester, { + Size size = const Size(390, 844), + double textScale = 1, + Locale locale = const Locale('en'), + bool dark = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: appProvider), + ChangeNotifierProvider.value(value: controlProvider), + ], + child: MaterialApp( + locale: locale, + navigatorKey: chewieProvider.globalNavigatorKey, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(textScale), + ), + child: child!, + ), + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const MineScreen(); + }), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(requests, 1); + expect(options.path, '/v1.1/usercounts.api'); + } + + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final dark in [false, true]) { + for (final size in [const Size(280, 480), const Size(720, 480)]) { + for (final scale in [1.0, 2.0]) { + testWidgets( + 'loaded profile fits $size $locale dark=$dark at ${scale}x text', + (tester) async { + await mount(tester, + size: size, textScale: scale, locale: locale, dark: dark); + final dynamic state = tester.state(find.byType(MineScreen)); + state.setState(() { + state.blogInfo = _LongProfile(); + state.meInfoData = _LoadedStatistics(); + }); + await tester.pump(); + expect(find.text(_LongProfile().blogNickName), findsOneWidget); + final label = + AppLocalizations.of(tester.element(find.byType(MineScreen)))! + .downloadManagement; + final downloadItem = find.text(label, findRichText: true); + await tester.scrollUntilVisible(downloadItem, 180, + scrollable: find.byType(Scrollable).first); + await tester.pump(const Duration(seconds: 1)); + await tester.ensureVisible(downloadItem); + await tester.pump(const Duration(seconds: 1)); + expect(downloadItem.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + pending.resolve( + Response(requestOptions: options, statusCode: 200, data: { + 'meta': {'status': 503, 'desc': 'Unavailable'} + })); + await tester.pump(const Duration(seconds: 5)); + }); + } + } + } + } + + testWidgets('narrow large-text profile keeps its actions reachable', + (tester) async { + await mount(tester, size: const Size(280, 480), textScale: 2); + expect(tester.takeException(), isNull); + final label = AppLocalizations.of(tester.element(find.byType(MineScreen)))! + .downloadManagement; + final downloadItem = find.text(label, findRichText: true); + await tester.scrollUntilVisible(downloadItem, 180, + scrollable: find.byType(Scrollable).first); + await tester.pump(const Duration(seconds: 1)); + await tester.ensureVisible(downloadItem); + await tester.pump(const Duration(seconds: 1)); + expect(downloadItem.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + pending.resolve(Response(requestOptions: options, statusCode: 200, data: { + 'meta': {'status': 503, 'desc': 'Unavailable'} + })); + await tester.pump(const Duration(seconds: 5)); + }); + + testWidgets( + 'fixed actions remain available while profile is pending or fails', + (tester) async { + await mount(tester); + final label = AppLocalizations.of( + tester.element(find.byType(MineScreen)), + )! + .downloadManagement; + expect(find.text(label, findRichText: true), findsOneWidget); + pending.resolve(Response( + requestOptions: options, + statusCode: 200, + data: { + 'meta': {'status': 503, 'desc': 'Unavailable'} + }, + )); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text(label, findRichText: true), findsOneWidget); + expect(requests, 1); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 5)); + }); + + testWidgets( + 'untitled mine app bar keeps notification and dress in both layouts', + (tester) async { + await mount(tester, size: const Size(720, 480)); + final appBar = + tester.widget(find.byType(ResponsiveAppBar)); + expect(appBar.title, isEmpty); + expect(appBar.titleWidget, isA()); + expect(appBar.landscapeActions, same(appBar.actions)); + for (final icon in [LoftifyIcons.notifications, LoftifyIcons.dress]) { + expect( + find + .byWidgetPredicate( + (widget) => widget is ChewieIconButton && widget.icon == icon, + ) + .hitTestable(), + findsOneWidget, + ); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + pending.resolve(Response(requestOptions: options, statusCode: 200, data: { + 'meta': {'status': 503, 'desc': 'Unavailable'} + })); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('mine app bar uses balanced Lucide actions and animated theme', + (tester) async { + appProvider.themeMode = ActiveThemeMode.light; + await mount(tester); + for (final icon in [ + LoftifyIcons.dress, + LoftifyIcons.notifications, + LoftifyIcons.settings, + ]) { + final button = tester.widget( + find.byWidgetPredicate( + (widget) => widget is ChewieIconButton && widget.icon == icon, + ), + ); + expect(button.iconSize, isNull); + final scope = tester.widget( + find + .ancestor( + of: find.byWidget(button), + matching: find.byType(ChewieIconButtonVisualScope), + ) + .first, + ); + expect(scope.maximumIconSize, 22); + expect(button.foregroundColor, isNotNull); + } + final themeIcon = tester.widget( + find.byType(LoftifyLottie).first, + ); + expect(themeIcon.spec.asset, LottieFiles.sunLight); + expect(themeIcon.controller, isNotNull); + await tester.pump(const Duration(seconds: 1)); + final controller = tester + .widget( + find.byType(LoftifyLottie).first, + ) + .controller!; + expect(controller.value, 1); + final themeLabel = AppLocalizations.of( + tester.element(find.byType(MineScreen)), + )! + .themeMode; + await tester.tap(find.byTooltip(themeLabel)); + expect(controller.status, AnimationStatus.reverse); + await tester.pump(); + expect(appProvider.themeMode, ActiveThemeMode.dark); + expect(controller.status, AnimationStatus.reverse); + await tester.pump(const Duration(seconds: 1)); + expect(controller.value, 0); + await tester.tap(find.byTooltip(themeLabel)); + expect(controller.status, AnimationStatus.forward); + await tester.pump(); + expect(appProvider.themeMode, ActiveThemeMode.light); + expect(controller.status, AnimationStatus.forward); + await tester.pump(const Duration(seconds: 1)); + expect(controller.value, 1); + await tester.pumpWidget(const SizedBox.shrink()); + pending.resolve(Response(requestOptions: options, statusCode: 200, data: { + 'meta': {'status': 503, 'desc': 'Unavailable'} + })); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('network timeout ends refresh and permits another attempt', + (tester) async { + await mount(tester); + pending.reject(DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout, + )); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + final refresh = tester.widget(find.byType(EasyRefresh)); + final result = refresh.onRefresh!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(requests, 2); + pending.reject(DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout, + )); + await tester.pump(); + expect(await result, IndicatorResult.fail); + final retry = refresh.onRefresh!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(requests, 3); + pending.resolve(Response( + requestOptions: options, + statusCode: 200, + data: { + 'meta': {'status': 503, 'desc': 'Unavailable'} + }, + )); + await tester.pump(); + expect(await retry, IndicatorResult.fail); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 5)); + }); + + for (final changeAccount in [false, true]) { + testWidgets( + 'late response is ignored after ${changeAccount ? 'account changes' : 'disposal'}', + (tester) async { + await mount(tester); + if (changeAccount) { + appProvider.token = 'different-test-account'; + } else { + await tester.pumpWidget(const SizedBox.shrink()); + } + // A malformed successful payload must never be parsed once obsolete. + pending.resolve(Response( + requestOptions: options, + statusCode: 200, + data: { + 'meta': {'status': 200}, + 'response': null + }, + )); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(requests, 1); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 5)); + }, + ); + } +} diff --git a/test/screens/navigation/notice_like_loading_test.dart b/test/screens/navigation/notice_like_loading_test.dart new file mode 100644 index 00000000..1c3c31d6 --- /dev/null +++ b/test/screens/navigation/notice_like_loading_test.dart @@ -0,0 +1,3 @@ +import 'notice_loading_test.dart' as suite; + +void main() => suite.noticeLoadingTests('like'); diff --git a/test/screens/navigation/notice_like_missing_cache_test.dart b/test/screens/navigation/notice_like_missing_cache_test.dart new file mode 100644 index 00000000..71984044 --- /dev/null +++ b/test/screens/navigation/notice_like_missing_cache_test.dart @@ -0,0 +1,3 @@ +import 'notice_loading_test.dart' as suite; + +void main() => suite.noticeLoadingTests('like', missingCache: true); diff --git a/test/screens/navigation/notice_loading_test.dart b/test/screens/navigation/notice_loading_test.dart new file mode 100644 index 00000000..b95b1d2e --- /dev/null +++ b/test/screens/navigation/notice_loading_test.dart @@ -0,0 +1,411 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/system_notice_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; + +class _UnusedCookieManager extends Fake implements CookieManager {} + +Map _message(int id) { + final author = { + 'blogId': id, + 'blogNickName': 'Creator $id', + 'blogName': 'creator$id', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }; + return { + 'actUserBlogInfo': author, + 'blogInfo': author, + 'actUserId': id, + 'blogId': id, + 'commentLikeType': 0, + 'id': id, + 'publishTime': 1724918400000, + 'thumbnail': '', + 'type': 0, + 'defString': 'Creator $id recommended your story', + 'content': jsonEncode({ + 'postViewRank': 0, + 'postUrl': '', + 'bigAvaImg': '', + 'blogNickName': 'Creator $id', + 'blogName': 'creator$id', + 'postPermalink': '', + 'postId': id, + 'postTitle': 'Story $id', + 'postType': 1, + 'isReblog': 0, + }), + }; +} + +void main() => noticeLoadingTests('all'); + +void noticeLoadingTests(String tab, {bool missingCache = false}) { + setUpAll(() async { + final dir = + Directory('build/test_hive/notice_loading_${tab}_$missingCache'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + RequestUtil.cookieManager = _UnusedCookieManager(); + appProvider.token = 'test-account'; + // Match the application's refresh indicators instead of the package's + // text-based defaults, which are not used by Loftify. + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + hapticFeedback: true, + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + EasyRefresh.defaultFooterBuilder = () => LottieCupertinoFooter( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(36, false), + triggerOffset: 52, + maxOverOffset: 76, + infiniteOffset: 240, + radius: 18, + ); + }); + + Future pumpFrames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount( + WidgetTester tester, { + Size size = const Size(390, 844), + double textScale = 1, + bool dark = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const SystemNoticeScreen(); + }), + )); + await pumpFrames(tester); + } + + group(tab, () { + setUpAll(() async { + await ChewieHiveUtil.put(HiveUtil.systemNoticeTabIdKey, tab); + await ChewieHiveUtil.put( + HiveUtil.userInfoKey, + missingCache + ? {} + : { + 'blogId': 1, + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }); + }); + + if (missingCache) { + testWidgets('missing user cache ends refresh and releases loading lock', + (tester) async { + var requests = 0; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + requests++; + handler.reject(DioException(requestOptions: options)); + }, + )); + await mount(tester); + final refresh = + tester.widget(find.byType(EasyRefresh).first); + for (var attempt = 0; attempt < 2; attempt++) { + final result = Future.sync(refresh.onRefresh!); + await pumpFrames(tester); + expect(await result, IndicatorResult.fail); + } + expect(requests, 0); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + return; + } + + for (final size in [const Size(280, 320), const Size(720, 360)]) { + for (final dark in [false, true]) { + testWidgets('loading failure retry and empty fit $size dark=$dark', + (tester) async { + final pending = []; + final options = []; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add(InterceptorsWrapper( + onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }, + )); + await mount(tester, size: size, textScale: 2, dark: dark); + expect( + tester + .widget(find.byType(LoftifyStateView)) + .visual, + LoftifyStateVisual.loading); + pending.first.resolve(Response(requestOptions: options.first, data: { + 'meta': {'status': 503, 'desc': 'Unavailable'}, + })); + await pumpFrames(tester); + final error = + tester.widget(find.byType(LoftifyStateView)); + expect(error.visual, LoftifyStateVisual.error); + expect(find.byType(SystemNoticeTabPlaceholder), findsNothing); + final retry = find.text(error.actionLabel!); + await tester.ensureVisible(retry); + await pumpFrames(tester); + expect(retry.hitTestable(), findsOneWidget); + await tester.tap(retry); + await pumpFrames(tester); + expect(pending.length, 2); + expect( + tester + .widget(find.byType(LoftifyStateView)) + .visual, + LoftifyStateVisual.loading); + pending.last.resolve(Response(requestOptions: options.last, data: { + 'meta': {'status': 200}, + 'response': [], + })); + await pumpFrames(tester); + expect(find.byType(SystemNoticeTabPlaceholder), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + } + } + + testWidgets('loaded rows survive failures and refresh replaces pagination', + (tester) async { + final pending = []; + final options = []; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add(InterceptorsWrapper( + onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }, + )); + void respond(List rows) => pending.last.resolve(Response( + requestOptions: options.last, + data: { + 'meta': {'status': 200}, + 'response': rows + }, + )); + List visibleAuthors() => tester + .widgetList( + find.byType(SystemNoticeMessageTile), + ) + .map((tile) => tile.nickname) + .toList(); + Future request({bool refresh = false}) { + final widget = + tester.widget(find.byType(EasyRefresh).first); + return Future.sync(refresh ? widget.onRefresh! : widget.onLoad!); + } + + await mount(tester); + respond([_message(1)]); + await pumpFrames(tester); + expect(visibleAuthors(), ['Creator 1']); + final failedPage = request(); + await pumpFrames(tester); + expect(options.last.queryParameters['offset'], 1); + // A partially valid page must not append its first row before parsing fails. + respond([_message(2), {}]); + await pumpFrames(tester); + expect(await failedPage, IndicatorResult.fail); + expect(visibleAuthors(), ['Creator 1']); + final retryPage = request(); + await pumpFrames(tester); + expect(options.last.queryParameters['offset'], 1); + respond([_message(2)]); + await pumpFrames(tester); + expect(await retryPage, IndicatorResult.success); + expect(visibleAuthors(), ['Creator 1', 'Creator 2']); + final failedRefresh = request(refresh: true); + await pumpFrames(tester); + expect(options.last.queryParameters['offset'], 0); + respond([{}]); + await pumpFrames(tester); + expect(await failedRefresh, IndicatorResult.fail); + expect(visibleAuthors(), ['Creator 1', 'Creator 2']); + expect(find.byType(LoftifyStateView), findsNothing); + final fresh = request(refresh: true); + await pumpFrames(tester); + respond([_message(3)]); + await pumpFrames(tester); + expect(await fresh, IndicatorResult.success); + expect(visibleAuthors(), ['Creator 3']); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + + testWidgets('timeout releases loading lock and allows refresh', + (tester) async { + var requests = 0; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + requests++; + expectSync(options.queryParameters['method'], + tab == 'like' ? 'getNewLikeList' : 'getSystemNoticeList'); + if (requests == 1) { + handler.reject(DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout, + )); + } else { + handler.resolve(Response(requestOptions: options, data: { + 'meta': {'status': 200}, + 'response': [], + })); + } + }, + )); + await mount(tester); + expect(requests, 1); + expect(tester.takeException(), isNull); + final refresh = + tester.widget(find.byType(EasyRefresh).first); + final result = Future.sync(refresh.onRefresh!); + await pumpFrames(tester); + expect(await result, IndicatorResult.success); + expect(requests, 2); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + + for (final oldResponseFirst in [false, true]) { + testWidgets( + 'refresh supersedes pending pagination oldFirst=$oldResponseFirst', + (tester) async { + final pending = []; + final options = []; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add(InterceptorsWrapper( + onRequest: (request, handler) { + options.add(request); + pending.add(handler); + }, + )); + void respond(int index) => pending[index].resolve(Response( + requestOptions: options[index], + data: { + 'meta': {'status': 200}, + 'response': [] + }, + )); + await mount(tester); + respond(0); + await pumpFrames(tester); + final refresh = + tester.widget(find.byType(EasyRefresh).first); + final oldLoad = Future.sync(refresh.onLoad!); + await pumpFrames(tester); + expect(pending.length, 2); + final newRefresh = Future.sync(refresh.onRefresh!); + await pumpFrames(tester); + expect(pending.length, 3); + if (oldResponseFirst) { + respond(1); + await pumpFrames(tester); + expect(await oldLoad, IndicatorResult.none); + // The obsolete request must not release the new refresh's lock. + final duplicateLoad = Future.sync(refresh.onLoad!); + await pumpFrames(tester); + expect(await duplicateLoad, IndicatorResult.none); + expect(pending.length, 3); + respond(2); + } else { + respond(2); + await pumpFrames(tester); + respond(1); + } + await pumpFrames(tester); + expect(await newRefresh, IndicatorResult.success); + expect(await oldLoad, IndicatorResult.none); + // Pagination can run again after the fresh first page has completed. + final nextLoad = Future.sync(refresh.onLoad!); + await pumpFrames(tester); + expect(pending.length, 4); + respond(3); + await pumpFrames(tester); + expect(await nextLoad, IndicatorResult.noMore); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('response after dispose is ignored', (tester) async { + late RequestInterceptorHandler pending; + late RequestOptions options; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add(InterceptorsWrapper( + onRequest: (request, handler) { + pending = handler; + options = request; + }, + )); + await mount(tester); + await tester.pumpWidget(const SizedBox()); + pending.resolve(Response(requestOptions: options, data: { + 'meta': {'status': 503, 'desc': 'Unavailable'}, + })); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/screens/navigation/notice_locale_test.dart b/test/screens/navigation/notice_locale_test.dart new file mode 100644 index 00000000..82e99ef9 --- /dev/null +++ b/test/screens/navigation/notice_locale_test.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/system_notice_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _UnusedCookieManager extends Fake implements CookieManager {} + +void main() { + var requests = 0; + setUpAll(() async { + final dir = Directory('build/test_hive/notice_locale'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.systemNoticeTabIdKey, 'like'); + await ChewieHiveUtil.put(HiveUtil.userInfoKey, { + 'blogId': 1, + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + }); + RequestUtil.cookieManager = _UnusedCookieManager(); + appProvider.token = 'test-account'; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors + .add(InterceptorsWrapper(onRequest: (options, handler) { + requests++; + handler.resolve(Response(requestOptions: options, data: { + 'meta': {'status': 200}, + 'response': [] + })); + })); + }); + + Future mount(WidgetTester tester, Locale locale, bool dark) async { + await tester.pumpWidget(MaterialApp( + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const SystemNoticeScreen(); + }), + )); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + for (final dark in [false, true]) { + testWidgets( + 'notice navigation relocalizes without recreating tabs or loading again dark=$dark', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await mount(tester, const Locale('en'), dark); + final state = tester.state(find.byType(SystemNoticeScreen)); + final tabs = tester.widget(find.byType(TabBar)).controller!; + final loadedRequests = requests; + for (final locale in [ + const Locale('zh'), + const Locale('zh', 'TW'), + const Locale('en') + ]) { + await mount(tester, locale, dark); + final l10n = AppLocalizations.of( + tester.element(find.byType(SystemNoticeScreen)))!; + for (final label in [ + l10n.all, + l10n.like, + l10n.recommend, + l10n.gift, + l10n.atMe, + l10n.subscribe, + l10n.favorite, + l10n.other, + ]) { + expect(find.text(label), findsOneWidget); + } + expect(tester.state(find.byType(SystemNoticeScreen)), same(state)); + expect( + tester.widget(find.byType(TabBar)).controller, same(tabs)); + expect(tabs.index, 1); + expect(requests, loadedRequests); + } + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/screens/navigation/notice_missing_cache_test.dart b/test/screens/navigation/notice_missing_cache_test.dart new file mode 100644 index 00000000..9b2cdecc --- /dev/null +++ b/test/screens/navigation/notice_missing_cache_test.dart @@ -0,0 +1,3 @@ +import 'notice_loading_test.dart' as suite; + +void main() => suite.noticeLoadingTests('all', missingCache: true); diff --git a/test/screens/navigation/search_navigation_layout_test.dart b/test/screens/navigation/search_navigation_layout_test.dart new file mode 100644 index 00000000..5411ede9 --- /dev/null +++ b/test/screens/navigation/search_navigation_layout_test.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Navigation/search_screen.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _UnusedCookieManager extends Fake implements CookieManager {} + +void main() { + setUpAll(() async { + final directory = Directory('build/test_hive/search_navigation_layout'); + await directory.create(recursive: true); + Hive.init(directory.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + RequestUtil.cookieManager = _UnusedCookieManager(); + }); + + for (final showBack in [false, true]) { + testWidgets( + 'search bar fills the available app bar width (back: $showBack)', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (options, handler) { + handler.resolve(Response( + requestOptions: options, + data: { + 'code': 0, + 'data': { + 'guessKeywords': [], + 'rankList': [], + 'configList': [], + }, + }, + )); + }), + ); + + await tester.pumpWidget(MaterialApp( + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return SearchScreen(showBack: showBack); + }), + )); + await tester.pump(const Duration(seconds: 1)); + + final rightEdge = tester + .getTopRight(find.byKey(const ValueKey('search-navigation-bar'))) + .dx; + final barWidth = tester + .getSize(find.byKey(const ValueKey('search-navigation-bar'))) + .width; + // Desktop app bars reserve a 44px trailing window-control area; compact + // search bars should fill every remaining pixel instead of subtracting + // another fixed-width action slot. + final trailingSpace = ResponsiveUtil.isLandscapeLayout() ? 44.0 : 0.0; + final leadingSpace = showBack ? 52.0 : 0.0; + expect(rightEdge, greaterThanOrEqualTo(390 - trailingSpace - 1)); + expect( + barWidth, greaterThanOrEqualTo(390 - trailingSpace - leadingSpace)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + } +} diff --git a/test/screens/navigation/search_suggestions_test.dart b/test/screens/navigation/search_suggestions_test.dart new file mode 100644 index 00000000..a46008a4 --- /dev/null +++ b/test/screens/navigation/search_suggestions_test.dart @@ -0,0 +1,670 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Navigation/search_screen.dart'; +import 'package:loftify/Screens/Post/search_result_screen.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _UnusedCookieManager extends Fake implements CookieManager {} + +void main() { + final pending = <(RequestOptions, RequestInterceptorHandler)>[]; + final initial = <(RequestOptions, RequestInterceptorHandler)>[]; + bool holdInitial = false; + bool serveRanks = false; + int requestCount = 0; + setUpAll(() async { + final dir = Directory('build/test_hive/search_suggestions'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + await ChewieHiveUtil.put(HiveUtil.showSearchRankKey, true); + await ChewieHiveUtil.put(HiveUtil.searchResultTabIdKey, 'all'); + RequestUtil.cookieManager = _UnusedCookieManager(); + }); + setUp(() { + pending.clear(); + initial.clear(); + holdInitial = false; + serveRanks = false; + requestCount = 0; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (options, handler) { + requestCount++; + if (options.path.endsWith('/sug.json')) { + pending.add((options, handler)); + } else if (serveRanks && options.path.endsWith('/ranklist.json')) { + Map rank( + String name, String title, String score, int sortNo) => + { + 'listName': name, + 'ruleUrl': '', + 'sortNo': sortNo, + 'type': 0, + 'hotLists': [ + { + 'icon': '', + 'interactionCount': 0, + 'isAuth': false, + 'isVerify': false, + 'postType': 0, + 'pv': 0, + 'resource': 0, + 'score': score, + 'title': title, + 'trend': 0, + 'url': '', + }, + ], + }; + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': { + 'rankList': [ + rank('Board One', 'Alpha', '98.00', 1), + rank('Board Two', 'Beta', '88.00', 2), + ], + }, + })); + } else if (holdInitial) { + initial.add((options, handler)); + } else { + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': {}, + })); + } + }), + ); + }); + Future mount( + WidgetTester tester, { + Size size = const Size(390, 844), + double textScale = 1, + Locale locale = const Locale('en'), + bool dark = false, + bool resultsPage = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + navigatorKey: chewieProvider.globalNavigatorKey, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return resultsPage + ? const SearchResultScreen(searchKey: 'initial') + : const SearchScreen(); + }), + )); + await tester.pump(const Duration(milliseconds: 100)); + } + + Future type(WidgetTester tester, String value) async { + await tester.enterText(find.byType(TextField), value); + await tester.pump(const Duration(milliseconds: 100)); + } + + Future respond(WidgetTester tester, int index, String label, + {int joinCount = -1, + bool ranked = false, + String? rankName = 'Popular creative community'}) async { + final (options, handler) = pending[index]; + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': { + 'items': [ + { + 'type': ranked ? 0 : 1, + 'tagInfo': { + 'tagName': label, + 'joinCount': joinCount, + 'subscribed': ranked, + 'rankName': ranked ? rankName : null, + 'recommendReport': {'algInfo': '', 'recId': ''}, + } + }, + ] + }, + })); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + testWidgets('rank row fills page and horizontal swipe stays on next board', + (tester) async { + serveRanks = true; + await mount(tester); + await tester.pump(const Duration(milliseconds: 400)); + + final pageView = find.byType(PageView); + expect(pageView, findsOneWidget); + final pageRight = tester.getRect(pageView).right; + final scoreRight = tester.getRect(find.text('98.00')).right; + expect(pageRight - scoreRight, lessThan(55)); + + await tester.drag(pageView, const Offset(-300, 0)); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('Beta'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final dark in [false, true]) { + for (final width in [280.0, 720.0]) { + for (final scale in [1.0, 2.0]) { + testWidgets( + 'user statistics remain visible at $width / $scale / $locale dark=$dark', + (tester) async { + await mount(tester, + size: Size(width, 480), + textScale: scale, + locale: locale, + dark: dark); + await type(tester, 'author'); + final (options, handler) = pending.single; + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': { + 'items': [ + { + 'type': 2, + 'blogData': { + 'blogInfo': { + 'blogName': 'a-very-long-creative-author-identifier', + 'blogNickName': + 'A creative author with a very long display name', + }, + 'blogCount': { + 'publicPostCount': 123456789, + 'followerCount': 987654321 + }, + 'recommendReport': {'algInfo': '', 'recId': ''}, + }, + } + ] + }, + })); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + final l10n = + AppLocalizations.of(tester.element(find.byType(SearchScreen)))!; + final followers = find.text('${l10n.follower}: 987654321'); + final posts = find.text('${l10n.article}: 123456789'); + expect(posts, findsOneWidget); + expect(followers, findsOneWidget); + await tester.ensureVisible(followers); + await tester.pump(); + expect(followers.hitTestable(), findsOneWidget); + final foreground = tester.widget(followers).style!.color!; + final surface = tester + .widget(find + .ancestor( + of: followers, + matching: find.byType(Material), + ) + .first) + .color!; + final textLuminance = + Color.alphaBlend(foreground, surface).computeLuminance(); + final backgroundLuminance = surface.computeLuminance(); + final contrast = textLuminance > backgroundLuminance + ? (textLuminance + 0.05) / (backgroundLuminance + 0.05) + : (backgroundLuminance + 0.05) / (textLuminance + 0.05); + expect(contrast, greaterThanOrEqualTo(4.5), + reason: + 'Small user statistics must remain readable on their card surface'); + expect(tester.takeException(), isNull); + }); + } + } + testWidgets( + 'ranked subscribed tag fits narrow large text in $locale dark=$dark', + (tester) async { + await mount(tester, + size: const Size(280, 480), + textScale: 2, + locale: locale, + dark: dark); + await type(tester, 'art'); + await respond(tester, 0, 'Long creative tag name', + joinCount: 123456789, ranked: true); + expect(find.text('#Long creative tag name'), findsOneWidget); + final context = tester.element(find.byType(SearchScreen)); + final enter = find.text(AppLocalizations.of(context)!.enter); + await tester.ensureVisible(enter); + await tester.pump(); + expect(enter.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets( + 'long tag suggestion fits narrow large-text layout in $locale dark=$dark', + (tester) async { + await mount(tester, + size: const Size(280, 480), + textScale: 2, + locale: locale, + dark: dark); + await type(tester, 'art'); + const label = 'A long creative community tag with multiple interests'; + await respond(tester, 0, label, joinCount: 123456789); + expect(find.text(label).hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets( + 'normal-sized ranked suggestion keeps the action beside its content', + (tester) async { + await mount(tester); + await type(tester, 'art'); + await respond(tester, 0, 'art', + joinCount: 15269, ranked: true, rankName: null); + final title = find.text('#art'); + final l10n = + AppLocalizations.of(tester.element(find.byType(SearchScreen)))!; + final action = find.text(l10n.enter); + expect( + tester.getRect(action).left, greaterThan(tester.getRect(title).right)); + expect(tester.getRect(action).bottom - tester.getRect(title).top, + lessThan(90)); + expect(tester.takeException(), isNull); + }); + + testWidgets('suggestion scrolling reaches the navigation scroll listeners', + (tester) async { + await mount(tester); + await type(tester, 'art'); + final (options, handler) = pending.single; + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': { + 'items': List.generate( + 30, + (index) => { + 'type': 1, + 'tagInfo': { + 'tagName': 'Suggestion $index', + 'subscribed': false, + 'recommendReport': {'algInfo': '', 'recId': ''}, + }, + }) + }, + })); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + final state = tester.state(find.byType(SearchScreen)); + var scrollUpdates = 0; + void onScroll() => scrollUpdates++; + final controllers = state.getScrollControllers(); + expect(controllers, hasLength(1)); + final navigation = OverlayEntry( + builder: (_) => Positioned( + bottom: 0, + left: 0, + right: 0, + child: ScrollToHide.multi( + scrollControllers: controllers, + hideDirection: Axis.vertical, + child: + const SizedBox(height: 50, child: Text('Navigation test')), + ), + )); + Overlay.of(tester.element(find.byType(SearchScreen))).insert(navigation); + await tester.pump(); + for (final controller in controllers) { + controller.addListener(onScroll); + } + await tester.drag(find.text('Suggestion 1'), const Offset(0, -350)); + await tester.pump(const Duration(seconds: 1)); + expect(scrollUpdates, greaterThan(0)); + final visibility = + tester.state(find.byType(ScrollToHide)); + expect(visibility.isShown, isFalse); + await tester.drag(find.byType(ListView), const Offset(0, 100)); + await tester.pump(const Duration(seconds: 1)); + expect(visibility.isShown, isTrue); + for (final controller in controllers) { + controller.removeListener(onScroll); + } + navigation.remove(); + await tester.pump(); + navigation.dispose(); + await type(tester, ''); + expect(state.getScrollControllers(), hasLength(1)); + expect( + state.getScrollControllers().single, isNot(same(controllers.single))); + expect(tester.takeException(), isNull); + }); + + testWidgets('new query starts at the first suggestion after scrolling', + (tester) async { + await mount(tester); + Future results(String query) async { + await type(tester, query); + final (options, handler) = pending.last; + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': { + 'items': List.generate( + 30, + (index) => { + 'type': 1, + 'tagInfo': { + 'tagName': '$query result $index', + 'subscribed': false, + 'recommendReport': {'algInfo': '', 'recId': ''}, + }, + }) + }, + })); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + await results('old'); + await tester.drag(find.text('old result 1'), const Offset(0, -500)); + await tester.pump(const Duration(seconds: 1)); + final state = tester.state(find.byType(SearchScreen)); + expect(state.getScrollControllers().single.offset, greaterThan(0)); + await results('new'); + expect(state.getScrollControllers().single.offset, 0); + expect(find.text('new result 0').hitTestable(), findsOneWidget); + await tester.drag(find.text('new result 1'), const Offset(0, -250)); + await tester.pump(const Duration(seconds: 1)); + final offset = state.getScrollControllers().single.offset; + expect(offset, greaterThan(0)); + tester.view.viewInsets = const FakeViewPadding(bottom: 250); + addTearDown(tester.view.resetViewInsets); + await tester.pump(); + expect(state.getScrollControllers().single.offset, offset); + tester.view.resetViewInsets(); + await tester.pump(); + expect(state.getScrollControllers().single.offset, offset); + expect(pending.length, 2); + expect(tester.takeException(), isNull); + }); + + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final dark in [false, true]) { + for (final scale in [1.0, 2.0]) { + testWidgets( + 'all thirty ranking entries remain reachable $locale / $scale dark=$dark', + (tester) async { + holdInitial = true; + await mount(tester, + size: const Size(280, 480), + textScale: scale, + locale: locale, + dark: dark); + for (final (options, handler) in initial) { + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': options.path.contains('ranklist') + ? { + 'rankList': [ + { + 'listName': 'Ranking', + 'ruleUrl': '', + 'sortNo': 0, + 'type': 0, + 'hotLists': List.generate( + 30, + (index) => { + 'title': 'Ranked entry $index', + 'interactionCount': 0, + 'isAuth': false, + 'isVerify': false, + 'postType': 0, + 'pv': 123456789, + 'resource': 0, + 'trend': 1, + 'url': '', + }), + } + ], + } + : {}, + })); + } + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + final state = + tester.state(find.byType(SearchScreen)); + state.getScrollControllers().single.jumpTo( + state.getScrollControllers().single.position.maxScrollExtent); + await tester.pump(); + expect(find.text('Ranked entry 29').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + } + + testWidgets('open result tabs update when the locale changes', + (tester) async { + await mount(tester, resultsPage: true, locale: const Locale('en')); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + final state = tester.state(find.byType(SearchResultScreen)); + final english = + AppLocalizations.of(tester.element(find.byType(SearchResultScreen)))!; + expect(find.text(english.comprehensive), findsOneWidget); + final tabs = tester.widget(find.byType(TabBar)).controller!; + expect(tabs.index, 0); + final loadedRequests = requestCount; + await mount(tester, resultsPage: true, locale: const Locale('zh')); + await tester.pump(const Duration(seconds: 1)); + expect(tester.state(find.byType(SearchResultScreen)), same(state)); + final chinese = + AppLocalizations.of(tester.element(find.byType(SearchResultScreen)))!; + expect(chinese.comprehensive, isNot(english.comprehensive)); + for (final label in [ + chinese.comprehensive, + chinese.tag, + chinese.collection, + chinese.grain, + chinese.article, + chinese.user + ]) { + expect(find.text(label), findsOneWidget); + } + expect(tester.widget(find.byType(TabBar)).controller, same(tabs)); + expect(tabs.index, 0); + expect(requestCount, inInclusiveRange(loadedRequests, loadedRequests + 1)); + final requestsAfterInitialLoad = requestCount; + await mount(tester, resultsPage: true, locale: const Locale('zh', 'TW')); + await tester.pump(const Duration(seconds: 1)); + final traditional = + AppLocalizations.of(tester.element(find.byType(SearchResultScreen)))!; + expect(find.text(traditional.comprehensive), findsOneWidget); + expect(tabs.index, 0); + expect(requestCount, requestsAfterInitialLoad); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('submitted search ignores its still-pending suggestions', + (tester) async { + await mount(tester, resultsPage: true); + await tester.pump(const Duration(seconds: 1)); + await type(tester, 'submitted'); + expect(pending, isNotEmpty); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(const Duration(seconds: 1)); + for (var index = 0; index < pending.length; index++) { + await respond(tester, index, 'late suggestion'); + } + expect(find.text('late suggestion'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('result suggestions recover from timeout and ignore disposal', + (tester) async { + await mount(tester, resultsPage: true); + await tester.pump(const Duration(seconds: 1)); + await type(tester, 'timeout'); + expect(pending, hasLength(1)); + final field = tester.widget(find.byType(TextField)); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(const Duration(milliseconds: 100)); + expect(pending, hasLength(1)); + final (options, handler) = pending.single; + handler.reject(DioException( + requestOptions: options, type: DioExceptionType.connectionTimeout)); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + await type(tester, 'retry'); + await respond(tester, 1, 'recovered suggestion'); + expect(find.text('recovered suggestion'), findsOneWidget); + await type(tester, 'disposal'); + expect(find.text('recovered suggestion'), findsNothing); + await tester.pumpWidget(const SizedBox()); + final (lastOptions, lastHandler) = pending.last; + lastHandler.reject(DioException( + requestOptions: lastOptions, type: DioExceptionType.connectionTimeout)); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + + testWidgets('late suggestion cannot replace the newer query', (tester) async { + await mount(tester); + await type(tester, 'old'); + await type(tester, 'new'); + expect(pending.length, 2); + await respond(tester, 1, 'new result'); + expect(find.text('new result'), findsOneWidget); + await respond(tester, 0, 'old result'); + expect(find.text('new result'), findsOneWidget); + expect(find.text('old result'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('initial requests completing after disposal do not create tabs', + (tester) async { + holdInitial = true; + await mount(tester); + expect(initial.length, 2); + await tester.pumpWidget(const SizedBox()); + for (final (options, handler) in initial) { + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': {'guessKeywords': [], 'rankList': [], 'configList': []}, + })); + } + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed initial requests can be retried successfully', + (tester) async { + holdInitial = true; + await mount(tester); + for (final (options, handler) in initial) { + handler.reject(DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout, + )); + } + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + final state = tester.state(find.byType(SearchScreen)); + final retries = + Future.wait([state.fetchGuessList(), state.fetchRankList()]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(initial.length, 4); + for (final (options, handler) in initial.skip(2)) { + handler.resolve(Response(requestOptions: options, data: { + 'code': 0, + 'data': {'guessKeywords': [], 'rankList': [], 'configList': []}, + })); + } + await tester.pump(const Duration(milliseconds: 100)); + await retries; + expect(find.byType(SearchScreen), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + testWidgets('clearing and retyping invalidates even the same query', + (tester) async { + await mount(tester); + await type(tester, 'same'); + await type(tester, ''); + await type(tester, 'same'); + await respond(tester, 0, 'stale result'); + expect(find.text('stale result'), findsNothing); + await respond(tester, 1, 'fresh result'); + expect(find.text('fresh result'), findsOneWidget); + await type(tester, ''); + expect(find.text('fresh result'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets( + 'cleared query ignores late failure and accepts subsequent results', + (tester) async { + await mount(tester); + await type(tester, 'obsolete'); + await type(tester, ''); + final (options, handler) = pending.single; + handler.reject(DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout, + )); + await tester.pump(const Duration(milliseconds: 100)); + await type(tester, 'retry'); + await respond(tester, 1, 'retry result'); + expect(find.text('retry result'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('selection does not request again and disposal ignores response', + (tester) async { + await mount(tester); + await type(tester, 'query'); + final field = tester.widget(find.byType(TextField)); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(const Duration(milliseconds: 100)); + expect(pending.length, 1); + await tester.pumpWidget(const SizedBox()); + await respond(tester, 0, 'late result'); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/screens/panel_screen_back_test.dart b/test/screens/panel_screen_back_test.dart new file mode 100644 index 00000000..d32ae403 --- /dev/null +++ b/test/screens/panel_screen_back_test.dart @@ -0,0 +1,124 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/panel_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:provider/provider.dart'; + +void main() { + setUpAll(() async { + final directory = Directory('build/test_hive/panel_restore'); + await directory.create(recursive: true); + Hive.init(directory.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + }); + + test('hiding search preserves the logical page indices', () { + expect( + visiblePanelChoices(hideSearch: false), + SideBarChoice.values, + ); + final choices = visiblePanelChoices(hideSearch: true); + expect(choices, [ + SideBarChoice.Home, + SideBarChoice.Dynamic, + SideBarChoice.Mine, + ]); + expect(choices[1].index, SideBarChoice.Dynamic.index); + expect(choices[2].index, SideBarChoice.Mine.index); + expect(choices.indexOf(SideBarChoice.Search), -1); + expect(visiblePanelPageIndex(SideBarChoice.Home, hideSearch: true), 0); + expect(visiblePanelPageIndex(SideBarChoice.Search, hideSearch: true), -1); + expect(visiblePanelPageIndex(SideBarChoice.Dynamic, hideSearch: true), 1); + expect(visiblePanelPageIndex(SideBarChoice.Mine, hideSearch: true), 2); + }); + + testWidgets('system back delegates to the nested panel when root pop is off', + (tester) async { + var nestedPopCount = 0; + await tester.pumpWidget( + MaterialApp( + home: PanelBackScope( + canRootPop: false, + onNestedPop: () => nestedPopCount++, + child: const SizedBox.expand(), + ), + ), + ); + + await tester.binding.handlePopRoute(); + await tester.pump(); + + expect(nestedPopCount, 1); + }); + + testWidgets('completed root pop does not run nested panel cleanup', + (tester) async { + final navigatorKey = GlobalKey(); + var nestedPopCount = 0; + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Text('root'), + ), + ); + navigatorKey.currentState!.push( + MaterialPageRoute( + builder: (_) => PanelBackScope( + canRootPop: true, + onNestedPop: () => nestedPopCount++, + child: const Text('nested route'), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('nested route'), findsNothing); + expect(find.text('root'), findsOneWidget); + expect(nestedPopCount, 0); + }); + + for (final hideSearch in [false, true]) { + testWidgets( + 'restored navigation page matches selected item (hide search: $hideSearch)', + (tester) async { + appProvider.hideSearchNavigation = hideSearch; + appProvider.sidebarChoice = SideBarChoice.Dynamic; + addTearDown(() { + appProvider.sidebarChoice = SideBarChoice.Home; + appProvider.hideSearchNavigation = false; + }); + + await tester.pumpWidget(ChangeNotifierProvider.value( + value: appProvider, + child: MaterialApp( + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const PanelScreen(); + }), + ), + )); + final controller = + tester.widget(find.byType(PageView)).controller!; + final expectedIndex = hideSearch ? 1 : 2; + expect(controller.initialPage, expectedIndex); + await tester.pump(); + expect(controller.page, expectedIndex.toDouble()); + await tester.pumpWidget(const SizedBox()); + }); + } +} diff --git a/test/screens/setting/filename_setting_screen_test.dart b/test/screens/setting/filename_setting_screen_test.dart new file mode 100644 index 00000000..d38ecdd1 --- /dev/null +++ b/test/screens/setting/filename_setting_screen_test.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Setting/filename_setting_screen.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/filename_setting_screen', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('filename settings fit a narrow large-text screen', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + navigatorKey: chewieProvider.globalNavigatorKey, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return const MediaQuery( + data: MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: FilenameSettingScreen(showTitleBar: false), + ); + }, + ), + ), + ); + await tester.pump(); + + expect(find.byType(InputItem), findsOneWidget); + expect(find.byType(CircleIconButton), findsNWidgets(2)); + expect( + find.byKey(const ValueKey('filename-field-originalName')), + findsOneWidget, + ); + expect(find.byType(Table), findsNothing); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 1)); + }); +} diff --git a/test/screens/setting/management_loading_test.dart b/test/screens/setting/management_loading_test.dart new file mode 100644 index 00000000..eb6b9298 --- /dev/null +++ b/test/screens/setting/management_loading_test.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Setting/blacklist_setting_screen.dart'; +import 'package:loftify/Screens/Setting/tagshield_setting_screen.dart'; +import 'package:loftify/Screens/Setting/userdynamicshield_setting_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Utils/request_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:loftify/Widgets/Item/setting_management_item.dart'; + +class _UnusedCookieManager extends Fake implements CookieManager {} + +void main() { + setUpAll(() async { + final dir = Directory('build/test_hive/management_loading'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + RequestUtil.cookieManager = _UnusedCookieManager(); + appProvider.token = 'test-account'; + EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( + backgroundColor: Colors.transparent, + indicator: LottieFiles.buildLoadingAnimation(40, false), + hapticFeedback: true, + triggerOffset: 56, + maxOverOffset: 84, + radius: 20, + ); + }); + + Future frames(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + Future mount(WidgetTester tester, Widget screen) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return screen; + }), + )); + await frames(tester); + } + + testWidgets('dynamic shield keeps rows until a complete refresh succeeds', + (tester) async { + Finder authorRow(int id) => find.byWidgetPredicate((widget) => + widget is SettingManagementItem && widget.title == 'Author $id'); + final pending = []; + final options = []; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors + .add(InterceptorsWrapper(onRequest: (request, handler) { + pending.add(handler); + options.add(request); + })); + Map author(int id) => { + 'blogId': id, + 'blogName': 'author$id', + 'blogNickName': 'Author $id', + }; + void respond(List rows) => + pending.last.resolve(Response(requestOptions: options.last, data: { + 'code': 0, + 'data': {'blogInfos': rows}, + })); + await mount(tester, const UserDynamicShieldSettingScreen()); + respond([author(1)]); + await frames(tester); + expect(authorRow(1), findsOneWidget); + final refresh = tester.widget(find.byType(EasyRefresh)); + final invalid = Future.sync(refresh.onRefresh!); + await frames(tester); + respond([author(2), {}]); + await frames(tester); + expect(await invalid, IndicatorResult.fail); + expect(authorRow(1), findsOneWidget); + expect(authorRow(2), findsNothing); + final retry = Future.sync(refresh.onRefresh!); + await frames(tester); + respond([author(2)]); + await frames(tester); + expect(await retry, IndicatorResult.success); + expect(authorRow(1), findsNothing); + expect(authorRow(2), findsOneWidget); + final empty = Future.sync(refresh.onRefresh!); + await frames(tester); + respond([]); + await frames(tester); + expect(await empty, IndicatorResult.success); + expect(authorRow(2), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + + for (final screen in [ + const BlacklistSettingScreen(), + const TagShieldSettingScreen(), + const UserDynamicShieldSettingScreen() + ]) { + testWidgets('${screen.runtimeType} ignores failed response after leaving', + (tester) async { + late RequestInterceptorHandler pending; + late RequestOptions options; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors + .add(InterceptorsWrapper(onRequest: (request, handler) { + pending = handler; + options = request; + })); + await mount(tester, screen); + await tester.pumpWidget(const SizedBox()); + pending.resolve(Response(requestOptions: options, data: { + 'meta': {'status': 503, 'desc': 'Unavailable'}, + 'code': 503, + 'desc': 'Unavailable', + })); + await tester.pump(const Duration(seconds: 5)); + expect(tester.takeException(), isNull); + }); + + testWidgets('${screen.runtimeType} can refresh after timeout', + (tester) async { + var requests = 0; + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors + .add(InterceptorsWrapper(onRequest: (options, handler) { + requests++; + if (requests == 1) { + handler.reject(DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout)); + } else { + handler.resolve(Response(requestOptions: options, data: { + 'meta': {'status': 200}, + 'response': {'blogs': [], 'list': []}, + 'code': 0, + 'data': {'blogInfos': []}, + })); + } + })); + await mount(tester, screen); + expect(requests, 1); + final refresh = tester.widget(find.byType(EasyRefresh)); + final result = Future.sync(refresh.onRefresh!); + await frames(tester); + expect(await result, IndicatorResult.success); + expect(requests, 2); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + } +} diff --git a/test/screens/setting/mobile_setting_navigation_screen_test.dart b/test/screens/setting/mobile_setting_navigation_screen_test.dart new file mode 100644 index 00000000..d93686ec --- /dev/null +++ b/test/screens/setting/mobile_setting_navigation_screen_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Setting/mobile_setting_navigation_screen.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/mobile_settings_navigation', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('mobile settings use the shared borderless scaffold', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + navigatorKey: chewieProvider.globalNavigatorKey, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return const MediaQuery( + data: MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: MobileSettingNavigationScreen(), + ); + }, + ), + ), + ); + await tester.pump(); + + final appBar = tester.widget( + find.byType(ResponsiveAppBar), + ); + expect(appBar.showBorder, isFalse); + expect(find.byType(BlankIconButton), findsNothing); + expect(find.byType(EntryItem), findsNWidgets(5)); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 1)); + }); +} diff --git a/test/screens/setting/select_theme_screen_test.dart b/test/screens/setting/select_theme_screen_test.dart new file mode 100644 index 00000000..f4c8b19d --- /dev/null +++ b/test/screens/setting/select_theme_screen_test.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Setting/select_theme_screen.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/select_theme_screen', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('accent colors keep accessible targets on narrow screens', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + navigatorKey: chewieProvider.globalNavigatorKey, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return const MediaQuery( + data: MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: SelectThemeScreen(showTitleBar: false), + ); + }, + ), + ), + ); + await tester.pump(); + + final reset = find.byKey( + const ValueKey('theme-accent-light-reset'), + ); + expect(reset, findsOneWidget); + expect(tester.getSize(reset), const Size(44, 44)); + expect( + tester.getSize(find.byType(ThemeItem).first).height, + greaterThan(136), + ); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 1)); + }); + + testWidgets('theme and add cards expose one full-card action', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final semantics = tester.ensureSemantics(); + final themeData = ChewieThemeColorData.defaultLightThemes.first; + int? selectedIndex; + var longPresses = 0; + var addTaps = 0; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + theme: themeData.toThemeData(), + navigatorKey: chewieProvider.globalNavigatorKey, + localizationsDelegates: const [ChewieLocalizations.delegate], + supportedLocales: ChewieLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return MediaQuery( + data: const MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ThemeItem( + themeColorData: themeData, + index: 0, + groupIndex: 0, + onChanged: (index) => selectedIndex = index, + onLongPress: () => longPresses++, + ), + EmptyThemeItem(onTap: () => addTaps++), + ], + ), + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ChewieSelectionIndicator), findsNothing); + expect(find.byIcon(Icons.radio_button_checked), findsNothing); + expect(find.byIcon(Icons.radio_button_unchecked), findsNothing); + expect( + tester.getSemantics(find.byType(ThemeItem)), + matchesSemantics( + label: themeData.i18nName, + isButton: true, + hasSelectedState: true, + isSelected: true, + hasTapAction: true, + hasLongPressAction: true, + ), + ); + + final themeRect = tester.getRect(find.byType(ThemeItem)); + await tester.tapAt(Offset(themeRect.center.dx, themeRect.bottom - 4)); + await tester.longPress(find.byType(ThemeItem)); + final addRect = tester.getRect(find.byType(EmptyThemeItem)); + await tester.tapAt(Offset(addRect.center.dx, addRect.bottom - 4)); + await tester.pump(); + + expect(selectedIndex, 0); + expect(longPresses, 1); + expect(addTaps, 1); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); +} diff --git a/test/scroll_to_hide_test.dart b/test/scroll_to_hide_test.dart new file mode 100644 index 00000000..73ecffb2 --- /dev/null +++ b/test/scroll_to_hide_test.dart @@ -0,0 +1,186 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _TrackingScrollController extends ScrollController { + int added = 0; + int removed = 0; + + @override + void addListener(VoidCallback listener) { + added++; + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + removed++; + super.removeListener(listener); + } +} + +void main() { + testWidgets('ScrollToHide moves and releases controller listeners', + (tester) async { + final first = _TrackingScrollController(); + final second = _TrackingScrollController(); + final visibility = ScrollToHideController(); + + Widget build(ScrollController controller) { + return MaterialApp( + home: ScrollToHide( + scrollController: controller, + hideDirection: Axis.vertical, + controller: visibility, + child: const SizedBox(height: 40), + ), + ); + } + + await tester.pumpWidget(build(first)); + expect(first.added, 1); + expect(visibility.doHide, isNotNull); + + await tester.pumpWidget(build(second)); + expect(first.removed, 1); + expect(second.added, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(second.removed, 1); + expect(visibility.doShow, isNull); + expect(visibility.doHide, isNull); + + first.dispose(); + second.dispose(); + }); + + testWidgets( + 'multi controller reacts only to the controller that actually scrolls', + (tester) async { + final first = ScrollController(); + final second = ScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + Expanded( + child: ListView.builder( + controller: first, + itemExtent: 60, + itemCount: 40, + itemBuilder: (_, index) => Text('First $index'), + ), + ), + Expanded( + child: ListView.builder( + controller: second, + itemExtent: 60, + itemCount: 40, + itemBuilder: (_, index) => Text('Second $index'), + ), + ), + ], + ), + bottomNavigationBar: ScrollToHide.multi( + scrollControllers: [first, second], + hideDirection: Axis.vertical, + child: const SizedBox(height: 60), + ), + ), + ), + ); + + final opacityFinder = find.byKey( + const ValueKey('scroll-to-hide-opacity'), + ); + await tester.drag(find.text('Second 0'), const Offset(0, -360)); + await tester.pumpAndSettle(); + expect(tester.widget(opacityFinder).opacity, 0); + + await tester.drag(find.text('Second 6'), const Offset(0, 240)); + await tester.pumpAndSettle(); + expect(tester.widget(opacityFinder).opacity, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + first.dispose(); + second.dispose(); + }); + + testWidgets('multi controller swaps active registrations without stale input', + (tester) async { + final first = _TrackingScrollController(); + final second = _TrackingScrollController(); + final visibility = ScrollToHideController(); + var useFirst = true; + late StateSetter rebuild; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + final active = useFirst ? first : second; + return Scaffold( + body: ListView.builder( + key: ValueKey(useFirst), + controller: active, + itemExtent: 60, + itemCount: 40, + itemBuilder: (_, index) => Text('Item $index'), + ), + bottomNavigationBar: ScrollToHide.multi( + controller: visibility, + scrollControllers: [active], + hideDirection: Axis.vertical, + child: const SizedBox(height: 60), + ), + ); + }, + ), + ), + ); + + expect(first.added, 1); + await tester.drag(find.text('Item 0'), const Offset(0, -360)); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const ValueKey('scroll-to-hide-opacity')), + ) + .opacity, + 0, + ); + + rebuild(() => useFirst = false); + await tester.pumpAndSettle(); + expect(first.removed, 1); + expect(second.added, 1); + expect( + tester + .widget( + find.byKey(const ValueKey('scroll-to-hide-opacity')), + ) + .opacity, + 1, + ); + + await tester.drag(find.text('Item 0'), const Offset(0, -360)); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const ValueKey('scroll-to-hide-opacity')), + ) + .opacity, + 0, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(second.removed, 1); + first.dispose(); + second.dispose(); + }); +} diff --git a/test/tab_state_util_test.dart b/test/tab_state_util_test.dart new file mode 100644 index 00000000..6e7caaca --- /dev/null +++ b/test/tab_state_util_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/tab_state_util.dart'; + +void main() { + test('saved tab index restores only while it remains valid', () { + expect(TabStatePreference.restoreIndex(2, 4), 2); + expect(TabStatePreference.restoreIndex(-1, 4), 0); + expect(TabStatePreference.restoreIndex(9, 4), 0); + expect( + TabStatePreference.restoreIndex(9, 4, fallbackIndex: 1), + 1, + ); + expect(TabStatePreference.restoreIndex(1, 0), 0); + }); + + test('lazy tab loading does not repeat a request for a loaded tab', () { + final state = LazyTabLoadState(itemCount: 6, savedIndex: 4); + + expect(state.currentIndex, 4); + expect(state.selectAndShouldLoad(4), isTrue); + expect(state.selectAndShouldLoad(4), isFalse); + expect(state.selectAndShouldLoad(2), isTrue); + expect(state.selectAndShouldLoad(4), isFalse); + }); + + test('stable tab id restores the same source after tabs are reordered', () { + final resolution = TabStatePreference.resolve( + itemIds: const ['grain', 'all', 'tag', 'collection'], + savedId: 'collection', + legacySavedIndex: 2, + ); + + expect(resolution.index, 3); + expect(resolution.id, 'collection'); + expect(resolution.usedFallback, isFalse); + expect(resolution.migratedLegacyIndex, isFalse); + }); + + test('unknown stable id safely falls back instead of trusting stale index', + () { + final resolution = TabStatePreference.resolve( + itemIds: const ['all', 'article', 'user'], + savedId: 'removed-source', + legacySavedIndex: 2, + fallbackId: 'all', + ); + + expect(resolution.index, 0); + expect(resolution.id, 'all'); + expect(resolution.usedFallback, isTrue); + expect(resolution.migratedLegacyIndex, isFalse); + }); + + test('legacy numeric preference migrates only when no stable id exists', () { + final resolution = TabStatePreference.resolve( + itemIds: const ['all', 'tag', 'article'], + legacySavedIndex: 2, + ); + + expect(resolution.index, 2); + expect(resolution.id, 'article'); + expect(resolution.migratedLegacyIndex, isTrue); + }); + + test('page lifecycle policy persists selection but not response or scroll', + () { + const policy = TabStateRetentionPolicy.userFacingTabs; + + expect(policy.selectionAcrossRestart, isTrue); + expect(policy.loadedDataAcrossRestart, isFalse); + expect(policy.scrollOffsetAcrossRestart, isFalse); + }); + + test('failed lazy request is retryable without duplicating successful tabs', + () { + final state = LazyTabLoadState( + itemIds: const ['all', 'tag'], + savedId: 'tag', + ); + + expect(state.selectAndShouldLoad(1), isTrue); + state.markLoadFailed(1); + expect(state.selectAndShouldLoad(1), isTrue); + expect(state.selectAndShouldLoad(1), isFalse); + }); + + test('new data source clears load markers and can return to default tab', () { + final state = LazyTabLoadState(itemCount: 6, savedIndex: 5); + state.selectAndShouldLoad(5); + + state.reset(index: 0); + + expect(state.currentIndex, 0); + expect(state.isCurrentLoaded, isFalse); + expect(state.selectAndShouldLoad(0), isTrue); + }); +} diff --git a/test/tag_filter_state_test.dart b/test/tag_filter_state_test.dart new file mode 100644 index 00000000..08981df2 --- /dev/null +++ b/test/tag_filter_state_test.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Api/tag_api.dart'; +import 'package:loftify/Utils/enums.dart'; + +void main() { + test('tag filter preference restores every user-facing filter', () { + final original = GetTagPostListParams( + tag: 'old-tag', + tagPostResultType: TagPostResultType.month, + recentDayType: TagRecentDayType.oneWeek, + tagRangeType: TagRangeType.follow, + protectedFlag: true, + postTypes: TagPostType.image, + postYm: '2026-08', + ); + + final restored = GetTagPostListParams.fromPreference( + tag: 'current-tag', + value: jsonDecode(jsonEncode(original.toPreferenceJson())), + fallbackResultType: TagPostResultType.week, + ); + + expect(restored.tag, 'current-tag'); + expect(restored.offset, 0); + expect(restored.tagPostResultType, TagPostResultType.month); + expect(restored.recentDayType, TagRecentDayType.oneWeek); + expect(restored.tagRangeType, TagRangeType.follow); + expect(restored.protectedFlag, isTrue); + expect(restored.postTypes, TagPostType.image); + expect(restored.postYm, '2026-08'); + }); + + test('invalid saved filter values fall back without throwing', () { + final restored = GetTagPostListParams.fromPreference( + tag: 'safe-tag', + value: { + 'resultType': 99, + 'recentDayType': -4, + 'rangeType': 'bad', + 'postType': 200, + 'protected': 'true', + 'postYm': 202608, + }, + fallbackResultType: TagPostResultType.newPost, + ); + + expect(restored.tagPostResultType, TagPostResultType.newPost); + expect(restored.recentDayType, TagRecentDayType.noLimit); + expect(restored.tagRangeType, TagRangeType.noLimit); + expect(restored.protectedFlag, isFalse); + expect(restored.postTypes, TagPostType.noLimit); + expect(restored.postYm, isEmpty); + }); +} diff --git a/test/theme/loftify_design_theme_test.dart b/test/theme/loftify_design_theme_test.dart new file mode 100644 index 00000000..3feef319 --- /dev/null +++ b/test/theme/loftify_design_theme_test.dart @@ -0,0 +1,421 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/loftify_design_theme', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + group('LoftifyTheme', () { + test('derives the frozen light and dark semantic surfaces', () { + final light = LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ); + final dark = LoftifyTheme.build( + ChewieThemeColorData.defaultDarkThemes.first, + ); + final lightDesign = light.extension()!; + final darkDesign = dark.extension()!; + + expect(lightDesign.colors.page, const Color(0xFFFFFFFF)); + expect(lightDesign.colors.surface, const Color(0xFFF7F8F7)); + expect(lightDesign.colors.textPrimary, const Color(0xFF202522)); + expect(darkDesign.colors.page, const Color(0xFF121412)); + expect(darkDesign.colors.surface, const Color(0xFF191C1A)); + expect(darkDesign.colors.textPrimary, const Color(0xFFF2F5F3)); + expect(light.scaffoldBackgroundColor, Colors.white); + expect(light.navigationBarTheme.backgroundColor, Colors.white); + expect( + light.cardColor, + ChewieThemeColorData.defaultLightThemes.first.cardColor, + ); + expect(light.cardColor, isNot(Colors.white)); + expect(light.appBarTheme.scrolledUnderElevation, 0); + expect(light.appBarTheme.shadowColor, Colors.transparent); + }); + + test('preserves user-selected accents and independent status hues', () { + const accent = Color(0xFFE91E63); + final source = ChewieThemeColorData.defaultLightThemes.first.copyWith( + primaryColor: accent, + ); + final theme = LoftifyTheme.build(source); + final design = theme.extension()!; + + expect(design.colors.accent, accent); + expect( + HSLColor.fromColor(design.colors.accentForeground).hue, + closeTo(HSLColor.fromColor(accent).hue, 2), + ); + expect(theme.colorScheme.primary, accent); + expect( + HSLColor.fromColor(design.colors.success).hue, + closeTo(HSLColor.fromColor(source.successColor).hue, 2), + ); + expect( + HSLColor.fromColor(design.colors.warning).hue, + closeTo(HSLColor.fromColor(source.warningColor).hue, 2), + ); + expect( + HSLColor.fromColor(design.colors.danger).hue, + closeTo(HSLColor.fromColor(source.errorColor).hue, 2), + ); + expect(design.colors.danger, isNot(accent)); + }); + + test('defines the frozen typography, geometry and motion ladders', () { + final design = LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ).extension()!; + + expect(design.typography.pageTitle.fontSize, 20); + expect(design.typography.body.fontSize, 15); + expect(design.typography.body.height, 1.6); + expect(design.typography.readingBody.fontSize, 17); + expect(design.typography.readingBody.height, 1.8); + expect(design.typography.pageTitle.decoration, TextDecoration.none); + expect( + [ + design.spacing.xxs, + design.spacing.xs, + design.spacing.sm, + design.spacing.md, + design.spacing.lg, + design.spacing.xl, + design.spacing.xxl, + design.spacing.xxxl, + design.spacing.huge, + design.spacing.hero, + ], + [2, 4, 6, 8, 12, 16, 20, 24, 32, 40], + ); + expect(design.radii.card, 14); + expect(design.radii.panel, 20); + expect(design.icons.minimumTapTarget, 48); + expect(design.motion.press, const Duration(milliseconds: 90)); + expect(design.motion.panel, const Duration(milliseconds: 260)); + + // Design roles are opt-in. Legacy Material roles keep the original app + // scale so screens that apply local font deltas are not enlarged twice. + final theme = LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ); + expect(theme.textTheme.titleLarge!.fontSize, 18); + expect(theme.textTheme.titleMedium!.fontSize, 16); + expect(theme.textTheme.titleSmall!.fontSize, 14); + expect(theme.textTheme.bodyLarge!.fontSize, 16); + expect(theme.textTheme.bodyMedium!.fontSize, 14); + }); + + test('supports copyWith and interpolates theme changes', () { + final light = LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ).extension()!; + final dark = LoftifyTheme.build( + ChewieThemeColorData.defaultDarkThemes.first, + ).extension()!; + final replaced = light.copyWith( + colors: light.colors.copyWith(accent: Colors.purple), + ); + final middle = light.lerp(dark, 0.5); + + expect(replaced.colors.accent, Colors.purple); + expect(replaced.spacing.xl, light.spacing.xl); + expect( + middle.colors.page, + Color.lerp(light.colors.page, dark.colors.page, 0.5), + ); + expect(middle.radii.card, 14); + }); + + testWidgets('components can resolve a safe token fallback during reload', ( + tester, + ) async { + late LoftifyDesignThemeData resolved; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(), + home: Builder( + builder: (context) { + resolved = LoftifyDesignThemeData.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(resolved.colors.page, Colors.white); + expect(resolved.icons.minimumTapTarget, 48); + expect(tester.takeException(), isNull); + }); + + test('every semantic text level meets AA contrast on neutral surfaces', () { + final failures = []; + for (final source in [ + ChewieThemeColorData.defaultLightThemes.first, + ChewieThemeColorData.defaultDarkThemes.first, + ]) { + final colors = LoftifyTheme.build( + source, + ).extension()!.colors; + for (final background in [ + colors.page, + colors.surface, + colors.surfaceRaised, + colors.surfaceMuted, + ]) { + for (final foreground in [ + colors.textPrimary, + colors.textSecondary, + colors.textMuted, + ]) { + final ratio = _contrastRatio(foreground, background); + if (ratio < 4.5) { + failures.add( + '${source.id}: $foreground on $background is ' + '${ratio.toStringAsFixed(3)}:1', + ); + } + } + } + } + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('accent foreground meets AA contrast across built-in themes', () { + for (final source in [ + ...ChewieThemeColorData.defaultLightThemes, + ...ChewieThemeColorData.defaultDarkThemes, + ]) { + final colors = LoftifyTheme.build( + source, + ).extension()!.colors; + expect( + _contrastRatio(colors.onAccent, colors.accent), + greaterThanOrEqualTo(4.5), + reason: '${source.id} needs a readable foreground on its accent', + ); + } + }); + + test('custom bright accents choose a readable foreground', () { + for (final accent in [ + const Color(0xFF14C2BB), + const Color(0xFFFF9800), + const Color(0xFF4CAF50), + ]) { + final source = ChewieThemeColorData.defaultLightThemes.first.copyWith( + primaryColor: accent, + ); + final colors = LoftifyTheme.build( + source, + ).extension()!.colors; + expect( + _contrastRatio(colors.onAccent, colors.accent), + greaterThanOrEqualTo(4.5), + reason: '$accent needs a readable foreground', + ); + } + }); + + test('accent strokes and labels stay readable on neutral surfaces', () { + final sources = [ + ...ChewieThemeColorData.defaultLightThemes, + ...ChewieThemeColorData.defaultDarkThemes, + for (final accent in [ + const Color(0xFF14C2BB), + const Color(0xFFFF9800), + const Color(0xFF4CAF50), + ]) + ChewieThemeColorData.defaultLightThemes.first.copyWith( + id: 'Custom-$accent', + primaryColor: accent, + ), + ]; + final failures = []; + for (final source in sources) { + final colors = LoftifyTheme.build( + source, + ).extension()!.colors; + for (final background in [ + colors.page, + colors.surface, + colors.surfaceRaised, + colors.surfaceMuted, + ]) { + final ratio = _contrastRatio(colors.accentForeground, background); + if (ratio < 4.5) { + failures.add( + '${source.id}: ${colors.accentForeground} on $background is ' + '${ratio.toStringAsFixed(3)}:1', + ); + } + } + } + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('Material interaction themes use the readable accent foreground', () { + final theme = LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ); + final colors = theme.extension()!.colors; + final focusedBorder = + theme.inputDecorationTheme.focusedBorder! as OutlineInputBorder; + + expect(focusedBorder.borderSide.color, colors.accentForeground); + expect( + theme.textButtonTheme.style!.foregroundColor!.resolve({}), + colors.accentForeground, + ); + expect( + theme.outlinedButtonTheme.style!.foregroundColor!.resolve({}), + colors.accentForeground, + ); + expect( + theme.bottomNavigationBarTheme.selectedItemColor, + colors.accentForeground, + ); + expect(theme.filledButtonTheme.style!.backgroundColor!.resolve({}), + colors.accent); + }); + + test('interactive outlines reach non-text contrast on every surface', () { + final failures = []; + for (final source in [ + ...ChewieThemeColorData.defaultLightThemes, + ...ChewieThemeColorData.defaultDarkThemes, + ]) { + final colors = LoftifyTheme.build( + source, + ).extension()!.colors; + for (final background in [ + colors.page, + colors.surface, + colors.surfaceRaised, + colors.surfaceMuted, + ]) { + final ratio = _contrastRatio(colors.outlineStrong, background); + if (ratio < 3) { + failures.add( + '${source.id}: ${colors.outlineStrong} on $background is ' + '${ratio.toStringAsFixed(3)}:1', + ); + } + } + } + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('accent container content meets AA contrast in every theme', () { + final sources = [ + ...ChewieThemeColorData.defaultLightThemes, + ...ChewieThemeColorData.defaultDarkThemes, + for (final accent in [ + const Color(0xFF14C2BB), + const Color(0xFFFF9800), + const Color(0xFF4CAF50), + ]) + ChewieThemeColorData.defaultLightThemes.first.copyWith( + id: 'Custom-$accent', + primaryColor: accent, + ), + ]; + for (final source in sources) { + final colors = LoftifyTheme.build( + source, + ).extension()!.colors; + expect( + _contrastRatio(colors.onAccentContainer, colors.accentContainer), + greaterThanOrEqualTo(4.5), + reason: '${source.id} needs readable tonal content', + ); + } + }); + + test('semantic status colors remain readable on page and tinted surfaces', + () { + for (final source in [ + ...ChewieThemeColorData.defaultLightThemes, + ...ChewieThemeColorData.defaultDarkThemes, + ]) { + final colors = LoftifyTheme.build( + source, + ).extension()!.colors; + for (final status in [ + colors.success, + colors.warning, + colors.danger, + ]) { + final tintedPage = Color.alphaBlend( + status.withValues(alpha: 0.10), + colors.page, + ); + final tintedRaised = Color.alphaBlend( + status.withValues(alpha: 0.10), + colors.surfaceRaised, + ); + for (final background in [ + colors.page, + colors.surfaceRaised, + tintedPage, + tintedRaised, + ]) { + expect( + _contrastRatio(status, background), + greaterThanOrEqualTo(4.5), + reason: '${source.id} status color needs readable contrast', + ); + } + } + } + }); + }); + + group('LoftifyGridTokens', () { + const grid = LoftifyGridTokens(); + + test('uses width rather than device labels for responsive classes', () { + expect(grid.windowClassFor(599), LoftifyWindowClass.compact); + expect(grid.windowClassFor(600), LoftifyWindowClass.medium); + expect(grid.windowClassFor(840), LoftifyWindowClass.expanded); + expect(grid.windowClassFor(1200), LoftifyWindowClass.large); + expect(grid.pagePaddingFor(320), 12); + expect(grid.pagePaddingFor(390), 16); + expect(grid.pagePaddingFor(700), 24); + expect(grid.denseFeedPagePaddingFor(320), 8); + expect(grid.denseFeedPagePaddingFor(390), 10); + expect(grid.denseFeedPagePaddingFor(700), 24); + }); + + test('keeps card grids bounded from narrow phone to large desktop', () { + expect(grid.contentColumnCount(320), 1); + expect(grid.contentColumnCount(390), 2); + expect(grid.contentColumnCount(700), 3); + expect(grid.contentColumnCount(2560), 6); + expect(grid.maximumDenseCardExtent, 300); + expect(grid.maximumReadingWidth, 720); + }); + }); +} + +double _contrastRatio(Color foreground, Color background) { + final lighter = foreground.computeLuminance() > background.computeLuminance() + ? foreground + : background; + final darker = identical(lighter, foreground) ? background : foreground; + return (lighter.computeLuminance() + 0.05) / + (darker.computeLuminance() + 0.05); +} diff --git a/test/tool/capture_flutter_render_test.dart b/test/tool/capture_flutter_render_test.dart new file mode 100644 index 00000000..7ed59b01 --- /dev/null +++ b/test/tool/capture_flutter_render_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../tool/capture_flutter_render.dart'; + +void main() { + test('converts HTTP VM service URIs to inspector WebSocket URIs', () { + expect( + flutterRenderWebSocketUri( + Uri.parse('http://127.0.0.1:58789/secure-token=/'), + ), + 'ws://127.0.0.1:58789/secure-token=/ws', + ); + expect( + flutterRenderWebSocketUri( + Uri.parse('https://example.test/service/'), + ), + 'wss://example.test/service/ws', + ); + }); + + test('keeps an existing WebSocket screenshot endpoint stable', () { + expect( + flutterRenderWebSocketUri( + Uri.parse('ws://127.0.0.1:58789/secure-token=/ws'), + ), + 'ws://127.0.0.1:58789/secure-token=/ws', + ); + }); +} diff --git a/test/video_list_controller_test.dart b/test/video_list_controller_test.dart new file mode 100644 index 00000000..9b6ae75b --- /dev/null +++ b/test/video_list_controller_test.dart @@ -0,0 +1,657 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Models/recommend_response.dart'; +import 'package:loftify/Screens/Post/video_detail_screen.dart'; +import 'package:loftify/Screens/Post/video_list_controller.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; +import 'package:loftify/l10n/l10n.dart'; +import 'package:video_player/video_player.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeVideoPlayerPlatform platform; + + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/video_list_controller', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + setUp(() { + platform = FakeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = platform; + }); + + test('video back exits fullscreen before closing author or route', () { + expect( + resolveVideoBackTarget( + fullScreenTransitionInProgress: false, + fullScreen: true, + authorVisible: true, + ), + VideoBackTarget.exitFullScreen, + ); + expect( + resolveVideoBackTarget( + fullScreenTransitionInProgress: true, + fullScreen: false, + authorVisible: false, + ), + VideoBackTarget.wait, + ); + expect( + resolveVideoBackTarget( + fullScreenTransitionInProgress: false, + fullScreen: false, + authorVisible: true, + ), + VideoBackTarget.closeAuthor, + ); + expect( + resolveVideoBackTarget( + fullScreenTransitionInProgress: false, + fullScreen: false, + authorVisible: false, + ), + VideoBackTarget.popPage, + ); + }); + + CustomVideoController createPlayer([String suffix = 'video']) { + return CustomVideoController( + builder: () => VideoPlayerController.networkUrl( + Uri.parse('https://example.com/$suffix.mp4'), + ), + ); + } + + test('pause before initialization does not allocate a native player', + () async { + final player = createPlayer(); + + await player.pause(showPauseIcon: true); + + expect(platform.createCount, 0); + expect(player.loadState, VideoLoadState.idle); + expect(player.controllerOrNull, isNull); + await player.close(); + }); + + test('failed initialization releases resources and retry can autoplay', + () async { + platform.failNextInitialization = true; + final player = createPlayer(); + + await player.init(); + + expect(player.loadState, VideoLoadState.failed); + expect(player.controllerOrNull, isNull); + expect(platform.disposedIds, contains(1)); + + await player.retry(autoplay: true); + + expect(player.loadState, VideoLoadState.ready); + expect(player.prepared, isTrue); + expect(player.isPlaying, isTrue); + expect(platform.playedIds, contains(2)); + await player.close(); + expect(platform.disposedIds, contains(2)); + }); + + test('released controller can initialize a fresh native player', () async { + final player = createPlayer(); + await player.init(); + final firstController = player.controllerOrNull; + + await player.release(); + await player.init(); + + expect(platform.createCount, 2); + expect(player.controllerOrNull, isNot(same(firstController))); + expect(player.loadState, VideoLoadState.ready); + await player.close(); + }); + + test('player keeps looping and playback speed preferences across reloads', + () async { + final player = CustomVideoController( + looping: false, + playbackSpeed: 1.5, + builder: () => VideoPlayerController.networkUrl( + Uri.parse('https://example.com/video.mp4'), + ), + ); + + await player.init(); + expect(platform.loopingValues.last, isFalse); + expect(player.controllerOrNull!.value.playbackSpeed, 1.5); + await player.play(); + expect(platform.playbackSpeeds.last, 1.5); + + await player.setLooping(true); + await player.setPlaybackSpeed(2); + expect(platform.loopingValues.last, isTrue); + expect(platform.playbackSpeeds.last, 2); + + await player.release(); + await player.init(); + expect(platform.loopingValues.last, isTrue); + expect(player.controllerOrNull!.value.playbackSpeed, 2); + await player.play(); + expect(platform.playbackSpeeds.last, 2); + await player.close(); + }); + + test('video list allows only the selected player to keep playing', () async { + final first = createPlayer('first'); + final second = createPlayer('second'); + final list = VideoListController(preloadCount: 1); + + await list.init( + initialList: [first, second], + videoProvider: (_, __) async => const [], + ); + await _waitUntil(() => second.prepared); + expect(first.isPlaying, isTrue); + expect(second.prepared, isTrue); + expect(second.isPlaying, isFalse); + + await list.loadIndex(1); + + expect(first.isPlaying, isFalse); + expect(second.isPlaying, isTrue); + expect(list.currentPlayerOrNull, same(second)); + list.dispose(); + await Future.delayed(Duration.zero); + }); + + testWidgets('long press expands the immersive scrubber and seeks smoothly', + (tester) async { + final player = createPlayer(); + await player.play(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + width: 400, + child: ImmersiveVideoProgressBar( + player: player, + canResume: () => true, + semanticLabel: 'Video', + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final progress = find.byType(ImmersiveVideoProgressBar); + final playedTrack = find.byWidgetPredicate( + (widget) => widget is ColoredBox && widget.color == Colors.white, + ); + expect(tester.getSize(playedTrack).height, 3); + final gesture = await tester.startGesture(tester.getCenter(progress)); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50)); + await tester.pump(); + + expect(player.isPlaying, isFalse); + expect(find.text('0:15 / 0:30'), findsOneWidget); + expect(tester.getSize(playedTrack), const Size(200, 7)); + + await gesture.moveBy(const Offset(120, 0)); + await tester.pump(const Duration(milliseconds: 60)); + expect( + platform.soughtPositions.last, + greaterThan( + const Duration(seconds: 20), + )); + + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + expect(player.isPlaying, isTrue); + expect(find.textContaining(' / '), findsNothing); + + unawaited(player.pause()); + await tester.pump(const Duration(milliseconds: 100)); + expect(player.isPlaying, isFalse); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgets('screen-edge long press temporarily accelerates playback', + (tester) async { + final player = createPlayer(); + await player.play(); + var menuOpens = 0; + + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: 400, + height: 600, + child: VideoLongPressGesture( + player: player, + onOpenMenu: () => menuOpens++, + child: const ColoredBox(color: Colors.black), + ), + ), + ), + ); + + final edgeGesture = await tester.startGesture(const Offset(12, 240)); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50)); + expect(platform.playbackSpeeds.last, 2); + expect(find.text('2x'), findsOneWidget); + + await edgeGesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + expect(platform.playbackSpeeds.last, 1); + expect(find.text('2x'), findsNothing); + + expect(menuOpens, 0); + unawaited(player.pause()); + await tester.pump(const Duration(milliseconds: 100)); + expect(player.isPlaying, isFalse); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgets('danmaku lanes do not overlap and pause with the video', + (tester) async { + final player = createPlayer(); + await player.play(); + final messages = List.generate( + 8, + (index) => 'Long danmaku message $index that fills its lane safely', + ); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 400, + height: 320, + child: ColoredBox( + color: Colors.black, + child: DanmakuOverlay(messages: messages, player: player), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 4100)); + + final firstRect = tester.getRect(find.byKey(const ValueKey('danmaku-0'))); + final nextRect = tester.getRect(find.byKey(const ValueKey('danmaku-4'))); + expect(firstRect.right, lessThan(nextRect.left)); + + final beforePause = tester.getTopLeft( + find.byKey(const ValueKey('danmaku-4')), + ); + await player.pause(); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + final afterPause = tester.getTopLeft( + find.byKey(const ValueKey('danmaku-4')), + ); + expect(afterPause.dx, closeTo(beforePause.dx, 0.01)); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgets('danmaku travel is independent of 60, 90 and 120Hz cadence', + (tester) async { + Future measureAt(int refreshRate) async { + final player = createPlayer(); + await player.play(); + await tester.pumpWidget( + MaterialApp( + key: ValueKey(refreshRate), + home: Center( + child: SizedBox( + width: 400, + height: 320, + child: ColoredBox( + color: Colors.black, + child: DanmakuOverlay( + messages: const ['Cadence-independent danmaku'], + player: player, + ), + ), + ), + ), + ), + ); + final frame = Duration( + microseconds: (Duration.microsecondsPerSecond / refreshRate).round(), + ); + var remaining = const Duration(seconds: 2); + while (remaining > Duration.zero) { + final step = remaining < frame ? remaining : frame; + await tester.pump(step); + remaining -= step; + } + final left = tester + .getTopLeft( + find.byKey(const ValueKey('danmaku-0')), + ) + .dx; + unawaited(player.pause()); + await tester.pump(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 100)); + return left; + } + + final left60 = await measureAt(60); + final left90 = await measureAt(90); + final left120 = await measureAt(120); + + expect(left60, closeTo(left90, 0.25)); + expect(left90, closeTo(left120, 0.25)); + }); + + testWidgets('followed video author does not show an avatar status badge', + (tester) async { + var likeTaps = 0; + final author = SimpleBlogInfo( + bigAvaImg: '', + blogId: 1, + blogName: 'author', + blogNickName: 'Author', + extraBits: 0, + ); + + Widget buildButtons({required bool following, int likeCount = 0}) { + return MaterialApp( + home: Scaffold( + backgroundColor: Colors.black, + body: VideoListButtonColumn( + blogInfo: author, + likeCount: likeCount, + shareCount: 0, + commentCount: 0, + isFollowing: following, + showDownloadButton: false, + onLike: () => likeTaps++, + ), + ), + ); + } + + await tester.pumpWidget(buildButtons(following: true)); + expect(find.byIcon(LoftifyIcons.check), findsNothing); + expect(find.byIcon(LoftifyIcons.add), findsNothing); + + await tester.pumpWidget(buildButtons(following: false)); + expect(find.byIcon(LoftifyIcons.add), findsOneWidget); + + await tester.pumpWidget( + buildButtons(following: true, likeCount: 29509), + ); + expect(find.text('2.9w'), findsOneWidget); + final compactCount = tester.widget(find.text('2.9w')); + expect(compactCount.maxLines, 1); + expect(compactCount.softWrap, isFalse); + await tester.tap(find.text('2.9w')); + expect(likeTaps, 1); + }); + + testWidgets('author page follows the horizontal drag and handles back', + (tester) async { + var opened = 0; + var closed = 0; + await tester.pumpWidget( + MaterialApp( + home: SizedBox.expand( + child: InteractiveAuthorSwipe( + authorId: 1, + onOpen: () => opened++, + onClose: () => closed++, + authorBuilder: (_) => const ColoredBox( + color: Colors.white, + child: Center(child: Text('Author page')), + ), + child: const ColoredBox(color: Colors.black), + ), + ), + ), + ); + + final swipe = find.byType(InteractiveAuthorSwipe); + final gesture = await tester.startGesture( + tester.getTopRight(swipe) + const Offset(-10, 200), + ); + await gesture.moveBy(const Offset(-120, 0)); + await tester.pump(); + + expect(find.text('Author page'), findsOneWidget); + final authorSwipeState = tester.state(swipe); + expect(authorSwipeState.isAuthorVisible, isTrue); + final movingAuthor = tester.widget( + find.byKey(const ValueKey('interactive-author-pane')), + ); + expect( + movingAuthor.transform.getTranslation().x, + inExclusiveRange(0, tester.getSize(swipe).width), + ); + + await gesture.moveBy(const Offset(-240, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(opened, 1); + expect( + tester + .widget( + find.byKey(const ValueKey('interactive-author-pane')), + ) + .transform + .getTranslation() + .x, + 0, + ); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(closed, 1); + expect(authorSwipeState.isAuthorVisible, isFalse); + expect( + tester + .widget( + find.byKey(const ValueKey('interactive-video-pane')), + ) + .transform + .getTranslation() + .x, + 0, + ); + }); + + testWidgets('changing author content preserves the video page position', + (tester) async { + var authorId = 1; + late StateSetter updateHost; + final pageController = PageController(); + addTearDown(pageController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + updateHost = setState; + return InteractiveAuthorSwipe( + authorId: authorId, + authorBuilder: (_) => const ColoredBox(color: Colors.white), + child: PageView( + controller: pageController, + scrollDirection: Axis.vertical, + children: const [ + ColoredBox(color: Colors.black), + ColoredBox(color: Colors.blue), + ColoredBox(color: Colors.red), + ], + ), + ); + }, + ), + ), + ); + + pageController.jumpToPage(1); + await tester.pump(); + expect(pageController.page, 1); + + updateHost(() => authorId = 2); + await tester.pump(); + + expect(pageController.page, 1); + }); + + testWidgets('video action sheet updates playback controls', (tester) async { + bool? continuous; + bool? danmaku; + double? speed; + await tester.pumpWidget( + MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + locale: const Locale('zh', 'CN'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return VideoPlaybackActionsSheet( + continuousPlayback: false, + danmakuEnabled: false, + playbackSpeed: 1, + downloading: false, + onContinuousPlaybackChanged: (value) => continuous = value, + onDanmakuChanged: (value) => danmaku = value, + onPlaybackSpeedChanged: (value) => speed = value, + onShare: () {}, + onDownload: () {}, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('自动连播')); + await tester.tap(find.text('弹幕')); + await tester.tap(find.text('1.5x')); + await tester.pump(); + + expect(continuous, isTrue); + expect(danmaku, isTrue); + expect(speed, 1.5); + }); +} + +Future _waitUntil(bool Function() condition) async { + for (var attempt = 0; attempt < 50 && !condition(); attempt++) { + await Future.delayed(const Duration(milliseconds: 2)); + } +} + +class FakeVideoPlayerPlatform extends VideoPlayerPlatform { + final Map> _streams = {}; + final Set disposedIds = {}; + final List playedIds = []; + final List soughtPositions = []; + final List loopingValues = []; + final List playbackSpeeds = []; + int createCount = 0; + bool failNextInitialization = false; + + @override + Future init() async {} + + @override + Future createWithOptions(VideoCreationOptions options) async { + final id = ++createCount; + _streams[id] = StreamController(); + return id; + } + + @override + Stream videoEventsFor(int playerId) { + final stream = _streams[playerId]!; + final shouldFail = failNextInitialization; + failNextInitialization = false; + scheduleMicrotask(() { + if (shouldFail) { + stream.addError(PlatformException( + code: 'video_test_error', + message: 'Could not load video', + )); + } else { + stream.add(VideoEvent( + eventType: VideoEventType.initialized, + duration: const Duration(seconds: 30), + size: const Size(1080, 1920), + )); + } + }); + return stream.stream; + } + + @override + Future dispose(int playerId) async { + disposedIds.add(playerId); + await _streams.remove(playerId)?.close(); + } + + @override + Future play(int playerId) async { + playedIds.add(playerId); + } + + @override + Future pause(int playerId) async {} + + @override + Future setLooping(int playerId, bool looping) async { + loopingValues.add(looping); + } + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future seekTo(int playerId, Duration position) async { + soughtPositions.add(position); + } + + @override + Future setPlaybackSpeed(int playerId, double speed) async { + playbackSpeeds.add(speed); + } + + @override + Future getPosition(int playerId) async => Duration.zero; + + @override + Future setMixWithOthers(bool mixWithOthers) async {} + + @override + Widget buildView(int playerId) => const SizedBox.shrink(); +} diff --git a/test/widgets/comment/collection_panel_layout_test.dart b/test/widgets/comment/collection_panel_layout_test.dart new file mode 100644 index 00000000..9438e905 --- /dev/null +++ b/test/widgets/comment/collection_panel_layout_test.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/BottomSheet/collection_bottom_sheet.dart'; +import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; + +void main() { + testWidgets('short large-text collection panel preserves one scroll viewport', + (tester) async { + await tester.pumpWidget( + _host( + size: const Size(320, 420), + textScaler: const TextScaler.linear(2), + child: LoftifyCollectionPanelFrame( + body: ListView.builder( + itemCount: 12, + itemBuilder: (context, index) => SizedBox( + height: 60, + child: Text('Collection post $index'), + ), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('loftify-panel-handle')), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.scrollUntilVisible( + find.text('Collection post 11'), + 180, + scrollable: find.byType(Scrollable).last, + ); + expect(find.text('Collection post 11'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + test('collection grid keeps three true square columns on phones', () { + final geometry = LoftifyArchiveGridGeometry.calculate(296); + expect(geometry.columnCount, 3); + expect(geometry.tileExtent, closeTo((296 - 12) / 3, 0.001)); + + final wide = LoftifyArchiveGridGeometry.calculate(1000); + expect(wide.columnCount, 6); + expect(wide.tileExtent, closeTo((1000 - 30) / 6, 0.001)); + }); + + test('live collection sheet uses semantic panel and real cell extent', () { + final source = File( + 'lib/Widgets/BottomSheet/collection_bottom_sheet.dart', + ).readAsStringSync(); + expect(source, contains('LoftifyCollectionPanelFrame(')); + expect(source, contains('LoftifyPanel(')); + expect(source, contains('LoftifyPostArchiveGrid(')); + expect(source, contains('wh: tileExtent')); + expect(source, isNot(contains('GridView.extent('))); + expect(source, isNot(contains('Radius.circular(20)'))); + }); +} + +Widget _host({ + required Size size, + required Widget child, + TextScaler textScaler = TextScaler.noScaling, +}) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: Colors.teal), + home: Scaffold( + body: MediaQuery( + data: MediaQueryData(size: size, textScaler: textScaler), + child: Align(alignment: Alignment.bottomCenter, child: child), + ), + ), + ); +} diff --git a/test/widgets/comment/comment_author_badge_test.dart b/test/widgets/comment/comment_author_badge_test.dart new file mode 100644 index 00000000..6b174256 --- /dev/null +++ b/test/widgets/comment/comment_author_badge_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Models/post_detail_response.dart'; +import 'package:loftify/Widgets/Item/loftify_item_builder.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/comment_author_badge', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('author badge stays next to nickname, clear of the like action', + (tester) async { + final comment = Comment.fromJson({ + 'blogId': 1, + 'postId': 2, + 'content': 'Comment', + 'publisherBlogInfo': { + 'blogId': 1, + 'blogName': 'short', + 'blogNickName': '短昵称', + 'bigAvaImg': '', + }, + }); + + await tester.pumpWidget(MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: SizedBox( + width: 320, + child: LoftifyItemBuilder.buildL2CommentRow( + context, + comment, + writerId: 1, + ), + ), + ); + }), + )); + await tester.pump(); + + final nickname = tester.getRect(find.text('短昵称')); + final badge = tester.getRect(find.text('作者')); + expect(badge.left - nickname.right, lessThan(12)); + expect(badge.left, greaterThan(nickname.left)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/comment/comment_panel_layout_test.dart b/test/widgets/comment/comment_panel_layout_test.dart new file mode 100644 index 00000000..978f4743 --- /dev/null +++ b/test/widgets/comment/comment_panel_layout_test.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/BottomSheet/comment_bottom_sheet.dart'; +import 'package:loftify/Widgets/Design/loftify_surfaces.dart'; + +void main() { + testWidgets('short large-text panel keeps header stable and list scrollable', + (tester) async { + await tester.pumpWidget( + _host( + size: const Size(320, 300), + textScaler: const TextScaler.linear(2), + child: LoftifyCommentPanel( + title: 'Latest comments with a long localized title', + body: ListView.builder( + key: const ValueKey('comment-list'), + physics: const ClampingScrollPhysics(), + itemCount: 12, + itemBuilder: (context, index) => SizedBox( + height: 64, + child: Text('Comment $index'), + ), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('loftify-panel-handle')), findsOneWidget); + expect(tester.getSize(find.byKey(const ValueKey('loftify-panel-handle'))), + const Size(36, 4)); + final panelClip = tester.widget( + find.descendant( + of: find.byType(LoftifyPanel), + matching: find.byType(ClipRRect), + ), + ); + expect((panelClip.borderRadius! as BorderRadius).topLeft.x, 20); + expect(find.textContaining('Latest comments'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.scrollUntilVisible( + find.text('Comment 11'), + 180, + scrollable: find.byType(Scrollable).last, + ); + expect(find.text('Comment 11'), findsOneWidget); + expect(find.textContaining('Latest comments'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('keyboard inset reduces comment panel instead of hiding it', + (tester) async { + await tester.pumpWidget( + _host( + size: const Size(360, 640), + viewInsets: const EdgeInsets.only(bottom: 280), + child: const LoftifyCommentPanel( + title: 'Latest comments', + body: SizedBox.expand(), + ), + ), + ); + + final panel = tester.getSize( + find.byKey(const ValueKey('loftify-comment-panel')), + ); + expect(panel.height, closeTo((640 - 280) * 0.82, 0.01)); + expect(tester.takeException(), isNull); + }); + + test('live comment sheet uses the shared panel without a list header clone', + () { + final source = File( + 'lib/Widgets/BottomSheet/comment_bottom_sheet.dart', + ).readAsStringSync(); + expect(source, contains('LoftifyCommentPanel(')); + expect(source, contains('LoftifyPanel(')); + expect(source, isNot(contains('SliverPersistentHeader('))); + expect(source, isNot(contains('Radius.circular(16)'))); + }); +} + +Widget _host({ + required Size size, + required Widget child, + TextScaler textScaler = TextScaler.noScaling, + EdgeInsets viewInsets = EdgeInsets.zero, +}) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: Colors.teal), + home: Scaffold( + body: MediaQuery( + data: MediaQueryData( + size: size, + textScaler: textScaler, + viewInsets: viewInsets, + ), + child: Align(alignment: Alignment.bottomCenter, child: child), + ), + ), + ); +} diff --git a/test/widgets/comment/subscribe_panel_layout_test.dart b/test/widgets/comment/subscribe_panel_layout_test.dart new file mode 100644 index 00000000..14b4d9a3 --- /dev/null +++ b/test/widgets/comment/subscribe_panel_layout_test.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/BottomSheet/subscribe_post_bottom_sheet.dart'; + +void main() { + testWidgets('one favorite folder does not stretch the panel to screen height', + (tester) async { + await tester.pumpWidget( + _host( + size: const Size(390, 800), + child: const LoftifySubscribePanelFrame( + title: '选择收藏夹', + createLabel: '新建', + onCreate: _noop, + itemCount: 1, + body: SizedBox.expand(), + footer: SizedBox(height: 48), + ), + ), + ); + + expect( + tester.getSize(find.byKey(const ValueKey('loftify-subscribe-panel'))) + .height, + closeTo(300, 0.01), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('short large-text folder panel keeps every action reachable', + (tester) async { + var createTaps = 0; + var confirmTaps = 0; + await tester.pumpWidget( + _host( + size: const Size(320, 480), + textScaler: const TextScaler.linear(2), + child: LoftifySubscribePanelFrame( + title: 'Select a favorite folder with a long title', + createLabel: 'Create a new folder', + onCreate: () => createTaps++, + body: ListView( + children: const [SizedBox(height: 600, child: Text('Folders'))], + ), + footer: LoftifyResponsivePanelActions( + secondary: OutlinedButton( + key: const ValueKey('cancel-folder'), + onPressed: _noop, + child: const Text('Cancel'), + ), + primary: FilledButton( + key: const ValueKey('confirm-folder'), + onPressed: () => confirmTaps++, + child: const Text('Confirm selection'), + ), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('loftify-panel-handle')), findsOneWidget); + expect(find.byKey(const ValueKey('confirm-folder')), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.text('Create a new folder')); + await tester.tap(find.byKey(const ValueKey('confirm-folder'))); + expect(createTaps, 1); + expect(confirmTaps, 1); + }); + + testWidgets('visible keyboard reduces folder panel height', (tester) async { + await tester.pumpWidget( + _host( + size: const Size(360, 700), + viewInsets: const EdgeInsets.only(bottom: 300), + child: const LoftifySubscribePanelFrame( + title: 'Folders', + createLabel: 'New', + onCreate: _noop, + body: SizedBox.expand(), + footer: SizedBox(height: 48), + ), + ), + ); + + final size = tester.getSize( + find.byKey(const ValueKey('loftify-subscribe-panel')), + ); + expect(size.height, closeTo((700 - 300) * 0.92, 0.01)); + expect(tester.takeException(), isNull); + }); + + test('live folder sheet no longer owns a custom panel shell', () { + final source = File( + 'lib/Widgets/BottomSheet/subscribe_post_bottom_sheet.dart', + ).readAsStringSync(); + expect(source, contains('LoftifyPanel(')); + expect(source, contains('LoftifyResponsivePanelActions(')); + expect(source, isNot(contains('RoundIconTextButton('))); + expect(source, isNot(contains('MyDivider('))); + expect(source, isNot(contains('Radius.circular(20)'))); + }); +} + +void _noop() {} + +Widget _host({ + required Size size, + required Widget child, + TextScaler textScaler = TextScaler.noScaling, + EdgeInsets viewInsets = EdgeInsets.zero, +}) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: Colors.teal), + home: Scaffold( + body: MediaQuery( + data: MediaQueryData( + size: size, + textScaler: textScaler, + viewInsets: viewInsets, + ), + child: Align(alignment: Alignment.bottomCenter, child: child), + ), + ), + ); +} diff --git a/test/widgets/design/loftify_content_reference_test.dart b/test/widgets/design/loftify_content_reference_test.dart new file mode 100644 index 00000000..a1c27570 --- /dev/null +++ b/test/widgets/design/loftify_content_reference_test.dart @@ -0,0 +1,301 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Design/loftify_content_reference.dart'; +import 'package:loftify/Widgets/Design/loftify_surfaces.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/content_reference', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + Widget buildApp( + Widget child, { + bool dark = false, + double textScale = 1, + }) { + return MaterialApp( + theme: LoftifyTheme.build( + dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first, + ), + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: Scaffold(body: SingleChildScrollView(child: child)), + ), + ); + } + + testWidgets( + 'content reference grows on a narrow screen with large localized text', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var actionTaps = 0; + var disabledTaps = 0; + + await tester.pumpWidget( + buildApp( + Padding( + padding: const EdgeInsets.all(16), + child: LoftifyContentReferenceCard( + icon: LoftifyIcons.collection, + eyebrow: 'Included in this collection', + title: 'A deliberately long collection title that must wrap safely', + trailing: TextButton( + onPressed: () {}, + child: const Text('Subscribed to collection'), + ), + actions: [ + LoftifyContentReferenceAction( + label: 'Already at the first post', + enabled: false, + onPressed: () {}, + onDisabledPressed: () => disabledTaps++, + ), + LoftifyContentReferenceAction( + label: 'Open the complete catalogue', + emphasized: true, + onPressed: () => actionTaps++, + ), + LoftifyContentReferenceAction( + label: 'Continue to the next post', + onPressed: () {}, + ), + ], + ), + ), + textScale: 2, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('content-reference-header-stacked')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('content-reference-actions-stacked')), + findsOneWidget, + ); + expect(find.byType(LoftifyCard), findsOneWidget); + await tester.ensureVisible(find.text('Open the complete catalogue')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open the complete catalogue')); + await tester.ensureVisible(find.text('Already at the first post')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Already at the first post')); + await tester.pump(); + expect(actionTaps, 1); + expect(disabledTaps, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('content reference uses inline actions and semantic token colors', + (tester) async { + await tester.binding.setSurfaceSize(const Size(720, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + for (final dark in [false, true]) { + await tester.pumpWidget( + buildApp( + const Padding( + padding: EdgeInsets.all(16), + child: LoftifyContentReferenceCard( + icon: LoftifyIcons.grain, + eyebrow: 'Included in', + title: 'A compact grain title', + actions: [ + LoftifyContentReferenceAction( + label: 'Previous', + onPressed: _noop, + ), + LoftifyContentReferenceAction( + label: 'Catalogue', + emphasized: true, + onPressed: _noop, + ), + LoftifyContentReferenceAction( + label: 'Next', + onPressed: _noop, + ), + ], + ), + ), + dark: dark, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('content-reference-actions-inline')), + findsOneWidget, + ); + final icon = tester.widget( + find.byKey(const ValueKey('loftify-content-reference-icon')), + ); + final theme = LoftifyTheme.build( + dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first, + ).extension()!; + expect(icon.color, theme.colors.accentForeground); + final cardSurface = tester.widget( + find + .descendant( + of: find.byKey(const ValueKey('loftify-content-reference-card')), + matching: find.byType(AnimatedContainer), + ) + .first, + ); + final decoration = cardSurface.decoration! as BoxDecoration; + expect(decoration.color, theme.colors.surfaceMuted); + expect(decoration.border, isNull); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('post collection reference keeps a compact phone footprint', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: LoftifyContentReferenceCard( + icon: LoftifyIcons.collection, + eyebrow: '合集', + title: '短篇作品集', + trailing: TextButton( + onPressed: _noop, + style: TextButton.styleFrom( + minimumSize: const Size(40, 40), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('订阅'), + ), + actions: const [ + LoftifyContentReferenceAction(label: '上一篇', onPressed: _noop), + LoftifyContentReferenceAction( + label: '目录', emphasized: true, onPressed: _noop), + LoftifyContentReferenceAction(label: '下一篇', onPressed: _noop), + ], + ), + ), + )); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(LoftifyContentReferenceCard)).height, + lessThanOrEqualTo(84)); + expect(find.byKey(const ValueKey('content-reference-actions-inline')), + findsOneWidget); + final catalogueMaterial = tester.widget( + find + .ancestor( + of: find.text('目录'), + matching: find.byType(Material), + ) + .first, + ); + final colors = + LoftifyTheme.build(ChewieThemeColorData.defaultLightThemes.first) + .extension()! + .colors; + final catalogueBackground = + Color.lerp(colors.accentContainer, colors.accent, 0.25)!; + expect(catalogueMaterial.color, catalogueBackground); + final catalogueInk = tester.widget(find.text('目录')).style!.color!; + final backgroundLuminance = catalogueBackground.computeLuminance(); + final inkLuminance = catalogueInk.computeLuminance(); + final contrast = (backgroundLuminance > inkLuminance + ? backgroundLuminance + 0.05 + : inkLuminance + 0.05) / + (backgroundLuminance < inkLuminance + ? backgroundLuminance + 0.05 + : inkLuminance + 0.05); + expect(contrast, greaterThanOrEqualTo(4.5)); + expect(tester.takeException(), isNull); + }); + + testWidgets('long collection title does not increase the normal card height', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + const title = '💚💛豹豹猫猫你们要幸福啊!这是一段很长的合集标题'; + await tester.pumpWidget(buildApp( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: LoftifyContentReferenceCard( + icon: LoftifyIcons.collection, + eyebrow: '合集', + title: title, + trailing: TextButton( + onPressed: _noop, + style: TextButton.styleFrom( + minimumSize: const Size(40, 40), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('订阅合集'), + ), + actions: const [ + LoftifyContentReferenceAction(label: '上一篇', onPressed: _noop), + LoftifyContentReferenceAction( + label: '目录', emphasized: true, onPressed: _noop), + LoftifyContentReferenceAction(label: '下一篇', onPressed: _noop), + ], + ), + ), + )); + await tester.pumpAndSettle(); + + final titleWidget = tester.widget(find.text(title)); + expect(titleWidget.maxLines, 1); + expect(titleWidget.overflow, TextOverflow.ellipsis); + expect(tester.getSize(find.byType(LoftifyContentReferenceCard)).height, + lessThanOrEqualTo(84)); + expect(tester.takeException(), isNull); + }); + + testWidgets('AppBar context pill stays compact and remains tappable', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var taps = 0; + + await tester.pumpWidget( + buildApp( + Align( + alignment: Alignment.topRight, + child: LoftifyContextPill( + icon: LoftifyIcons.collection, + label: 'Collection 123/999 with extra context', + onPressed: () => taps++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final pill = find.byKey(const ValueKey('loftify-context-pill')); + expect(tester.getSize(pill).width, lessThanOrEqualTo(180)); + expect(tester.getSize(pill).height, greaterThanOrEqualTo(36)); + await tester.tap(pill); + await tester.pump(); + expect(taps, 1); + expect(tester.takeException(), isNull); + }); +} + +void _noop() {} diff --git a/test/widgets/design/loftify_controls_test.dart b/test/widgets/design/loftify_controls_test.dart new file mode 100644 index 00000000..8753efc7 --- /dev/null +++ b/test/widgets/design/loftify_controls_test.dart @@ -0,0 +1,337 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Design/loftify_controls.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/loftify_controls', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('localized button labels grow instead of overflowing', ( + tester, + ) async { + await tester.pumpWidget( + _TestApp( + width: 240, + textScaler: const TextScaler.linear(2), + child: LoftifyButton( + key: const Key('long-button'), + label: 'Confirm this deliberately long localized operation safely', + onPressed: () {}, + expand: true, + ), + ), + ); + await tester.pump(); + + expect( + tester.getSize(find.byKey(const Key('long-button'))).height, + greaterThan(48), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('button exposes loading, success, warning and disabled states', ( + tester, + ) async { + await tester.pumpWidget( + _TestApp( + child: Column( + children: [ + LoftifyButton( + label: 'Loading', + status: LoftifyControlStatus.loading, + onPressed: () {}, + ), + LoftifyButton( + label: 'Success', + status: LoftifyControlStatus.success, + onPressed: () {}, + ), + LoftifyButton( + label: 'Warning', + status: LoftifyControlStatus.warning, + onPressed: () {}, + ), + const LoftifyButton(label: 'Disabled'), + ], + ), + ), + ); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byIcon(LoftifyIcons.check), findsOneWidget); + expect(find.byIcon(LoftifyIcons.warning), findsOneWidget); + final disabledInkWell = tester.widget( + find + .descendant( + of: find.widgetWithText(LoftifyButton, 'Disabled'), + matching: find.byType(InkWell), + ) + .first, + ); + expect(disabledInkWell.onTap, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('field focus and validation share semantic token states', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + _TestApp( + width: 260, + textScaler: const TextScaler.linear(1.6), + child: LoftifyTextField( + key: const Key('field'), + focusNode: focusNode, + hintText: 'Enter a value that may use a long translation', + status: LoftifyFieldStatus.error, + statusText: + 'This explanatory validation message must remain fully readable.', + ), + ), + ); + await tester.pump(); + + final decorationBefore = _fieldDecoration(tester); + expect(decorationBefore.border!.top.width, 1); + expect(find.byIcon(LoftifyIcons.warning), findsOneWidget); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + final decorationAfter = _fieldDecoration(tester); + expect(decorationAfter.border!.top.width, 1.5); + expect(tester.takeException(), isNull); + }); + + testWidgets('long filter labels wrap and preserve selected state', ( + tester, + ) async { + var selected = 'all'; + late StateSetter rebuild; + await tester.pumpWidget( + _TestApp( + width: 220, + textScaler: const TextScaler.linear(2), + child: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return LoftifyChoiceGroup( + values: const ['all', 'following'], + selectedValue: selected, + labelBuilder: (value) => value == 'all' + ? 'No limit' + : 'Creators followed by this account recently', + onSelected: (value) { + selected = value; + rebuild(() {}); + }, + ); + }, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Creators followed by this account recently')); + await tester.pump(); + expect(selected, 'following'); + expect(find.byIcon(LoftifyIcons.check), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('short filter choices remain inline before wrapping', ( + tester, + ) async { + await tester.pumpWidget( + _TestApp( + width: 390, + child: LoftifyChoiceGroup( + values: const ['all', 'text', 'images'], + selectedValue: 'all', + labelBuilder: (value) => value, + onSelected: (_) {}, + ), + ), + ); + await tester.pump(); + + final tags = find.byType(LoftifyTag); + expect(tags, findsNWidgets(3)); + expect(tester.getTopLeft(tags.at(0)).dy, tester.getTopLeft(tags.at(1)).dy); + expect(tester.getTopLeft(tags.at(1)).dy, tester.getTopLeft(tags.at(2)).dy); + expect(tester.takeException(), isNull); + }); + + testWidgets('high contrast strengthens borders and reduced motion is instant', + (tester) async { + await tester.pumpWidget( + _TestApp( + mediaQuery: const MediaQueryData( + size: Size(320, 640), + highContrast: true, + disableAnimations: true, + ), + child: LoftifyTag( + key: const Key('tag'), + label: 'Selected', + selected: true, + onPressed: _noop, + ), + ), + ); + await tester.pump(); + + final animated = tester.widget( + find + .descendant( + of: find.byKey(const Key('tag')), + matching: find.byType(AnimatedContainer), + ) + .first, + ); + final decoration = animated.decoration! as BoxDecoration; + final colors = LoftifyDesignThemeData.of( + tester.element(find.byKey(const Key('tag'))), + ).colors; + expect(animated.duration, Duration.zero); + expect(decoration.border!.top.width, 2); + expect(decoration.border!.top.color, colors.accentForeground); + expect(tester.takeException(), isNull); + }); + + testWidgets('buttons, fields and tags resolve dark semantic surfaces', ( + tester, + ) async { + await tester.pumpWidget( + _TestApp( + dark: true, + child: Column( + children: [ + LoftifyButton( + key: const Key('dark-button'), + label: 'Secondary', + variant: LoftifyButtonVariant.secondary, + onPressed: _noop, + ), + const LoftifyTextField( + key: Key('dark-field'), + hintText: 'Dark input', + ), + const LoftifyTag( + key: Key('dark-tag'), + label: 'Dark tag', + selected: true, + onPressed: _noop, + ), + ], + ), + ), + ); + await tester.pump(); + + final buttonDecoration = tester + .widget( + find + .descendant( + of: find.byKey(const Key('dark-button')), + matching: find.byType(AnimatedContainer), + ) + .first, + ) + .decoration! as BoxDecoration; + final fieldDecoration = tester + .widget( + find + .descendant( + of: find.byKey(const Key('dark-field')), + matching: find.byType(AnimatedContainer), + ) + .first, + ) + .decoration! as BoxDecoration; + expect(buttonDecoration.color, const Color(0xFF202421)); + expect( + buttonDecoration.border!.top.color, + LoftifyDesignThemeData.of( + tester.element(find.byKey(const Key('dark-button'))), + ).colors.outlineStrong, + ); + expect(fieldDecoration.color, const Color(0xFF191C1A)); + expect(find.text('Dark tag'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +BoxDecoration _fieldDecoration(WidgetTester tester) { + final animated = tester.widget( + find + .descendant( + of: find.byKey(const Key('field')), + matching: find.byType(AnimatedContainer), + ) + .first, + ); + return animated.decoration! as BoxDecoration; +} + +void _noop() {} + +class _TestApp extends StatelessWidget { + const _TestApp({ + required this.child, + this.width = 390, + this.textScaler = TextScaler.noScaling, + this.mediaQuery, + this.dark = false, + }); + + final Widget child; + final double width; + final TextScaler textScaler; + final MediaQueryData? mediaQuery; + final bool dark; + + @override + Widget build(BuildContext context) { + final data = mediaQuery ?? + MediaQueryData( + size: Size(width, 800), + textScaler: textScaler, + ); + return MaterialApp( + theme: LoftifyTheme.build( + dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first, + ), + home: MediaQuery( + data: data, + child: Scaffold( + body: SingleChildScrollView( + child: Align( + alignment: Alignment.topCenter, + child: SizedBox(width: width, child: child), + ), + ), + ), + ), + ); + } +} diff --git a/test/widgets/design/loftify_download_progress_button_test.dart b/test/widgets/design/loftify_download_progress_button_test.dart new file mode 100644 index 00000000..e566a845 --- /dev/null +++ b/test/widgets/design/loftify_download_progress_button_test.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Design/loftify_download_progress_button.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/download_progress_button', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('real byte progress stays readable and blocks duplicate taps', + (tester) async { + var taps = 0; + await tester.pumpWidget( + _TestApp( + child: LoftifyDownloadProgressButton( + label: 'Save this image', + icon: LoftifyIcons.download, + progress: 0.42, + onPressed: () => taps++, + ), + ), + ); + await tester.pump(); + + final button = find.byType(LoftifyDownloadProgressButton); + final semantics = tester.getSemantics(button); + final progress = tester.widget( + find.byKey(const ValueKey('loftify-download-progress-track')), + ); + expect(semantics.label, 'Save this image'); + expect(semantics.value, '42%'); + expect(progress.value, 0.42); + expect(find.text('Save this image 42%'), findsOneWidget); + await tester.tap(find.text('Save this image 42%')); + await tester.pump(); + expect(taps, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('idle action remains reachable on narrow two-times text', + (tester) async { + var taps = 0; + await tester.pumpWidget( + _TestApp( + child: LoftifyDownloadProgressButton( + label: 'Download the currently selected decoration image', + icon: LoftifyIcons.download, + onPressed: () => taps++, + ), + ), + ); + await tester.pump(); + + await tester.tap( + find.text('Download the currently selected decoration image'), + ); + await tester.pump(); + expect(taps, 1); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact card action exposes a real circular progress value', + (tester) async { + var taps = 0; + await tester.pumpWidget( + _TestApp( + child: LoftifyDownloadProgressIconButton( + semanticLabel: 'Download decoration part', + icon: LoftifyIcons.download, + progress: 0.68, + onPressed: () => taps++, + ), + ), + ); + await tester.pump(); + + final button = find.byType(LoftifyDownloadProgressIconButton); + final semantics = tester.getSemantics(button); + final progress = tester.widget( + find.byKey(const ValueKey('loftify-download-progress-ring')), + ); + expect(semantics.label, 'Download decoration part'); + expect(semantics.value, '68%'); + expect(progress.value, 0.68); + await tester.tap(button); + await tester.pump(); + expect(taps, 0); + expect(tester.takeException(), isNull); + }); + + test('download progress button does not use Lottie loading feedback', () { + final source = File( + 'lib/Widgets/Design/loftify_download_progress_button.dart', + ).readAsStringSync(); + expect(source, isNot(contains('LoftifyLottie'))); + expect(source, isNot(contains("package:lottie"))); + expect(source, contains('LinearProgressIndicator')); + + final sheetSource = File( + 'lib/Widgets/BottomSheet/custom_bg_avatar_detail_bottom_sheet.dart', + ).readAsStringSync(); + expect(sheetSource, isNot(contains('CustomLoadingDialog'))); + expect(sheetSource, contains('LoftifyFileUtil.saveImage')); + expect(sheetSource, contains('LoftifyFileUtil.saveImages')); + + for (final path in [ + 'lib/Screens/Suit/emote_detail_screen.dart', + 'lib/Screens/Suit/dress_detail_screen.dart', + ]) { + final detailSource = File(path).readAsStringSync(); + expect(detailSource, isNot(contains('CustomLoadingDialog')), + reason: path); + expect(detailSource, contains('LoftifyFileUtil.saveImage'), reason: path); + expect(detailSource, contains('_downloadProgress'), reason: path); + } + }); +} + +class _TestApp extends StatelessWidget { + const _TestApp({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ), + home: MediaQuery( + data: const MediaQueryData( + size: Size(280, 568), + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: Center( + child: SizedBox(width: 260, child: child), + ), + ), + ), + ); + } +} diff --git a/test/widgets/design/loftify_lottie_test.dart b/test/widgets/design/loftify_lottie_test.dart new file mode 100644 index 00000000..774df02e --- /dev/null +++ b/test/widgets/design/loftify_lottie_test.dart @@ -0,0 +1,400 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Widgets/Navigation/loftify_glass_navigation_bar.dart'; +import 'package:lottie/lottie.dart'; + +Finder _assetLottie(String asset) { + return find.byWidgetPredicate( + (widget) => + widget is LottieBuilder && + widget.lottie is AssetLottie && + (widget.lottie as AssetLottie).assetName == asset, + ); +} + +Widget _lottieHost({ + required Widget child, + Brightness brightness = Brightness.light, + bool disableAnimations = false, +}) { + final colorScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF16AFA8), + brightness: brightness, + ); + final theme = ThemeData(brightness: brightness, colorScheme: colorScheme); + return MaterialApp( + theme: theme, + darkTheme: theme, + themeMode: brightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light, + home: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: Center(child: child), + ), + ); +} + +void main() { + test('every Lottie JSON has a registered source canvas and valid viewport', + () { + final files = Directory('assets/lottie') + .listSync() + .whereType() + .where((file) => file.path.endsWith('.json')) + .toList(); + final assets = files.map((file) => file.path.replaceAll('\\', '/')).toSet(); + + expect(LottieFiles.specs.keys.toSet(), assets); + for (final file in files) { + final json = jsonDecode(file.readAsStringSync()) as Map; + final asset = file.path.replaceAll('\\', '/'); + final spec = LottieFiles.specFor(asset); + expect(spec.sourceSize.width, (json['w'] as num).toDouble()); + expect(spec.sourceSize.height, (json['h'] as num).toDouble()); + expect(spec.effectiveContentBounds.width, greaterThan(0)); + expect(spec.effectiveContentBounds.height, greaterThan(0)); + final sourceBounds = Offset.zero & spec.sourceSize; + final viewport = spec.effectiveContentBounds; + expect(viewport.left, greaterThanOrEqualTo(sourceBounds.left)); + expect(viewport.top, greaterThanOrEqualTo(sourceBounds.top)); + expect(viewport.right, lessThanOrEqualTo(sourceBounds.right)); + expect(viewport.bottom, lessThanOrEqualTo(sourceBounds.bottom)); + } + }); + + test('navigation assets keep readable idle and selected frames', () { + for (final asset in [ + LottieFiles.navHome, + LottieFiles.navSearch, + LottieFiles.navHeart, + LottieFiles.navUser, + ]) { + final json = + jsonDecode(File(asset).readAsStringSync()) as Map; + final layers = (json['layers'] as List).cast>(); + if (asset == LottieFiles.navHome) { + expect(json['w'], 128); + expect(json['h'], 128); + expect(json['op'], 50); + expect(json['nm'], contains('loading_mark')); + expect(layers, hasLength(2)); + expect( + layers.every( + (layer) => layer['nm'] == 'draw' || layer['nm'] == 'draw 3'), + isTrue); + final spec = LottieFiles.specFor(asset); + expect( + spec.effectiveContentBounds, const Rect.fromLTWH(34, 22, 60, 76)); + expect(spec.opticalFill, 1); + continue; + } + expect(json['w'], 48, reason: asset); + expect(json['h'], 48, reason: asset); + expect(json['fr'], 30, reason: asset); + expect(json['op'], 24, reason: asset); + expect(layers.any((layer) => (layer['nm'] as String).contains('Outline')), + isTrue, + reason: asset); + expect( + layers.any((layer) => (layer['shapes'] as List).cast().any( + (shape) => + shape['ty'] == 'fl' || + (shape['nm'] as String).contains('Bold'))), + isTrue, + reason: asset); + final spec = LottieFiles.specFor(asset); + expect(spec.effectiveContentBounds, const Rect.fromLTWH(4, 4, 40, 40)); + expect(spec.opticalFill, 0.9); + + List opacityFrames(Map layer) { + final transform = layer['ks'] as Map; + final opacity = transform['o'] as Map; + return opacity['k'] as List; + } + + final outlineOpacity = opacityFrames(layers + .firstWhere((layer) => (layer['nm'] as String).contains('Outline'))); + final fillOpacity = opacityFrames(layers + .firstWhere((layer) => !(layer['nm'] as String).contains('Outline'))); + expect( + ((outlineOpacity.first as Map)['s'] as List).first, + 100, + reason: asset, + ); + expect( + ((outlineOpacity.last as Map)['s'] as List).first, + 0, + reason: asset, + ); + expect( + ((fillOpacity.first as Map)['s'] as List).first, + 0, + reason: asset, + ); + expect( + ((fillOpacity.last as Map)['s'] as List).first, + 100, + reason: asset, + ); + } + }); + + testWidgets('home loading mark plays once then returns to its idle frame', + (tester) async { + Widget host(bool selected) => _lottieHost( + child: LoftifyNavigationLottieIcon( + asset: LottieFiles.navHome, + selected: selected, + color: const Color(0xFF16AFA8), + ), + ); + + await tester.pumpWidget(host(false)); + await tester.pump(); + final controller = tester + .widget(_assetLottie(LottieFiles.navHome)) + .controller! as AnimationController; + expect(controller.value, 0); + List strokeOverrides() => tester + .widget(_assetLottie(LottieFiles.navHome)) + .delegates! + .values! + .where((delegate) => delegate.value is double) + .map((delegate) => delegate.value as double) + .toList(); + expect(strokeOverrides(), isEmpty); + + await tester.pumpWidget(host(true)); + await tester.pump(const Duration(milliseconds: 300)); + expect(strokeOverrides(), [6.8]); + expect(controller.value, greaterThan(0)); + expect(controller.value, lessThan(1)); + expect(controller.isAnimating, isTrue); + await tester.pump(const Duration(seconds: 1)); + expect(controller.value, 0); + expect(controller.isAnimating, isFalse); + await tester.pumpWidget(host(false)); + await tester.pump(); + expect(strokeOverrides(), isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'icon assets expose one exact optical canvas at any requested size', + (tester) async { + await tester.pumpWidget( + _lottieHost( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + LottieFiles.buildAnimation( + LottieFiles.collectionBigNormalLight, + size: 20, + ), + LottieFiles.buildAnimation( + LottieFiles.likeMediumLight, + size: 24, + ), + LottieFiles.buildAnimation( + LottieFiles.recommendVideoNormal, + size: 32, + ), + ], + ), + ), + ); + await tester.pump(); + + expect( + tester.getSize( + find.byKey( + const ValueKey( + 'loftify-lottie-icon-assets/lottie/collection_big_normal_light.json', + ), + ), + ), + const Size.square(20), + ); + expect( + tester.getSize( + find.byKey( + const ValueKey( + 'loftify-lottie-icon-assets/lottie/like_medium_light.json', + ), + ), + ), + const Size.square(24), + ); + expect( + tester.getSize( + find.byKey( + const ValueKey( + 'loftify-lottie-icon-assets/lottie/recommend_video_normal.json', + ), + ), + ), + const Size.square(32), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('all newly drawn navigation assets render', (tester) async { + await tester.pumpWidget(_lottieHost( + disableAnimations: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final asset in [ + LottieFiles.navHome, + LottieFiles.navSearch, + LottieFiles.navHeart, + LottieFiles.navUser, + ]) + LottieFiles.buildAnimation(asset, size: 22), + ], + ), + )); + await tester.pump(); + expect(find.byType(LottieBuilder), findsNWidgets(4)); + expect(tester.takeException(), isNull); + }); + + testWidgets('theme tint and reduced motion use semantic runtime state', + (tester) async { + Future pump(Brightness brightness) async { + await tester.pumpWidget( + _lottieHost( + brightness: brightness, + disableAnimations: true, + child: LottieFiles.buildLoadingAnimation(24, false), + ), + ); + await tester.pumpAndSettle(); + return tester.widget(find.byType(LottieBuilder)); + } + + final light = await pump(Brightness.light); + expect( + (light.lottie as AssetLottie).assetName, + LottieFiles.loadingLight, + ); + final lightPrimary = ColorScheme.fromSeed( + seedColor: const Color(0xFF16AFA8), + ).primary; + expect(light.animate, isFalse); + expect(light.repeat, isFalse); + final lightPalette = light.delegates!.values! + .map((delegate) => delegate.value) + .cast() + .toList(); + expect(lightPalette, hasLength(3)); + expect(lightPalette.first, lightPrimary); + expect(lightPalette.toSet(), hasLength(3)); + + final dark = await pump(Brightness.dark); + expect((dark.lottie as AssetLottie).assetName, LottieFiles.loadingDark); + final darkPrimary = ColorScheme.fromSeed( + seedColor: const Color(0xFF16AFA8), + brightness: Brightness.dark, + ).primary; + final darkPalette = dark.delegates!.values! + .map((delegate) => delegate.value) + .cast() + .toList(); + expect(darkPalette, hasLength(3)); + expect(darkPalette.first, darkPrimary); + expect(darkPalette.toSet(), hasLength(3)); + expect(darkPrimary, isNot(lightPrimary)); + expect(darkPalette, isNot(lightPalette)); + }); + + testWidgets('navigation keeps a clear idle frame and plays selection once', + (tester) async { + Widget host(bool selected, {bool reduceMotion = false}) { + return _lottieHost( + disableAnimations: reduceMotion, + child: LoftifyNavigationLottieIcon( + key: const ValueKey('nav-search'), + asset: LottieFiles.navSearch, + selected: selected, + color: const Color(0xFF16AFA8), + ), + ); + } + + await tester.pumpWidget(host(false)); + await tester.pump(); + var lottie = tester.widget( + _assetLottie(LottieFiles.navSearch), + ); + var controller = lottie.controller! as AnimationController; + expect(controller.value, 0); + expect(controller.isAnimating, isFalse); + + await tester.pumpWidget(host(true)); + await tester.pump(); + lottie = tester.widget(_assetLottie(LottieFiles.navSearch)); + controller = lottie.controller! as AnimationController; + expect(controller.isAnimating, isTrue); + + await tester.pump(const Duration(milliseconds: 180)); + expect(controller.value, greaterThan(0)); + expect(controller.value, lessThan(1)); + await tester.pump(const Duration(seconds: 1)); + expect(controller.value, 1); + expect(controller.isAnimating, isFalse); + + await tester.pumpWidget(host(false)); + await tester.pump(); + expect(controller.value, 0); + await tester.pumpWidget(host(true, reduceMotion: true)); + await tester.pump(); + expect(controller.value, 1); + expect(controller.isAnimating, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets('navigation and loading assets paint different animation frames', + (tester) async { + Future> pixels() async { + final boundary = tester.renderObject( + find.byKey(const ValueKey('lottie-frame-boundary')), + ); + return (await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 1); + final bytes = + await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + return bytes!.buffer.asUint8List().toList(); + }))!; + } + + for (final asset in [LottieFiles.navHome, LottieFiles.loadingLight]) { + final controller = AnimationController(vsync: tester); + addTearDown(controller.dispose); + await tester.pumpWidget(_lottieHost( + child: RepaintBoundary( + key: const ValueKey('lottie-frame-boundary'), + child: LottieFiles.buildAnimation( + asset, + size: 48, + controller: controller, + ), + ), + )); + await tester.pump(); + controller.value = 0; + await tester.pump(); + final first = await pixels(); + controller.value = 0.6; + await tester.pump(); + final later = await pixels(); + expect(later, isNot(first), reason: '$asset paints the same frame'); + } + }); +} diff --git a/test/widgets/design/loftify_media_overlays_test.dart b/test/widgets/design/loftify_media_overlays_test.dart new file mode 100644 index 00000000..35b34615 --- /dev/null +++ b/test/widgets/design/loftify_media_overlays_test.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/Design/loftify_media_overlays.dart'; +import 'package:loftify/Widgets/Item/item_builder.dart'; + +void main() { + test('cover scrim keeps white content readable on the brightest artwork', () { + final weakestBackground = Color.alphaBlend( + LoftifyCoverScrim.topColor, + Colors.white, + ); + final secondary = Color.alphaBlend( + LoftifyCoverScrim.secondaryForeground, + weakestBackground, + ); + + expect( + _contrastRatio(Colors.white, weakestBackground), + greaterThanOrEqualTo(4.5), + ); + expect( + _contrastRatio(secondary, weakestBackground), + greaterThanOrEqualTo(4.5), + ); + }); + + test('top text scrim protects white preview content without hiding artwork', + () { + final protectedBackground = Color.alphaBlend( + LoftifyTopTextScrim.protectedColor, + Colors.white, + ); + + expect( + _contrastRatio(Colors.white, protectedBackground), + greaterThanOrEqualTo(4.5), + ); + expect(LoftifyTopTextScrim.bottomColor.a, 0); + }); + + test('all artwork-backed detail headers use the shared cover scrim', () { + for (final path in [ + 'lib/Screens/Info/user_detail_screen.dart', + 'lib/Screens/Post/collection_detail_screen.dart', + 'lib/Screens/Post/grain_detail_screen.dart', + ]) { + final source = File(path).readAsStringSync(); + expect(source, contains('LoftifyCoverScrim()'), reason: path); + } + }); + + test('wallpaper previews protect their white clock with a top scrim', () { + final source = File( + 'lib/Screens/Suit/custom_bg_avatar_list_screen.dart', + ).readAsStringSync(); + expect(source, contains('LoftifyTopTextScrim()')); + }); + + testWidgets('translucent media badges clamp weak requested opacity', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ItemBuilder.buildTranslucentTag( + context, + text: '123', + opacity: 0.2, + ), + ), + ), + ), + ); + await tester.pump(); + + final badge = tester.widget( + find + .ancestor(of: find.text('123'), matching: find.byType(Container)) + .first, + ); + final decoration = badge.decoration! as BoxDecoration; + final effectiveBackground = Color.alphaBlend( + decoration.color!, + Colors.white, + ); + final foreground = tester.widget(find.text('123')).style!.color!; + + expect(decoration.color!.a, LoftifyCoverScrim.minimumBadgeOpacity); + expect( + _contrastRatio(foreground, effectiveBackground), + greaterThanOrEqualTo(4.5), + ); + }); +} + +double _contrastRatio(Color foreground, Color background) { + final foregroundLuminance = foreground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance + ? foregroundLuminance + : backgroundLuminance; + final darker = foregroundLuminance > backgroundLuminance + ? backgroundLuminance + : foregroundLuminance; + return (lighter + 0.05) / (darker + 0.05); +} diff --git a/test/widgets/design/loftify_section_test.dart b/test/widgets/design/loftify_section_test.dart new file mode 100644 index 00000000..c2fdc983 --- /dev/null +++ b/test/widgets/design/loftify_section_test.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Design/loftify_section.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/loftify_section', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('entry grows for long localized text at 2x scale', + (tester) async { + await tester.pumpWidget( + _TestApp( + width: 280, + textScaler: const TextScaler.linear(2), + child: LoftifySection( + title: 'Content center with a deliberately long localized heading', + children: const [ + LoftifyEntryItem( + key: Key('long-entry'), + title: 'Download management with a long title that must wrap', + description: + 'Completed files and active tasks stay available here.', + showLeading: true, + leading: LoftifyIcons.download, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byKey(const Key('long-entry'))).height, + greaterThan(56)); + expect(tester.takeException(), isNull); + }); + + testWidgets('section expands and collapses with an accessible tap target', ( + tester, + ) async { + await tester.pumpWidget( + const _TestApp( + child: LoftifySection( + title: '内容中心', + children: [ + LoftifyEntryItem(title: '下载管理'), + ], + ), + ), + ); + + expect(find.text('下载管理'), findsOneWidget); + final headerInkWell = + tester.widgetList(find.byType(InkWell)).first; + expect(headerInkWell.onTap, isNotNull); + expect(headerInkWell.splashFactory, NoSplash.splashFactory); + + final entryInkWell = tester.widgetList(find.byType(InkWell)).last; + expect(entryInkWell.splashFactory, NoSplash.splashFactory); + + await tester.tap(find.text('内容中心')); + await tester.pumpAndSettle(); + expect(find.text('下载管理'), findsNothing); + + await tester.tap(find.text('内容中心')); + await tester.pumpAndSettle(); + expect(find.text('下载管理'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('entry exposes selected, disabled and status states', + (tester) async { + await tester.pumpWidget( + const _TestApp( + child: LoftifySection( + title: '状态', + children: [ + LoftifyEntryItem( + title: 'Selected', + visualState: LoftifyEntryVisualState.selected, + ), + LoftifyEntryItem( + title: 'Disabled', + enabled: false, + ), + LoftifyEntryItem( + title: 'Success', + visualState: LoftifyEntryVisualState.success, + ), + LoftifyEntryItem( + title: 'Warning', + visualState: LoftifyEntryVisualState.warning, + ), + LoftifyEntryItem( + title: 'Error', + visualState: LoftifyEntryVisualState.error, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(LoftifyIcons.check), findsOneWidget); + expect(find.byIcon(LoftifyIcons.warning), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); +} + +class _TestApp extends StatelessWidget { + const _TestApp({ + required this.child, + this.width = 390, + this.textScaler = TextScaler.noScaling, + }); + + final Widget child; + final double width; + final TextScaler textScaler; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: LoftifyTheme.build(ChewieThemeColorData.defaultLightThemes.first), + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 800), + textScaler: textScaler, + ), + child: Scaffold( + body: SingleChildScrollView( + child: Align( + alignment: Alignment.topCenter, + child: SizedBox(width: width, child: child), + ), + ), + ), + ), + ); + } +} diff --git a/test/widgets/design/loftify_surfaces_test.dart b/test/widgets/design/loftify_surfaces_test.dart new file mode 100644 index 00000000..c5d3d8bb --- /dev/null +++ b/test/widgets/design/loftify_surfaces_test.dart @@ -0,0 +1,686 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/BottomSheet/list_bottom_sheet.dart'; +import 'package:loftify/Widgets/Design/loftify_controls.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; +import 'package:loftify/Widgets/Design/loftify_surfaces.dart'; +import 'package:loftify/Widgets/Suit/dress_preview_card.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:lottie/lottie.dart'; +import 'package:tuple/tuple.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/loftify_surfaces', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('card state matrix is token driven and ripple free', ( + tester, + ) async { + var taps = 0; + await tester.pumpWidget( + _TestApp( + width: 280, + textScaler: const TextScaler.linear(2), + mediaQuery: const MediaQueryData( + size: Size(280, 800), + textScaler: TextScaler.linear(2), + highContrast: true, + disableAnimations: true, + ), + child: SingleChildScrollView( + child: Column( + children: [ + LoftifyCard( + key: const Key('normal-card'), + onTap: () => taps++, + child: const Text( + 'A deliberately long English card title that must grow', + ), + ), + const LoftifyCard( + key: Key('selected-card'), + status: LoftifySurfaceStatus.selected, + child: Text('Selected'), + ), + const LoftifyCard( + status: LoftifySurfaceStatus.success, + child: Text('Success'), + ), + const LoftifyCard( + status: LoftifySurfaceStatus.warning, + child: Text('Warning'), + ), + const LoftifyCard( + status: LoftifySurfaceStatus.error, + child: Text('Error'), + ), + LoftifyCard( + key: const Key('disabled-card'), + status: LoftifySurfaceStatus.disabled, + onTap: () {}, + child: const Text('Disabled'), + ), + ], + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(LoftifyCard), findsNWidgets(6)); + final normalInkWell = tester.widget( + find.descendant( + of: find.byKey(const Key('normal-card')), + matching: find.byType(InkWell), + ), + ); + expect(normalInkWell.splashFactory, NoSplash.splashFactory); + expect( + find.descendant( + of: find.byKey(const Key('disabled-card')), + matching: find.byType(InkWell), + ), + findsNothing, + ); + final selectedDecoration = tester + .widget( + find.descendant( + of: find.byKey(const Key('selected-card')), + matching: find.byType(AnimatedContainer), + ), + ) + .decoration! as BoxDecoration; + expect(selectedDecoration.border!.top.width, 2); + expect( + selectedDecoration.border!.top.color, + LoftifyDesignThemeData.of( + tester.element(find.byKey(const Key('selected-card'))), + ).colors.accentForeground, + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('selected-card')), + matching: find.byType(AnimatedContainer), + ), + ) + .duration, + Duration.zero, + ); + await tester.tap( + find.text('A deliberately long English card title that must grow')); + await tester.pump(); + expect(taps, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('menu rows preserve long copy, statuses and selection', ( + tester, + ) async { + await tester.pumpWidget( + _TestApp( + width: 280, + textScaler: const TextScaler.linear(2), + child: SingleChildScrollView( + child: Column( + children: [ + LoftifyMenuItem( + key: const Key('selected-menu'), + label: 'Selected item with a long localized title', + description: + 'Supporting information remains below the title and wraps.', + selected: true, + onTap: _noop, + ), + LoftifyMenuItem( + label: 'Danger', + status: LoftifyMenuStatus.danger, + showTrailing: false, + onTap: _noop, + ), + const LoftifyMenuItem( + key: Key('disabled-menu'), + label: 'Disabled', + showTrailing: false, + ), + ], + ), + ), + ), + ); + await tester.pump(); + + expect(find.byIcon(LoftifyIcons.check), findsOneWidget); + expect(find.byIcon(LoftifyIcons.next), findsNothing); + expect( + tester.getSize(find.byKey(const Key('selected-menu'))).height, + greaterThan(48), + ); + final disabledInkWell = tester.widget( + find.descendant( + of: find.byKey(const Key('disabled-menu')), + matching: find.byType(InkWell), + ), + ); + expect(disabledInkWell.onTap, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('panel corner and handle policy responds to window width', ( + tester, + ) async { + Future pump(double width) async { + await tester.pumpWidget( + _TestApp( + width: width, + child: const LoftifyPanel( + title: 'Panel title', + subtitle: 'A panel subtitle', + body: Text('Body'), + ), + ), + ); + await tester.pump(); + return tester + .widget( + find.descendant( + of: find.byType(LoftifyPanel), + matching: find.byType(ClipRRect), + ), + ) + .borderRadius as BorderRadius; + } + + final compact = await pump(390); + expect(find.byKey(const ValueKey('loftify-panel-handle')), findsOneWidget); + expect(compact.bottomLeft.x, 0); + final wide = await pump(700); + expect(find.byKey(const ValueKey('loftify-panel-handle')), findsNothing); + expect(wide.bottomLeft.x, 20); + expect(tester.takeException(), isNull); + }); + + testWidgets('Chewie page states share custom app feedback and retry', ( + tester, + ) async { + final previous = chewieProvider.stateWidgetBuilder; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + addTearDown(() => chewieProvider.stateWidgetBuilder = previous); + var retries = 0; + + await tester.pumpWidget( + const _TestApp( + child: LoadingWidget(size: 28, bottomPadding: 0), + ), + ); + await tester.pump(); + expect(find.byType(LoftifyStateView), findsOneWidget); + expect(find.byType(LottieBuilder), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + + await tester.pumpWidget( + _TestApp( + child: EmptyPlaceholder( + text: 'No saved works', + topPadding: 24, + onTap: () => retries++, + ), + ), + ); + await tester.pump(); + expect(find.byIcon(LoftifyIcons.empty), findsOneWidget); + await tester.tap(find.text('Retry')); + await tester.pump(); + + await tester.pumpWidget( + _TestApp( + child: CustomErrorWidget( + text: 'Unable to load', + onTap: () => retries++, + ), + ), + ); + await tester.pump(); + expect(find.byIcon(LoftifyIcons.error), findsOneWidget); + await tester.tap(find.text('Retry')); + await tester.pump(); + expect(retries, 2); + expect(tester.takeException(), isNull); + }); + + testWidgets('icon-only loading state keeps its localized semantic label', + (tester) async { + final previous = chewieProvider.stateWidgetBuilder; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + addTearDown(() => chewieProvider.stateWidgetBuilder = previous); + + await tester.pumpWidget( + const _TestApp( + child: LoadingWidget( + text: 'Loading the current artwork', + showText: false, + bottomPadding: 0, + ), + ), + ); + await tester.pump(); + + expect(find.text('Loading the current artwork'), findsNothing); + expect( + find.bySemanticsLabel('Loading the current artwork'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'state visuals cover non-loading feedback without system spinners', ( + tester, + ) async { + await tester.pumpWidget( + const _TestApp( + locale: Locale('zh', 'TW'), + child: SingleChildScrollView( + child: Column( + children: [ + LoftifyStateView( + visual: LoftifyStateVisual.empty, + title: '暫無內容', + ), + LoftifyStateView( + visual: LoftifyStateVisual.error, + title: '載入失敗', + ), + LoftifyStateView( + visual: LoftifyStateVisual.success, + title: '操作成功', + ), + LoftifyStateView( + visual: LoftifyStateVisual.warning, + title: '請注意', + ), + ], + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(LoftifyStateView), findsNWidgets(4)); + expect(find.byIcon(LoftifyIcons.empty), findsOneWidget); + expect(find.byIcon(LoftifyIcons.error), findsOneWidget); + expect(find.byIcon(LoftifyIcons.check), findsOneWidget); + expect(find.byIcon(LoftifyIcons.warning), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('short Android error state keeps localized retry reachable', + (tester) async { + var retries = 0; + await tester.pumpWidget( + _TestApp( + width: 320, + mediaQuery: const MediaQueryData( + size: Size(320, 220), + textScaler: TextScaler.linear(2), + ), + child: SizedBox( + height: 220, + child: LoftifyStateView( + visual: LoftifyStateVisual.error, + title: 'Unable to load this section right now', + message: + 'Check your connection and try again without losing your current reading position.', + actionLabel: 'Try loading this section again', + onAction: () => retries++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('Try loading this section again')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Try loading this section again')); + await tester.pump(); + expect(retries, 1); + }); + + testWidgets('Android async recovery replaces stale error feedback', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final stateKey = GlobalKey<_AsyncStateHarnessState>(); + + await tester.pumpWidget( + _TestApp( + width: 320, + mediaQuery: const MediaQueryData( + size: Size(320, 568), + textScaler: TextScaler.linear(1.6), + ), + child: _AsyncStateHarness(key: stateKey), + ), + ); + await tester.pump(); + + expect(find.byType(LottieBuilder), findsOneWidget); + expect(find.bySemanticsLabel('Loading artwork'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + + stateKey.currentState!.showError(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(LoftifyIcons.error), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + expect(find.bySemanticsLabel('Unable to load, Check your connection'), + findsOneWidget); + + await tester.tap(find.text('Try again')); + await tester.pump(); + + expect(find.byType(LottieBuilder), findsOneWidget); + expect(find.bySemanticsLabel('Retrying'), findsOneWidget); + expect(stateKey.currentState!.retryAttempts, 1); + await tester.tapAt(tester.getCenter(find.text('Try again'))); + await tester.pump(); + expect(stateKey.currentState!.retryAttempts, 1); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(LottieBuilder), findsOneWidget); + expect(find.text('Try again'), findsNothing); + expect(find.byIcon(LoftifyIcons.error), findsNothing); + + stateKey.currentState!.completeRetry(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(LoftifyIcons.check), findsOneWidget); + expect(find.bySemanticsLabel('Loaded'), findsOneWidget); + expect(find.byType(LottieBuilder), findsNothing); + expect(find.text('Try again'), findsNothing); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('real dress card and option panel remain responsive on Windows', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + dynamic selected; + await tester.pumpWidget( + _TestApp( + width: 320, + textScaler: const TextScaler.linear(1.6), + child: Builder( + builder: (context) => SingleChildScrollView( + child: Column( + children: [ + const DressPreviewCard( + title: 'A responsive appearance preview card', + subtitle: + 'Long descriptions remain readable across window sizes.', + badge: 'Preview', + previewHeight: 80, + preview: ColoredBox(color: Colors.teal), + ), + TileList.fromOptions( + const [Tuple2('Normal option', 1), Tuple2('Remove', 2)], + (value) => selected = value, + context: context, + selected: 1, + redOptions: const [2], + title: 'Options', + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(LoftifyCard), findsOneWidget); + expect(find.byType(LoftifyPanel), findsOneWidget); + expect(find.byType(LoftifyMenuItem), findsNWidgets(2)); + await tester.tap(find.text('Remove')); + await tester.pump(); + expect(selected, 2); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('long option panels keep title and cancel action visible', ( + tester, + ) async { + await tester.pumpWidget( + _TestApp( + width: 320, + mediaQuery: const MediaQueryData(size: Size(320, 400)), + child: Builder( + builder: (context) => TileList.fromOptions( + List.generate(30, (index) => Tuple2('Option $index', index)), + (_) {}, + context: context, + title: 'Choose an option', + showCancel: true, + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.getSize(find.byType(TileList)).height, lessThanOrEqualTo(328)); + expect(find.text('Choose an option'), findsOneWidget); + expect(find.byType(LoftifyButton), findsOneWidget); + expect(find.byType(Scrollable), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('dark Android surfaces keep every semantic state distinct', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + await tester.pumpWidget( + const _TestApp( + dark: true, + width: 360, + child: SingleChildScrollView( + child: Column( + children: [ + LoftifyCard( + key: Key('dark-flat'), + child: Text('Flat'), + ), + LoftifyCard( + key: Key('dark-outlined'), + variant: LoftifyCardVariant.outlined, + child: Text('Outlined'), + ), + LoftifyCard( + key: Key('dark-raised'), + variant: LoftifyCardVariant.raised, + child: Text('Raised'), + ), + LoftifyCard( + key: Key('dark-muted'), + variant: LoftifyCardVariant.muted, + child: Text('Muted'), + ), + LoftifyStateView( + visual: LoftifyStateVisual.warning, + title: 'Warning', + ), + LoftifyPanel( + title: 'Actions', + body: LoftifyMenuItem( + label: 'Destructive action', + status: LoftifyMenuStatus.danger, + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + + expect(Theme.of(tester.element(find.text('Flat'))).brightness, + Brightness.dark); + final decorations = { + for (final key in const [ + 'dark-flat', + 'dark-outlined', + 'dark-raised', + 'dark-muted', + ]) + key: tester + .widget( + find.descendant( + of: find.byKey(Key(key)), + matching: find.byType(AnimatedContainer), + ), + ) + .decoration! as BoxDecoration, + }; + expect(decorations['dark-outlined']!.border, isNotNull); + expect(decorations['dark-raised']!.boxShadow, isNotEmpty); + expect(decorations['dark-flat']!.color, + isNot(decorations['dark-muted']!.color)); + expect(find.byKey(const ValueKey('loftify-panel-handle')), findsOneWidget); + expect(find.byIcon(LoftifyIcons.warning), findsOneWidget); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); +} + +void _noop() {} + +class _AsyncStateHarness extends StatefulWidget { + const _AsyncStateHarness({super.key}); + + @override + State<_AsyncStateHarness> createState() => _AsyncStateHarnessState(); +} + +class _AsyncStateHarnessState extends State<_AsyncStateHarness> { + LoftifyStateVisual _visual = LoftifyStateVisual.loading; + Completer? _retry; + int retryAttempts = 0; + + void showError() { + setState(() => _visual = LoftifyStateVisual.error); + } + + void completeRetry() { + _retry?.complete(); + } + + void _retryLoading() { + retryAttempts++; + setState(() => _visual = LoftifyStateVisual.loading); + final retry = _retry = Completer(); + retry.future.then((_) { + if (mounted && identical(_retry, retry)) { + setState(() => _visual = LoftifyStateVisual.success); + } + }); + } + + @override + Widget build(BuildContext context) { + return LoftifyStateView( + visual: _visual, + title: switch (_visual) { + LoftifyStateVisual.loading => + _retry == null ? 'Loading artwork' : 'Retrying', + LoftifyStateVisual.error => 'Unable to load', + LoftifyStateVisual.success => 'Loaded', + LoftifyStateVisual.empty => 'Empty', + LoftifyStateVisual.warning => 'Warning', + }, + message: + _visual == LoftifyStateVisual.error ? 'Check your connection' : null, + actionLabel: _visual == LoftifyStateVisual.error ? 'Try again' : null, + onAction: _visual == LoftifyStateVisual.error ? _retryLoading : null, + ); + } +} + +class _TestApp extends StatelessWidget { + const _TestApp({ + required this.child, + this.width = 390, + this.textScaler = TextScaler.noScaling, + this.mediaQuery, + this.locale = const Locale('en'), + this.dark = false, + }); + + final Widget child; + final double width; + final TextScaler textScaler; + final MediaQueryData? mediaQuery; + final Locale locale; + final bool dark; + + @override + Widget build(BuildContext context) { + final data = mediaQuery ?? + MediaQueryData( + size: Size(width, 800), + textScaler: textScaler, + ); + final source = dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first; + return MaterialApp( + locale: locale, + theme: LoftifyTheme.build(source), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return MediaQuery( + data: data, + child: Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: SizedBox(width: width, child: child), + ), + ), + ); + }, + ), + ); + } +} diff --git a/test/widgets/design/newest_filter_bottom_sheet_test.dart b/test/widgets/design/newest_filter_bottom_sheet_test.dart new file mode 100644 index 00000000..9dcb27d1 --- /dev/null +++ b/test/widgets/design/newest_filter_bottom_sheet_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Api/tag_api.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Widgets/BottomSheet/newest_filter_bottom_sheet.dart'; +import 'package:loftify/Widgets/Design/loftify_controls.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/newest_filter_panel', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('filter panel keeps long English choices scrollable and visible', + ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(280, 560)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final params = GetTagPostListParams(tag: 'design'); + + await tester.pumpWidget(_host(params)); + await tester.pumpAndSettle(); + + expect(find.text('Reset'), findsOneWidget); + expect(find.text('Confirm'), findsOneWidget); + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(find.text('Not Viewed in the Last 7 Days'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.text('Following')); + await tester.pumpAndSettle(); + expect(params.tagRangeType, TagRangeType.follow); + + await tester.scrollUntilVisible( + find.text('Tag Protection').last, + 140, + scrollable: find + .descendant( + of: find.byType(SingleChildScrollView), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.tap(find.text('Tag Protection').last); + await tester.pumpAndSettle(); + expect(params.protectedFlag, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('filter reset restores every choice without controller drift', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final params = GetTagPostListParams( + tag: 'design', + tagRangeType: TagRangeType.follow, + postTypes: TagPostType.image, + recentDayType: TagRecentDayType.oneMonth, + protectedFlag: true, + ); + + await tester.pumpWidget(_host(params)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reset')); + await tester.pumpAndSettle(); + + expect(params.tagRangeType, TagRangeType.noLimit); + expect(params.postTypes, TagPostType.noLimit); + expect(params.recentDayType, TagRecentDayType.noLimit); + expect(params.protectedFlag, isFalse); + expect(find.byType(LoftifyTag), findsWidgets); + expect(tester.takeException(), isNull); + }); +} + +Widget _host(GetTagPostListParams params) { + return MaterialApp( + locale: const Locale('en'), + navigatorKey: chewieProvider.globalNavigatorKey, + theme: LoftifyTheme.build(ChewieThemeColorData.defaultLightThemes.first), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: const TextScaler.linear(1.5), + ), + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: NewestFilterBottomSheet(params: params), + ), + ), + ); + }, + ), + ); +} diff --git a/test/widgets/dialog/action_fill_contrast_test.dart b/test/widgets/dialog/action_fill_contrast_test.dart new file mode 100644 index 00000000..fc14ad8e --- /dev/null +++ b/test/widgets/dialog/action_fill_contrast_test.dart @@ -0,0 +1,17 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('filled dialog action stays readable for light accent colors', () { + for (final accent in [ + const Color(0xFFFFB428), + const Color(0xFF58BDB8), + const Color(0xFFFF8B17), + ]) { + final fill = CustomDialogColors.readableActionFill(accent); + final contrast = 1.05 / (fill.computeLuminance() + 0.05); + expect(contrast, greaterThanOrEqualTo(4.5)); + } + }); +} diff --git a/test/widgets/download/batch_download_screen_test.dart b/test/widgets/download/batch_download_screen_test.dart new file mode 100644 index 00000000..3b6316f3 --- /dev/null +++ b/test/widgets/download/batch_download_screen_test.dart @@ -0,0 +1,431 @@ +import 'dart:io'; +import 'dart:async'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Models/download_task.dart'; +import 'package:loftify/Screens/Download/batch_download_screen.dart'; +import 'package:loftify/Utils/download_task_manager.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Utils/post_batch_download_resolver.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; + +class _FakeResolver extends PostBatchDownloadResolver { + int failuresRemaining = 0; + _FakeResolver() + : super( + detailLoader: ( + {required postId, + required blogId, + required blogName}) async => + {}, + ); + + @override + Future resolve(GeneralPostItem item) async { + if (failuresRemaining > 0) { + failuresRemaining--; + throw StateError('Unable to resolve resources'); + } + return PostDownloadResolution( + postId: item.postId, + requests: [ + DownloadRequest( + url: 'https://example.com/${item.postId}.jpg', + fileName: '${item.postId}.jpg', + mediaType: DownloadMediaType.image, + ), + ], + ); + } +} + +class _FakeManager extends DownloadTaskManager { + int failuresRemaining = 0; + List requests = []; + DownloadSourceDescriptor? source; + + @override + Future enqueueBatch( + Iterable requests, { + DownloadSourceDescriptor? source, + int unavailableCount = 0, + }) async { + if (failuresRemaining > 0) { + failuresRemaining--; + throw StateError('Unable to persist download queue'); + } + this.requests = requests.toList(growable: false); + this.source = source; + return DownloadBatchResult( + requestedCount: this.requests.length, + queuedCount: this.requests.length, + skippedCount: 0, + invalidCount: 0, + requeuedCount: 0, + tasks: const [], + ); + } +} + +GeneralPostItem _item(int id) => GeneralPostItem( + type: PostType.article, + photoLinks: const [], + blogId: 1, + postId: id, + permalink: '', + collectionId: 0, + liked: false, + blogName: 'author', + blogNickName: 'Author', + title: 'Post $id', + digest: '', + content: '', + firstImageUrl: '', + duration: 0, + likeCount: 0, + tags: const [], + bigAvaImg: '', + ); + +Widget _host( + Widget child, { + Locale locale = const Locale('zh'), + bool dark = false, + double textScale = 1, +}) => + MaterialApp( + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + navigatorKey: chewieProvider.globalNavigatorKey, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return child; + }, + ), + ); + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/batch_download_screen', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + for (final size in [const Size(280, 480), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final dark in [false, true]) { + testWidgets('empty batch can load posts $size $locale dark=$dark', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final previousBuilder = chewieProvider.stateWidgetBuilder; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + addTearDown( + () => chewieProvider.stateWidgetBuilder = previousBuilder); + var loadCount = 0; + final manager = _FakeManager(); + await tester.pumpWidget(_host( + BatchDownloadScreen( + sourceTitle: 'Empty collection', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Empty collection'), + initialItems: const [], + loadAllItems: () async { + loadCount++; + return [_item(1)]; + }, + resolver: _FakeResolver(), + manager: manager, + ), + locale: locale, + dark: dark, + textScale: 2)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.byType(CheckboxItem)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CheckboxItem)); + await tester.pumpAndSettle(); + expect(loadCount, 1); + expect(find.text('Post 1'), findsOneWidget); + final l10n = AppLocalizations.of( + tester.element(find.byType(BatchDownloadScreen)))!; + final download = find.text(l10n.download).last; + await tester.ensureVisible(download); + await tester.pumpAndSettle(); + await tester.tap(download); + await tester.pumpAndSettle(); + expect(manager.requests, hasLength(1)); + final buttonTexts = find.descendant( + of: find.byType(RoundIconTextButton), + matching: find.byType(Text), + ); + for (final element in buttonTexts.evaluate()) { + final widget = element.widget as Text; + final paragraph = element.renderObject! as RenderParagraph; + final boxes = paragraph.getBoxesForSelection(TextSelection( + baseOffset: 0, + extentOffset: widget.data!.length, + )); + for (final box in boxes) { + expect(box.bottom, lessThanOrEqualTo(paragraph.size.height), + reason: widget.data); + } + } + expect(tester.takeException(), isNull); + }); + } + } + } + + testWidgets('load-all failure preserves selection and the next tap retries', + (tester) async { + var attempts = 0; + await tester.pumpWidget(_host(BatchDownloadScreen( + sourceTitle: 'Collection', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Collection'), + initialItems: [_item(1), _item(2)], + loadAllItems: () async { + if (++attempts == 1) throw StateError('offline'); + return [_item(1), _item(2), _item(3)]; + }, + resolver: _FakeResolver(), + manager: _FakeManager(), + ))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Post 1')); + await tester.tap(find.byType(CheckboxItem)); + await tester.pumpAndSettle(); + expect(find.textContaining('已选择 1 / 2'), findsWidgets); + await tester.tap(find.byType(CheckboxItem)); + await tester.pumpAndSettle(); + expect(attempts, 2); + expect(find.textContaining('已选择 3 / 3'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'select-all loads beyond the selected first page and blocks early submission', + (tester) async { + final loaded = Completer>(); + final manager = _FakeManager(); + var attempts = 0; + await tester.pumpWidget(_host(BatchDownloadScreen( + sourceTitle: 'Collection', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Collection'), + initialItems: [_item(1)], + loadAllItems: () { + attempts++; + return loaded.future; + }, + resolver: _FakeResolver(), + manager: manager, + ))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Post 1')); + await tester.pump(); + await tester.tap(find.byType(CheckboxItem)); + await tester.pump(); + expect(attempts, 1); + await tester.tap(find.byKey(const Key('batch-download-primary'))); + await tester.pump(); + expect(manager.requests, isEmpty); + loaded.complete([_item(1), _item(2)]); + await tester.pumpAndSettle(); + expect(find.textContaining('已选择 2 / 2'), findsWidgets); + await tester.tap(find.byKey(const Key('batch-download-primary'))); + await tester.pumpAndSettle(); + expect(manager.requests, hasLength(2)); + expect(tester.takeException(), isNull); + }); + + for (final failure in ['resolver', 'queue']) { + testWidgets('batch submission recovers from $failure failures', + (tester) async { + final manager = _FakeManager() + ..failuresRemaining = failure == 'queue' ? 1 : 0; + final resolver = _FakeResolver() + ..failuresRemaining = failure == 'resolver' ? 1 : 0; + await tester.pumpWidget(_host(BatchDownloadScreen( + sourceTitle: 'Collection', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Collection'), + initialItems: [_item(1), _item(2)], + manager: manager, + resolver: resolver, + ))); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CheckboxItem)); + await tester.pump(); + await tester.tap(find.byKey(const Key('batch-download-primary'))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.textContaining('已选择 2 / 2'), findsWidgets); + expect(manager.requests, isEmpty); + expect(find.byType(LinearProgressIndicator), findsNothing); + await tester.tap(find.byKey(const Key('batch-download-primary'))); + await tester.pumpAndSettle(); + expect(manager.requests, hasLength(2)); + expect(manager.source?.stableKey, 'collection:42'); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('partial selection and select-all loading stay synchronized', + (tester) async { + await tester.binding.setSurfaceSize(const Size(360, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var loadAllCount = 0; + + await tester.pumpWidget(_host(BatchDownloadScreen( + sourceTitle: '测试集合', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: '测试集合', + ), + initialItems: [_item(1), _item(2)], + loadAllItems: () async { + loadAllCount++; + return [_item(1), _item(2), _item(3)]; + }, + resolver: _FakeResolver(), + manager: _FakeManager(), + ))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Post 1')); + await tester.pump(); + expect(find.textContaining('已选择 1 / 2'), findsWidgets); + + await tester.tap(find.byType(CheckboxItem)); + await tester.pumpAndSettle(); + expect(loadAllCount, 1); + expect(find.text('Post 3'), findsOneWidget); + expect(find.textContaining('已选择 3 / 3'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('confirmed resources show an enqueue summary', (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final manager = _FakeManager(); + await tester.pumpWidget(_host(BatchDownloadScreen( + sourceTitle: '测试集合', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: '测试集合', + ), + initialItems: [_item(1), _item(2)], + resolver: _FakeResolver(), + manager: manager, + ))); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CheckboxItem)); + await tester.pump(); + await tester.tap(find.text('下载').last); + await tester.pumpAndSettle(); + + expect(manager.requests, hasLength(2)); + expect(manager.source?.stableKey, 'collection:42'); + expect(find.textContaining('已加入 2 项'), findsOneWidget); + expect(find.text('下载管理'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('batch download actions reflow on narrow large-text screens', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final manager = _FakeManager(); + + await tester.pumpWidget( + _host( + MediaQuery( + data: const MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: BatchDownloadScreen( + sourceTitle: 'A very long favorite folder title', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.favoriteFolder, + sourceId: 'large-text', + title: 'A very long favorite folder title', + ), + initialItems: [_item(1), _item(2)], + resolver: _FakeResolver(), + manager: manager, + ), + ), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + final selectAll = tester.widget(find.byType(CheckboxItem)); + await selectAll.onTap?.call(); + await tester.pump(); + await tester.tap(find.text('Download').last); + await tester.pumpAndSettle(); + + expect(manager.requests, hasLength(2)); + expect(find.text('Download Manager'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect( + tester.getRect(find.byKey(const Key('batch-download-retry'))).right, + lessThanOrEqualTo(280), + ); + expect( + tester.getRect(find.byKey(const Key('batch-download-primary'))).right, + lessThanOrEqualTo(280), + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/download/download_management_screen_test.dart b/test/widgets/download/download_management_screen_test.dart new file mode 100644 index 00000000..891ffa02 --- /dev/null +++ b/test/widgets/download/download_management_screen_test.dart @@ -0,0 +1,588 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Models/download_task.dart'; +import 'package:loftify/Screens/Download/download_group_detail_screen.dart'; +import 'package:loftify/Screens/Download/download_management_screen.dart'; +import 'package:loftify/Utils/download_task_manager.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; + +class _MemoryStore implements DownloadTaskStore { + List tasks = []; + List groups = []; + + @override + Future> read() async => tasks; + + @override + Future> readGroups() async => groups; + + @override + Future write(List value) async { + tasks = List.from(value); + } + + @override + Future writeGroups(List value) async { + groups = List.from(value); + } +} + +class _PendingExecutor extends DownloadTaskExecutor { + @override + Future run( + DownloadTask task, { + required CancelToken cancelToken, + required DownloadProgressCallback onProgress, + }) { + return Completer().future; + } + + @override + Future deleteTemporaryFiles(DownloadTask task) async {} +} + +Widget _host( + Widget child, { + Locale locale = const Locale('zh'), + bool dark = false, + double textScale = 1, +}) => + MaterialApp( + locale: locale, + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return child; + }, + ), + ); + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/download_management_screen', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + for (final detail in [false, true]) { + for (final size in [const Size(280, 320), const Size(720, 320)]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final dark in [false, true]) { + testWidgets( + 'download empty state is initially visible detail=$detail $size $locale dark=$dark', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final previousBuilder = chewieProvider.stateWidgetBuilder; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + addTearDown( + () => chewieProvider.stateWidgetBuilder = previousBuilder); + final manager = DownloadTaskManager( + store: _MemoryStore(), executor: _PendingExecutor()); + await manager.initialize(); + await tester.pumpWidget(_host( + detail + ? DownloadGroupDetailScreen( + groupId: 'missing', manager: manager) + : DownloadManagementScreen(manager: manager), + locale: locale, + dark: dark, + textScale: 2, + )); + await tester.pumpAndSettle(); + final state = + tester.widget(find.byType(LoftifyStateView)); + expect(state.visual, LoftifyStateVisual.empty); + final label = find.text(state.title); + expect( + tester.getRect(label).bottom, lessThanOrEqualTo(size.height)); + expect(label.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + manager.dispose(); + }); + } + } + } + } + + for (final width in [280.0, 720.0]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final dark in [false, true]) { + testWidgets( + 'group actions retain text and work $width $locale dark=$dark', + (tester) async { + tester.view.physicalSize = Size(width, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final manager = DownloadTaskManager( + store: _MemoryStore(), executor: _PendingExecutor()); + await manager.initialize(); + final result = await manager.enqueueBatch(const [ + DownloadRequest( + url: 'https://example.com/group-actions.jpg', + fileName: 'group-actions.jpg', + mediaType: DownloadMediaType.image, + ) + ], + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Collection')); + await tester.pumpWidget(_host( + DownloadGroupDetailScreen( + groupId: result.group!.id, manager: manager), + locale: locale, + dark: dark, + textScale: 2)); + Future pumpFrames() async { + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + await pumpFrames(); + Future checkButton(String key, {bool tap = true}) async { + final button = find.byKey(Key(key)); + await tester.scrollUntilVisible(button, 160, + scrollable: find.byType(Scrollable).first); + await tester.ensureVisible(button); + await pumpFrames(); + final texts = + find.descendant(of: button, matching: find.byType(Text)); + expect(texts, findsOneWidget); + final text = tester.widget(texts); + final paragraph = tester.renderObject(texts); + for (final box in paragraph.getBoxesForSelection(TextSelection( + baseOffset: 0, extentOffset: text.data!.length))) { + expect(box.bottom, lessThanOrEqualTo(paragraph.size.height), + reason: text.data); + } + expect(button.hitTestable(), findsOneWidget); + if (tap) { + await tester.tap(button); + await pumpFrames(); + } + } + + await checkButton('download-view-original', tap: false); + await checkButton('download-group-pause'); + expect(manager.tasks.single.status, DownloadTaskStatus.paused); + await checkButton('download-group-resume'); + expect(manager.tasks.single.isActive, isTrue); + await checkButton('download-group-cancel'); + expect(manager.tasks.single.status, DownloadTaskStatus.cancelled); + await checkButton('download-group-retry'); + expect(manager.tasks.single.isActive, isTrue); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + manager.dispose(); + }); + } + } + } + + for (final detail in [false, true]) { + for (final status in [ + DownloadTaskStatus.paused, + DownloadTaskStatus.cancelled, + DownloadTaskStatus.failed, + DownloadTaskStatus.completed + ]) { + for (final total in [0, 100]) { + testWidgets( + 'stopped task has consistent progress detail=$detail $status total=$total', + (tester) async { + final now = DateTime(2026, 9, 16); + final task = DownloadTask( + id: 'stopped', + url: 'https://example.com/stopped.jpg', + fileName: 'stopped.jpg', + mediaType: DownloadMediaType.image, + status: status, + totalBytes: total, + receivedBytes: status == DownloadTaskStatus.completed ? total : 0, + createdAt: now, + updatedAt: now); + final store = _MemoryStore()..tasks = [task]; + if (detail) { + store.groups = [ + DownloadGroup( + id: 'group', + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Collection'), + taskIds: [task.id], + requestedCount: 1, + createdAt: now, + updatedAt: now) + ]; + } + final manager = + DownloadTaskManager(store: store, executor: _PendingExecutor()); + await manager.initialize(); + await tester.pumpWidget(_host(detail + ? DownloadGroupDetailScreen(groupId: 'group', manager: manager) + : DownloadManagementScreen(manager: manager))); + await tester.pump(const Duration(milliseconds: 200)); + if (detail) { + await tester.scrollUntilVisible( + find.byKey(const ValueKey('download-resource-stopped')), 150, + scrollable: find.byType(Scrollable).first); + await tester.pump(const Duration(milliseconds: 200)); + } + final bars = tester.widgetList( + find.byType(LinearProgressIndicator)); + expect(bars, isNotEmpty); + for (final bar in bars) { + expect(bar.value, isNotNull); + } + if (total > 0 && status == DownloadTaskStatus.completed) { + expect(find.text('100 B / 100 B · 100%'), findsOneWidget); + for (final bar in bars) { + expect(bar.value, 1); + } + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + manager.dispose(); + }); + } + } + } + + testWidgets('download manager shows one parent instead of flat child tasks', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final manager = DownloadTaskManager( + store: _MemoryStore(), + executor: _PendingExecutor(), + maxConcurrentTasks: 1, + ); + await manager.initialize(); + final result = await manager.enqueueBatch( + const [ + DownloadRequest( + url: 'https://example.com/first.jpg', + fileName: 'first.jpg', + mediaType: DownloadMediaType.image, + title: 'First child', + ), + DownloadRequest( + url: 'https://example.com/second.jpg', + fileName: 'second.jpg', + mediaType: DownloadMediaType.image, + title: 'Second child', + ), + ], + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Parent collection', + ), + ); + await tester.pumpWidget( + _host(DownloadManagementScreen(manager: manager)), + ); + await tester.pump(); + + expect(find.text('Parent collection'), findsOneWidget); + expect(find.text('合集'), findsOneWidget); + expect(find.text('First child'), findsNothing); + expect(find.text('Second child'), findsNothing); + expect(find.text('first.jpg'), findsNothing); + expect(find.text('second.jpg'), findsNothing); + expect(find.textContaining('/ 2'), findsOneWidget); + expect( + tester + .widget( + find.byKey(ValueKey('download-group-${result.group!.id}')), + ) + .onTap, + isNotNull, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('group detail shows child progress and controls on narrow phones', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final manager = DownloadTaskManager( + store: _MemoryStore(), + executor: _PendingExecutor(), + maxConcurrentTasks: 1, + ); + await manager.initialize(); + final result = await manager.enqueueBatch( + const [ + DownloadRequest( + url: 'https://example.com/detail-first.jpg', + fileName: 'detail-first.jpg', + mediaType: DownloadMediaType.image, + title: 'First child', + ), + DownloadRequest( + url: 'https://example.com/detail-second.jpg', + fileName: 'detail-second.jpg', + mediaType: DownloadMediaType.image, + title: 'Second child', + ), + ], + source: const DownloadSourceDescriptor( + type: DownloadSourceType.collection, + sourceId: '42', + title: 'Parent collection', + ), + ); + await tester.pumpWidget( + _host( + DownloadGroupDetailScreen( + groupId: result.group!.id, + manager: manager, + ), + ), + ); + await tester.pump(); + + expect(find.text('下载任务详情'), findsOneWidget); + expect(find.text('查看原内容'), findsOneWidget); + expect(find.text('First child'), findsOneWidget); + expect(find.text('Second child'), findsOneWidget); + expect(find.text('资源列表'), findsOneWidget); + expect(find.byKey(const Key('download-group-pause')), findsOneWidget); + expect(find.byKey(const Key('download-group-cancel')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('download tiles remain usable at two-times text scale', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final manager = DownloadTaskManager( + store: _MemoryStore(), + executor: _PendingExecutor(), + maxConcurrentTasks: 1, + ); + await manager.initialize(); + await manager.enqueueBatch( + const [ + DownloadRequest( + url: 'https://example.com/large-text-group.jpg', + fileName: 'large-text-group.jpg', + mediaType: DownloadMediaType.image, + title: 'A very long child title for the grouped download', + ), + ], + source: const DownloadSourceDescriptor( + type: DownloadSourceType.favoriteFolder, + sourceId: 'large-text', + title: 'A very long favorite folder download title', + ), + ); + await manager.enqueue( + url: 'https://example.com/large-text-standalone.mp4', + fileName: 'a-very-long-standalone-video-file-name.mp4', + mediaType: DownloadMediaType.video, + title: 'A very long standalone download title', + ); + + await tester.pumpWidget( + _host( + MediaQuery( + data: const MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: DownloadManagementScreen(manager: manager), + ), + locale: const Locale('en'), + ), + ); + await tester.pump(); + + expect(find.text('A very long favorite folder download title'), + findsOneWidget); + expect( + tester.getRect(find.byKey(const ValueKey('download-group-status'))).right, + lessThanOrEqualTo(280), + ); + await tester.scrollUntilVisible( + find.text('A very long standalone download title'), + 160, + scrollable: find.byType(Scrollable).last, + ); + await tester.scrollUntilVisible( + find.byKey(const ValueKey('download-task-actions')), + 80, + scrollable: find.byType(Scrollable).last, + ); + await tester.pump(); + expect(find.text('A very long standalone download title'), findsOneWidget); + expect( + tester.getRect(find.byKey(const ValueKey('download-task-status'))).right, + lessThanOrEqualTo(280), + ); + expect( + tester.getRect(find.byKey(const ValueKey('download-task-actions'))).right, + lessThanOrEqualTo(280), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('download group details reflow on narrow large-text screens', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final manager = DownloadTaskManager( + store: _MemoryStore(), + executor: _PendingExecutor(), + maxConcurrentTasks: 1, + ); + await manager.initialize(); + final result = await manager.enqueueBatch( + const [ + DownloadRequest( + url: 'https://example.com/detail-large-one.jpg', + fileName: 'detail-large-one.jpg', + mediaType: DownloadMediaType.image, + title: 'A very long first resource download title', + ), + DownloadRequest( + url: 'https://example.com/detail-large-two.jpg', + fileName: 'detail-large-two.jpg', + mediaType: DownloadMediaType.image, + title: 'A very long second resource download title', + ), + ], + source: const DownloadSourceDescriptor( + type: DownloadSourceType.favoriteFolder, + sourceId: 'detail-large-text', + title: 'A very long source title for download details', + ), + ); + final secondTaskId = result.tasks.last.id; + + await tester.pumpWidget( + _host( + MediaQuery( + data: const MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: DownloadGroupDetailScreen( + groupId: result.group!.id, + manager: manager, + ), + ), + locale: const Locale('en'), + ), + ); + await tester.pump(); + expect(find.text('A very long source title for download details'), + findsOneWidget); + final detailScrollable = find.descendant( + of: find.byKey(const Key('download-group-detail-list')), + matching: find.byType(Scrollable), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('download-group-overview-status')), + 120, + scrollable: detailScrollable, + ); + await tester.scrollUntilVisible( + find.byKey(const Key('download-group-cancel')), + 120, + scrollable: detailScrollable, + ); + expect( + tester + .getRect(find.byKey(const Key('download-group-overview-status'))) + .right, + lessThanOrEqualTo(280), + ); + expect( + tester.getRect(find.byKey(const Key('download-group-cancel'))).right, + lessThanOrEqualTo(280), + ); + await tester.scrollUntilVisible( + find.text('A very long second resource download title'), + 180, + scrollable: detailScrollable, + ); + await tester.scrollUntilVisible( + find.byKey(ValueKey('download-resource-actions-$secondTaskId')), + 100, + scrollable: detailScrollable, + ); + await tester.pump(); + + expect(find.text('A very long second resource download title'), + findsOneWidget); + expect( + tester + .getRect( + find.byKey(ValueKey('download-resource-status-$secondTaskId')), + ) + .right, + lessThanOrEqualTo(280), + ); + expect( + tester + .getRect( + find.byKey(ValueKey('download-resource-actions-$secondTaskId')), + ) + .right, + lessThanOrEqualTo(280), + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/dynamic/dynamic_collection_card_frame_test.dart b/test/widgets/dynamic/dynamic_collection_card_frame_test.dart new file mode 100644 index 00000000..26230b8b --- /dev/null +++ b/test/widgets/dynamic/dynamic_collection_card_frame_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/Dynamic/dynamic_collection_card_frame.dart'; + +void main() { + test('collection grids keep breathing room across phone and wide layouts', + () { + expect( + DynamicCollectionGridLayout.paddingFor(390), + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ); + expect(DynamicCollectionGridLayout.spacingFor(390), 10); + expect( + DynamicCollectionGridLayout.paddingFor(900), + const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + ); + expect(DynamicCollectionGridLayout.spacingFor(900), 12); + }); + + Widget buildFrame({ + required double width, + required double textScale, + required Widget child, + }) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + child: DynamicCollectionCardFrame( + key: const ValueKey('frame'), + cover: const ColoredBox(color: Colors.teal), + child: child, + ), + ), + ), + ), + ), + ); + } + + testWidgets('uses the 100px cover as the compact minimum height', + (tester) async { + await tester.pumpWidget( + buildFrame( + width: 560, + textScale: 1, + child: const Text('合集'), + ), + ); + + expect(tester.getSize(find.byKey(const ValueKey('frame'))).height, 116); + expect(tester.takeException(), isNull); + }); + + testWidgets('grows for narrow layouts, long copy and large text', + (tester) async { + await tester.pumpWidget( + buildFrame( + width: 280, + textScale: 2, + child: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '很长很长的合集标题与更新状态', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 3), + Text( + '发布了很多图片\n发布了很多图片\n发布了很多图片', + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 6), + Align( + alignment: Alignment.centerRight, + child: Text('继续阅读'), + ), + ], + ), + ), + ); + + expect( + tester.getSize(find.byKey(const ValueKey('frame'))).height, + greaterThan(116), + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/general/app_bar_wrapper_test.dart b/test/widgets/general/app_bar_wrapper_test.dart new file mode 100644 index 00000000..a6ab70a6 --- /dev/null +++ b/test/widgets/general/app_bar_wrapper_test.dart @@ -0,0 +1,374 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:loftify/Widgets/Design/loftify_content_reference.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/app_bar_wrapper', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('responsive app bar extends its color through one safe area', + (tester) async { + const background = Color(0xFF17202A); + await tester.pumpWidget( + _buildHost( + (context) => Scaffold( + appBar: const ResponsiveAppBar( + title: 'Details', + showBack: true, + backgroundColor: background, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final responsiveAppBar = find.byType(ResponsiveAppBar); + final coloredBox = tester.widget( + find + .descendant(of: responsiveAppBar, matching: find.byType(ColoredBox)) + .first, + ); + final annotatedRegion = + tester.widget>( + find + .descendant( + of: responsiveAppBar, + matching: find.byWidgetPredicate( + (widget) => widget is AnnotatedRegion, + ), + ) + .first, + ); + + expect(coloredBox.color, background); + expect(annotatedRegion.value.statusBarColor, Colors.transparent); + expect(annotatedRegion.value.statusBarIconBrightness, Brightness.light); + expect( + find.descendant(of: responsiveAppBar, matching: find.byType(SafeArea)), + findsOneWidget, + ); + expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget); + final iconButton = find.byType(IconButton); + final toolButton = find.byType(ToolButton); + final target = iconButton.evaluate().isNotEmpty ? iconButton : toolButton; + expect(tester.getSize(target), const Size.square(44)); + expect( + tester.getSize( + find.byKey(const ValueKey('chewie-icon-button-visual')), + ), + const Size.square(34), + ); + expect( + tester.widget(find.byIcon(LucideIcons.arrowLeft)).size, + 18, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('app bar caps explicit actions without shrinking tap targets', + (tester) async { + await tester.pumpWidget( + _buildHost( + (context) => Scaffold( + appBar: ResponsiveAppBar( + title: 'Actions', + showBack: true, + actions: [ + ChewieIconButton( + icon: LucideIcons.ellipsis, + iconSize: 24, + style: ChewieIconButtonStyle.soft, + tooltip: 'More', + onPressed: () {}, + ), + ], + landscapeActions: [ + ChewieIconButton( + icon: LucideIcons.ellipsis, + iconSize: 24, + style: ChewieIconButtonStyle.soft, + tooltip: 'More', + onPressed: () {}, + ), + ], + ), + ), + ), + ); + + for (final button in find.byType(IconButton).evaluate()) { + expect( + tester.getSize(find.byWidget(button.widget)), + const Size.square(44), + ); + } + for (final visual + in find.byKey(const ValueKey('chewie-icon-button-visual')).evaluate()) { + expect( + tester.getSize(find.byWidget(visual.widget)), + const Size.square(34), + ); + } + expect( + tester.widget(find.byIcon(LucideIcons.ellipsis)).size, + 18, + ); + expect(find.bySemanticsLabel('More'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('responsive app bar never paints a bottom divider', + (tester) async { + await tester.pumpWidget( + _buildHost( + (context) => const Scaffold( + appBar: ResponsiveAppBar( + title: 'Divider free', + showBorder: true, + ), + ), + ), + ); + + final surface = tester.widget( + find.byKey(const ValueKey('responsive-app-bar-surface')), + ); + final decoration = surface.decoration as BoxDecoration; + expect(decoration.border, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'compact app bar keeps context and icon actions inside large text width', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + _buildHost( + (context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + size: const Size(320, 568), + textScaler: const TextScaler.linear(2), + ), + child: Scaffold( + appBar: AppBarWrapper( + title: const Text( + 'Post details with a localized title', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leadingIcon: LucideIcons.arrowLeft, + onLeadingTap: () {}, + actions: [ + LoftifyContextPill( + icon: LucideIcons.layers, + label: 'Collection 123/999', + onPressed: () {}, + ), + ChewieIconButton( + icon: LucideIcons.download, + tooltip: 'Download', + onPressed: () {}, + ), + ChewieIconButton( + icon: LucideIcons.ellipsis, + tooltip: 'More', + onPressed: () {}, + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(MyAppBar)).leadingWidth, 52); + expect(tester.takeException(), isNull); + expect( + tester + .getSize( + find.byKey(const ValueKey('loftify-context-pill')), + ) + .width, + lessThanOrEqualTo(96), + ); + }); + + testWidgets('landscape app bar constrains a localized large-text title', + (tester) async { + await tester.pumpWidget( + _buildHost( + (context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + size: const Size(640, 360), + textScaler: const TextScaler.linear(2), + ), + child: Scaffold( + appBar: ResponsiveAppBar( + title: + 'A deliberately long localized page title that must not push actions away', + showBack: true, + landscapeActions: [ + ChewieIconButton( + icon: LucideIcons.ellipsis, + tooltip: 'More', + onPressed: () {}, + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final title = tester.widget( + find.text( + 'A deliberately long localized page title that must not push actions away', + ), + ); + expect(title.maxLines, 1); + expect(title.overflow, TextOverflow.ellipsis); + expect(find.bySemanticsLabel('More'), findsOneWidget); + }); + + testWidgets('dark app bar background uses light status icons', + (tester) async { + const background = Color(0xFF17202A); + await tester.pumpWidget( + _buildHost( + (context) => const Scaffold( + appBar: AppBarWrapper( + title: Text('Details'), + backgroundColor: background, + ), + ), + ), + ); + + final appBar = tester.widget(find.byType(MyAppBar)); + expect(appBar.backgroundColor, background); + expect(appBar.systemOverlayStyle?.statusBarColor, Colors.transparent); + expect( + appBar.systemOverlayStyle?.statusBarIconBrightness, + Brightness.light, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('translucent app bar color uses its composited brightness', + (tester) async { + const tint = Color.fromARGB(30, 0, 120, 70); + await tester.pumpWidget( + _buildHost( + (context) => const Scaffold( + appBar: AppBarWrapper( + title: Text('Tinted'), + backgroundColor: tint, + ), + ), + ), + ); + + final appBar = tester.widget(find.byType(MyAppBar)); + expect( + appBar.systemOverlayStyle?.statusBarIconBrightness, + Brightness.dark, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('image-backed sliver app bar defaults to light status icons', + (tester) async { + await tester.pumpWidget( + _buildHost( + (context) => Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBarWrapper( + context: context, + backgroundWidget: const ColoredBox(color: Colors.black), + title: const Text('Profile'), + actions: [ + ChewieIconButton( + icon: LucideIcons.ellipsis, + tooltip: 'More', + onPressed: () {}, + ), + ], + ), + const SliverToBoxAdapter(child: SizedBox(height: 600)), + ], + ), + ), + ), + ); + + final appBar = tester.widget(find.byType(MySliverAppBar)); + expect( + appBar.systemOverlayStyle?.statusBarIconBrightness, + Brightness.light, + ); + expect(appBar.systemOverlayStyle?.statusBarColor, Colors.transparent); + expect( + tester.getSize( + find.byKey(const ValueKey('chewie-icon-button-visual')), + ), + const Size.square(34), + ); + expect(tester.takeException(), isNull); + }); + + test('global system bar style follows theme brightness', () { + final lightStyle = AppBarWrapper.systemUiOverlayStyleForBrightness( + Brightness.light, + includeNavigationBar: true, + ); + final darkStyle = AppBarWrapper.systemUiOverlayStyleForBrightness( + Brightness.dark, + includeNavigationBar: true, + ); + + expect(lightStyle.statusBarIconBrightness, Brightness.dark); + expect(lightStyle.systemNavigationBarIconBrightness, Brightness.dark); + expect(darkStyle.statusBarIconBrightness, Brightness.light); + expect(darkStyle.systemNavigationBarIconBrightness, Brightness.light); + expect(lightStyle.statusBarColor, Colors.transparent); + expect(lightStyle.systemNavigationBarColor, Colors.transparent); + }); +} + +Widget _buildHost(Widget Function(BuildContext context) builder) { + return MaterialApp( + theme: ThemeData.light().copyWith( + scaffoldBackgroundColor: Colors.white, + appBarTheme: const AppBarTheme(backgroundColor: Colors.white), + ), + home: MediaQuery( + data: const MediaQueryData(size: Size(390, 844)), + child: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return builder(context); + }, + ), + ), + ); +} diff --git a/test/widgets/general/appearance_refresh_test.dart b/test/widgets/general/appearance_refresh_test.dart new file mode 100644 index 00000000..6c8fc710 --- /dev/null +++ b/test/widgets/general/appearance_refresh_test.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:intl/intl.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/l10n/l10n.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/appearance_refresh', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + await Hive.box(ChewieHiveUtil.settingsBox).clear(); + }); + + tearDown(() async { + appProvider.hideHomeAppBarOnScroll = false; + appProvider.hideSearchNavigation = false; + appProvider.locale = null; + appProvider.themeMode = ActiveThemeMode.system; + await ChewieHiveUtil.put( + ChewieHiveUtil.fontFamilyKey, + CustomFont.Default.fontFamily, + ); + appProvider.currentFont = CustomFont.Default; + }); + + test('locale resolver prefers exact region and then language', () { + expect( + resolveAppLocale(const Locale('zh', 'TW')), const Locale('zh', 'TW')); + expect(resolveAppLocale(const Locale('zh', 'HK')), const Locale('zh')); + expect(resolveAppLocale(const Locale('en', 'GB')), const Locale('en')); + expect(resolveAppLocale(const Locale('ja')), const Locale('en')); + }); + + test('home app bar and navigation search preferences persist separately', + () async { + expect(appProvider.hideHomeAppBarOnScroll, isFalse); + expect(appProvider.hideSearchNavigation, isFalse); + + appProvider.hideHomeAppBarOnScroll = true; + appProvider.hideSearchNavigation = true; + await Hive.box(ChewieHiveUtil.settingsBox).flush(); + + expect(ChewieHiveUtil.getBool(HiveUtil.hideHomeAppBarOnScrollKey), isTrue); + expect(ChewieHiveUtil.getBool(HiveUtil.hideSearchNavigationKey), isTrue); + }); + + test('custom font selection can be restored from persisted metadata', + () async { + const customFont = CustomFont( + fontName: 'Local Test Font', + fontFamily: 'LocalTestFont', + fontUrl: 'local-test-font.ttf', + ); + ChewieHiveUtil.setCustomFonts([customFont]); + await ChewieHiveUtil.put( + ChewieHiveUtil.fontFamilyKey, + customFont.fontFamily, + ); + + expect(CustomFont.getCurrentFont(), customFont); + + ChewieHiveUtil.setCustomFonts([]); + }); + + testWidgets('system locale refresh updates intl and notifies listeners', + (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale('en'); + addTearDown( + tester.binding.platformDispatcher.clearLocaleTestValue, + ); + appProvider.locale = null; + var notifications = 0; + void listener() => notifications++; + appProvider.addListener(listener); + addTearDown(() => appProvider.removeListener(listener)); + + tester.binding.platformDispatcher.localeTestValue = + const Locale('zh', 'TW'); + appProvider.refreshSystemLocale(); + + expect(Intl.defaultLocale, 'zh_TW'); + expect(notifications, greaterThan(0)); + }); + + test('persisted locale refresh keeps intl aligned after cold start', () { + appProvider.locale = const Locale('zh', 'TW'); + Intl.defaultLocale = 'en'; + + appProvider.refreshSystemLocale(); + + expect(Intl.defaultLocale, 'zh_TW'); + }); + + test('theme and font changes notify and produce a fresh root theme', + () async { + var notifications = 0; + void listener() => notifications++; + appProvider.addListener(listener); + addTearDown(() => appProvider.removeListener(listener)); + appProvider.themeMode = ActiveThemeMode.dark; + expect(appProvider.getBrightness(), Brightness.dark); + + await ChewieHiveUtil.put( + ChewieHiveUtil.fontFamilyKey, + CustomFont.MiSans.fontFamily, + ); + appProvider.currentFont = CustomFont.MiSans; + final theme = appProvider.darkTheme.toThemeData(); + expect(theme.textTheme.bodyMedium?.fontFamily, 'MiSans'); + expect(CustomFont.getCurrentFont(), CustomFont.MiSans); + expect(notifications, greaterThanOrEqualTo(2)); + }); + + testWidgets('localized archive month follows the active locale', + (tester) async { + const timestamp = 1730592000000; // 2024-11-03 UTC + late String formattedMonth; + + Future pumpFor(Locale locale) async { + await tester.pumpWidget( + MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + formattedMonth = formatLocalizedYearMonth(timestamp); + return const SizedBox.shrink(); + }, + ), + ), + ); + await tester.pump(); + } + + await pumpFor(const Locale('en')); + expect(formattedMonth, '2024-11'); + + await pumpFor(const Locale('zh', 'TW')); + expect(formattedMonth, '2024年11月'); + }); +} diff --git a/test/widgets/general/chewie_icon_test.dart b/test/widgets/general/chewie_icon_test.dart new file mode 100644 index 00000000..ec6b5c9e --- /dev/null +++ b/test/widgets/general/chewie_icon_test.dart @@ -0,0 +1,423 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +Widget _host( + Widget child, { + ChewieIconThemeData? iconTheme, + Brightness brightness = Brightness.light, + bool highContrast = false, +}) { + return MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.teal, + brightness: brightness, + ), + extensions: >[ + iconTheme ?? ChewieIconThemeData.standard, + ], + ), + home: MediaQuery( + data: MediaQueryData(highContrast: highContrast), + child: Scaffold(body: Center(child: child)), + ), + ); +} + +void main() { + testWidgets('shared Lucide primitives keep optical and touch sizes separate', + (tester) async { + var taps = 0; + await tester.pumpWidget( + _host( + ChewieIconButton( + icon: LucideIcons.search, + tooltip: 'Search', + onPressed: () => taps++, + ), + ), + ); + + final icon = tester.widget(find.byIcon(LucideIcons.search)); + final button = tester.widget(find.byType(IconButton)); + expect(icon.size, 20); + expect(tester.getSize(find.byType(IconButton)), const Size.square(44)); + expect( + tester.getSize( + find.byKey(const ValueKey('chewie-icon-button-visual')), + ), + const Size.square(44), + ); + expect(button.style!.shape!.resolve({}), isA()); + expect(find.bySemanticsLabel('Search'), findsOneWidget); + + await tester.tap(find.byType(IconButton)); + expect(taps, 1); + }); + + testWidgets('an explicit corner radius remains available for inline tools', ( + tester, + ) async { + await tester.pumpWidget( + _host( + ChewieIconButton( + icon: LucideIcons.slidersHorizontal, + cornerRadius: 12, + onPressed: () {}, + ), + ), + ); + + final button = tester.widget(find.byType(IconButton)); + expect(button.style!.shape!.resolve({}), isA()); + }); + + testWidgets('custom specification controls all shared measurements', + (tester) async { + const specification = ChewieIconThemeData( + regularSize: 22, + minimumTapTarget: 48, + cornerRadius: 14, + ); + await tester.pumpWidget( + _host( + ChewieIconButton( + icon: LucideIcons.settings, + onPressed: () {}, + ), + iconTheme: specification, + ), + ); + + final icon = tester.widget(find.byIcon(LucideIcons.settings)); + expect(icon.size, 22); + expect(tester.getSize(find.byType(IconButton)), const Size.square(48)); + }); + + testWidgets('selected and disabled states retain one Lucide glyph', + (tester) async { + await tester.pumpWidget( + _host( + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + ChewieIconButton( + icon: LucideIcons.heart, + selected: true, + tooltip: 'Selected', + onPressed: _emptyCallback, + ), + ChewieIconButton( + icon: LucideIcons.heart, + foregroundColor: Colors.red, + tooltip: 'Disabled', + onPressed: null, + ), + ], + ), + ), + ); + + final icons = tester.widgetList(find.byIcon(LucideIcons.heart)); + expect(icons, hasLength(2)); + expect(icons.first.color, + Theme.of(tester.element(find.byType(Row))).colorScheme.primary); + expect(icons.last.color!.a, closeTo(0.38, 0.01)); + expect(find.byIcon(LucideIcons.heart), findsNWidgets(2)); + }); + + test('theme extension copies and interpolates icon measurements', () { + const start = ChewieIconThemeData(); + final end = start.copyWith(regularSize: 24, minimumTapTarget: 48); + final middle = start.lerp(end, 0.5); + + expect(middle.regularSize, 22); + expect(middle.minimumTapTarget, 46); + expect(end.disabledOpacity, start.disabledOpacity); + }); + + testWidgets('semantic icons preserve optional contrast shadows', + (tester) async { + const shadows = [ + Shadow(color: Colors.black, blurRadius: 8), + ]; + await tester.pumpWidget( + const MaterialApp( + home: ChewieIcon( + LoftifyIcons.nextPost, + shadows: shadows, + ), + ), + ); + + final icon = tester.widget(find.byIcon(LoftifyIcons.nextPost)); + expect(icon.shadows, shadows); + }); + + testWidgets('high contrast strengthens selected and disabled states', + (tester) async { + for (final brightness in Brightness.values) { + await tester.pumpWidget( + _host( + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + ChewieIconButton( + icon: LucideIcons.heart, + selected: true, + tooltip: 'Selected', + onPressed: _emptyCallback, + ), + ChewieIconButton( + icon: LucideIcons.download, + tooltip: 'Disabled', + onPressed: null, + ), + ], + ), + brightness: brightness, + highContrast: true, + ), + ); + + final visuals = find.byKey( + const ValueKey('chewie-icon-button-visual'), + ); + final selectedDecoration = tester + .widget( + find + .descendant( + of: visuals.first, + matching: find.byType(DecoratedBox), + ) + .first, + ) + .decoration as BoxDecoration; + final disabledIcon = + tester.widget(find.byIcon(LucideIcons.download)); + + expect(selectedDecoration.border!.top.width, 1.2); + expect(selectedDecoration.border!.top.color, + Theme.of(tester.element(find.byType(Row))).colorScheme.primary); + expect(selectedDecoration.color!.a, greaterThanOrEqualTo(0.2)); + expect(disabledIcon.color!.a, closeTo(0.5, 0.01)); + expect( + tester.getSize(find.byType(IconButton).first), const Size.square(44)); + } + }); + + testWidgets('localized icon labels preserve semantics and tap targets', + (tester) async { + await tester.pumpWidget( + _host( + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + ChewieIconButton( + icon: LucideIcons.download, + tooltip: 'Download', + onPressed: _emptyCallback, + ), + ChewieIconButton( + icon: LucideIcons.download, + tooltip: '下载', + onPressed: _emptyCallback, + ), + ], + ), + ), + ); + + expect(find.bySemanticsLabel('Download'), findsOneWidget); + expect(find.bySemanticsLabel('下载'), findsOneWidget); + for (final button in find.byType(IconButton).evaluate()) { + expect( + tester.getSize(find.byWidget(button.widget)), const Size.square(44)); + } + expect(tester.takeException(), isNull); + }); + + test('product semantic icons all come from the Lucide font', () { + const icons = [ + LoftifyIcons.home, + LoftifyIcons.search, + LoftifyIcons.activity, + LoftifyIcons.profile, + LoftifyIcons.logout, + LoftifyIcons.dress, + LoftifyIcons.notifications, + LoftifyIcons.settings, + LoftifyIcons.flag, + LoftifyIcons.copyright, + LoftifyIcons.block, + LoftifyIcons.tag, + LoftifyIcons.shield, + LoftifyIcons.previous, + LoftifyIcons.next, + LoftifyIcons.previousPost, + LoftifyIcons.nextPost, + LoftifyIcons.expand, + LoftifyIcons.sortDirection, + LoftifyIcons.favorite, + LoftifyIcons.recommend, + LoftifyIcons.hot, + LoftifyIcons.egg, + LoftifyIcons.magic, + LoftifyIcons.select, + LoftifyIcons.more, + LoftifyIcons.moreVertical, + LoftifyIcons.slide, + LoftifyIcons.edit, + LoftifyIcons.history, + LoftifyIcons.premium, + LoftifyIcons.shop, + LoftifyIcons.avatarFrame, + LoftifyIcons.copy, + LoftifyIcons.follow, + LoftifyIcons.specialFollow, + LoftifyIcons.unfollow, + LoftifyIcons.bookmark, + LoftifyIcons.comment, + LoftifyIcons.article, + LoftifyIcons.invalidContent, + LoftifyIcons.originalPost, + LoftifyIcons.quote, + LoftifyIcons.reblog, + LoftifyIcons.collection, + LoftifyIcons.grain, + LoftifyIcons.filter, + LoftifyIcons.listLayout, + LoftifyIcons.gridLayout, + LoftifyIcons.scrollTop, + LoftifyIcons.trendUp, + LoftifyIcons.trendDown, + LoftifyIcons.refresh, + LoftifyIcons.save, + LoftifyIcons.add, + LoftifyIcons.check, + LoftifyIcons.warning, + LoftifyIcons.clear, + LoftifyIcons.visible, + LoftifyIcons.hidden, + LoftifyIcons.reset, + LoftifyIcons.openExternal, + LoftifyIcons.merge, + LoftifyIcons.bug, + LoftifyIcons.commit, + LoftifyIcons.review, + LoftifyIcons.share, + LoftifyIcons.support, + LoftifyIcons.contact, + LoftifyIcons.language, + LoftifyIcons.group, + LoftifyIcons.send, + LoftifyIcons.phone, + LoftifyIcons.verification, + LoftifyIcons.password, + LoftifyIcons.lofterId, + LoftifyIcons.email, + LoftifyIcons.generalSettings, + LoftifyIcons.appearance, + LoftifyIcons.image, + LoftifyIcons.basicSettings, + LoftifyIcons.experiment, + LoftifyIcons.info, + LoftifyIcons.about, + LoftifyIcons.download, + LoftifyIcons.batchDownload, + LoftifyIcons.file, + LoftifyIcons.video, + LoftifyIcons.videoUnavailable, + LoftifyIcons.videoSettings, + LoftifyIcons.continuousPlayback, + LoftifyIcons.danmaku, + LoftifyIcons.back, + LoftifyIcons.sound, + LoftifyIcons.mute, + LoftifyIcons.enterFullscreen, + LoftifyIcons.exitFullscreen, + LoftifyIcons.pause, + LoftifyIcons.play, + LoftifyIcons.retry, + LoftifyIcons.close, + LoftifyIcons.delete, + ]; + + expect(icons.every((icon) => icon.fontFamily == 'Lucide'), isTrue); + expect(icons.every((icon) => icon.fontPackage == 'lucide_icons'), isTrue); + }); + + test('reusable component semantics all come from the Lucide font', () { + const icons = [ + ChewieIcons.back, + ChewieIcons.previous, + ChewieIcons.next, + ChewieIcons.expand, + ChewieIcons.collapse, + ChewieIcons.arrowUp, + ChewieIcons.arrowDown, + ChewieIcons.arrowLeft, + ChewieIcons.arrowRight, + ChewieIcons.add, + ChewieIcons.remove, + ChewieIcons.close, + ChewieIcons.check, + ChewieIcons.copy, + ChewieIcons.copyDone, + ChewieIcons.more, + ChewieIcons.refresh, + ChewieIcons.retry, + ChewieIcons.search, + ChewieIcons.share, + ChewieIcons.openExternal, + ChewieIcons.info, + ChewieIcons.success, + ChewieIcons.warning, + ChewieIcons.error, + ChewieIcons.imageUnavailable, + ChewieIcons.inbox, + ChewieIcons.archive, + ChewieIcons.star, + ChewieIcons.starHalf, + ChewieIcons.pin, + ChewieIcons.minimizeWindow, + ChewieIcons.maximizeWindow, + ChewieIcons.restoreWindow, + ChewieIcons.closeWindow, + ChewieIcons.square, + ChewieIcons.alarm, + ChewieIcons.time, + ]; + + expect(icons.every((icon) => icon.fontFamily == 'Lucide'), isTrue); + expect(icons.every((icon) => icon.fontPackage == 'lucide_icons'), isTrue); + }); + + test('application pages do not import icon fonts directly', () { + final violations = Directory('lib') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')) + .where( + (file) => !file.path.replaceAll('\\', '/').endsWith( + 'lib/Widgets/loftify_icons.dart', + ), + ) + .where( + (file) => file.readAsStringSync().contains( + 'package:lucide_icons/lucide_icons.dart', + ), + ) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + }); +} + +void _emptyCallback() {} diff --git a/test/widgets/general/dynamic_tool_button_test.dart b/test/widgets/general/dynamic_tool_button_test.dart new file mode 100644 index 00000000..6d2af1aa --- /dev/null +++ b/test/widgets/general/dynamic_tool_button_test.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Widgets/Item/item_builder.dart'; +import 'package:provider/provider.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/dynamic_tool_button', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('dynamic desktop tool button forwards its tap callback', + (tester) async { + var tapCount = 0; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appProvider, + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return ItemBuilder.buildDynamicToolButton( + context: context, + iconBuilder: (_) => const Icon(Icons.dark_mode_outlined), + onTap: () => tapCount++, + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(ToolButton)); + await tester.pumpAndSettle(); + + expect(tapCount, 1); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); +} diff --git a/test/widgets/general/font_item_accessibility_test.dart b/test/widgets/general/font_item_accessibility_test.dart new file mode 100644 index 00000000..ca9ecf2d --- /dev/null +++ b/test/widgets/general/font_item_accessibility_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/font_item_accessibility', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('font cards keep selection and delete targets accessible', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + CustomFont? selected; + CustomFont? deleted; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ChewieLocalizations.delegate], + supportedLocales: ChewieLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return MediaQuery( + data: const MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: Center( + child: FontItem( + font: CustomFont.Default, + currentFont: CustomFont.MiSans, + showDelete: true, + onChanged: (font) => selected = font, + onDelete: (font) => deleted = font, + ), + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); + + final delete = find.byKey(const ValueKey('font-delete-')); + expect(delete, findsOneWidget); + expect(tester.getSize(delete), const Size(48, 48)); + expect(find.byType(ChewieSelectionIndicator), findsNothing); + expect(find.byIcon(Icons.radio_button_checked), findsNothing); + expect(find.byIcon(Icons.radio_button_unchecked), findsNothing); + expect(tester.takeException(), isNull); + + await tester.tap(find.byType(FontItem)); + expect(selected, CustomFont.Default); + selected = null; + await tester.tap(delete); + expect(deleted, CustomFont.Default); + expect(selected, isNull); + }); + + testWidgets('the complete add-font card remains one large action', + (tester) async { + tester.view.physicalSize = const Size(280, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var taps = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ChewieLocalizations.delegate], + supportedLocales: ChewieLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return MediaQuery( + data: const MediaQueryData( + size: Size(280, 480), + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: Center(child: EmptyFontItem(onTap: () => taps++)), + ), + ); + }, + ), + ), + ); + await tester.pump(); + + final card = find.byType(EmptyFontItem); + final rect = tester.getRect(card); + await tester.tapAt(Offset(rect.center.dx, rect.bottom - 4)); + await tester.pump(); + + expect(taps, 1); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/general/group_button_test.dart b/test/widgets/general/group_button_test.dart new file mode 100644 index 00000000..c0d8cf2c --- /dev/null +++ b/test/widgets/general/group_button_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/group_button', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('group button expands past its minimum width for long labels', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: SizedBox( + width: 320, + child: ChewieItemBuilder.buildGroupButtons( + buttons: const ['不限', '最近七天没有访问过'], + ), + ), + ); + }, + ), + ), + ); + + final longButton = find.ancestor( + of: find.text('最近七天没有访问过'), + matching: find.byType(RoundIconTextButton), + ); + expect(longButton, findsOneWidget); + expect(tester.getSize(longButton).width, greaterThan(80)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/general/high_refresh_animation_test.dart b/test/widgets/general/high_refresh_animation_test.dart new file mode 100644 index 00000000..8587512f --- /dev/null +++ b/test/widgets/general/high_refresh_animation_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:awesome_chewie/src/Utils/System/route_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('page transition progress is independent of frame cadence', + (tester) async { + Future measureAt(int refreshRate) async { + final navigatorKey = GlobalKey(); + final pageKey = ValueKey('page-$refreshRate'); + await tester.pumpWidget( + MaterialApp( + key: ValueKey(refreshRate), + navigatorKey: navigatorKey, + home: const SizedBox.shrink(), + ), + ); + unawaited( + navigatorKey.currentState!.push( + RouteUtil.getFadeRoute( + ColoredBox(key: pageKey, color: Colors.white), + duration: const Duration(milliseconds: 300), + ), + ), + ); + await tester.pump(); + await _pumpAtCadence( + tester, + refreshRate: refreshRate, + elapsed: const Duration(milliseconds: 150), + ); + + final transition = tester.widget( + find.ancestor( + of: find.byKey(pageKey), + matching: find.byType(FadeTransition), + ), + ); + return transition.opacity.value; + } + + final progress60 = await measureAt(60); + final progress90 = await measureAt(90); + final progress120 = await measureAt(120); + + expect(progress60, closeTo(progress90, 0.001)); + expect(progress90, closeTo(progress120, 0.001)); + expect(progress120, closeTo(0.5, 0.01)); + }); +} + +Future _pumpAtCadence( + WidgetTester tester, { + required int refreshRate, + required Duration elapsed, +}) async { + final frame = Duration( + microseconds: (Duration.microsecondsPerSecond / refreshRate).round(), + ); + var remaining = elapsed; + while (remaining > Duration.zero) { + final step = remaining < frame ? remaining : frame; + await tester.pump(step); + remaining -= step; + } +} diff --git a/test/widgets/general/icon_state_policy_test.dart b/test/widgets/general/icon_state_policy_test.dart new file mode 100644 index 00000000..71290ae9 --- /dev/null +++ b/test/widgets/general/icon_state_policy_test.dart @@ -0,0 +1,412 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('product icon semantics never encode a visual state variant', () { + final source = File('lib/Widgets/loftify_icons.dart').readAsStringSync(); + final declarations = RegExp( + r'static const IconData\s+([A-Za-z0-9_]+)', + ).allMatches(source); + final visualVariant = RegExp( + r'(active|inactive|selected|unselected|filled|outlined)', + caseSensitive: false, + ); + + final violations = declarations + .map((match) => match.group(1)!) + .where(visualVariant.hasMatch) + .toList(); + + expect(violations, isEmpty); + }); + + test('there are no unregistered paired SVG interface icons', () { + const excludedBrandAssets = {'assets/logo.svg'}; + final interfaceSvgAssets = Directory('assets') + .listSync(recursive: true) + .whereType() + .map((file) => file.path.replaceAll('\\', '/')) + .where((path) => path.endsWith('.svg')) + .where((path) => !excludedBrandAssets.contains(path)) + .toList(); + + expect( + interfaceSvgAssets, + isEmpty, + reason: 'Register every paired SVG exception in Icon_Migration_Map.md ' + 'and this allowlist before using it.', + ); + }); + + test('runtime and asset manifests contain no legacy interface icons', () { + final violations = []; + for (final root in ['lib', 'third-party/chewie/lib']) { + for (final file in Directory(root) + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart'))) { + final lines = file.readAsLinesSync(); + for (var index = 0; index < lines.length; index++) { + final source = lines[index].trimLeft(); + if (source.startsWith('//') || source.startsWith('*')) continue; + if (RegExp(r'\b(?:Icons|CupertinoIcons)\.').hasMatch(source)) { + violations.add('${file.path}:${index + 1}'); + } + } + } + } + + expect(violations, isEmpty); + expect(Directory('assets/icon').existsSync(), isFalse); + expect(File('pubspec.yaml').readAsStringSync(), + isNot(contains('assets/icon/'))); + expect( + File('lib/Utils/asset_util.dart').readAsStringSync(), + isNot(contains('assets/icon/')), + ); + }); + + test('global navigation uses semantic icons without glyph swapping', () { + final sources = { + 'main': File('lib/Screens/main_screen.dart').readAsStringSync(), + 'panel': File('lib/Screens/panel_screen.dart').readAsStringSync(), + 'glass': File( + 'lib/Widgets/Navigation/loftify_glass_navigation_bar.dart', + ).readAsStringSync(), + }; + + for (final entry in sources.entries) { + expect( + RegExp(r'\bIcons\.').hasMatch(entry.value), + isFalse, + reason: '${entry.key} navigation must not use Material icons', + ); + expect( + entry.value.contains('assets/icon/'), + isFalse, + reason: '${entry.key} navigation must not use legacy icon images', + ); + } + + const semantics = ['home', 'search', 'activity', 'profile']; + for (final semantic in semantics) { + expect( + RegExp( + 'icon:\\s*LoftifyIcons\\.$semantic,\\s*' + 'selectedIcon:\\s*LoftifyIcons\\.$semantic,', + ).hasMatch(sources['main']!), + isTrue, + ); + expect( + RegExp( + 'LoftifyNavigationDestination\\(\\s*' + 'icon:\\s*LoftifyIcons\\.$semantic,', + ).hasMatch(sources['panel']!), + isTrue, + ); + } + expect(sources['panel'], isNot(contains('activeIcon:'))); + expect(sources['glass'], contains('ChewieIcon(icon, size: 22')); + }); + + test('reusable Chewie runtime has no Material or Cupertino glyphs', () { + final violations = Directory('third-party/chewie/lib') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')) + .expand( + (file) => file.readAsLinesSync().asMap().entries.where( + (line) { + final source = line.value.trimLeft(); + if (source.startsWith('//') || source.startsWith('*')) { + return false; + } + return RegExp(r'\b(?:Icons|CupertinoIcons)\.').hasMatch(source); + }, + ).map((line) => '${file.path}:${line.key + 1}'), + ) + .toList(); + + expect(violations, isEmpty); + }); + + test('settings and login primitives have no legacy glyphs', () { + final files = [ + ...Directory('lib/Screens/Setting') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')), + File('lib/Widgets/Item/setting_management_item.dart'), + File('lib/Widgets/Item/login_input_item.dart'), + ]; + final violations = files + .where( + (file) => RegExp(r'\b(?:Icons|CupertinoIcons)\.') + .hasMatch(file.readAsStringSync()), + ) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + }); + + test('shared content item builders use semantic Lucide icons', () { + final files = [ + File('lib/Widgets/Item/item_builder.dart'), + File('lib/Widgets/Item/loftify_item_builder.dart'), + ]; + final legacyGlyph = RegExp(r'\b(?:Icons|CupertinoIcons)\.'); + final legacyInterfaceAsset = RegExp( + r'AssetUtil\.(?:hotIcon|searchLightIcon|searchDarkIcon|' + r'likeFilledIcon|likeLightIcon)', + ); + final violations = files + .where((file) { + final source = file.readAsStringSync(); + return legacyGlyph.hasMatch(source) || + legacyInterfaceAsset.hasMatch(source); + }) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + + final source = File( + 'lib/Widgets/Item/loftify_item_builder.dart', + ).readAsStringSync(); + expect(source, contains('ChewieIcon(\n LoftifyIcons.favorite,')); + expect( + source, contains('ChewieIcon(\n LoftifyIcons.recommend,')); + }); + + test('business bottom sheets use semantic Lucide icons', () { + final files = Directory('lib/Widgets/BottomSheet') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); + final legacyGlyph = RegExp(r'\b(?:Icons|CupertinoIcons)\.'); + final legacyInterfaceAsset = RegExp( + r'AssetUtil\.(?:orderDownDarkIcon|orderUpDarkIcon)', + ); + final violations = files + .where((file) { + final source = file.readAsStringSync(); + return legacyGlyph.hasMatch(source) || + legacyInterfaceAsset.hasMatch(source); + }) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + + final subscribeSource = File( + 'lib/Widgets/BottomSheet/subscribe_post_bottom_sheet.dart', + ).readAsStringSync(); + expect(RegExp(r'icon:\s*LoftifyIcons\.select').allMatches(subscribeSource), + hasLength(1)); + }); + + test('content management menus use semantic Lucide icons', () { + const paths = [ + 'lib/Screens/Info/share_screen.dart', + 'lib/Screens/Info/like_screen.dart', + 'lib/Screens/Info/history_screen.dart', + 'lib/Screens/Info/favorite_folder_list_screen.dart', + 'lib/Screens/Info/supporter_screen.dart', + ]; + final legacyGlyph = RegExp(r'\b(?:Icons|CupertinoIcons)\.'); + final violations = paths + .map(File.new) + .where((file) => legacyGlyph.hasMatch(file.readAsStringSync())) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + + for (final path in paths.take(3)) { + final source = File(path).readAsStringSync(); + expect(source, contains('LoftifyIcons.moreVertical')); + expect(source, contains('status: MenuItemStatus.error')); + } + }); + + test('profile detail uses semantic Lucide icons and stable follow glyph', () { + final source = File( + 'lib/Screens/Info/user_detail_screen.dart', + ).readAsStringSync(); + + expect(RegExp(r'\b(?:Icons|CupertinoIcons)\.').hasMatch(source), isFalse); + expect(source.contains('AssetUtil.collectionWhiteIcon'), isFalse); + expect( + RegExp(r'LoftifyIcons\.following|LoftifyIcons\.followed') + .hasMatch(source), + isFalse, + ); + expect(source, contains('LoftifyIcons.follow')); + expect(source, contains('status: MenuItemStatus.error')); + }); + + test('tag collection and grain details use semantic Lucide icons', () { + const paths = [ + 'lib/Screens/Post/collection_detail_screen.dart', + 'lib/Screens/Post/grain_detail_screen.dart', + 'lib/Screens/Post/tag_detail_screen.dart', + 'lib/Screens/Post/tag_collection_grain_screen.dart', + ]; + final legacyGlyph = RegExp(r'\b(?:Icons|CupertinoIcons)\.'); + final legacyInterfaceAsset = RegExp( + r'AssetUtil\.(?:orderDownDarkIcon|orderUpDarkIcon|tagLightIcon|' + r'tagDarkIcon|searchLightIcon|searchDarkIcon|collectionWhiteIcon|' + r'grainWhiteIcon|hottestIcon|hotIcon|hotlessIcon)', + ); + final violations = paths + .map(File.new) + .where((file) { + final source = file.readAsStringSync(); + return legacyGlyph.hasMatch(source) || + legacyInterfaceAsset.hasMatch(source); + }) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + }); + + test('post cards and content fallback use semantic Lucide icons', () { + const paths = [ + 'lib/Widgets/PostItem/general_post_item_builder.dart', + 'lib/Widgets/PostDetail/post_content_section.dart', + ]; + final legacyGlyph = RegExp(r'\b(?:Icons|CupertinoIcons)\.'); + final violations = paths + .map(File.new) + .where((file) => legacyGlyph.hasMatch(file.readAsStringSync())) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + + final postItemSource = File(paths.first).readAsStringSync(); + expect( + RegExp(r'LoftifyIcons\.favorite').allMatches(postItemSource), + hasLength(2), + ); + expect( + RegExp(r'LoftifyIcons\.recommend').allMatches(postItemSource), + hasLength(3), + ); + }); + + test('post detail uses semantic Lucide icons and stable bookmark glyph', () { + final source = File( + 'lib/Screens/Post/post_detail_screen.dart', + ).readAsStringSync(); + final downloadActionSource = File( + 'lib/Widgets/PostDetail/post_download_action_icon.dart', + ).readAsStringSync(); + + expect(RegExp(r'\b(?:Icons|CupertinoIcons)\.').hasMatch(source), isFalse); + expect(source.contains('AssetUtil.collectionLightIcon'), isFalse); + expect(source.contains('AssetUtil.collectionDarkIcon'), isFalse); + expect( + RegExp(r'LoftifyIcons\.bookmark').allMatches(source).length, + greaterThanOrEqualTo(2), + ); + expect(source, contains('PostDownloadActionIcon(')); + expect(source, isNot(contains('CircularProgressIndicator('))); + expect(downloadActionSource, contains('LoftifyIcons.download')); + expect(downloadActionSource, contains('CircularProgressIndicator(')); + expect(downloadActionSource, isNot(contains('buildLoadingAnimation'))); + }); + + test('login screens use semantic Lucide field and method icons', () { + final files = Directory('lib/Screens/Login') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); + final legacyGlyph = RegExp(r'\b(?:Icons|CupertinoIcons)\.'); + final violations = files + .where((file) => legacyGlyph.hasMatch(file.readAsStringSync())) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + + final sources = files.map((file) => file.readAsStringSync()).join('\n'); + for (final semantic in [ + 'phone', + 'verification', + 'password', + 'lofterId', + 'email', + ]) { + expect(sources, contains('LoftifyIcons.$semantic')); + } + }); + + test('primary navigation pages use semantic Lucide action icons', () { + final files = Directory('lib/Screens/Navigation') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); + final legacyGlyph = RegExp(r'\b(?:Icons|CupertinoIcons)\.'); + final legacyInterfaceAsset = RegExp( + r'AssetUtil\.(?:dressLightIcon|dressDarkIcon|settingLightIcon|' + r'settingDarkIcon|tagWhiteIcon|tagLightIcon)', + ); + final violations = files + .where((file) { + final source = file.readAsStringSync(); + return legacyGlyph.hasMatch(source) || + legacyInterfaceAsset.hasMatch(source); + }) + .map((file) => file.path) + .toList(); + + expect(violations, isEmpty); + + final dynamicSource = File( + 'lib/Screens/Navigation/dynamic_screen.dart', + ).readAsStringSync(); + expect(dynamicSource, contains('LoftifyIcons.bookmark')); + expect( + RegExp(r'bookmarkAdded|bookmarkAdd|bookmarkSelected') + .hasMatch(dynamicSource), + isFalse, + ); + }); + + test('video controls use Lucide semantics and progress download states', () { + final source = File( + 'lib/Screens/Post/video_detail_screen.dart', + ).readAsStringSync(); + + expect(RegExp(r'\b(?:Icons|CupertinoIcons)\.').hasMatch(source), isFalse); + expect(source, contains('LoftifyIcons.favorite')); + expect(source, contains('LoftifyIcons.recommend')); + expect(source, contains('LoftifyIcons.danmaku')); + expect(source, contains('iconWidget: widget.downloading')); + expect(source, contains('CircularProgressIndicator(')); + expect( + RegExp(r'favoriteFilled|recommendFilled|danmakuSelected') + .hasMatch(source), + isFalse, + ); + }); + + test('dress cards and detail actions use Lucide semantics', () { + final previewSource = File( + 'lib/Widgets/Suit/dress_preview_card.dart', + ).readAsStringSync(); + final detailSource = File( + 'lib/Screens/Suit/dress_detail_screen.dart', + ).readAsStringSync(); + + for (final source in [previewSource, detailSource]) { + expect(RegExp(r'\b(?:Icons|CupertinoIcons)\.').hasMatch(source), isFalse); + } + expect(previewSource, contains('LoftifyIcons.next')); + expect(detailSource, contains('LoftifyIcons.download')); + }); +} diff --git a/test/widgets/general/loading_animation_test.dart b/test/widgets/general/loading_animation_test.dart new file mode 100644 index 00000000..84fc479f --- /dev/null +++ b/test/widgets/general/loading_animation_test.dart @@ -0,0 +1,465 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:awesome_chewie/src/Widgets/Module/PhotoView/src/photo_view_default_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Info/nested_mixin.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Widgets/Design/loftify_lottie.dart'; +import 'package:lottie/lottie.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/loading_animation', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + setUp(() { + chewieProvider.loadingWidgetBuilder = LottieFiles.buildLoadingAnimation; + }); + + testWidgets('loading animation follows light, dark and forced-dark surfaces', + (tester) async { + Future pumpAndReadAsset({ + required ThemeData theme, + required bool forceDark, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + themeAnimationDuration: Duration.zero, + home: LottieFiles.buildLoadingAnimation(42, forceDark), + ), + ); + await tester.pump(); + final normalized = tester.widget( + find.byType(LoftifyLottie), + ); + final lottie = tester.widget(find.byType(LottieBuilder)); + expect(normalized.size, 42); + expect( + tester.getSize( + find.byKey( + ValueKey('loftify-lottie-icon-${normalized.spec.asset}'), + ), + ), + const Size.square(42), + ); + return (lottie.lottie as AssetLottie).assetName; + } + + expect( + await pumpAndReadAsset(theme: ThemeData.light(), forceDark: false), + LottieFiles.loadingLight, + ); + expect( + await pumpAndReadAsset(theme: ThemeData.dark(), forceDark: false), + LottieFiles.loadingDark, + ); + expect( + await pumpAndReadAsset(theme: ThemeData.light(), forceDark: true), + LottieFiles.loadingDarkTransparent, + ); + }); + + testWidgets('LoadingWidget uses Lottie over a transparent page surface', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(), + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return const LoadingWidget(showText: false, size: 36); + }, + ), + ), + ); + await tester.pump(); + + final container = tester.widget( + find.descendant( + of: find.byType(LoadingWidget), + matching: find.byType(Container), + ), + ); + expect(container.color, Colors.transparent); + expect(find.byType(LottieBuilder), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('loading timeline respects reduced motion', (tester) async { + await tester.pumpWidget(MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: LottieFiles.buildLoadingAnimation(40, false), + ), + )); + await tester.pump(const Duration(milliseconds: 200)); + final controller = tester.widget(find.byType(Lottie)).controller!; + expect(controller.value, 0); + expect(controller.isAnimating, isFalse); + }); + + testWidgets('photo viewer default loading state uses forced-dark Lottie', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(), + home: const PhotoViewDefaultLoading(), + ), + ); + await tester.pump(); + + final lottie = tester.widget(find.byType(LottieBuilder)); + expect( + (lottie.lottie as AssetLottie).assetName, + LottieFiles.loadingDarkTransparent, + ); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(tester.takeException(), isNull); + }); + + test('Lottie refresh indicators stay above content with bounded travel', () { + const indicator = SizedBox(key: ValueKey('refresh-lottie')); + const header = LottieCupertinoHeader(indicator: indicator); + const footer = LottieCupertinoFooter(indicator: indicator); + + expect(header.position, IndicatorPosition.above); + expect(header.triggerOffset, 56); + expect(header.maxOverOffset, 84); + expect(header.springRebound, isTrue); + expect(header.radius, 20); + expect(header.indicatorOffset, 0); + expect(footer.position, IndicatorPosition.above); + expect(footer.triggerOffset, 52); + expect(footer.maxOverOffset, 76); + expect(footer.infiniteOffset, 240); + expect(footer.radius, 18); + }); + + test('nested refresh keeps a compact indicator without a short trigger', () { + final header = buildNestedRefreshHeader() as LottieCupertinoHeader; + + expect(header.position, IndicatorPosition.above); + expect(header.triggerOffset, 52); + expect(header.maxOverOffset, 76); + expect(header.safeArea, isFalse); + expect(header.triggerOffset, lessThan(header.maxOverOffset)); + expect(header.radius, 18); + }); + + testWidgets('pulling reveals Lottie while the list follows the gesture', + (tester) async { + final refreshCompleter = Completer(); + const firstItemKey = ValueKey('first-refresh-item'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EasyRefresh.builder( + header: const LottieCupertinoHeader( + indicator: SizedBox( + key: ValueKey('visible-refresh-lottie'), + width: 44, + height: 44, + ), + ), + onRefresh: () => refreshCompleter.future, + childBuilder: (context, physics) => ListView( + physics: physics, + children: const [ + SizedBox(key: firstItemKey, height: 80), + SizedBox(height: 900), + ], + ), + ), + ), + ), + ); + + final initialTop = tester.getTopLeft(find.byKey(firstItemKey)).dy; + final gesture = await tester.startGesture(const Offset(200, 120)); + await gesture.moveBy(const Offset(0, 8)); + await tester.pump(); + + expect( + find.byKey(const ValueKey('visible-refresh-lottie')), findsOneWidget); + double indicatorScale() => tester + .widget(find.byKey(const ValueKey('indicatorPullScale'))) + .transform + .storage[0]; + void expectArtworkInsideReveal() { + final reveal = tester + .getRect(find.byKey(const ValueKey('refresh-indicator-viewport'))); + final artwork = + tester.getRect(find.byKey(const ValueKey('visible-refresh-lottie'))); + expect(artwork.top, greaterThanOrEqualTo(reveal.top - 0.1)); + expect(artwork.bottom, lessThanOrEqualTo(reveal.bottom + 0.1)); + expect(reveal.bottom, + closeTo(tester.getTopLeft(find.byKey(firstItemKey)).dy, 1)); + } + + final firstScale = indicatorScale(); + final firstGap = + tester.getTopLeft(find.byKey(firstItemKey)).dy - initialTop; + expect(40 * firstScale, lessThanOrEqualTo(firstGap * 0.9 + 0.05)); + expect(40 * firstScale, closeTo(firstGap * 0.9, 1)); + expectArtworkInsideReveal(); + await gesture.moveBy(const Offset(0, 32)); + await tester.pump(); + final secondScale = indicatorScale(); + final secondGap = + tester.getTopLeft(find.byKey(firstItemKey)).dy - initialTop; + expect(firstScale, lessThan(secondScale)); + expect(secondScale, lessThanOrEqualTo(1)); + expect(40 * secondScale, lessThanOrEqualTo(secondGap * 0.9 + 0.05)); + expect(40 * secondScale, closeTo(secondGap * 0.9, 1)); + expectArtworkInsideReveal(); + expect(tester.getTopLeft(find.byKey(firstItemKey)).dy, + greaterThan(initialTop)); + await gesture.moveBy(const Offset(0, -18)); + await tester.pump(); + final returningScale = indicatorScale(); + final returningGap = + tester.getTopLeft(find.byKey(firstItemKey)).dy - initialTop; + expect(returningScale, lessThan(secondScale)); + expect(40 * returningScale, lessThanOrEqualTo(returningGap * 0.9 + 0.05)); + expectArtworkInsideReveal(); + expect(tester.takeException(), isNull); + + await gesture.up(); + refreshCompleter.complete(IndicatorResult.success); + await tester.pumpAndSettle(); + }); + + testWidgets('refresh Lottie timeline keeps playing through drag rebuilds', + (tester) async { + final refreshCompleter = Completer(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EasyRefresh.builder( + header: LottieCupertinoHeader( + indicator: LottieFiles.buildLoadingAnimation(40, false), + ), + onRefresh: () => refreshCompleter.future, + childBuilder: (context, physics) => ListView( + physics: physics, + children: const [SizedBox(height: 900)], + ), + ), + ), + )); + + final gesture = await tester.startGesture(const Offset(200, 100)); + await gesture.moveBy(const Offset(0, 48)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(const Duration(milliseconds: 150)); + final lottie = tester.widget(find.byType(Lottie)); + final timeline = lottie.controller!; + final before = timeline.value; + expect(before, greaterThan(0)); + + await gesture.moveBy(const Offset(0, 12)); + await tester.pump(const Duration(milliseconds: 150)); + expect( + tester.widget(find.byType(Lottie)).controller, same(timeline)); + expect(timeline.value, greaterThan(before)); + final afterFirstDrag = timeline.value; + await gesture.moveBy(const Offset(0, 12)); + await tester.pump(const Duration(milliseconds: 150)); + expect(timeline.value, greaterThan(afterFirstDrag)); + + await gesture.up(); + refreshCompleter.complete(IndicatorResult.success); + await tester.pump(const Duration(milliseconds: 500)); + }); + + testWidgets('processing animation stays fitted when the gap contracts', + (tester) async { + final pending = Completer(); + const firstItemKey = ValueKey('processing-first-item'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EasyRefresh.builder( + header: const LottieCupertinoHeader( + triggerOffset: 28, + safeArea: false, + radius: 20, + indicator: SizedBox(width: 40, height: 40), + ), + onRefresh: () => pending.future, + childBuilder: (context, physics) => ListView( + physics: physics, + children: const [SizedBox(key: firstItemKey, height: 900)], + ), + ), + ), + )); + final initialTop = tester.getTopLeft(find.byKey(firstItemKey)).dy; + final gesture = await tester.startGesture(const Offset(200, 100)); + await gesture.moveBy(const Offset(0, 300)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + + final scale = tester + .widget(find.byKey(const ValueKey('indicatorPullScale'))) + .transform + .storage[0]; + final revealedHeight = tester + .getSize(find.byKey(const ValueKey('refresh-indicator-viewport'))) + .height; + expect(find.byKey(const ValueKey('indicatorReady')), findsOneWidget); + expect(40 * scale, lessThanOrEqualTo(revealedHeight + 0.01)); + final visibleGap = + tester.getTopLeft(find.byKey(firstItemKey)).dy - initialTop; + expect(40 * scale, lessThanOrEqualTo(visibleGap + 0.01)); + expect(tester.takeException(), isNull); + pending.complete(IndicatorResult.success); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'programmatic refresh uses the supplied nested position before any drag', + (tester) async { + final refreshCompleter = Completer(); + final refreshController = EasyRefreshController(); + final scrollController = ScrollController(); + addTearDown(refreshController.dispose); + addTearDown(scrollController.dispose); + var refreshCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EasyRefresh.builder( + controller: refreshController, + header: const LottieCupertinoHeader( + triggerOffset: 52, + indicator: SizedBox(key: ValueKey('programmatic-lottie')), + ), + onRefresh: () { + refreshCount++; + return refreshCompleter.future; + }, + childBuilder: (context, physics) => ListView( + controller: scrollController, + physics: physics, + children: const [SizedBox(height: 900)], + ), + ), + ), + ), + ); + await tester.pump(); + + unawaited( + refreshController.callRefresh( + overOffset: 28, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + scrollController: scrollController, + jumpToEdge: false, + ), + ); + await tester.pump(const Duration(milliseconds: 140)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byKey(const ValueKey('programmatic-lottie')), findsOneWidget); + expect(refreshCount, 1); + expect(tester.takeException(), isNull); + + refreshCompleter.complete(IndicatorResult.success); + await tester.pumpAndSettle(); + }); + + testWidgets('footer prefetches before the list reaches its hard edge', + (tester) async { + var loads = 0; + double? extentAfterAtLoad; + final loadCompleter = Completer(); + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EasyRefresh( + footer: const LottieCupertinoFooter( + infiniteOffset: 240, + indicator: SizedBox(width: 32, height: 32), + ), + onLoad: () { + loads++; + extentAfterAtLoad = controller.position.extentAfter; + return loadCompleter.future; + }, + child: ListView.builder( + controller: controller, + itemExtent: 100, + itemCount: 12, + itemBuilder: (_, index) => Text('Item $index'), + ), + ), + ), + ), + ); + + await tester.timedDrag( + find.byType(ListView), + const Offset(0, -520), + const Duration(milliseconds: 800), + ); + await tester.pumpAndSettle(); + + expect(loads, 1); + expect( + extentAfterAtLoad, + greaterThan(0), + reason: 'load should start before the list is dragged past its end', + ); + loadCompleter.complete(IndicatorResult.success); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + }); + + testWidgets('sliver empty state does not request viewport intrinsics', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return const Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar(expandedHeight: 180), + SliverEmptyPlaceholder(text: 'No content'), + ], + ), + ); + }, + ), + ), + ); + await tester.pump(); + + expect(find.text('No content'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -160)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/general/loftify_reaction_icon_test.dart b/test/widgets/general/loftify_reaction_icon_test.dart new file mode 100644 index 00000000..6e6f8180 --- /dev/null +++ b/test/widgets/general/loftify_reaction_icon_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; +import 'package:loftify/Widgets/loftify_reaction_icon.dart'; + +void main() { + testWidgets('selected reactions are tintable vector silhouettes', + (tester) async { + for (final kind in LoftifyReactionKind.values) { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: LoftifyReactionIcon( + kind: kind, + selected: true, + size: 28, + ), + ), + ), + ); + + final paint = tester.widget(find.descendant( + of: find.byType(LoftifyReactionIcon), + matching: find.byType(CustomPaint), + )); + final painter = paint.painter! as LoftifyReactionIconPainter; + expect(painter.kind, kind); + expect(painter.color, LoftifyReactionColors.forKind(kind)); + expect( + tester.getSize(find.byType(LoftifyReactionIcon)), const Size(28, 28)); + expect(tester.takeException(), isNull); + } + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: LoftifyReactionIcon( + kind: LoftifyReactionKind.bookmark, + selected: true, + color: Colors.purple, + ), + ), + ), + ); + final custom = tester.widget(find.descendant( + of: find.byType(LoftifyReactionIcon), + matching: find.byType(CustomPaint), + )); + expect( + (custom.painter! as LoftifyReactionIconPainter).color, Colors.purple); + }); + + testWidgets('idle reactions keep matching Lucide outlines', (tester) async { + const icons = { + LoftifyReactionKind.like: LoftifyIcons.favorite, + LoftifyReactionKind.recommend: LoftifyIcons.recommend, + LoftifyReactionKind.bookmark: LoftifyIcons.bookmark, + }; + for (final entry in icons.entries) { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: LoftifyReactionIcon(kind: entry.key, selected: false), + ), + ), + ); + expect(find.byIcon(entry.value), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); +} diff --git a/test/widgets/general/login_input_item_test.dart b/test/widgets/general/login_input_item_test.dart new file mode 100644 index 00000000..45df690a --- /dev/null +++ b/test/widgets/general/login_input_item_test.dart @@ -0,0 +1,99 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/Item/login_input_item.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +void main() { + testWidgets('login clear action uses Lucide and a 48px touch target', + (tester) async { + final controller = TextEditingController(text: 'content'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _host( + LoginInputItem( + controller: controller, + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.clear, + ), + ), + ), + ); + + final icon = find.byIcon(LoftifyIcons.clear); + expect(icon, findsOneWidget); + final touchTarget = find.ancestor( + of: icon, + matching: find.byWidgetPredicate( + (widget) => + widget is SizedBox && widget.width == 48 && widget.height == 48, + ), + ); + expect(touchTarget, findsOneWidget); + + await tester.tap(icon); + expect(controller.text, isEmpty); + }); + + testWidgets('password action changes meaning without leaving Lucide', + (tester) async { + final controller = TextEditingController(text: 'secret'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _host( + LoginInputItem( + controller: controller, + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.password, + ), + ), + ), + ); + + expect(find.byIcon(LoftifyIcons.visible), findsOneWidget); + await tester.tap(find.byIcon(LoftifyIcons.visible)); + await tester.pump(); + expect(find.byIcon(LoftifyIcons.hidden), findsOneWidget); + }); + + testWidgets('captcha action stays on one line in a narrow login field', + (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _host( + LoginInputItem( + controller: controller, + hint: '输入短信验证码', + leadingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.icon, + icon: LoftifyIcons.verification, + ), + tailingConfig: InputItemLeadingTailingConfig( + type: InputItemLeadingTailingType.text, + text: '获取验证码', + ), + ), + ), + ); + + final action = tester.widget(find.text('获取验证码')); + expect(action.maxLines, 1); + expect(tester.takeException(), isNull); + }); +} + +Widget _host(Widget child) { + return MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), + extensions: const >[ + ChewieIconThemeData.standard, + ], + ), + home: Scaffold(body: Center(child: SizedBox(width: 320, child: child))), + ); +} diff --git a/test/widgets/general/menu_components_test.dart b/test/widgets/general/menu_components_test.dart new file mode 100644 index 00000000..faf747fb --- /dev/null +++ b/test/widgets/general/menu_components_test.dart @@ -0,0 +1,383 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:awesome_chewie/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/menu_components', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + test('inline multi-select copy keeps mode, search and interaction style', () { + final first = StringDropdownItem('first'); + final original = InlineSelectionItem.multiSelect( + title: 'Mode', + items: [first, StringDropdownItem('second')], + selectedItems: [first], + searchText: 'old', + ink: true, + ); + + final copy = original.copyWith(searchText: 'new') + as InlineSelectionItem; + + expect(copy.isMultiSelect, isTrue); + expect(copy.selectedItems, [first]); + expect(copy.searchText, 'new'); + expect(copy.ink, isTrue); + }); + + test('selection models match by stable value across translated labels', () { + final systemLabel = SelectionItemModel('Follow system', null); + final translatedLabel = SelectionItemModel('跟随系统', null); + + expect(systemLabel, translatedLabel); + expect(systemLabel.hashCode, translatedLabel.hashCode); + expect([systemLabel].contains(translatedLabel), isTrue); + }); + + testWidgets('inline dropdown stacks on narrow screens without overflow', + (tester) async { + await tester.pumpWidget( + _buildHost( + SizedBox( + width: 300, + child: InlineSelectionItem( + title: 'A long setting title', + description: 'A useful description', + items: [StringDropdownItem('First'), StringDropdownItem('Second')], + initItem: StringDropdownItem('First'), + roundTop: true, + roundBottom: true, + ), + ), + ), + ); + await tester.pump(); + + final dropdown = find.byWidgetPredicate( + (widget) => widget is CustomDropdown, + ); + expect(dropdown, findsOneWidget); + expect(tester.getSize(dropdown).width, greaterThan(250)); + expect( + find.descendant(of: dropdown, matching: find.byType(InkWell)), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('inline dropdown options use one accessible row target', + (tester) async { + tester.view.physicalSize = const Size(320, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final first = StringDropdownItem('First'); + final second = StringDropdownItem('Second'); + List? selection; + + await tester.pumpWidget( + _buildHost( + MediaQuery( + data: const MediaQueryData( + size: Size(320, 600), + textScaler: TextScaler.linear(2), + ), + child: Center( + child: SizedBox( + width: 300, + child: InlineSelectionItem.multiSelect( + title: 'Mode', + items: [first, second], + selectedItems: [first], + onListChanged: (items) => selection = items, + ), + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byWidgetPredicate( + (widget) => widget is CustomDropdown, + )); + await tester.pumpAndSettle(); + + final firstMarker = find.byKey( + const ValueKey('dropdown-selection-First'), + ); + final secondMarker = find.byKey( + const ValueKey('dropdown-selection-Second'), + ); + expect(firstMarker, findsOneWidget); + expect(secondMarker, findsOneWidget); + expect(find.byType(Checkbox), findsNothing); + + final firstRow = find.ancestor( + of: firstMarker, + matching: find.byType(InkWell), + ); + final secondRow = find.ancestor( + of: secondMarker, + matching: find.byType(InkWell), + ); + expect(firstRow, findsOneWidget); + expect(secondRow, findsOneWidget); + expect(tester.getSize(firstRow).height, greaterThanOrEqualTo(48)); + + await tester.tap(secondRow); + await tester.pumpAndSettle(); + expect(selection, containsAll([first, second])); + expect(tester.takeException(), isNull); + }); + + testWidgets('dropdown refreshes an equivalent item with its current label', + (tester) async { + var languageLabel = 'Follow system'; + var selectionChanges = 0; + late StateSetter rebuildHost; + + await tester.pumpWidget( + _buildHost( + StatefulBuilder( + builder: (context, setState) { + rebuildHost = setState; + return InlineSelectionItem>( + title: 'Language', + items: [SelectionItemModel(languageLabel, null)], + initItem: SelectionItemModel(languageLabel, null), + onChanged: (_) => selectionChanges++, + ); + }, + ), + ), + ); + await tester.pump(); + + expect(find.text('Follow system'), findsOneWidget); + + rebuildHost(() => languageLabel = '跟随系统'); + await tester.pump(); + await tester.pump(); + + expect(find.text('跟随系统'), findsOneWidget); + expect(find.text('Follow system'), findsNothing); + expect(selectionChanges, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('long mobile context menus become scrollable', (tester) async { + final entries = [ + const MenuHeader(text: 'Actions'), + const MenuDivider(), + ...List.generate( + 20, + (index) => FlutterContextMenuItem( + 'Action $index', + iconData: Icons.circle_outlined, + ), + ), + ]; + + await tester.pumpWidget( + _buildHost( + SizedBox( + width: 320, + height: 500, + child: ContextMenuBottomSheet( + menu: FlutterContextMenu(entries: entries), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(find.text('ACTIONS'), findsOneWidget); + expect(find.byType(TextButton), findsOneWidget); + expect(tester.getSize(find.byType(ContextMenuBottomSheet)).height, + lessThanOrEqualTo(500)); + expect(tester.takeException(), isNull); + }); + + testWidgets('custom menu divider keeps its inset and subtle thickness', + (tester) async { + await tester.pumpWidget( + _buildHost( + SizedBox( + width: 320, + child: ContextMenuBottomSheet( + menu: FlutterContextMenu( + entries: const [ + MenuDivider(thickness: 0.6, indent: 46, endIndent: 8), + ], + ), + ), + ), + ), + ); + + final dividerLines = + tester.widgetList(find.byType(Container)).where( + (container) => container.constraints?.minHeight == 0.6, + ); + expect(dividerLines, isNotEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('bottom sheet wrapper clips every panel to rounded top corners', + (tester) async { + await tester.pumpWidget( + _buildHost( + const BottomSheetWrapperWidget( + child: SizedBox(width: 320, height: 180), + ), + ), + ); + + final clip = tester.widget( + find.descendant( + of: find.byType(BottomSheetWrapperWidget), + matching: find.byType(ClipRRect), + ), + ); + final radius = clip.borderRadius.resolve(TextDirection.ltr); + expect(radius.topLeft, ChewieDimens.defaultRadius); + expect(radius.topRight, ChewieDimens.defaultRadius); + }); + + testWidgets('wide responsive panels shrink-wrap short content', + (tester) async { + await tester.pumpWidget( + _buildHost( + const BottomSheetWrapperWidget( + useVerticalMargin: true, + preferMinWidth: 450, + child: SizedBox(height: 180), + ), + ), + ); + + final clip = find.descendant( + of: find.byType(BottomSheetWrapperWidget), + matching: find.byType(ClipRRect), + ); + expect(tester.getSize(clip).width, 450); + expect(tester.getSize(clip).height, 180); + expect(tester.getTopLeft(clip).dy, closeTo(210, 0.1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('desktop context menus stay inside safe screen bounds', + (tester) async { + late BuildContext menuContext; + const mediaQuery = MediaQueryData( + size: Size(320, 640), + padding: EdgeInsets.only(top: 24, bottom: 34), + ); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: mediaQuery, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 300, + top: 620, + child: Builder( + builder: (context) { + menuContext = context; + return const SizedBox(width: 160, height: 100); + }, + ), + ), + ], + ), + ), + ), + ); + + final result = calculateContextMenuBoundaries( + menuContext, + FlutterContextMenu(entries: const []), + null, + AlignmentDirectional.topEnd, + false, + ); + + expect(result.pos.dx, greaterThanOrEqualTo(8)); + expect(result.pos.dx + 160, lessThanOrEqualTo(312)); + expect(result.pos.dy, greaterThanOrEqualTo(32)); + expect(result.pos.dy + 100, lessThanOrEqualTo(598)); + }); + + testWidgets('disabled context menu regions expose no menu gestures', + (tester) async { + await tester.pumpWidget( + _buildHost( + ContextMenuRegion( + key: const ValueKey('disabled-region'), + enable: false, + showOnClicked: true, + contextMenu: FlutterContextMenu(entries: const []), + child: const Text('Disabled'), + ), + ), + ); + + final gesture = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('disabled-region')), + matching: find.byType(GestureDetector), + ), + ); + expect(gesture.onTap, isNull); + expect(gesture.onLongPress, isNull); + expect(gesture.onSecondaryTap, isNull); + }); + + test('menu style copy preserves and overrides the correct fields', () { + const iconColor = Color(0xFF123456); + const style = MenuItemStyle( + normalIconColor: iconColor, + radius: 12, + disabledOpacity: 0.4, + ); + + final preserved = style.copyWith(); + expect(preserved.normalIconColor, iconColor); + expect(preserved.radius, 12); + expect(preserved.disabledOpacity, 0.4); + + const overrideColor = Color(0xFF654321); + final overridden = style.copyWith(normalIconColor: overrideColor); + expect(overridden.normalIconColor, overrideColor); + }); +} + +Widget _buildHost(Widget child) { + return MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + theme: ThemeData.light(), + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold(body: child); + }, + ), + ); +} diff --git a/test/widgets/general/my_divider_theme_test.dart b/test/widgets/general/my_divider_theme_test.dart new file mode 100644 index 00000000..f2fa232f --- /dev/null +++ b/test/widgets/general/my_divider_theme_test.dart @@ -0,0 +1,24 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('divider follows the nearest light or dark theme', + (tester) async { + for (final brightness in [Brightness.light, Brightness.dark]) { + final dividerColor = brightness == Brightness.light + ? const Color(0xFFE4E7E6) + : const Color(0xFF383E3D); + await tester.pumpWidget(MaterialApp( + theme: ThemeData(brightness: brightness, dividerColor: dividerColor), + home: const Scaffold(body: MyDivider(key: ValueKey('divider'))), + )); + await tester.pumpAndSettle(); + final container = tester.widget(find.descendant( + of: find.byKey(const ValueKey('divider')), + matching: find.byType(Container), + )); + expect((container.decoration! as BoxDecoration).color, dividerColor); + } + }); +} diff --git a/test/widgets/general/photo_view_dismiss_metrics_test.dart b/test/widgets/general/photo_view_dismiss_metrics_test.dart new file mode 100644 index 00000000..6fafe93f --- /dev/null +++ b/test/widgets/general/photo_view_dismiss_metrics_test.dart @@ -0,0 +1,85 @@ +import 'package:awesome_chewie/src/Widgets/Basic/photo_view_dismiss_metrics.dart'; +import 'package:awesome_chewie/src/Widgets/Basic/hero_photo_view_screen.dart'; +import 'package:awesome_chewie/src/Utils/System/route_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PhotoViewDismissMetrics', () { + test('uses a reachable distance threshold on small and large screens', () { + expect(PhotoViewDismissMetrics.threshold(400), 96); + expect(PhotoViewDismissMetrics.threshold(1000), 140); + }); + + test('normalizes visual progress and clamps its bounds', () { + expect(PhotoViewDismissMetrics.progress(-20, 800), 0); + expect(PhotoViewDismissMetrics.progress(136, 800), closeTo(0.5, 0.001)); + expect(PhotoViewDismissMetrics.progress(900, 800), 1); + expect(PhotoViewDismissMetrics.scale(1), 0.86); + expect(PhotoViewDismissMetrics.backgroundOpacity(1), 0); + expect(PhotoViewDismissMetrics.contentOpacity(1), 0.88); + expect(PhotoViewDismissMetrics.appBarOpacity(0), 1); + expect(PhotoViewDismissMetrics.appBarOpacity(0.2), 0.5); + expect(PhotoViewDismissMetrics.appBarOpacity(0.4), 0); + }); + + test('dismisses by distance or a deliberate downward fling', () { + expect( + PhotoViewDismissMetrics.shouldDismiss( + distance: 120, + velocity: 0, + viewportHeight: 800, + ), + isTrue, + ); + expect( + PhotoViewDismissMetrics.shouldDismiss( + distance: 80, + velocity: 1000, + viewportHeight: 800, + ), + isTrue, + ); + expect( + PhotoViewDismissMetrics.shouldDismiss( + distance: 60, + velocity: 1500, + viewportHeight: 800, + ), + isFalse, + ); + expect( + PhotoViewDismissMetrics.shouldDismiss( + distance: 80, + velocity: 400, + viewportHeight: 800, + ), + isFalse, + ); + }); + }); + + test('photo viewer fade routes can reveal the page underneath', () { + final route = RouteUtil.getFadeRoute( + const SizedBox.shrink(), + opaque: false, + ) as PageRoute; + + expect(route.opaque, isFalse); + expect(route.barrierColor, Colors.transparent); + }); + + test('photo viewer strips thumbnail processing without changing hero input', + () { + expect( + resolvePhotoViewOriginalUrl( + 'https://img.example/a.jpg?imageView&thumbnail=500x0&quality=96', + ), + 'https://img.example/a.jpg', + ); + expect( + resolvePhotoViewOriginalUrl('https://img.example/a.jpg?token=required'), + 'https://img.example/a.jpg?token=required', + ); + }); +} diff --git a/test/widgets/general/responsive_util_test.dart b/test/widgets/general/responsive_util_test.dart new file mode 100644 index 00000000..75a3e237 --- /dev/null +++ b/test/widgets/general/responsive_util_test.dart @@ -0,0 +1,23 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ResponsiveUtil tablet breakpoint', () { + test('requires both minimum logical dimensions', () { + expect(ResponsiveUtil.isTabletSize(const Size(599, 1200)), isFalse); + expect(ResponsiveUtil.isTabletSize(const Size(600, 899)), isFalse); + expect(ResponsiveUtil.isTabletSize(const Size(600, 900)), isTrue); + }); + + test('is independent of orientation', () { + expect(ResponsiveUtil.isTabletSize(const Size(640, 1428)), isTrue); + expect(ResponsiveUtil.isTabletSize(const Size(1428, 640)), isTrue); + }); + + test('keeps phone-sized windows in portrait layout', () { + expect(ResponsiveUtil.isTabletSize(const Size(426, 952)), isFalse); + expect(ResponsiveUtil.isTabletSize(const Size(952, 426)), isFalse); + }); + }); +} diff --git a/test/widgets/general/setting_management_item_test.dart b/test/widgets/general/setting_management_item_test.dart new file mode 100644 index 00000000..7e9be16c --- /dev/null +++ b/test/widgets/general/setting_management_item_test.dart @@ -0,0 +1,200 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Widgets/Item/setting_management_item.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/setting_management_item', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + for (final width in [280.0, 720.0]) { + for (final scale in [1.0, 2.0]) { + for (final dark in [false, true]) { + for (final label in ['No content available', '暂无内容', '暫無內容']) { + testWidgets( + 'management empty state grows without nested scrolling $width $scale $dark $label', + (tester) async { + tester.view.physicalSize = Size(width, 320); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + theme: (dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first) + .toThemeData(), + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: MediaQuery( + data: MediaQueryData( + size: Size(width, 320), + textScaler: TextScaler.linear(scale)), + child: ListView(children: [ + CaptionItem( + title: 'Management', + children: [SettingManagementEmptyState(text: label)]) + ]), + )); + }), + )); + await tester.pumpAndSettle(); + final stateRect = + tester.getRect(find.byType(SettingManagementEmptyState)); + final textRect = tester.getRect(find.text(label)); + expect(stateRect.height, greaterThanOrEqualTo(140)); + expect(textRect.bottom, lessThanOrEqualTo(stateRect.bottom)); + expect(textRect.right, lessThanOrEqualTo(stateRect.right)); + expect(find.byType(Scrollable), findsOneWidget); + await tester.ensureVisible(find.text(label)); + await tester.pumpAndSettle(); + expect(find.text(label).hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + } + } + + for (final width in [280.0, 720.0]) { + for (final scale in [1.0, 2.0]) { + for (final label in ['Remove from Blacklist', '解除黑名单', '解除黑名單']) { + testWidgets( + 'management action remains inside its button $width $scale $label', + (tester) async { + tester.view.physicalSize = Size(width, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var calls = 0; + await tester.pumpWidget(MaterialApp( + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: MediaQuery( + data: MediaQueryData( + size: Size(width, 480), + textScaler: TextScaler.linear(scale)), + child: ListView(children: [ + CaptionItem(title: 'Management', children: [ + SettingManagementItem( + title: 'A long author nickname / 很长的作者昵称', + description: 'long-author-account-name', + leading: const SizedBox(width: 40, height: 40), + actionLabel: label, + onAction: () => calls++, + ) + ]) + ]), + )); + }), + )); + await tester.pumpAndSettle(); + final button = find.byType(RoundIconTextButton); + await tester.ensureVisible(button); + await tester.pumpAndSettle(); + final textRect = tester.getRect(find.text(label)); + final paragraph = + tester.renderObject(find.text(label)); + final lineBoxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: label.length)); + for (final box in lineBoxes) { + expect(box.bottom, lessThanOrEqualTo(paragraph.size.height)); + } + final buttonRect = tester.getRect(button); + expect(textRect.top, greaterThanOrEqualTo(buttonRect.top)); + expect(textRect.bottom, lessThanOrEqualTo(buttonRect.bottom)); + expect(buttonRect.right, lessThanOrEqualTo(width)); + await tester.tap(button); + expect(calls, 1); + expect(tester.takeException(), isNull); + }); + } + } + } + + testWidgets( + 'management items use caption spacing, dividers and theme actions', + (tester) async { + const accent = Color(0xFF14C2BB); + var actionCount = 0; + var rowTapCount = 0; + final theme = ChewieThemeColorData.defaultLightThemes.first.toThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: ListView( + padding: EdgeInsets.zero, + children: [ + CaptionItem( + title: '管理列表', + children: [ + SettingManagementItem( + title: '用户 A', + leadingIcon: Icons.person_rounded, + actionLabel: '解除', + onTap: () => rowTapCount++, + onAction: () => actionCount++, + ), + SettingManagementItem( + title: '标签 B', + leadingIcon: Icons.tag_rounded, + actionLabel: '解除', + onAction: () => actionCount++, + ), + ], + ), + ], + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final captionMaterial = find + .descendant( + of: find.byType(CaptionItem), + matching: find.byType(Material), + ) + .first; + expect(tester.getTopLeft(captionMaterial).dy, 10); + expect(find.byType(SettingManagementItem), findsNWidgets(2)); + expect(find.byType(EntryItem), findsNWidgets(2)); + + final entries = tester.widgetList(find.byType(EntryItem)); + final buttons = tester.widgetList( + find.byType(RoundIconTextButton), + ); + expect(buttons, hasLength(2)); + expect(buttons.first.color, accent); + expect(buttons.first.background, accent.withAlpha(22)); + expect(buttons.first.border?.top.color, accent.withAlpha(72)); + + entries.first.onTap?.call(); + expect(rowTapCount, 1); + + buttons.first.onPressed?.call(); + expect(rowTapCount, 1); + expect(actionCount, 1); + }); +} diff --git a/test/widgets/general/setting_section_test.dart b/test/widgets/general/setting_section_test.dart new file mode 100644 index 00000000..620d8f52 --- /dev/null +++ b/test/widgets/general/setting_section_test.dart @@ -0,0 +1,181 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Setting/base_setting_screen.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/setting_section', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('caption follows shared section rhythm and divider policy', + (tester) async { + await tester.pumpWidget( + _buildHost( + child: ListView( + padding: EdgeInsets.zero, + children: const [ + CaptionItem( + title: 'General', + children: [ + EntryItem(title: 'Language'), + EntryItem(title: 'Appearance'), + ], + ), + CaptionItem( + title: 'Without dividers', + showDivider: false, + children: [EntryItem(title: 'Single entry')], + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final captions = find.byType(CaptionItem); + final firstSurface = find + .descendant(of: captions.first, matching: find.byType(Material)) + .first; + final firstMaterial = tester.widget(firstSurface); + final firstTitle = tester.widget(find.text('General')); + + expect( + tester.getTopLeft(firstSurface).dy, + BaseSettingScreen.sectionTopMargin, + ); + expect(firstMaterial.borderRadius, BorderRadius.circular(14)); + expect(firstTitle.style?.fontSize, 16); + expect(firstTitle.style?.fontWeight, FontWeight.w600); + expect( + find.descendant( + of: captions.first, + matching: find.byType(Divider), + ), + findsNWidgets(2), + ); + expect( + find.descendant( + of: captions.at(1), + matching: find.byType(Divider), + ), + findsNothing, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('inline selection stacks for narrow large-text layouts', + (tester) async { + await tester.pumpWidget( + _buildHost( + size: const Size(320, 640), + textScaler: const TextScaler.linear(2), + child: ListView( + padding: EdgeInsets.zero, + children: [ + CaptionItem( + title: 'Language', + children: [ + InlineSelectionItem>( + title: 'Interface language', + description: 'Choose the language used throughout Loftify', + items: [ + SelectionItemModel('Simplified Chinese', 'zh-CN'), + SelectionItemModel('English', 'en'), + ], + initItem: SelectionItemModel( + 'Simplified Chinese', + 'zh-CN', + ), + ), + ], + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final inlineItem = find.byType( + InlineSelectionItem>, + ); + final title = find.descendant( + of: inlineItem, + matching: find.byWidgetPredicate( + (widget) => + widget is RichText && + widget.text.toPlainText() == 'Interface language', + ), + ); + final dropdown = find.descendant( + of: inlineItem, + matching: find.byWidgetPredicate( + (widget) => widget.runtimeType.toString().startsWith('CustomDropdown'), + ), + ); + + expect(title, findsOneWidget); + expect(dropdown, findsOneWidget); + expect( + tester.getTopLeft(dropdown).dy, + greaterThan(tester.getBottomLeft(title).dy), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('setting scaffold defaults to a divider-free app bar', + (tester) async { + await tester.pumpWidget( + _buildHost( + child: Builder( + builder: (context) => ChewieItemBuilder.buildSettingScreen( + context: context, + title: 'Settings', + showTitleBar: true, + padding: BaseSettingScreen.defaultPagePadding, + overrideBody: const SizedBox.expand(), + ), + ), + ), + ); + await tester.pump(); + + final appBar = tester.widget( + find.byType(ResponsiveAppBar), + ); + expect(appBar.showBorder, isFalse); + expect(tester.takeException(), isNull); + }); +} + +Widget _buildHost({ + required Widget child, + Size size = const Size(390, 844), + TextScaler textScaler = TextScaler.noScaling, +}) { + return MaterialApp( + theme: LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ), + home: MediaQuery( + data: MediaQueryData(size: size, textScaler: textScaler), + child: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold(body: child); + }, + ), + ), + ); +} diff --git a/test/widgets/general/slide_captcha_geometry_test.dart b/test/widgets/general/slide_captcha_geometry_test.dart new file mode 100644 index 00000000..178c510e --- /dev/null +++ b/test/widgets/general/slide_captcha_geometry_test.dart @@ -0,0 +1,283 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Widgets/BottomSheet/slide_captcha_bottom_sheet.dart'; +import 'package:loftify/l10n/l10n.dart'; + +void main() { + const sourceSize = Size(352, 160); + const puzzleSourceWidth = 44.0; + + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/slide_captcha_geometry', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + test('nominal captcha geometry preserves source coordinates', () { + final geometry = SlideCaptchaGeometry.calculate( + availableWidth: 400, + sourceSize: sourceSize, + puzzleSourceWidth: puzzleSourceWidth, + ); + + expect(geometry.panelWidth, 352); + expect(geometry.contentWidth, 320); + expect(geometry.sourceScale, closeTo(1.1, 0.0001)); + expect(geometry.puzzleWidth, closeTo(40, 0.0001)); + expect(geometry.handleTravel, closeTo(280, 0.0001)); + expect(geometry.puzzleTravel, closeTo(280, 0.0001)); + expect(geometry.sourceOffset(1), closeTo(308, 0.0001)); + }); + + test('narrow layouts scale image and submission travel together', () { + final geometry = SlideCaptchaGeometry.calculate( + availableWidth: 320, + sourceSize: sourceSize, + puzzleSourceWidth: puzzleSourceWidth, + ); + + expect( + geometry.panelWidth + SlideCaptchaGeometry.outerPadding * 2, + lessThanOrEqualTo(320), + ); + expect(geometry.contentWidth, 256); + expect(geometry.sourceScale, closeTo(1.375, 0.0001)); + expect(geometry.sourceOffset(0.5), closeTo(154, 0.0001)); + expect(geometry.puzzleOffset(1), closeTo(224, 0.0001)); + expect(geometry.handleOffset(1), closeTo(216, 0.0001)); + }); + + test('wide and landscape layouts never upscale the captcha', () { + final geometry = SlideCaptchaGeometry.calculate( + availableWidth: 1200, + sourceSize: sourceSize, + puzzleSourceWidth: puzzleSourceWidth, + ); + + expect(geometry.panelWidth, sourceSize.width); + expect(geometry.sourceScale, greaterThanOrEqualTo(1)); + expect(geometry.imageHeight, lessThanOrEqualTo(sourceSize.height)); + }); + + test('server offset is independent of physical pixel density', () { + final geometry = SlideCaptchaGeometry.calculate( + availableWidth: 360, + sourceSize: sourceSize, + puzzleSourceWidth: puzzleSourceWidth, + ); + + for (final devicePixelRatio in [1, 2, 3, 3.5, 4]) { + final physicalDelta = geometry.handleTravel * 0.4 * devicePixelRatio; + final logicalDelta = physicalDelta / devicePixelRatio; + final progress = geometry.progressAfterDelta(0, logicalDelta); + expect(geometry.sourceOffset(progress), closeTo(123.2, 0.0001)); + } + }); + + test('extreme constraints and drag deltas stay finite and clamped', () { + final geometry = SlideCaptchaGeometry.calculate( + availableWidth: 48, + sourceSize: sourceSize, + puzzleSourceWidth: puzzleSourceWidth, + ); + + expect(geometry.panelWidth, greaterThan(0)); + expect(geometry.contentWidth, greaterThan(0)); + expect(geometry.imageHeight.isFinite, isTrue); + expect(geometry.progressAfterDelta(0.5, -10000), 0); + expect(geometry.progressAfterDelta(0.5, 10000), 0); + expect(geometry.sourceOffset(-1), 0); + expect(geometry.sourceOffset(2), geometry.sourceTravel); + }); + + testWidgets( + 'panel layout and submitted source coordinate stay aligned across devices', + (tester) async { + const backgroundSourceSize = Size(1125, 222); + const puzzleSourceSize = Size(72, 72); + final assets = await tester.runAsync(() async => [ + await File('assets/mess/tag_row_bg.png').readAsBytes(), + await _buildSquarePng(72), + ]); + final background = assets![0]; + final puzzle = assets[1]; + const accent = Color(0xFFE91E63); + final captchaTheme = ChewieThemeColorData.defaultLightThemes.first + .copyWith(primaryColor: accent) + .toThemeData(); + final originalPhysicalSize = tester.view.physicalSize; + final originalDevicePixelRatio = tester.view.devicePixelRatio; + addTearDown(() { + tester.view.physicalSize = originalPhysicalSize; + tester.view.devicePixelRatio = originalDevicePixelRatio; + }); + + const variants = <({Size logicalSize, double pixelRatio})>[ + (logicalSize: Size(280, 560), pixelRatio: 1), + (logicalSize: Size(360, 800), pixelRatio: 4), + (logicalSize: Size(1024, 600), pixelRatio: 2), + ]; + + for (final variant in variants) { + tester.view.devicePixelRatio = variant.pixelRatio; + tester.view.physicalSize = Size( + variant.logicalSize.width * variant.pixelRatio, + variant.logicalSize.height * variant.pixelRatio, + ); + double? submittedOffset; + + await tester.pumpWidget( + MaterialApp( + theme: captchaTheme, + locale: const Locale('zh'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: SlideCaptchaBottomSheet( + challengeLoader: () async => { + 'code': 0, + 'data': { + 'id': 'layout-test', + 'bg': base64Encode(background), + 'front': base64Encode(puzzle), + }, + }, + challengeVerifier: ({ + required id, + required offset, + required rawKey, + required rawIv, + }) { + submittedOffset = offset; + final encrypted = CryptUtil.encryptDataByAES( + { + 'code': 0, + 'data': {'success': false}, + }, + rawKey, + rawIv, + ); + return Future.value(base64Decode(encrypted)); + }, + ), + ); + }, + ), + ), + ); + await _pumpUntilFound( + tester, + find.byKey(const ValueKey('slideCaptchaHandle')), + ); + + final panel = find.byKey(const ValueKey('slideCaptchaPanel')); + final backgroundFinder = + find.byKey(const ValueKey('slideCaptchaBackground')); + final puzzleFinder = find.byKey(const ValueKey('slideCaptchaPuzzle')); + final track = find.byKey(const ValueKey('slideCaptchaTrack')); + final progress = find.byKey(const ValueKey('slideCaptchaProgress')); + final handle = find.byKey(const ValueKey('slideCaptchaHandle')); + final panelWidth = tester.getSize(panel).width; + final trackWidth = tester.getSize(track).width; + final imageSize = tester.getSize(backgroundFinder); + final expectedPanelWidth = + (variant.logicalSize.width - 32).clamp(1.0, 1125.0); + + expect(panelWidth, closeTo(expectedPanelWidth, 0.01)); + expect(trackWidth, closeTo(panelWidth - 32, 0.01)); + expect(imageSize.width, closeTo(trackWidth, 0.01)); + expect( + imageSize.height, + closeTo( + trackWidth * backgroundSourceSize.height / backgroundSourceSize.width, + 0.01, + ), + ); + expect(tester.getSize(puzzleFinder).width, + closeTo(trackWidth * 72 / 1125, 0.01)); + final progressDecoration = + tester.widget(progress).decoration! as BoxDecoration; + final handleDecoration = + tester.widget(handle).decoration! as BoxDecoration; + expect(progressDecoration.color, accent.withValues(alpha: 0.14)); + expect(handleDecoration.color, accent); + expect(tester.takeException(), isNull); + + final trackRect = tester.getRect(track); + final dragDistance = (trackWidth - SlideCaptchaGeometry.handleWidth) / 2; + final gesture = await tester.startGesture(tester.getCenter(handle)); + await gesture.moveBy(Offset(dragDistance, 0)); + await tester.pump(); + + final handleOffset = tester.getRect(handle).left - trackRect.left; + final puzzleOffset = tester.getRect(puzzleFinder).left - + tester.getRect(backgroundFinder).left; + expect(handleOffset, closeTo(dragDistance, 0.5)); + expect( + puzzleOffset, + closeTo( + (trackWidth - + trackWidth * + puzzleSourceSize.width / + backgroundSourceSize.width) / + 2, + 0.5, + ), + ); + + await gesture.up(); + await tester.pump(); + expect( + submittedOffset, + closeTo( + (backgroundSourceSize.width - puzzleSourceSize.width) / 2, + 0.5, + ), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + } + }); +} + +Future _buildSquarePng(int size) async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + canvas.drawRect( + ui.Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble()), + ui.Paint()..color = const ui.Color(0xFFFFFFFF), + ); + final image = await recorder.endRecording().toImage(size, size); + try { + final data = await image.toByteData(format: ui.ImageByteFormat.png); + return data!.buffer.asUint8List(); + } finally { + image.dispose(); + } +} + +Future _pumpUntilFound(WidgetTester tester, Finder finder) async { + for (var attempt = 0; attempt < 30 && finder.evaluate().isEmpty; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 20)); + } + expect(finder, findsOneWidget); +} diff --git a/test/widgets/general/tab_bar_wrapper_test.dart b/test/widgets/general/tab_bar_wrapper_test.dart new file mode 100644 index 00000000..173da39c --- /dev/null +++ b/test/widgets/general/tab_bar_wrapper_test.dart @@ -0,0 +1,203 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Widgets/Item/item_builder.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/tab_bar_wrapper', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('many tabs scroll and keep every label on one line', + (tester) async { + await tester.pumpWidget( + const _TabHarness( + width: 320, + labels: ['综合', '标签', '合集', '粮单', '文章', '使用者'], + ), + ); + + final tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.isScrollable, isTrue); + expect(tabBar.tabAlignment, TabAlignment.start); + + for (final text in tester.widgetList(find.byType(Text))) { + expect(text.maxLines, 1); + expect(text.softWrap, isFalse); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('three tabs fill available width', (tester) async { + await tester.pumpWidget( + const _TabHarness( + width: 360, + labels: ['推荐', '最新', '最热'], + ), + ); + + final tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.isScrollable, isFalse); + expect(tabBar.tabAlignment, TabAlignment.fill); + expect(tester.takeException(), isNull); + }); + + testWidgets('six compact result tabs can be forced to fill one row', + (tester) async { + await tester.pumpWidget( + const _TabHarness( + width: 390, + labels: ['综合', '标签', '合集', '粮单', '文章', '用户'], + forceFill: true, + ), + ); + + final tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.isScrollable, isFalse); + expect(tabBar.tabAlignment, TabAlignment.fill); + expect(tester.takeException(), isNull); + }); + + testWidgets('tab strip keeps underline styling without ripple feedback', + (tester) async { + const background = Color(0xFFFFFFFF); + const primary = Color(0xFF14C2BB); + await tester.pumpWidget( + const _TabHarness( + width: 360, + labels: ['推荐', '最新', '最热'], + background: background, + primary: primary, + ), + ); + + final wrapper = find.byKey(const ValueKey('tab-wrapper')); + final material = tester.widget( + find.descendant(of: wrapper, matching: find.byType(Material)).first, + ); + final tabBar = tester.widget(find.byType(TabBar)); + final pressedColor = tabBar.overlayColor!.resolve({WidgetState.pressed}); + + expect(material.color, background); + expect(pressedColor, Colors.transparent); + expect(tabBar.splashFactory, NoSplash.splashFactory); + expect(tabBar.indicator, isA()); + expect( + (tabBar.indicator! as UnderlinedTabIndicator).borderColor, + primary, + ); + }); + + testWidgets('tab strip does not restore an app bar divider', (tester) async { + await tester.pumpWidget( + const _TabHarness( + width: 360, + labels: ['推荐', '最新', '最热'], + showBorder: true, + ), + ); + + final wrapper = find.byKey(const ValueKey('tab-wrapper')); + final decoratedContainers = tester + .widgetList( + find.descendant(of: wrapper, matching: find.byType(Container)), + ) + .where((container) => container.decoration is BoxDecoration); + expect( + decoratedContainers.every( + (container) => (container.decoration! as BoxDecoration).border == null, + ), + isTrue, + ); + }); +} + +class _TabHarness extends StatefulWidget { + const _TabHarness({ + required this.width, + required this.labels, + this.background = const Color(0xFFFFFFFF), + this.primary = const Color(0xFF14C2BB), + this.forceFill = false, + this.showBorder = false, + }); + + final double width; + final List labels; + final Color background; + final Color primary; + final bool forceFill; + final bool showBorder; + + @override + State<_TabHarness> createState() => _TabHarnessState(); +} + +class _TabHarnessState extends State<_TabHarness> + with SingleTickerProviderStateMixin { + late final TabController _controller; + + @override + void initState() { + super.initState(); + _controller = TabController(length: widget.labels.length, vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + scaffoldBackgroundColor: widget.background, + colorScheme: ColorScheme.fromSeed(seedColor: widget.primary).copyWith( + primary: widget.primary, + ), + ), + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: widget.width, + child: TabBarWrapper( + key: const ValueKey('tab-wrapper'), + tabController: _controller, + isScrollable: widget.forceFill ? false : null, + showBorder: widget.showBorder, + tabs: widget.labels + .asMap() + .entries + .map( + (entry) => ItemBuilder.buildAnimatedTab( + context, + selected: entry.key == _controller.index, + text: entry.value, + ), + ) + .toList(), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/test/widgets/general/theme_background_test.dart b/test/widgets/general/theme_background_test.dart new file mode 100644 index 00000000..de820a45 --- /dev/null +++ b/test/widgets/general/theme_background_test.dart @@ -0,0 +1,180 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/theme_background', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + test('every light theme keeps full-page and navigation surfaces white', () { + for (final themeData in ChewieThemeColorData.defaultLightThemes) { + final theme = themeData.toThemeData(); + + expect( + theme.scaffoldBackgroundColor, + Colors.white, + reason: '${themeData.id} scaffold must stay white', + ); + expect( + theme.bottomNavigationBarTheme.backgroundColor, + Colors.white, + reason: '${themeData.id} bottom navigation must stay white', + ); + expect( + theme.navigationBarTheme.backgroundColor, + Colors.white, + reason: '${themeData.id} Material 3 navigation must stay white', + ); + expect(theme.canvasColor, themeData.canvasColor); + expect(theme.cardColor, themeData.cardColor); + } + }); + + test('custom light colors cannot reintroduce a grey page surface', () { + final customTheme = ChewieThemeColorData.defaultLightThemes.first.copyWith( + id: 'custom-light', + scaffoldBackgroundColor: const Color(0xFFE0E0E0), + canvasColor: const Color(0xFFF2F3F4), + ); + final theme = customTheme.toThemeData(); + + expect(customTheme.effectivePageBackgroundColor, Colors.white); + expect(theme.scaffoldBackgroundColor, Colors.white); + expect(theme.bottomNavigationBarTheme.backgroundColor, Colors.white); + expect(theme.canvasColor, const Color(0xFFF2F3F4)); + }); + + test('dark themes preserve their configured page and navigation colors', () { + for (final themeData in ChewieThemeColorData.defaultDarkThemes) { + final theme = themeData.toThemeData(); + + expect(theme.scaffoldBackgroundColor, themeData.scaffoldBackgroundColor); + expect( + theme.bottomNavigationBarTheme.backgroundColor, + themeData.scaffoldBackgroundColor, + ); + expect( + theme.navigationBarTheme.backgroundColor, + themeData.scaffoldBackgroundColor, + ); + } + }); + + test('pure themes keep the original Loftify accent across Material themes', + () { + const originalAccent = Color(0xFF14C2BB); + final pureWhite = ChewieThemeColorData.defaultLightThemes.first; + final pureBlack = ChewieThemeColorData.defaultDarkThemes.first; + + for (final themeData in [pureWhite, pureBlack]) { + final theme = themeData.toThemeData(); + expect(themeData.primaryColor, originalAccent); + expect(theme.primaryColor, originalAccent); + expect(theme.colorScheme.primary, originalAccent); + expect(theme.colorScheme.secondary, originalAccent); + expect(theme.progressIndicatorTheme.color, originalAccent); + expect(theme.colorScheme.surface, theme.scaffoldBackgroundColor); + } + }); + + test('custom accent updates primary Material component colors together', () { + const accent = Color(0xFFE91E63); + final theme = ChewieThemeColorData.defaultLightThemes.first + .copyWith(primaryColor: accent) + .toThemeData(); + + expect(theme.primaryColor, accent); + expect(theme.colorScheme.primary, accent); + expect(theme.colorScheme.secondary, accent); + expect(theme.progressIndicatorTheme.color, accent); + }); + + test('switching themes reloads persisted accents and reset restores defaults', + () { + const accent = Color(0xFFFF5722); + addTearDown(() { + ChewieHiveUtil.setCustomLightPrimaryColor(null); + ChewieHiveUtil.setCustomDarkPrimaryColor(null); + ChewieHiveUtil.setLightTheme(0); + ChewieHiveUtil.setDarkTheme(0); + ChewieHiveUtil.setLightThemePrimaryColorIndex(0); + ChewieHiveUtil.setDarkThemePrimaryColorIndex(0); + }); + + ChewieHiveUtil.setCustomLightPrimaryColor(accent); + ChewieHiveUtil.setCustomDarkPrimaryColor(accent); + ChewieHiveUtil.setLightTheme(1); + ChewieHiveUtil.setDarkTheme(1); + expect(ChewieHiveUtil.getLightTheme().toThemeData().colorScheme.primary, + accent); + expect(ChewieHiveUtil.getDarkTheme().toThemeData().colorScheme.primary, + accent); + + ChewieHiveUtil.put( + ChewieHiveUtil.customLightThemePrimaryColorKey, + '#01010000', + ); + ChewieHiveUtil.setLightThemePrimaryColorIndex(1); + expect( + ChewieHiveUtil.getLightTheme().toThemeData().colorScheme.primary, + ChewieThemeColorData.defaultLightThemes[1].primaryColor, + ); + expect(ChewieHiveUtil.getLightThemePrimaryColorIndex(), 0); + + ChewieHiveUtil.setCustomLightPrimaryColor(null); + ChewieHiveUtil.setCustomDarkPrimaryColor(null); + ChewieHiveUtil.setLightTheme(0); + ChewieHiveUtil.setDarkTheme(0); + expect( + ChewieHiveUtil.getLightTheme().toThemeData().colorScheme.primary, + const Color(0xFF14C2BB), + ); + expect( + ChewieHiveUtil.getDarkTheme().toThemeData().colorScheme.primary, + const Color(0xFF14C2BB), + ); + }); + + testWidgets('custom bottom navigation inherits the white light surface', + (tester) async { + final theme = ChewieThemeColorData.defaultLightThemes[1].toThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + bottomNavigationBar: MyBottomNavigationBar( + currentIndex: 0, + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), + BottomNavigationBarItem( + icon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ), + ), + ); + + final materials = tester.widgetList( + find.descendant( + of: find.byType(MyBottomNavigationBar), + matching: find.byType(Material), + ), + ); + expect(materials.first.color, Colors.white); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/general/tooltip_lifecycle_test.dart b/test/widgets/general/tooltip_lifecycle_test.dart new file mode 100644 index 00000000..3344b04b --- /dev/null +++ b/test/widgets/general/tooltip_lifecycle_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/tooltip_lifecycle', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('tooltips survive resize, deactivation and recreation', + (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + var showTooltips = true; + late StateSetter setHostState; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + setHostState = setState; + return showTooltips + ? const Row( + children: [ + MyTooltip(message: 'First', child: Text('first')), + MyTooltip( + message: 'Second', + child: Text('second'), + ), + ], + ) + : const SizedBox.shrink(); + }, + ), + ); + }, + ), + ), + ); + + await tester.longPress(find.text('first')); + await tester.pump(const Duration(milliseconds: 50)); + setHostState(() => showTooltips = false); + await tester.pump(); + await tester.tapAt(const Offset(20, 20)); + await tester.binding.setSurfaceSize(const Size(800, 400)); + await tester.pump(); + + setHostState(() => showTooltips = true); + await tester.pump(); + await tester.longPress(find.text('second')); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/info/favorite_folder_card_test.dart b/test/widgets/info/favorite_folder_card_test.dart new file mode 100644 index 00000000..ee19baa9 --- /dev/null +++ b/test/widgets/info/favorite_folder_card_test.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/Favorite/favorite_folder_card.dart'; + +void main() { + testWidgets('ordinary favorite folder stays a compact single row', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 390, + child: LoftifyFavoriteFolderCard( + title: '默认收藏夹', + folderIdLabel: 'ID: 28243657', + postCountLabel: '1篇', + cover: const ColoredBox(color: Colors.teal), + editLabel: '编辑', + onTap: () {}, + onEdit: () {}, + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(LoftifyFavoriteFolderCard)).height, + lessThan(110)); + expect(find.byTooltip('编辑'), findsOneWidget); + }); + + testWidgets('favorite folder card grows on a narrow large-text viewport', + (tester) async { + var opened = 0; + var edited = 0; + var deleted = 0; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(320, 480), + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: SizedBox( + width: 320, + child: LoftifyFavoriteFolderCard( + title: 'A very long favorite folder title that can wrap', + folderIdLabel: 'Folder identifier: 12345678901234567890', + postCountLabel: '123456 posts', + cover: const ColoredBox(color: Colors.teal), + editLabel: 'Edit folder', + deleteLabel: 'Delete folder', + onTap: () => opened++, + onEdit: () => edited++, + onDelete: () => deleted++, + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(LoftifyFavoriteFolderCard)).height, + greaterThan(120)); + await tester.tap(find.byTooltip('Edit folder')); + await tester.tap(find.byTooltip('Delete folder')); + expect(edited, 1); + expect(deleted, 1); + expect(opened, 0); + }); + + testWidgets('default folder can omit the destructive action', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LoftifyFavoriteFolderCard( + title: 'Default', + folderIdLabel: 'ID: 1', + postCountLabel: '2 posts', + cover: const ColoredBox(color: Colors.teal), + editLabel: 'Edit folder', + deleteLabel: 'Delete folder', + onTap: () {}, + onEdit: () {}, + ), + ), + ), + ); + + expect(find.byTooltip('Edit folder'), findsOneWidget); + expect(find.byTooltip('Delete folder'), findsNothing); + expect(tester.takeException(), isNull); + }); + + test('favorite folder page uses the shared cover card', () { + final source = File( + 'lib/Screens/Info/favorite_folder_list_screen.dart', + ).readAsStringSync(); + expect(source, contains('LoftifyFavoriteFolderCard(')); + expect(source, contains('fit: BoxFit.cover')); + expect(source, isNot(contains('height: 80'))); + expect(source, isNot(contains('width: 80'))); + }); +} diff --git a/test/widgets/info/user_relation_controls_test.dart b/test/widgets/info/user_relation_controls_test.dart new file mode 100644 index 00000000..b306b5a4 --- /dev/null +++ b/test/widgets/info/user_relation_controls_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/Design/loftify_controls.dart'; +import 'package:loftify/Widgets/Item/loftify_item_builder.dart'; + +void main() { + testWidgets('relation control keeps a full target with large text', + (tester) async { + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(280, 320), + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: Builder( + builder: (context) => Align( + alignment: Alignment.topLeft, + child: LoftifyItemBuilder.buildFramedDoubleButton( + context: context, + isFollowed: false, + negtiveText: 'Follow this creator', + onTap: () => taps++, + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.byType(LoftifyCompactToggleButton)).height, + greaterThanOrEqualTo(48), + ); + final surface = tester.widget( + find.byKey(const ValueKey('loftify-compact-toggle-surface')), + ); + final decoration = surface.decoration! as BoxDecoration; + expect(decoration.color, Colors.transparent); + expect(decoration.border, isNotNull); + await tester.tap(find.text('Follow this creator')); + expect(taps, 1); + }); + + test('following cards and controls use responsive design components', () { + final source = File( + 'lib/Widgets/Item/loftify_item_builder.dart', + ).readAsStringSync(); + final cardStart = + source.indexOf('static Widget buildFollowerOrFollowingItem'); + final cardEnd = source.indexOf('static Widget buildLikedButton', cardStart); + final cardSource = source.substring(cardStart, cardEnd); + expect(cardSource, contains('LayoutBuilder(')); + expect(cardSource, contains('LoftifyCard(')); + expect(cardSource, contains('constraints.maxWidth < 420')); + + final buttonStart = source.indexOf('static Widget buildFramedDoubleButton'); + final buttonEnd = + source.indexOf('static buildUnLoginMainBody', buttonStart); + final buttonSource = source.substring(buttonStart, buttonEnd); + expect(buttonSource, contains('LoftifyCompactToggleButton(')); + expect(buttonSource, contains('selected: isFollowed')); + }); +} diff --git a/test/widgets/item/search_bar_alignment_test.dart b/test/widgets/item/search_bar_alignment_test.dart new file mode 100644 index 00000000..3eb05ee5 --- /dev/null +++ b/test/widgets/item/search_bar_alignment_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/Item/item_builder.dart'; + +void main() { + for (final height in [48, 56]) { + testWidgets('shared search hint is centered at height $height', + (tester) async { + const hint = '多个搜索词以空格隔开'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + key: const ValueKey('search-bar-host'), + width: 360, + height: height, + child: Builder( + builder: (context) => ItemBuilder.buildSearchBar( + context: context, + hintText: hint, + onSubmitted: (_) {}, + ), + ), + ), + ), + ), + ), + ); + + final field = find.byType(TextField); + final bar = find.byKey(const ValueKey('search-bar-host')); + final placeholder = find.text(hint); + expect(field, findsOneWidget); + expect(placeholder, findsOneWidget); + expect(tester.takeException(), isNull); + expect( + (tester.getCenter(placeholder).dy - tester.getCenter(bar).dy).abs(), + lessThan(2), + ); + }); + } +} diff --git a/test/widgets/login/loftify_login_layout_test.dart b/test/widgets/login/loftify_login_layout_test.dart new file mode 100644 index 00000000..e124bcc0 --- /dev/null +++ b/test/widgets/login/loftify_login_layout_test.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/Login/loftify_login_layout.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +void main() { + testWidgets('short large-text phone keeps every login action scrollable', + (tester) async { + var primaryTaps = 0; + var methodTaps = 0; + await tester.pumpWidget( + _host( + size: const Size(320, 360), + textScaler: const TextScaler.linear(2), + child: LoftifyLoginLayout( + formChildren: const [ + TextField(decoration: InputDecoration(hintText: 'Account')), + SizedBox(height: 10), + TextField(decoration: InputDecoration(hintText: 'Password')), + SizedBox(height: 120), + ], + primaryAction: ElevatedButton( + key: const ValueKey('login-primary'), + onPressed: () => primaryTaps++, + child: const Text('Sign in'), + ), + alternativeTitle: 'Other sign-in methods can have a long title', + alternativeMethods: [ + LoftifyLoginMethod( + label: 'SMS code login', + icon: LoftifyIcons.phone, + onPressed: () => methodTaps++, + ), + ], + ), + ), + ); + + expect(find.byKey(const ValueKey('loftify-login-scroll')), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.ensureVisible(find.byKey(const ValueKey('login-primary'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('login-primary'))); + expect(primaryTaps, 1); + + final method = find.byTooltip('SMS code login'); + await tester.ensureVisible(method); + await tester.pumpAndSettle(); + await tester.tap(method); + expect(methodTaps, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('wide login form is capped instead of stretching edge to edge', + (tester) async { + await tester.pumpWidget( + _host( + size: const Size(1000, 700), + child: LoftifyLoginLayout( + formChildren: const [TextField()], + primaryAction: const SizedBox(height: 48), + alternativeTitle: 'Other methods', + alternativeMethods: [ + LoftifyLoginMethod( + label: 'Password login', + icon: LoftifyIcons.password, + onPressed: _noop, + ), + ], + ), + ), + ); + + final fieldWidth = tester.getSize(find.byType(TextField)).width; + expect(fieldWidth, lessThanOrEqualTo(520)); + expect(tester.takeException(), isNull); + }); + + testWidgets('alternative heading and methods share the same center', + (tester) async { + await tester.pumpWidget( + _host( + size: const Size(420, 700), + child: LoftifyLoginLayout( + formChildren: const [TextField()], + primaryAction: const SizedBox(height: 48), + alternativeTitle: '其他登录方式', + alternativeMethods: [ + LoftifyLoginMethod( + label: 'Password', + icon: LoftifyIcons.password, + onPressed: _noop, + ), + LoftifyLoginMethod( + label: 'ID', + icon: LoftifyIcons.lofterId, + onPressed: _noop, + ), + ], + ), + ), + ); + + final heading = tester.getRect( + find.byKey(const ValueKey('loftify-login-alternative-heading')), + ); + final methods = tester.getRect( + find.byKey(const ValueKey('loftify-login-alternative-methods')), + ); + expect((heading.center.dx - methods.center.dx).abs(), lessThan(1)); + final titleCenter = tester.getCenter(find.text('其他登录方式')).dx; + final methodCenter = (tester.getCenter(find.byTooltip('Password')).dx + + tester.getCenter(find.byTooltip('ID')).dx) / + 2; + expect((titleCenter - methodCenter).abs(), lessThan(1)); + final dividers = find.descendant( + of: find.byKey(const ValueKey('loftify-login-alternative-heading')), + matching: find.byType(Divider), + ); + final titleRect = tester.getRect(find.text('其他登录方式')); + expect(titleRect.left - tester.getRect(dividers.first).right, + lessThanOrEqualTo(16)); + expect(tester.getRect(dividers.last).left - titleRect.right, + lessThanOrEqualTo(16)); + expect(tester.takeException(), isNull); + }); + + test('all login screens use the shared non-overlay layout', () { + for (final path in [ + 'lib/Screens/Login/login_by_captcha_screen.dart', + 'lib/Screens/Login/login_by_password_screen.dart', + 'lib/Screens/Login/login_by_lofterid_screen.dart', + 'lib/Screens/Login/login_by_mail_screen.dart', + ]) { + final source = File(path).readAsStringSync(); + expect(source, contains('LoftifyLoginLayout('), reason: path); + expect(source, isNot(contains('Positioned(')), reason: path); + expect(source, isNot(contains('ToolButton(')), reason: path); + } + }); +} + +void _noop() {} + +Widget _host({ + required Size size, + required Widget child, + TextScaler textScaler = TextScaler.noScaling, +}) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: Colors.teal), + home: Scaffold( + body: Center( + child: MediaQuery( + data: MediaQueryData(size: size, textScaler: textScaler), + child: SizedBox.fromSize(size: size, child: child), + ), + ), + ), + ); +} diff --git a/test/widgets/navigation/home_nested_refresh_layout_test.dart b/test/widgets/navigation/home_nested_refresh_layout_test.dart new file mode 100644 index 00000000..17839811 --- /dev/null +++ b/test/widgets/navigation/home_nested_refresh_layout_test.dart @@ -0,0 +1,200 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('floating app bar leaves the pull indicator below the toolbar', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + var refreshes = 0; + late ScrollController innerController; + await tester.pumpWidget(MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(390, 844), + padding: EdgeInsets.only(top: 24), + ), + child: Scaffold( + body: ExtendedNestedScrollView( + onlyOneScrollInBody: true, + floatHeaderSlivers: true, + headerSliverBuilder: (context, _) => [ + const SliverAppBar( + key: ValueKey('toolbar'), + floating: true, + snap: true, + toolbarHeight: 48, + title: Text('Home'), + ), + ], + body: Builder( + builder: (context) { + innerController = PrimaryScrollController.of(context); + return EasyRefresh.builder( + scrollController: innerController, + header: const LottieCupertinoHeader( + safeArea: false, + clamping: true, + indicator: SizedBox(width: 40, height: 40), + ), + onRefresh: () async => refreshes++, + childBuilder: (context, physics) => CustomScrollView( + controller: innerController, + physics: physics, + slivers: [ + SliverList.builder( + itemCount: 30, + itemBuilder: (context, index) => SizedBox( + height: 100, + child: Text('Item $index'), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ), + )); + await tester.pump(); + final feedElement = tester.element(find.byType(CustomScrollView)); + + const toolbarBottom = 24.0 + 48.0; + final firstItemTop = tester.getTopLeft(find.text('Item 0')).dy; + expect(firstItemTop, greaterThanOrEqualTo(toolbarBottom)); + + await tester.drag(find.text('Item 0'), const Offset(0, 350)); + await tester.pump(); + expect( + identical(tester.element(find.byType(CustomScrollView)), feedElement), + isTrue); + final indicatorTop = tester + .getTopLeft(find.byKey(const ValueKey('refresh-indicator-viewport'))) + .dy; + expect(indicatorTop, greaterThanOrEqualTo(toolbarBottom)); + final indicatorBottom = indicatorTop + + tester + .getSize(find.byKey(const ValueKey('refresh-indicator-viewport'))) + .height; + expect(tester.getTopLeft(find.text('Item 0')).dy, + greaterThanOrEqualTo(indicatorBottom)); + await tester.pumpAndSettle(); + expect(refreshes, 1); + expect( + identical(tester.element(find.byType(CustomScrollView)), feedElement), + isTrue); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + await tester.drag(find.text('Item 0'), const Offset(0, -320)); + await tester.pumpAndSettle(); + expect( + identical(tester.element(find.byType(CustomScrollView)), feedElement), + isTrue); + expect(tester.getTopLeft(find.byType(CustomScrollView)).dy, lessThan(24)); + innerController.jumpTo(0); + await tester.pumpAndSettle(); + for (var attempt = 0; attempt < 2; attempt++) { + await tester.drag(find.byType(CustomScrollView), const Offset(0, 30)); + await tester.pumpAndSettle(); + expect( + identical(tester.element(find.byType(CustomScrollView)), feedElement), + isTrue); + } + expect(refreshes, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('programmatic nested refresh uses the real header and haptics', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final haptics = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + haptics.add(call.arguments.toString()); + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + final refreshController = EasyRefreshController(); + late ScrollController innerController; + var refreshes = 0; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ExtendedNestedScrollView( + onlyOneScrollInBody: true, + floatHeaderSlivers: true, + headerSliverBuilder: (context, _) => [ + const SliverAppBar(floating: true, snap: true, title: Text('Home')), + ], + body: Builder(builder: (context) { + innerController = PrimaryScrollController.of(context); + return EasyRefresh.builder( + controller: refreshController, + scrollController: innerController, + refreshOnStart: true, + header: const LottieCupertinoHeader( + safeArea: false, + clamping: true, + hapticFeedback: true, + indicator: SizedBox(width: 40, height: 40), + ), + onRefresh: () async { + refreshes++; + return IndicatorResult.success; + }, + childBuilder: (context, physics) => CustomScrollView( + controller: innerController, + physics: physics, + slivers: [ + SliverList.builder( + itemCount: 30, + itemBuilder: (context, index) => SizedBox( + height: 100, + child: Text('Item $index'), + ), + ), + ], + ), + ); + }), + ), + ), + )); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + expect(refreshes, 1); + expect(haptics, isEmpty); + + final refreshFuture = refreshController.callRefresh( + scrollController: innerController, + ); + await tester.pumpAndSettle(); + await refreshFuture; + + expect(refreshes, 2); + expect(haptics, contains('HapticFeedbackType.mediumImpact')); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + refreshController.dispose(); + }); +} diff --git a/test/widgets/navigation/loftify_glass_navigation_bar_test.dart b/test/widgets/navigation/loftify_glass_navigation_bar_test.dart new file mode 100644 index 00000000..7001fc91 --- /dev/null +++ b/test/widgets/navigation/loftify_glass_navigation_bar_test.dart @@ -0,0 +1,799 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Utils/lottie_files.dart'; +import 'package:loftify/Widgets/Navigation/loftify_glass_navigation_bar.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +const _destinations = [ + LoftifyNavigationDestination( + icon: LoftifyIcons.home, + lottieAsset: LottieFiles.navHome, + label: 'Home', + ), + LoftifyNavigationDestination( + icon: LoftifyIcons.search, + lottieAsset: LottieFiles.navSearch, + label: 'Search', + ), + LoftifyNavigationDestination( + icon: LoftifyIcons.activity, + lottieAsset: LottieFiles.navHeart, + label: 'Activity', + badgeCount: 120, + ), + LoftifyNavigationDestination( + icon: LoftifyIcons.profile, + lottieAsset: LottieFiles.navUser, + label: 'Mine', + ), +]; + +ThemeData _themeForVariant(ChewieThemeColorData variant) { + final brightness = variant.isDarkMode ? Brightness.dark : Brightness.light; + final colorScheme = ColorScheme.fromSeed( + seedColor: variant.primaryColor, + brightness: brightness, + ).copyWith( + primary: variant.primaryColor, + surface: variant.effectivePageBackgroundColor, + onSurface: variant.textColor, + error: variant.errorColor, + ); + return ThemeData( + brightness: brightness, + colorScheme: colorScheme, + primaryColor: variant.primaryColor, + dividerColor: variant.dividerColor, + shadowColor: variant.shadowColor, + ); +} + +Widget _host({ + MediaQueryData mediaQuery = const MediaQueryData(size: Size(320, 640)), + Brightness brightness = Brightness.light, + ThemeData? theme, + bool enableBlur = true, + int currentIndex = 0, + List destinations = _destinations, + ValueChanged? onSelect, + ValueChanged? onDoubleTap, + VoidCallback? onBodyTap, + NavigationBarDisplayStyle displayStyle = + NavigationBarDisplayStyle.iconAndText, +}) { + final colorScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF14C2BB), + brightness: brightness, + ); + return MaterialApp( + theme: theme ?? ThemeData(colorScheme: colorScheme, brightness: brightness), + home: MediaQuery( + data: mediaQuery, + child: Scaffold( + extendBody: true, + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onBodyTap, + child: const ColoredBox(color: Color(0xFFB9DAD7)), + ), + bottomNavigationBar: LoftifyGlassNavigationBar( + destinations: destinations, + currentIndex: currentIndex, + enableBlur: enableBlur, + displayStyle: displayStyle, + onSelect: onSelect ?? (_) {}, + onDoubleTap: onDoubleTap, + ), + ), + ), + ); +} + +void main() { + testWidgets('three visible destinations can leave search unselected', + (tester) async { + final selected = []; + await tester.pumpWidget(_host( + currentIndex: -1, + destinations: [ + _destinations[0], + _destinations[2], + _destinations[3], + ], + onSelect: selected.add, + )); + + expect(tester.takeException(), isNull); + expect(find.text('Search'), findsNothing); + await tester.tap(find.text('Activity')); + expect(selected, [1]); + }); + + testWidgets('uses a clipped translucent blur surface with safe-area inset', ( + tester, + ) async { + await tester.pumpWidget( + _host( + mediaQuery: const MediaQueryData( + size: Size(320, 640), + viewPadding: EdgeInsets.only(bottom: 24), + ), + ), + ); + + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.byType(RepaintBoundary), findsWidgets); + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.byType(LoftifyGlassNavigationBar)).height, + LoftifyGlassNavigationBar.barHeight + 36, + ); + + final surface = tester.widget( + find.byKey(const ValueKey('loftify-glass-navigation-surface')), + ); + final decoration = surface.decoration as BoxDecoration; + expect(decoration.color!.a, closeTo(0.72, 0.01)); + expect(decoration.borderRadius, BorderRadius.circular(24)); + expect(decoration.boxShadow, hasLength(1)); + expect(decoration.border, isNotNull); + }); + + testWidgets('uses an opaque fallback for accessibility and explicit opt-out', + ( + tester, + ) async { + for (final configuration in <({MediaQueryData media, bool enabled})>[ + ( + media: const MediaQueryData( + size: Size(320, 640), + disableAnimations: true, + ), + enabled: true, + ), + ( + media: const MediaQueryData( + size: Size(320, 640), + highContrast: true, + ), + enabled: true, + ), + ( + media: const MediaQueryData(size: Size(320, 640)), + enabled: false, + ), + ]) { + await tester.pumpWidget( + _host( + mediaQuery: configuration.media, + enableBlur: configuration.enabled, + ), + ); + + expect(find.byType(BackdropFilter), findsNothing); + final surface = tester.widget( + find.byKey(const ValueKey('loftify-glass-navigation-surface')), + ); + final decoration = surface.decoration as BoxDecoration; + expect(decoration.color!.a, 1); + } + }); + + testWidgets('adapts glass tint and selected color to every built-in theme', ( + tester, + ) async { + final variants = [ + ...ChewieThemeColorData.defaultLightThemes, + ...ChewieThemeColorData.defaultDarkThemes, + ]; + + for (final variant in variants) { + await tester.pumpWidget(_host(theme: _themeForVariant(variant))); + await tester.pumpAndSettle(); + + final surface = tester.widget( + find.byKey(const ValueKey('loftify-glass-navigation-surface')), + ); + final decoration = surface.decoration as BoxDecoration; + expect( + decoration.color!.withValues(alpha: 1), + variant.effectivePageBackgroundColor, + reason: variant.id, + ); + expect( + decoration.color!.a, + closeTo(variant.isDarkMode ? 0.78 : 0.72, 0.01), + reason: variant.id, + ); + final selectedIcon = tester.widget( + find.byType(LoftifyNavigationLottieIcon).first, + ); + expect(selectedIcon.color, variant.primaryColor, reason: variant.id); + expect(tester.takeException(), isNull, reason: variant.id); + } + }); + + testWidgets('keeps four labels bounded and exposes selected semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + _host( + mediaQuery: const MediaQueryData( + size: Size(280, 600), + textScaler: TextScaler.linear(1.4), + ), + currentIndex: 2, + ), + ); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('Search'), findsOneWidget); + expect(find.text('Activity'), findsOneWidget); + expect(find.text('Mine'), findsOneWidget); + expect(find.text('99+'), findsOneWidget); + expect( + tester.getSemantics(find.text('Activity')), + matchesSemantics( + label: 'Activity, 120', + isButton: true, + hasSelectedState: true, + isSelected: true, + hasTapAction: true, + ), + ); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); + + testWidgets('supports icon, text and combined styles on narrow large text', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + for (final style in NavigationBarDisplayStyle.values) { + await tester.pumpWidget( + _host( + mediaQuery: const MediaQueryData( + size: Size(280, 600), + textScaler: TextScaler.linear(2), + ), + currentIndex: 2, + displayStyle: style, + ), + ); + await tester.pump(); + + final showsIcons = style != NavigationBarDisplayStyle.textOnly; + final showsLabels = style != NavigationBarDisplayStyle.iconOnly; + expect( + find.byType(LoftifyNavigationLottieIcon), + showsIcons ? findsNWidgets(4) : findsNothing, + reason: style.key, + ); + expect( + find.text('Home'), + showsLabels ? findsOneWidget : findsNothing, + reason: style.key, + ); + expect( + find.text('99+'), + findsOneWidget, + reason: 'badge remains visible for ${style.key}', + ); + expect( + find.bySemanticsLabel('Activity, 120'), + findsOneWidget, + reason: style.key, + ); + expect(tester.takeException(), isNull, reason: style.key); + } + semantics.dispose(); + }); + + test('defaults persisted and unknown styles to icons only', () { + final bar = LoftifyGlassNavigationBar( + destinations: _destinations, + currentIndex: 0, + onSelect: (_) {}, + ); + expect(bar.displayStyle, NavigationBarDisplayStyle.iconOnly); + expect( + NavigationBarDisplayStyle.fromKey(null), + NavigationBarDisplayStyle.iconOnly, + ); + expect( + NavigationBarDisplayStyle.fromKey('unsupported'), + NavigationBarDisplayStyle.iconOnly, + ); + }); + + testWidgets('dispatches tap and double-tap without material ripple', ( + tester, + ) async { + var selected = -1; + var doubleTapped = -1; + await tester.pumpWidget( + _host( + onSelect: (index) => selected = index, + onDoubleTap: (index) => doubleTapped = index, + ), + ); + + expect(find.byType(InkWell), findsNothing); + await tester.tap(find.text('Search')); + expect(selected, 1); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Mine')); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.text('Mine')); + await tester.pumpAndSettle(); + expect(doubleTapped, 3); + }); + + testWidgets('press feedback scales immediately without changing glyphs', ( + tester, + ) async { + await tester.pumpWidget(_host()); + final scaleFinder = find.ancestor( + of: find.text('Home'), + matching: find.byType(AnimatedScale), + ); + final gesture = await tester.startGesture( + tester.getCenter(find.text('Home')), + ); + await tester.pump(); + + expect(tester.widget(scaleFinder).scale, 0.94); + expect( + find.byType(LoftifyNavigationLottieIcon), + findsNWidgets(_destinations.length), + ); + expect(find.byType(ChewieIcon), findsNothing); + + await gesture.up(); + await tester.pump(); + expect(tester.widget(scaleFinder).scale, 1); + }); + + testWidgets('selected destination uses a fuller capsule and balanced glyph', ( + tester, + ) async { + await tester.pumpWidget( + _host( + currentIndex: 0, + displayStyle: NavigationBarDisplayStyle.iconOnly, + ), + ); + await tester.pumpAndSettle(); + + final selection = find.byKey( + const ValueKey('loftify-navigation-selection-Home'), + ); + // The render box includes the 6 px top/bottom margin, leaving a restrained + // 42 px visible capsule around the icon rather than filling the whole bar. + expect(tester.getSize(selection).height, 54); + final icon = tester.widget( + find.descendant( + of: selection, + matching: find.byType(LoftifyNavigationLottieIcon), + ), + ); + expect(icon.size, 22); + }); + + testWidgets('navigation consumes taps without activating content beneath', ( + tester, + ) async { + var bodyTaps = 0; + var selected = -1; + await tester.pumpWidget( + _host( + onBodyTap: () => bodyTaps++, + onSelect: (index) => selected = index, + ), + ); + + await tester.tap(find.text('Home')); + + expect(selected, 0); + expect(bodyTaps, 0); + }); + + testWidgets('long-list direction hides and restores the glass surface', ( + tester, + ) async { + final controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + extendBody: true, + body: ListView.builder( + controller: controller, + itemExtent: 64, + itemCount: 80, + itemBuilder: (context, index) => Text('Item $index'), + ), + bottomNavigationBar: ScrollToHide.multi( + scrollControllers: [controller], + hideDirection: Axis.vertical, + child: LoftifyGlassNavigationBar( + destinations: _destinations, + currentIndex: 0, + onSelect: (_) {}, + ), + ), + ), + ), + ); + final transitionFinder = find.ancestor( + of: find.byType(LoftifyGlassNavigationBar), + matching: find.byKey(const ValueKey('scroll-to-hide-transition')), + ); + final opacityFinder = find.byKey( + const ValueKey('scroll-to-hide-opacity'), + ); + + await tester.drag(find.byType(ListView), const Offset(0, -420)); + await tester.pumpAndSettle(); + var opacity = tester.widget(opacityFinder); + expect(opacity.opacity, 0); + expect(tester.getSize(transitionFinder).height, 0); + + await tester.drag(find.byType(ListView), const Offset(0, 260)); + await tester.pumpAndSettle(); + opacity = tester.widget(opacityFinder); + expect(opacity.opacity, 1); + expect(tester.getSize(transitionFinder).height, greaterThan(0)); + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + + testWidgets('rapid reverse scroll continues from the current smooth state', ( + tester, + ) async { + final controller = ScrollController(); + final visibility = ScrollToHideController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView.builder( + controller: controller, + itemExtent: 64, + itemCount: 80, + itemBuilder: (context, index) => Text('Item $index'), + ), + bottomNavigationBar: ScrollToHide.multi( + scrollControllers: [controller], + hideDirection: Axis.vertical, + controller: visibility, + child: LoftifyGlassNavigationBar( + destinations: _destinations, + currentIndex: 0, + onSelect: (_) {}, + ), + ), + ), + ), + ); + final opacityFinder = find.byKey( + const ValueKey('scroll-to-hide-opacity'), + ); + + visibility.hide(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 110)); + final hidingOpacity = tester.widget(opacityFinder).opacity; + expect(hidingOpacity, allOf(greaterThan(0), lessThan(1))); + + visibility.show(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + final returningOpacity = tester.widget(opacityFinder).opacity; + expect(returningOpacity, greaterThan(hidingOpacity)); + await tester.pumpAndSettle(); + expect(tester.widget(opacityFinder).opacity, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + + testWidgets('reduced motion applies scroll visibility without animation', ( + tester, + ) async { + final controller = ScrollController(); + final visibility = ScrollToHideController(); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: ScrollToHide( + scrollController: controller, + hideDirection: Axis.vertical, + controller: visibility, + child: const SizedBox(width: 100, height: 64), + ), + ), + ), + ); + + visibility.hide(); + await tester.pump(); + final opacity = tester.widget( + find.byKey(const ValueKey('scroll-to-hide-opacity')), + ); + expect(opacity.opacity, 0); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + + testWidgets('settled glass surface has no continuously scheduled frames', ( + tester, + ) async { + await tester.pumpWidget(_host()); + await tester.pumpAndSettle(); + + expect(tester.binding.transientCallbackCount, 0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('selection animation settles equally at 60, 90 and 120Hz', ( + tester, + ) async { + for (final refreshRate in [60, 90, 120]) { + final selectedIndex = ValueNotifier(0); + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: selectedIndex, + builder: (context, currentIndex, child) => _host( + currentIndex: currentIndex, + onSelect: (index) => selectedIndex.value = index, + ), + ), + ); + + await tester.tap(find.text('Search')); + final frame = Duration(microseconds: 1000000 ~/ refreshRate); + var elapsed = Duration.zero; + while (elapsed < const Duration(milliseconds: 220)) { + await tester.pump(frame); + elapsed += frame; + } + + final selectionFinder = find.ancestor( + of: find.text('Search'), + matching: find.byType(AnimatedContainer), + ); + final selection = tester.widget(selectionFinder); + final decoration = selection.decoration as BoxDecoration; + expect( + decoration.color!.a, + closeTo(0.11, 0.01), + reason: '$refreshRate Hz', + ); + expect(tester.takeException(), isNull, reason: '$refreshRate Hz'); + + await tester.pumpWidget(const SizedBox.shrink()); + selectedIndex.dispose(); + } + }); + + test('blur policy rejects web and all accessibility fallbacks', () { + const normal = MediaQueryData(); + expect( + LoftifyGlassNavigationBar.shouldUseBlur( + normal, + enabled: true, + isWeb: false, + platformReduceMotion: false, + ), + isTrue, + ); + expect( + LoftifyGlassNavigationBar.shouldUseBlur( + normal, + enabled: true, + isWeb: true, + platformReduceMotion: false, + ), + isFalse, + ); + expect( + LoftifyGlassNavigationBar.shouldUseBlur( + normal, + enabled: false, + platformReduceMotion: false, + ), + isFalse, + ); + expect( + LoftifyGlassNavigationBar.shouldUseBlur( + const MediaQueryData(accessibleNavigation: true), + enabled: true, + platformReduceMotion: false, + ), + isFalse, + ); + expect( + LoftifyGlassNavigationBar.shouldUseBlur( + normal, + enabled: true, + platformReduceMotion: true, + ), + isFalse, + ); + expect( + LoftifyGlassNavigationBar.pageTransitionDuration( + normal, + platformReduceMotion: false, + ), + LoftifyGlassNavigationBar.standardPageTransitionDuration, + ); + expect( + LoftifyGlassNavigationBar.pageTransitionDuration( + const MediaQueryData(disableAnimations: true), + platformReduceMotion: false, + ), + Duration.zero, + ); + expect( + LoftifyGlassNavigationBar.pageTransitionDuration( + normal, + platformReduceMotion: true, + ), + Duration.zero, + ); + expect( + LoftifyGlassNavigationBar.shouldShowForKeyboard(normal), + isTrue, + ); + expect( + LoftifyGlassNavigationBar.shouldShowForKeyboard( + const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 280)), + ), + isFalse, + ); + }); + + test('phone panel extends content behind the reusable glass navigation', () { + final source = File('lib/Screens/panel_screen.dart').readAsStringSync(); + final mainSource = File('lib/Screens/main_screen.dart').readAsStringSync(); + + expect(source, contains('extendBody: true')); + expect(source, contains('portrait: _buildBottomNavigationBar()')); + expect(source, contains('landscape: null')); + expect(source, contains('LoftifyGlassNavigationBar(')); + expect(source, contains('enableBlur: !preferences.reduceTransparency')); + expect(source, contains('shouldShowForKeyboard(')); + expect(source, + contains('_pageController.jumpToPage(_visibleIndexFor(index))')); + expect(source, isNot(contains('MyBottomNavigationBar('))); + expect(mainSource, contains('portrait: PanelScreen(key: panelScreenKey)')); + expect(mainSource, contains('_sideBar(leftPadding: 8, rightPadding: 8)')); + }); + + test('panel and tab navigation listen only to the active scroll source', () { + final panelSource = + File('lib/Screens/panel_screen.dart').readAsStringSync(); + final dynamicSource = File( + 'lib/Screens/Navigation/dynamic_screen.dart', + ).readAsStringSync(); + + expect( + panelSource, + allOf( + contains('_keyList[_currentIndex].currentState'), + contains('_scrollToHideController.show();'), + ), + ); + expect( + dynamicSource, + allOf( + contains('return [getCurrentController()];'), + contains('_scrollToHideController.show();'), + contains('panelScreenState?.showBottomNavigationBar();'), + contains('panelScreenState?.refreshScrollControllers();'), + ), + ); + }); + + test('reduce-transparency preference is persisted and localized', () { + final hiveSource = File('lib/Utils/hive_util.dart').readAsStringSync(); + final providerSource = File( + 'lib/Utils/app_provider.dart', + ).readAsStringSync(); + final appearanceSource = File( + 'lib/Screens/Setting/apperance_setting_screen.dart', + ).readAsStringSync(); + + expect(hiveSource, contains('reduceTransparencyKey')); + expect( + providerSource, + allOf( + contains('bool get reduceTransparency'), + contains('HiveUtil.reduceTransparencyKey'), + contains('notifyListeners()'), + ), + ); + expect( + appearanceSource, + allOf( + contains('appProvider.reduceTransparency'), + contains('appLocalizations.reduceTransparency'), + contains('appLocalizations.reduceTransparencyDescription'), + ), + ); + + for (final path in [ + 'lib/l10n/intl_en.arb', + 'lib/l10n/intl_zh.arb', + 'lib/l10n/intl_zh_CN.arb', + 'lib/l10n/intl_zh_TW.arb', + ]) { + final messages = jsonDecode(File(path).readAsStringSync()) as Map; + expect(messages['reduceTransparency'], isNotEmpty, reason: path); + expect( + messages['reduceTransparencyDescription'], + isNotEmpty, + reason: path, + ); + } + }); + + test('navigation display preference is persisted and localized', () { + final hiveSource = File('lib/Utils/hive_util.dart').readAsStringSync(); + final providerSource = File( + 'lib/Utils/app_provider.dart', + ).readAsStringSync(); + final appearanceSource = File( + 'lib/Screens/Setting/apperance_setting_screen.dart', + ).readAsStringSync(); + final panelSource = + File('lib/Screens/panel_screen.dart').readAsStringSync(); + + expect(hiveSource, contains('navigationBarDisplayStyleKey')); + expect( + providerSource, + allOf( + contains('NavigationBarDisplayStyle get navigationBarDisplayStyle'), + contains('HiveUtil.navigationBarDisplayStyleKey'), + contains('NavigationBarDisplayStyle.iconOnly.key'), + ), + ); + expect( + appearanceSource, + allOf( + contains('appLocalizations.navigationBarDisplayStyle'), + contains('NavigationBarDisplayStyle.values'), + contains('appProvider.navigationBarDisplayStyle = item.value'), + ), + ); + expect(panelSource, contains('displayStyle: preferences.displayStyle')); + + for (final path in [ + 'lib/l10n/intl_en.arb', + 'lib/l10n/intl_zh.arb', + 'lib/l10n/intl_zh_CN.arb', + 'lib/l10n/intl_zh_TW.arb', + ]) { + final messages = jsonDecode(File(path).readAsStringSync()) as Map; + for (final key in [ + 'navigationBarDisplayStyle', + 'navigationBarDisplayStyleDescription', + 'navigationBarIconAndText', + 'navigationBarIconOnly', + 'navigationBarTextOnly', + ]) { + expect(messages[key], isNotEmpty, reason: '$path: $key'); + } + } + }); +} diff --git a/test/widgets/navigation/mine_content_center_test.dart b/test/widgets/navigation/mine_content_center_test.dart new file mode 100644 index 00000000..fe1e262a --- /dev/null +++ b/test/widgets/navigation/mine_content_center_test.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('my app bar has no title and retains notification and dress actions', + () { + final source = File( + 'lib/Screens/Navigation/mine_screen.dart', + ).readAsStringSync(); + final start = source.indexOf('PreferredSizeWidget _buildAppBar()'); + final end = + source.indexOf('List getScrollControllers()', start); + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final appBar = source.substring(start, end); + + expect(appBar, contains('ResponsiveAppBar(')); + expect(appBar, isNot(contains('title:'))); + expect(appBar, contains('globalControl.showDress')); + expect(appBar, contains('const SuitScreen()')); + expect(appBar, contains('const SystemNoticeScreen()')); + expect(appBar, contains('LoftifyIcons.dress')); + expect(appBar, contains('LoftifyIcons.notifications')); + }); + + test('content center keeps downloads without duplicating app bar actions', + () { + final source = File( + 'lib/Screens/Navigation/mine_screen.dart', + ).readAsStringSync(); + final contentStart = source.indexOf('List _buildContent()'); + final creationStart = source.indexOf('List _buildCreation()'); + + expect(contentStart, isNonNegative); + expect(creationStart, greaterThan(contentStart)); + + final contentSource = source.substring(contentStart, creationStart); + final historyStart = contentSource.indexOf( + 'title: appLocalizations.myHistory', + ); + final downloadStart = contentSource.indexOf( + 'title: appLocalizations.downloadManagement', + ); + + expect(historyStart, isNonNegative); + expect(downloadStart, greaterThan(historyStart)); + expect( + contentSource.substring(historyStart, downloadStart), + isNot(contains('roundBottom: true')), + ); + expect( + contentSource.substring(downloadStart), + allOf( + contains('const DownloadManagementScreen()'), + contains('leading: LoftifyIcons.download'), + ), + ); + expect(contentSource, contains('CaptionItem(')); + expect(contentSource, contains('EntryItem(')); + expect(contentSource, isNot(contains('const SuitScreen()'))); + expect(contentSource, isNot(contains('const SystemNoticeScreen()'))); + }); + + test('image settings do not duplicate the download manager entrance', () { + final source = File( + 'lib/Screens/Setting/image_setting_screen.dart', + ).readAsStringSync(); + + expect(source, isNot(contains('DownloadManagementScreen'))); + expect(source, isNot(contains('appLocalizations.downloadManagement'))); + expect(source, contains('appLocalizations.downloadImagePath')); + expect(source, contains('appLocalizations.filenameFormat')); + }); + + test('mine, settings and about share the same section and entry widgets', () { + for (final path in [ + 'lib/Screens/Navigation/mine_screen.dart', + 'lib/Screens/Setting/setting_screen.dart', + 'lib/Screens/Setting/about_setting_screen.dart', + ]) { + final source = File(path).readAsStringSync(); + expect(source, contains('CaptionItem('), reason: path); + expect(source, contains('EntryItem('), reason: path); + expect(source, isNot(contains('LoftifyEntryItem(')), reason: path); + expect(source, isNot(contains('LoftifySection(')), reason: path); + } + }); +} diff --git a/test/widgets/navigation/primary_navigation_app_bar_test.dart b/test/widgets/navigation/primary_navigation_app_bar_test.dart new file mode 100644 index 00000000..0ddfe633 --- /dev/null +++ b/test/widgets/navigation/primary_navigation_app_bar_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final sources = { + 'home': File( + 'lib/Screens/Navigation/home_screen.dart', + ).readAsStringSync(), + 'search': File( + 'lib/Screens/Navigation/search_screen.dart', + ).readAsStringSync(), + 'dynamic': File( + 'lib/Screens/Navigation/dynamic_screen.dart', + ).readAsStringSync(), + 'mine': File( + 'lib/Screens/Navigation/mine_screen.dart', + ).readAsStringSync(), + }; + + test('primary pages retain responsive app bars', () { + for (final source in sources.values) { + expect(source, contains('appBar:')); + expect(source, contains('ResponsiveAppBar(')); + expect(source, isNot(contains('LoftifyNavigationHeader'))); + expect(source, isNot(contains('LoftifyFloatingCapsule'))); + expect( + source, + isNot(contains('loftify_floating_navigation_header.dart')), + ); + } + }); + + test('home supports optional floating top bar and search action', () { + expect(sources['home'], contains('title: appLocalizations.home')); + expect(sources['home'], contains('appBar: hideAppBar ? null')); + expect(sources['home'], contains('SliverAppBar(')); + expect(sources['home'], contains('ExtendedNestedScrollView(')); + expect(sources['home'], contains('floatHeaderSlivers: true')); + expect(sources['home'], contains('onlyOneScrollInBody: true')); + expect(sources['home'], contains('PrimaryScrollController.of(context)')); + expect( + sources['home'], isNot(contains('SafeArea(\n top: hideAppBar'))); + expect(sources['home'], contains('safeArea: false')); + expect(sources['home'], contains('refreshOnStart: true')); + expect(sources['home'], contains('clamping: true')); + expect(sources['home'], contains('await _triggerRefresh()')); + expect(sources['home'], + contains('distanceFromTop <= MediaQuery.sizeOf(context).height / 2')); + expect(sources['home'], isNot(contains('_refreshFeedDirectly'))); + expect(sources['home'], contains('floating: true')); + expect(sources['home'], contains('snap: true')); + expect(sources['home'], contains('hideSearchNavigation')); + expect(sources['home'], contains('SearchScreen(showBack: true)')); + expect( + sources['home'], + isNot(contains('SliverToBoxAdapter(child: _buildNavigationHeader())')), + ); + + expect(sources['search'], contains('titleWidget: _buildSearchBar()')); + expect(sources['search'], contains('showBack: widget.showBack')); + }); + + test('search keeps its original top-bar structure', () { + expect(sources['search'], contains('titleWidget: _buildSearchBar()')); + expect(sources['search'], contains('borderRadius: 8')); + expect(sources['search'], isNot(contains('search-navigation-avatar'))); + }); + + test('dynamic restores the scrollable underline tab app bar', () { + expect( + sources['dynamic'], contains('appBar: appProvider.token.isNotEmpty')); + expect(sources['dynamic'], contains('isScrollable: true')); + expect(sources['dynamic'], contains('TabAlignment.start')); + expect(sources['dynamic'], contains('UnderlinedTabIndicator(')); + }); + + test('mine restores untitled app bar with its original actions', () { + final appBar = sources['mine']!.split( + 'PreferredSizeWidget _buildAppBar()', + )[1]; + expect(appBar, isNot(contains('title: appLocalizations.mine'))); + expect(appBar, contains('_MineThemeModeButton(')); + expect(appBar, contains('LottieFiles.sunLight')); + expect(appBar, contains('icon: LoftifyIcons.settings')); + expect(appBar, contains('LoftifyIcons.notifications')); + expect(appBar, contains('LoftifyIcons.dress')); + }); + + test('retired floating navigation header implementation is removed', () { + expect( + File( + 'lib/Widgets/Navigation/loftify_floating_navigation_header.dart', + ).existsSync(), + isFalse, + ); + }); +} diff --git a/test/widgets/post_detail/detail_components_test.dart b/test/widgets/post_detail/detail_components_test.dart new file mode 100644 index 00000000..15655c27 --- /dev/null +++ b/test/widgets/post_detail/detail_components_test.dart @@ -0,0 +1,563 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Models/post_detail_response.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Design/loftify_reading.dart'; +import 'package:loftify/Widgets/Item/loftify_item_builder.dart'; +import 'package:loftify/Widgets/PostDetail/comment_item.dart'; +import 'package:loftify/Widgets/PostDetail/detail_bottom_bar.dart'; +import 'package:loftify/Widgets/PostDetail/post_content_section.dart'; +import 'package:loftify/Widgets/PostDetail/post_download_action_icon.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/detail_components', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + Widget buildApp(Widget home, {bool dark = false}) { + return MaterialApp( + key: ValueKey(dark), + theme: LoftifyTheme.build( + dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first, + ), + locale: const Locale('en'), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return home; + }, + ), + ); + } + + test('missing image base URL does not create an invalid empty scheme', () { + expect(WebUtil.getBaseUrl('').toString(), isEmpty); + expect(WebUtil.getBaseUrl('relative/path').toString(), isEmpty); + expect(WebUtil.getBaseUrl('://').toString(), isEmpty); + expect( + WebUtil.getBaseUrl('https://yyxzzhxb.lofter.com/post/1ece729f_2b4802463') + .toString(), + 'https://yyxzzhxb.lofter.com', + ); + }); + + testWidgets('post body image does not replace content with a URI error', + (tester) async { + await tester.pumpWidget(buildApp( + const Scaffold( + body: SingleChildScrollView( + child: PostContentSection( + title: 'Image post', + content: '

Before image

', + ), + ), + ), + )); + await tester.pump(); + + expect(find.textContaining('Error rendering content'), findsNothing); + expect(tester.takeException(), isNull); + }); + + test('post detail parsing tolerates missing and loosely typed fields', () { + final data = PostDetailData.fromJson({ + 'liked': 1, + 'shared': 'true', + 'post': { + 'id': '42', + 'blogId': 7.0, + 'publishTime': '123', + 'publisherUserId': null, + 'type': '1', + 'cited': 0, + 'firstImageWh': ['120', 240.5], + 'tagList': [1, 'tag'], + 'postCount': { + 'favoriteCount': '9', + 'responseCount': null, + }, + }, + }); + + expect(data.liked, isTrue); + expect(data.shared, isTrue); + expect(data.post?.id, 42); + expect(data.post?.blogId, 7); + expect(data.post?.publishTime, 123); + expect(data.post?.firstImageWh, [120, 240]); + expect(data.post?.tagList, ['1', 'tag']); + expect(data.post?.postCount?.favoriteCount, 9); + expect(data.post?.postCount?.responseCount, 0); + }); + + test('malformed comment metadata falls back to a renderable item', () { + final comment = Comment.fromJson({ + 'id': '5', + 'liked': 0, + 'content': null, + 'publisherBlogInfo': const {}, + 'l2Comments': [ + { + 'publisherBlogInfo': const {}, + }, + ], + }); + + expect(comment.id, 5); + expect(comment.content, isEmpty); + expect(comment.publisherBlogInfo.blogId, 0); + expect(comment.l2Comments, hasLength(1)); + }); + + testWidgets('four detail actions stay separated above the safe area', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var taps = 0; + + await tester.pumpWidget( + buildApp( + MediaQuery( + data: const MediaQueryData( + size: Size(320, 568), + padding: EdgeInsets.only(bottom: 24), + viewPadding: EdgeInsets.only(bottom: 24), + ), + child: Scaffold( + bottomNavigationBar: DetailBottomBar( + children: [ + DetailActionButton( + icon: const Icon(Icons.favorite_outline_rounded), + label: 'Likes with a long label', + onTap: () => taps++, + ), + DetailActionButton( + icon: const Icon(Icons.thumb_up_outlined), + label: 'Recommendations', + onTap: () => taps++, + ), + DetailActionButton( + icon: const Icon(Icons.comment_outlined), + label: '123456 comments', + onTap: () => taps++, + ), + DetailActionButton( + icon: const Icon(Icons.star_border_rounded), + label: 'Favorites with a very long label', + onTap: () => taps++, + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(DetailActionButton), findsNWidgets(4)); + expect( + tester.getSize(find.byType(DetailBottomBar)).height, + greaterThanOrEqualTo(88), + ); + await tester.tap(find.text('Favorites with a very long label')); + await tester.pump(); + expect(taps, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('reading content keeps a comfortable measure on every width', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + buildApp( + Scaffold( + body: Builder( + builder: (context) => PostContentSection( + title: 'A deliberately long article title', + content: '

Reading body

', + url: 'https://yyxzzhxb.lofter.com/post/1ece729f_2b4802463', + style: context.design.typography.readingBody, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(LoftifyReadingFrame), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('loftify-reading-frame'))).width, + 768, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('loftify-reading-content'))) + .width, + 720, + ); + final html = tester.widget(find.byType(CustomHtmlWidget)); + expect(html.url, 'https://yyxzzhxb.lofter.com/post/1ece729f_2b4802463'); + expect(html.style?.fontSize, 17); + expect(html.style?.height, 1.8); + expect(html.heightDelta, 0); + expect(html.letterSpacingDelta, 0); + + await tester.binding.setSurfaceSize(const Size(320, 568)); + await tester.pump(); + expect( + tester.getSize(find.byKey(const ValueKey('loftify-reading-frame'))).width, + 320, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('loftify-reading-content'))) + .width, + 296, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('floating detail actions use the raised token surface', + (tester) async { + await tester.pumpWidget( + buildApp( + const Scaffold( + body: Center( + child: DetailFloatingActionRail( + children: [ + DetailActionButton( + icon: Icon(Icons.favorite_outline_rounded), + label: 'Like', + ), + DetailActionButton( + icon: Icon(Icons.comment_outlined), + label: 'Comment', + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + final surface = tester.widget( + find.byKey(const ValueKey('detail-floating-action-rail')), + ); + final decoration = surface.decoration as BoxDecoration; + final design = LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ).extension()!; + expect(decoration.color, design.colors.surfaceRaised); + expect(decoration.borderRadius, BorderRadius.circular(design.radii.panel)); + expect(decoration.border!.top.color, design.colors.outline); + expect(decoration.border!.top.width, design.borders.hairline); + expect(decoration.boxShadow, design.shadows.floating); + expect(find.byType(DetailActionButton), findsNWidgets(2)); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + buildApp( + const Scaffold( + body: Center( + child: DetailFloatingActionRail( + children: [ + DetailActionButton( + icon: Icon(Icons.favorite_outline_rounded), + label: 'Like', + ), + ], + ), + ), + ), + dark: true, + ), + ); + await tester.pump(); + final darkDecoration = tester + .widget( + find.byKey(const ValueKey('detail-floating-action-rail')), + ) + .decoration as BoxDecoration; + final darkDesign = LoftifyTheme.build( + ChewieThemeColorData.defaultDarkThemes.first, + ).extension()!; + expect(darkDecoration.color, darkDesign.colors.surfaceRaised); + expect(darkDecoration.border!.top.color, darkDesign.colors.outline); + expect(darkDecoration.boxShadow, darkDesign.shadows.floating); + expect(tester.takeException(), isNull); + }); + + testWidgets('long comments and nested replies keep a bounded hierarchy', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var taps = 0; + + Widget item({required bool nested, List replies = const []}) { + return CommentItem( + nested: nested, + onTap: () => taps++, + avatar: CircleAvatar(radius: nested ? 14 : 19), + header: Row( + children: [ + const Expanded( + child: Text( + 'An exceptionally long author name that must stay bounded', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.all(2), + child: const Text('Author'), + ), + ], + ), + content: const Text( + 'A long comment body that wraps over several lines without pushing ' + 'the like action or nested replies outside the card boundary.', + ), + metadata: const Wrap( + spacing: 4, + children: [ + Text('2026-08-23 12:00:00'), + Text('·'), + Text('A very long IP location'), + ], + ), + trailing: const Icon(Icons.favorite_border_rounded, size: 18), + replies: replies, + ); + } + + await tester.pumpWidget( + buildApp( + Scaffold( + body: ListView( + children: [ + item(nested: false, replies: [item(nested: true)]), + ], + ), + ), + ), + ); + + expect(find.byType(CommentItem), findsNWidgets(2)); + expect(find.byType(ContainerItem), findsNothing); + expect( + tester.getCenter(find.textContaining('An exceptionally').first).dy, + closeTo(tester.getCenter(find.byType(CircleAvatar).first).dy, 3), + ); + await tester.tap(find.textContaining('A long comment body').first); + await tester.pump(); + expect(taps, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('reply target is placed below the vertically centered nickname', + (tester) async { + final reply = Comment.fromJson({ + 'id': 9, + 'content': 'Nested reply body', + 'publisherBlogInfo': { + 'blogId': 2, + 'blogName': 'alice', + 'blogNickName': 'Alice', + }, + 'replyBlogInfo': { + 'blogId': 3, + 'blogName': 'bob', + 'blogNickName': 'Bob', + }, + }); + + await tester.pumpWidget( + buildApp( + Scaffold( + body: Builder( + builder: (context) => LoftifyItemBuilder.buildL2CommentRow( + context, + reply, + writerId: 1, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Reply to Bob'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Reply to Bob')).dy, + greaterThan(tester.getTopLeft(find.text('Alice')).dy), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('article parsing failure remains local and retryable', + (tester) async { + var attempts = 0; + String failingExtractor(String _) { + attempts++; + throw const FormatException('broken article'); + } + + await tester.pumpWidget( + buildApp( + Scaffold( + body: Column( + children: [ + const Text('Author information remains visible'), + PostContentSection( + title: 'Broken article', + content: '', + textExtractor: failingExtractor, + ), + const Text('Recommendations remain visible'), + ], + ), + ), + ), + ); + + expect(find.text('Author information remains visible'), findsOneWidget); + expect(find.text('Recommendations remain visible'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(attempts, 1); + + await tester.tap(find.text('Retry')); + await tester.pump(); + expect(attempts, 2); + expect(tester.takeException(), isNull); + }); + + testWidgets('article extraction is cached across unrelated parent rebuilds', + (tester) async { + var attempts = 0; + var content = '

First version

'; + late StateSetter rebuildParent; + String extractor(String value) { + attempts++; + return value; + } + + await tester.pumpWidget( + buildApp( + StatefulBuilder( + builder: (context, setState) { + rebuildParent = setState; + return PostContentSection( + title: 'Cached article', + content: content, + textExtractor: extractor, + ); + }, + ), + ), + ); + expect(attempts, 1); + + rebuildParent(() {}); + await tester.pump(); + expect(attempts, 1); + + content = '

Second version

'; + rebuildParent(() {}); + await tester.pump(); + expect(attempts, 2); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'post download action reports determinate progress without Lottie', + (tester) async { + for (final dark in [false, true]) { + await tester.pumpWidget( + buildApp( + const Center( + child: PostDownloadActionIcon( + state: DownloadState.loading, + progress: 0.42, + semanticLabel: 'Downloading', + ), + ), + dark: dark, + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(PostDownloadActionIcon)), + const Size.square(PostDownloadActionIcon.visualSize), + ); + final progress = tester.widget( + find.byType(CircularProgressIndicator), + ); + expect(progress.value, closeTo(0.42, 0.001)); + expect(progress.strokeWidth, PostDownloadActionIcon.progressStrokeWidth); + expect(tester.getSize(find.byType(CircularProgressIndicator)), + const Size.square(18)); + expect(find.byType(ChewieIcon), findsNothing); + expect( + find.byKey(const ValueKey('post-download-progress')), findsOneWidget); + final semantics = tester.getSemantics( + find.byKey(const ValueKey('post-download-semantics')), + ); + expect(semantics.label, 'Downloading'); + expect(semantics.value, '42%'); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('post download action keeps every terminal state in one viewport', + (tester) async { + const cases = { + DownloadState.none: ValueKey('post-download-idle'), + DownloadState.succeed: ValueKey('post-download-success'), + DownloadState.failed: ValueKey('post-download-failed'), + }; + for (final entry in cases.entries) { + await tester.pumpWidget( + buildApp( + Center( + child: PostDownloadActionIcon( + state: entry.key, + semanticLabel: entry.key.name, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(entry.value), findsOneWidget); + expect( + tester.getSize(find.byType(PostDownloadActionIcon)), + const Size.square(PostDownloadActionIcon.visualSize), + ); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(tester.takeException(), isNull); + } + }); +} diff --git a/test/widgets/post_detail/lazy_comment_jump_test.dart b/test/widgets/post_detail/lazy_comment_jump_test.dart new file mode 100644 index 00000000..0f885380 --- /dev/null +++ b/test/widgets/post_detail/lazy_comment_jump_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/PostDetail/lazy_comment_jump.dart'; + +void main() { + for (final startInRecommendations in [false, true]) { + testWidgets( + 'comment jump finds lazy heading from ${startInRecommendations ? 'recommendations' : 'article'}', + (tester) async { + final controller = ScrollController(); + final anchorKey = GlobalKey(); + final contentKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + controller: controller, + slivers: [ + SliverList.list( + key: contentKey, + children: [ + const SizedBox(height: 2200, child: Text('Article')), + SizedBox( + key: anchorKey, + height: 56, + child: const Text('Comments'), + ), + const SizedBox(height: 500, child: Text('Replies')), + const SizedBox(height: 56, child: Text('More')), + ], + ), + SliverList.builder( + itemCount: 60, + itemBuilder: (_, index) => SizedBox( + height: 100, + child: Text('Recommendation $index'), + ), + ), + ], + ), + ), + ), + ); + + controller.jumpTo(startInRecommendations ? 4300 : 100); + await tester.pump(); + expect(anchorKey.currentContext, isNull); + final contentSliver = + contentKey.currentContext!.findRenderObject()! as RenderSliver; + final jump = revealLazyComment( + controller: controller, + anchorKey: anchorKey, + contentExtent: contentSliver.geometry!.scrollExtent, + isActive: () => true, + ); + for (var frame = 0; frame < 50; frame++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await jump; + + expect(anchorKey.currentContext, isNotNull); + final heading = tester.getRect(find.text('Comments')); + expect(heading.top, greaterThanOrEqualTo(0)); + expect(heading.top, lessThan(tester.view.physicalSize.height)); + expect(tester.takeException(), isNull); + controller.dispose(); + }, + ); + } +} diff --git a/test/widgets/post_detail/post_archive_grid_test.dart b/test/widgets/post_detail/post_archive_grid_test.dart new file mode 100644 index 00000000..16033168 --- /dev/null +++ b/test/widgets/post_detail/post_archive_grid_test.dart @@ -0,0 +1,120 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/PostItem/loftify_post_archive_grid.dart'; + +void main() { + testWidgets('archive grid reports the true cell after horizontal padding', + (tester) async { + final extents = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 320, + child: LoftifyPostArchiveGrid( + itemCount: 3, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + itemBuilder: (context, index, tileExtent) { + extents.add(tileExtent); + return ColoredBox( + key: ValueKey('archive-tile-$index'), + color: Colors.teal, + ); + }, + ), + ), + ), + ), + ); + + final expected = (320 - 24 - 12) / 3; + expect(extents, isNotEmpty); + expect( + extents.every((extent) => (extent - expected).abs() < 0.001), isTrue); + expect( + tester.getSize(find.byKey(const ValueKey('archive-tile-0'))), + Size.square(expected), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('archive sliver grid reports the true cell after padding', + (tester) async { + final extents = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 320, + child: CustomScrollView( + slivers: [ + LoftifyPostArchiveSliverGrid( + itemCount: 3, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + itemBuilder: (context, index, tileExtent) { + extents.add(tileExtent); + return ColoredBox( + key: ValueKey('archive-sliver-tile-$index'), + color: Colors.teal, + ); + }, + ), + ], + ), + ), + ), + ), + ); + + final expected = (320 - 24 - 12) / 3; + expect(extents, isNotEmpty); + expect( + extents.every((extent) => (extent - expected).abs() < 0.001), isTrue); + expect( + tester.getSize(find.byKey(const ValueKey('archive-sliver-tile-0'))), + Size.square(expected), + ); + expect(tester.takeException(), isNull); + }); + + test('all archive page families use the shared real-width grid', () { + const expectedGridByPath = { + 'lib/Widgets/BottomSheet/collection_bottom_sheet.dart': + 'LoftifyPostArchiveGrid(', + 'lib/Screens/Info/post_screen.dart': 'LoftifyPostArchiveSliverGrid(', + 'lib/Screens/Info/share_screen.dart': 'LoftifyPostArchiveSliverGrid(', + 'lib/Screens/Post/grain_detail_screen.dart': 'LoftifyPostArchiveGrid(', + 'lib/Screens/Post/collection_detail_screen.dart': + 'LoftifyPostArchiveGrid(', + 'lib/Screens/Info/favorite_folder_detail_screen.dart': + 'LoftifyPostArchiveSliverGrid(', + 'lib/Screens/Info/like_screen.dart': 'LoftifyPostArchiveSliverGrid(', + 'lib/Screens/Info/history_screen.dart': 'LoftifyPostArchiveSliverGrid(', + }; + for (final MapEntry(key: path, value: expectedGrid) + in expectedGridByPath.entries) { + final source = File(path).readAsStringSync(); + expect(source, contains(expectedGrid), reason: path); + expect(source, contains('wh: tileExtent'), reason: path); + expect(source, isNot(contains('GridView.extent(')), reason: path); + expect(source, isNot(contains('maxCrossAxisExtent: 160')), reason: path); + expect(source, isNot(contains('wh: 160')), reason: path); + } + }); + + test('tag result grid shares adaptive square archive columns', () { + final source = + File('lib/Screens/Post/tag_detail_screen.dart').readAsStringSync(); + expect('_buildTagPostGrid('.allMatches(source), hasLength(4)); + expect(source, contains('LoftifyArchiveGridGeometry.calculate(')); + expect(source, contains('wh: geometry.tileExtent')); + expect(source, contains('crossAxisCount: geometry.columnCount')); + + expect(LoftifyArchiveGridGeometry.calculate(288).columnCount, 3); + expect(LoftifyArchiveGridGeometry.calculate(361).columnCount, 3); + expect(LoftifyArchiveGridGeometry.calculate(792).columnCount, 4); + expect(LoftifyArchiveGridGeometry.calculate(1152).columnCount, 7); + }); +} diff --git a/test/widgets/post_detail/post_swipe_gesture_detector_test.dart b/test/widgets/post_detail/post_swipe_gesture_detector_test.dart new file mode 100644 index 00000000..61fd8bb9 --- /dev/null +++ b/test/widgets/post_detail/post_swipe_gesture_detector_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/PostDetail/post_swipe_gesture_detector.dart'; + +Widget _host(Widget child) => MaterialApp( + home: Scaffold(body: child), + ); + +void main() { + testWidgets('interactive horizontal child owns drags that start inside it', ( + tester, + ) async { + final excludedKey = GlobalKey(); + var outerUpdates = 0; + var innerUpdates = 0; + await tester.pumpWidget( + _host( + PostSwipeGestureDetector( + excludedRegions: [excludedKey], + onHorizontalDragUpdate: (_) => outerUpdates++, + child: Center( + child: GestureDetector( + key: excludedKey, + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (_) => innerUpdates++, + child: const SizedBox(width: 220, height: 180), + ), + ), + ), + ), + ); + + await tester.drag(find.byKey(excludedKey), const Offset(-150, 0)); + + expect(innerUpdates, greaterThan(0)); + expect(outerUpdates, 0); + }); + + testWidgets('post surface owns horizontal drags outside excluded children', ( + tester, + ) async { + final excludedKey = GlobalKey(); + var outerUpdates = 0; + var innerUpdates = 0; + await tester.pumpWidget( + _host( + PostSwipeGestureDetector( + excludedRegions: [excludedKey], + onHorizontalDragUpdate: (_) => outerUpdates++, + child: Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: Colors.white)), + Align( + alignment: Alignment.topCenter, + child: GestureDetector( + key: excludedKey, + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (_) => innerUpdates++, + child: const SizedBox(width: 220, height: 180), + ), + ), + ], + ), + ), + ), + ); + + await tester.dragFrom(const Offset(80, 500), const Offset(180, 0)); + + expect(outerUpdates, greaterThan(0)); + expect(innerUpdates, 0); + }); + + testWidgets('diagonal post swipe reports consistent horizontal drag details', + (tester) async { + final updates = []; + DragEndDetails? end; + await tester.pumpWidget(_host(PostSwipeGestureDetector( + onHorizontalDragUpdate: updates.add, + onHorizontalDragEnd: (details) => end = details, + child: const SizedBox.expand(), + ))); + + await tester.dragFrom(const Offset(80, 500), const Offset(180, 40)); + + expect(updates, isNotEmpty); + for (final update in updates) { + expect(update.delta.dy, 0); + expect(update.primaryDelta, update.delta.dx); + } + expect(end, isNotNull); + expect(end!.velocity.pixelsPerSecond.dy, 0); + expect(end!.primaryVelocity, end!.velocity.pixelsPerSecond.dx); + expect(tester.takeException(), isNull); + }); + + testWidgets('screen edge remains reserved for post navigation', ( + tester, + ) async { + final excludedKey = GlobalKey(); + var outerUpdates = 0; + var innerUpdates = 0; + await tester.pumpWidget( + _host( + PostSwipeGestureDetector( + excludedRegions: [excludedKey], + onHorizontalDragUpdate: (_) => outerUpdates++, + child: GestureDetector( + key: excludedKey, + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (_) => innerUpdates++, + child: const SizedBox.expand(), + ), + ), + ), + ); + + await tester.dragFrom(const Offset(8, 500), const Offset(180, 0)); + + expect(outerUpdates, greaterThan(0)); + expect(innerUpdates, 0); + }); + + testWidgets('selectable article text does not block post swipes', ( + tester, + ) async { + var outerUpdates = 0; + await tester.pumpWidget( + _host( + PostSwipeGestureDetector( + onHorizontalDragUpdate: (_) => outerUpdates++, + child: const SelectionArea( + child: SizedBox.expand( + child: Text( + 'Long-form article content remains selectable while a ' + 'deliberate horizontal drag switches between posts.', + ), + ), + ), + ), + ), + ); + + await tester.dragFrom(const Offset(8, 500), const Offset(180, 0)); + + expect(outerUpdates, greaterThan(0)); + }); + + testWidgets('selectable HTML text accepts swipes from the middle', + (tester) async { + var outerUpdates = 0; + await tester.pumpWidget( + _host( + PostSwipeGestureDetector( + onHorizontalDragUpdate: (_) => outerUpdates++, + child: const SelectionArea( + child: Center( + child: Text('Selectable post body content'), + ), + ), + ), + ), + ); + + await tester.drag( + find.text('Selectable post body content'), const Offset(-180, 0)); + expect(outerUpdates, greaterThan(0)); + }); + + testWidgets('vertical scrolling still wins the gesture arena', ( + tester, + ) async { + final controller = ScrollController(); + var outerUpdates = 0; + await tester.pumpWidget( + _host( + PostSwipeGestureDetector( + onHorizontalDragUpdate: (_) => outerUpdates++, + child: ListView.builder( + controller: controller, + itemExtent: 80, + itemCount: 40, + itemBuilder: (context, index) => Text('Item $index'), + ), + ), + ), + ); + + await tester.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); + + expect(controller.offset, greaterThan(0)); + expect(outerUpdates, 0); + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); +} diff --git a/test/widgets/post_item/general_post_item_test.dart b/test/widgets/post_item/general_post_item_test.dart new file mode 100644 index 00000000..3dea8cd6 --- /dev/null +++ b/test/widgets/post_item/general_post_item_test.dart @@ -0,0 +1,487 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Models/post_detail_response.dart'; +import 'package:loftify/Models/recommend_response.dart'; +import 'package:loftify/Utils/enums.dart'; +import 'package:loftify/Widgets/Design/loftify_surfaces.dart'; +import 'package:loftify/Widgets/PostItem/general_post_item_builder.dart'; +import 'package:loftify/Widgets/PostItem/recommend_flow_item_builder.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + late Directory hiveDirectory; + + setUpAll(() async { + hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/general_post_item', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + }); + + PhotoLink buildPhoto({ + int width = 120, + int height = 120, + String url = 'https://invalid.localhost/post.png', + }) { + return PhotoLink( + orign: url, + raw: url, + small: url, + middle: url, + rw: width, + rh: height, + ow: width, + oh: height, + ); + } + + GeneralPostItem buildArticle({ + PostType type = PostType.article, + List photoLinks = const [], + String title = 'Title', + String digest = 'Content', + String content = 'Content', + List tags = const [], + }) { + return GeneralPostItem( + type: type, + photoLinks: photoLinks, + blogId: 1, + postId: 2, + permalink: '', + collectionId: 0, + liked: false, + blogName: 'tester', + blogNickName: 'Tester', + title: title, + digest: digest, + content: content, + firstImageUrl: '', + duration: 0, + likeCount: 0, + tags: tags, + bigAvaImg: '', + showLikeButton: false, + ); + } + + Widget buildHost(Widget child, + {Locale? locale, bool dark = false, double textScale = 1}) { + return MaterialApp( + locale: locale, + theme: dark ? ThemeData.dark() : ThemeData.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + navigatorKey: chewieProvider.globalNavigatorKey, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold(body: child); + }, + ), + ); + } + + for (final extent in [80.0, 120.0, 160.0]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final dark in [false, true]) { + testWidgets('invalid grid fits $extent $locale dark=$dark', + (tester) async { + await tester.pumpWidget(buildHost( + Center( + child: SizedBox.square( + dimension: extent, + child: GridPostItemWidget( + item: buildArticle(type: PostType.invalid), + wh: extent))), + locale: locale, + dark: dark, + textScale: 2, + )); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(find.byType(ContainerItem), findsOneWidget); + final label = AppLocalizations.of( + tester.element(find.byType(GridPostItemWidget)))! + .invalidContent; + expect(find.text(label), findsOneWidget); + final textRect = tester.getRect(find.text(label)); + final cardRect = tester.getRect(find.byType(GridPostItemWidget)); + expect(textRect.top, greaterThanOrEqualTo(cardRect.top)); + expect(textRect.bottom, lessThanOrEqualTo(cardRect.bottom)); + final paragraph = tester.renderObject( + find.descendant( + of: find.text(label), matching: find.byType(RichText)), + ); + for (final box in paragraph.getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: label.length))) { + expect(box.bottom, lessThanOrEqualTo(paragraph.size.height + 0.01)); + } + }); + } + } + } + + testWidgets('waterfall text posts keep all four rounded corners', + (tester) async { + await tester.pumpWidget( + buildHost(WaterfallFlowPostItemWidget(item: buildArticle())), + ); + await tester.pumpAndSettle(); + + final card = tester.widget(find.byType(LoftifyCard)); + expect(card.variant, LoftifyCardVariant.muted); + final decoration = tester + .widget( + find.descendant( + of: find.byType(LoftifyCard), + matching: find.byType(AnimatedContainer), + ), + ) + .decoration! as BoxDecoration; + expect(decoration.borderRadius, BorderRadius.circular(14)); + expect(find.byType(InkWell), findsNothing); + expect( + find.ancestor( + of: find.text('Title'), + matching: find.byType(LoftifyCard), + ), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text('Tester'), + matching: find.byType(LoftifyCard), + ), + findsNothing, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('grid text posts keep all four rounded corners', (tester) async { + await tester.pumpWidget( + buildHost( + GridPostItemWidget( + item: buildArticle(), + wh: 160, + ), + ), + ); + await tester.pumpAndSettle(); + + final card = tester.widget(find.byType(ContainerItem)); + expect(card.roundTop, isTrue); + expect(card.roundBottom, isTrue); + expect(card.radius, 12); + expect(tester.takeException(), isNull); + }); + + testWidgets('missing image media falls back to the text card safely', + (tester) async { + await tester.pumpWidget( + buildHost( + WaterfallFlowPostItemWidget( + item: buildArticle(type: PostType.image), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(LoftifyCard), findsOneWidget); + expect(find.text('Title'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('missing media without text shows the same invalid card', + (tester) async { + final invalidMedia = buildArticle( + type: PostType.image, + title: '', + digest: '', + content: '', + ); + final layouts = [ + WaterfallFlowPostItemWidget(item: invalidMedia), + GridPostItemWidget(item: invalidMedia, wh: 160), + TilePostItemWidget(item: invalidMedia), + ]; + + for (var index = 0; index < layouts.length; index++) { + final layout = layouts[index]; + await tester.pumpWidget(buildHost(layout)); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is ChewieIcon && + widget.icon == LoftifyIcons.invalidContent, + ), + findsOneWidget, + ); + final card = tester.widget(find.byType(ContainerItem)); + expect(card.roundTop, isTrue); + expect(card.roundBottom, isTrue); + expect(card.radius, index == 0 ? 14 : 12); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('malformed recommendation remains a visible fallback card', + (tester) async { + final malformed = PostListItem( + favorite: false, + following: false, + groupInfo: null, + itemId: 42, + itemType: 1, + postCollection: null, + ); + + final generalItem = RecommendFlowItemBuilder.getGeneralPostItem(malformed); + expect(generalItem.type, PostType.invalid); + expect(generalItem.postId, 42); + + await tester.pumpWidget( + buildHost( + Builder( + builder: (context) => + RecommendFlowItemBuilder.buildWaterfallFlowPostItem( + context, + malformed, + ), + ), + ), + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is ChewieIcon && widget.icon == LoftifyIcons.invalidContent, + ), + findsOneWidget, + ); + expect(find.byKey(const ValueKey(42)), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('long titles and empty tags stay bounded in every card layout', + (tester) async { + final longTitle = List.filled(80, 'LongTitle').join(' '); + final sparsePost = buildArticle( + title: longTitle, + digest: '', + content: '', + tags: const [], + ); + + await tester.pumpWidget( + buildHost(WaterfallFlowPostItemWidget(item: sparsePost)), + ); + await tester.pump(); + expect(tester.widget(find.text(longTitle)).maxLines, 3); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + buildHost(GridPostItemWidget(item: sparsePost, wh: 160)), + ); + await tester.pump(); + expect(tester.widget(find.textContaining('LongTitle')).maxLines, 2); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + buildHost( + SingleChildScrollView( + child: TilePostItemWidget(item: sparsePost, isFirst: true), + ), + ), + ); + await tester.pump(); + expect(find.text(longTitle), findsOneWidget); + expect(find.byType(SelectionArea), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('bad ratios and failed image loads keep finite card geometry', + (tester) async { + final badPhoto = buildPhoto(width: 0, height: -1); + final mediaPost = buildArticle( + type: PostType.image, + photoLinks: [badPhoto], + title: '', + digest: '', + content: '', + ); + final layouts = [ + WaterfallFlowPostItemWidget(item: mediaPost), + GridPostItemWidget(item: mediaPost, wh: 160), + SingleChildScrollView( + child: TilePostItemWidget(item: mediaPost), + ), + ]; + + for (final layout in layouts) { + await tester.pumpWidget(buildHost(layout)); + await tester.pump(const Duration(milliseconds: 100)); + + final size = tester.getSize(find.byWidget(layout)); + expect(size.width.isFinite, isTrue); + expect(size.height.isFinite, isTrue); + expect(size.width, greaterThan(0)); + expect(size.height, greaterThan(0)); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('waterfall media geometry follows its real grid column width', ( + tester, + ) async { + final mediaPost = buildArticle( + type: PostType.image, + photoLinks: [buildPhoto(width: 200, height: 200)], + title: 'Square work', + ); + + await tester.pumpWidget( + buildHost( + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 220, + child: WaterfallFlowPostItemWidget(item: mediaPost), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + tester.getSize( + find.byKey(const ValueKey('waterfall-post-media-2')), + ), + const Size(220, 220), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('card state follows replacement items during layout reuse', + (tester) async { + final first = buildArticle(title: 'First card'); + final second = buildArticle(title: 'Second card'); + + await tester.pumpWidget( + buildHost( + WaterfallFlowPostItemWidget( + key: const ValueKey('waterfall-card'), + item: first, + ), + ), + ); + await tester.pumpWidget( + buildHost( + WaterfallFlowPostItemWidget( + key: const ValueKey('waterfall-card'), + item: second, + ), + ), + ); + await tester.pump(); + expect(find.text('Second card'), findsOneWidget); + expect(find.text('First card'), findsNothing); + + await tester.pumpWidget( + buildHost( + GridPostItemWidget( + key: const ValueKey('grid-card'), + item: first, + wh: 160, + ), + ), + ); + await tester.pumpWidget( + buildHost( + GridPostItemWidget( + key: const ValueKey('grid-card'), + item: second, + wh: 160, + ), + ), + ); + await tester.pump(); + expect(find.textContaining('Second card'), findsOneWidget); + expect(find.textContaining('First card'), findsNothing); + + await tester.pumpWidget( + buildHost( + SingleChildScrollView( + child: TilePostItemWidget( + key: const ValueKey('tile-card'), + item: first, + ), + ), + ), + ); + await tester.pumpWidget( + buildHost( + SingleChildScrollView( + child: TilePostItemWidget( + key: const ValueKey('tile-card'), + item: second, + ), + ), + ), + ); + await tester.pump(); + expect(find.text('Second card'), findsOneWidget); + expect(find.text('First card'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('first and following list cards retain rounded tap boundaries', + (tester) async { + await tester.pumpWidget( + buildHost( + SingleChildScrollView( + child: Column( + children: [ + TilePostItemWidget(item: buildArticle(), isFirst: true), + TilePostItemWidget(item: buildArticle(title: 'Following card')), + ], + ), + ), + ), + ); + await tester.pump(); + + for (final widget in tester.widgetList( + find.byType(TilePostItemWidget), + )) { + final cardFinder = find.descendant( + of: find.byWidget(widget), + matching: find.byType(InkWell), + ); + final roundedInkWell = tester.widgetList(cardFinder).where( + (inkWell) => inkWell.borderRadius == BorderRadius.circular(12)); + expect(roundedInkWell, isNotEmpty); + } + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/post_item/image_grid_test.dart b/test/widgets/post_item/image_grid_test.dart new file mode 100644 index 00000000..475634c0 --- /dev/null +++ b/test/widgets/post_item/image_grid_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/PostItem/image_grid.dart'; + +void main() { + Widget buildGrid( + int count, { + List? ratios, + void Function(int index, BorderRadius radius)? onBuildItem, + }) { + return MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 360, + child: ImageGrid( + itemCount: count, + ratios: ratios ?? List.filled(count, 1), + itemBuilder: (context, index, radius) { + onBuildItem?.call(index, radius); + return ClipRRect( + key: ValueKey('image-$index'), + borderRadius: radius, + child: ColoredBox( + color: Colors.green.shade100, + child: const SizedBox.expand(), + ), + ); + }, + ), + ), + ), + ), + ); + } + + testWidgets('renders and updates layouts for one through nine images', + (tester) async { + for (var count = 1; count <= 9; count++) { + await tester.pumpWidget(buildGrid(count)); + await tester.pump(); + + expect(tester.takeException(), isNull, reason: '$count image layout'); + for (var index = 0; index < count; index++) { + expect(find.byKey(ValueKey('image-$index')), findsOneWidget); + } + } + }); + + testWidgets('falls back safely for missing and invalid image ratios', + (tester) async { + await tester.pumpWidget( + buildGrid( + 9, + ratios: const [double.nan, 0, -1, double.infinity], + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + for (var index = 0; index < 9; index++) { + expect(find.byKey(ValueKey('image-$index')), findsOneWidget); + } + }); + + testWidgets('one through nine image layouts preserve every outer corner', + (tester) async { + for (var count = 1; count <= 9; count++) { + final radii = {}; + await tester.pumpWidget( + buildGrid( + count, + onBuildItem: (index, radius) => radii[index] = radius, + ), + ); + await tester.pump(); + + expect(radii.length, count, reason: '$count image radii'); + expect( + radii.values.where((radius) => radius.topLeft.x > 0), + hasLength(1), + reason: '$count image top-left corner', + ); + expect( + radii.values.where((radius) => radius.topRight.x > 0), + hasLength(1), + reason: '$count image top-right corner', + ); + expect( + radii.values.where((radius) => radius.bottomLeft.x > 0), + hasLength(1), + reason: '$count image bottom-left corner', + ); + expect( + radii.values.where((radius) => radius.bottomRight.x > 0), + hasLength(1), + reason: '$count image bottom-right corner', + ); + + final size = tester.getSize(find.byType(ImageGrid)); + expect(size.width, lessThanOrEqualTo(360)); + expect(size.height, greaterThan(0)); + expect(size.height.isFinite, isTrue); + expect(tester.takeException(), isNull, reason: '$count image layout'); + } + }); + + testWidgets( + 'layout updates keep finite geometry when count and ratios change', + (tester) async { + await tester.pumpWidget( + buildGrid(7, ratios: const [0.2, 4, 1, 1, 1, 1, 1]), + ); + await tester.pump(); + final sevenImageSize = tester.getSize(find.byType(ImageGrid)); + + await tester.pumpWidget( + buildGrid( + 8, + ratios: const [double.nan, 0, -1, double.infinity], + ), + ); + await tester.pump(); + final eightImageSize = tester.getSize(find.byType(ImageGrid)); + + expect(sevenImageSize.width, eightImageSize.width); + expect(sevenImageSize.height.isFinite, isTrue); + expect(eightImageSize.height.isFinite, isTrue); + expect(find.byKey(const ValueKey('image-7')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/post_item/my_cached_network_image_test.dart b/test/widgets/post_item/my_cached_network_image_test.dart new file mode 100644 index 00000000..c9b5b63b --- /dev/null +++ b/test/widgets/post_item/my_cached_network_image_test.dart @@ -0,0 +1,108 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:awesome_chewie/src/Widgets/Component/auto_image.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('pending image retries are cancelled after disposal', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: MyCachedNetworkImage( + imageUrl: 'https://invalid.localhost/image.png', + width: 120, + height: 120, + simpleError: true, + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pump(const Duration(seconds: 2)); + + expect(tester.takeException(), isNull); + }); + + testWidgets('network thumbnails decode to width without changing aspect', + (tester) async { + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox( + width: 120, + height: 80, + child: MyCachedNetworkImage( + imageUrl: 'https://invalid.localhost/thumbnail.png', + fit: BoxFit.cover, + ), + ), + ), + ), + ); + + final image = tester.widget( + find.byType(CachedNetworkImage), + ); + expect(image.memCacheWidth, 360); + expect(image.memCacheHeight, isNull); + expect(image.filterQuality, FilterQuality.medium); + }); + + testWidgets('explicit memory cache width preserves the source aspect ratio', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: MyCachedNetworkImage( + imageUrl: 'https://invalid.localhost/thumbnail.png', + width: 120, + height: 80, + memCacheWidth: 240, + memCacheHeight: 160, + ), + ), + ); + + final image = tester.widget( + find.byType(CachedNetworkImage), + ); + expect(image.memCacheWidth, 240); + expect(image.memCacheHeight, isNull); + }); + + testWidgets('auto image updates reused items and recognizes query URLs', + (tester) async { + Widget buildImage(String url) => MaterialApp( + home: AutoImage( + imageUrl: url, + showLoading: false, + ), + ); + + await tester.pumpWidget( + buildImage('https://invalid.localhost/first.png?quality=small'), + ); + await tester.pump(); + expect( + tester + .widget(find.byType(CachedNetworkImage)) + .imageUrl, + contains('first.png'), + ); + + await tester.pumpWidget( + buildImage('https://invalid.localhost/second.webp?quality=middle'), + ); + await tester.pump(); + expect( + tester + .widget(find.byType(CachedNetworkImage)) + .imageUrl, + contains('second.webp'), + ); + }); +} diff --git a/test/widgets/profile/profile_header_components_test.dart b/test/widgets/profile/profile_header_components_test.dart new file mode 100644 index 00000000..36718047 --- /dev/null +++ b/test/widgets/profile/profile_header_components_test.dart @@ -0,0 +1,316 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Design/loftify_media_overlays.dart'; +import 'package:loftify/Widgets/Profile/profile_header_components.dart'; +import 'package:loftify/Widgets/Profile/profile_overview_card.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/profile_header_components', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('identity grows safely for narrow two-times text', + (tester) async { + var descriptionTaps = 0; + var displayNameCopies = 0; + var idCopies = 0; + await tester.pumpWidget( + _TestApp( + width: 280, + textScaler: const TextScaler.linear(2), + child: ColoredBox( + color: Colors.black87, + child: Padding( + padding: const EdgeInsets.all(12), + child: LoftifyProfileIdentity( + avatar: const SizedBox.square( + dimension: 72, + child: ColoredBox(color: Colors.white), + ), + displayName: 'A very long localized creator display name', + idLabel: 'ID: an_equally_long_creator_identifier', + metadata: 'Gender: private · Location: a long place name', + descriptionLabel: 'Read full introduction', + onDescriptionPressed: () => descriptionTaps++, + onDisplayNameLongPress: () => displayNameCopies++, + onIdLongPress: () => idCopies++, + trailing: const Icon(LoftifyIcons.moreVertical), + ), + ), + ), + ), + ); + await tester.pump(); + + final identity = find.byType(LoftifyProfileIdentity); + expect(tester.getSize(identity).height, greaterThan(150)); + await tester.tap(find.text('Read full introduction')); + await tester.longPress( + find.text('A very long localized creator display name'), + ); + await tester.longPress( + find.text('ID: an_equally_long_creator_identifier'), + ); + expect(descriptionTaps, 1); + expect(displayNameCopies, 1); + expect(idCopies, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('cover scrim keeps profile text readable on a white cover', + (tester) async { + await tester.pumpWidget( + _TestApp( + width: 320, + child: Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: Colors.white)), + const Positioned.fill(child: LoftifyCoverScrim()), + Padding( + padding: const EdgeInsets.all(12), + child: LoftifyProfileIdentity( + avatar: const SizedBox.square(dimension: 64), + displayName: 'Creator', + idLabel: 'ID: bright_cover', + metadata: 'Location: white artwork', + ), + ), + ], + ), + ), + ); + await tester.pump(); + + final scrim = tester.widget( + find.byKey(const ValueKey('loftify-cover-scrim')), + ); + final gradient = + (scrim.decoration as BoxDecoration).gradient! as LinearGradient; + final weakestBackground = Color.alphaBlend( + gradient.colors.first, + Colors.white, + ); + final primary = tester.widget(find.text('Creator')).style!.color!; + final secondary = + tester.widget(find.text('ID: bright_cover')).style!.color!; + + expect( + _contrastRatio(primary, weakestBackground), greaterThanOrEqualTo(4.5)); + expect( + _contrastRatio( + Color.alphaBlend(secondary, weakestBackground), + weakestBackground, + ), + greaterThanOrEqualTo(4.5), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('primary follow action remains content-sized and clickable', + (tester) async { + var taps = 0; + for (final dark in [false, true]) { + await tester.pumpWidget( + _TestApp( + width: 320, + dark: dark, + child: LoftifyProfileAction( + label: 'Follow this creator', + icon: LoftifyIcons.follow, + emphasized: true, + onPressed: () => taps++, + ), + ), + ); + await tester.pump(); + + final action = find.byKey(const ValueKey('loftify-profile-action')); + expect(tester.getSize(action).height, greaterThanOrEqualTo(48)); + final design = LoftifyDesignThemeData.of(tester.element(action)); + final decoration = tester + .widget( + find.descendant( + of: action, + matching: find.byType(AnimatedContainer), + ), + ) + .decoration! as BoxDecoration; + final effectiveBackground = Color.alphaBlend( + decoration.color!, + design.colors.page, + ); + final label = tester.widget(find.text('Follow this creator')); + expect( + _contrastRatio(label.style!.color!, effectiveBackground), + greaterThanOrEqualTo(4.5), + ); + await tester.tap(find.text('Follow this creator')); + await tester.pump(); + expect(tester.takeException(), isNull); + } + expect(taps, 2); + }); + + testWidgets('blocked profile action stays dangerous and readable', + (tester) async { + var taps = 0; + for (final dark in [false, true]) { + await tester.pumpWidget( + _TestApp( + width: 320, + dark: dark, + child: LoftifyProfileAction( + label: 'Blocked creator', + icon: LoftifyIcons.block, + danger: true, + onPressed: () => taps++, + ), + ), + ); + await tester.pump(); + + final action = find.byKey(const ValueKey('loftify-profile-action')); + final design = LoftifyDesignThemeData.of(tester.element(action)); + final decoration = tester + .widget( + find.descendant( + of: action, + matching: find.byType(AnimatedContainer), + ), + ) + .decoration! as BoxDecoration; + final effectiveBackground = Color.alphaBlend( + decoration.color!, + design.colors.page, + ); + final label = tester.widget(find.text('Blocked creator')); + + expect(decoration.border!.top.color, design.colors.outline); + expect( + _contrastRatio(label.style!.color!, effectiveBackground), + greaterThanOrEqualTo(4.5), + ); + await tester.tap(find.text('Blocked creator')); + await tester.pump(); + expect(tester.takeException(), isNull); + } + expect(taps, 2); + }); + + testWidgets('header uses columns on phones and two panes on wide windows', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + Widget header() => const LoftifyProfileHeaderLayout( + summary: SizedBox(height: 120, child: ColoredBox(color: Colors.red)), + showcase: + SizedBox(height: 100, child: ColoredBox(color: Colors.blue)), + ); + + await tester.pumpWidget(_TestApp(width: 1000, child: header())); + await tester.pump(); + final summary = find.byKey(const ValueKey('loftify-profile-summary')); + final showcase = find.byKey(const ValueKey('loftify-profile-showcase')); + expect(tester.getTopLeft(showcase).dx, + greaterThan(tester.getTopLeft(summary).dx)); + expect(tester.getTopLeft(showcase).dy, tester.getTopLeft(summary).dy); + + await tester.pumpWidget(_TestApp(width: 320, child: header())); + await tester.pump(); + expect( + tester.getTopLeft(showcase).dy, + greaterThanOrEqualTo(tester.getBottomLeft(summary).dy + 12), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('profile statistics reflow to two rows for accessible text', + (tester) async { + await tester.pumpWidget( + _TestApp( + width: 240, + textScaler: const TextScaler.linear(2), + child: const ProfileOverviewCard( + foregroundColor: Colors.white, + backgroundColor: Colors.black54, + statistics: [ + ProfileStatisticData(title: 'Following accounts', count: 123), + ProfileStatisticData(title: 'Followers', count: 456), + ProfileStatisticData(title: 'Total popularity', count: 7890), + ProfileStatisticData(title: 'Supporters', count: 42), + ], + ), + ), + ); + await tester.pump(); + + final statistics = find.byType(ProfileOverviewCard); + expect(tester.getSize(statistics).height, greaterThan(120)); + expect(find.byType(InkWell), findsNWidgets(4)); + expect(tester.takeException(), isNull); + }); +} + +class _TestApp extends StatelessWidget { + const _TestApp({ + required this.child, + required this.width, + this.textScaler = TextScaler.noScaling, + this.dark = false, + }); + + final Widget child; + final double width; + final TextScaler textScaler; + final bool dark; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: LoftifyTheme.build( + dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first, + ), + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 900), + textScaler: textScaler, + ), + child: Scaffold( + body: SingleChildScrollView( + child: Align( + alignment: Alignment.topCenter, + child: SizedBox(width: width, child: child), + ), + ), + ), + ), + ); + } +} + +double _contrastRatio(Color foreground, Color background) { + final foregroundLuminance = foreground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance + ? foregroundLuminance + : backgroundLuminance; + final darker = foregroundLuminance > backgroundLuminance + ? backgroundLuminance + : foregroundLuminance; + return (lighter + 0.05) / (darker + 0.05); +} diff --git a/test/widgets/profile/profile_more_action_sheet_test.dart b/test/widgets/profile/profile_more_action_sheet_test.dart new file mode 100644 index 00000000..9a677f26 --- /dev/null +++ b/test/widgets/profile/profile_more_action_sheet_test.dart @@ -0,0 +1,199 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Profile/profile_more_action_sheet.dart'; +import 'package:loftify/Widgets/loftify_icons.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/profile_more_action_sheet', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + List actions() => [ + for (var index = 0; index < 7; index++) + FlutterContextMenuItem( + 'Action $index', + iconData: LoftifyIcons.share, + ), + ]; + + List privacyActions() => const [ + FlutterContextMenuItem( + 'Block account', + iconData: LoftifyIcons.block, + status: MenuItemStatus.error, + ), + FlutterContextMenuItem( + 'Hide recommendations', + iconData: LoftifyIcons.block, + status: MenuItemStatus.error, + ), + FlutterContextMenuItem( + '不看TA的动态内容', + iconData: LoftifyIcons.block, + status: MenuItemStatus.error, + ), + ]; + + Future mount( + WidgetTester tester, { + double width = 390, + double height = 844, + double textScale = 1, + bool dark = false, + ValueChanged? onSelected, + VoidCallback? onCancel, + }) async { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + theme: LoftifyTheme.build( + dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first, + ), + home: Builder(builder: (context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(textScale), + ), + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: LoftifyProfileMoreActionSheet( + privacyTitle: 'Privacy', + cancelLabel: 'Cancel', + actions: actions(), + privacyActions: privacyActions(), + onSelected: onSelected ?? (_) {}, + onCancel: onCancel ?? () {}, + ), + ), + ), + ); + }), + )); + await tester.pump(); + } + + testWidgets('phone action grid is compact and keeps privacy separate', + (tester) async { + await mount(tester); + + final sheet = find.byType(LoftifyProfileMoreActionSheet); + expect(tester.getSize(sheet).height, lessThan(520)); + expect(find.text('More actions'), findsNothing); + expect(find.byKey(const ValueKey('profile-more-handle')), findsOneWidget); + expect(find.byKey(const ValueKey('profile-more-cancel')), findsOneWidget); + expect(find.text('Privacy'), findsOneWidget); + expect( + tester.getTopLeft(find.byKey(const ValueKey('profile-more-Action 0'))).dy, + tester.getTopLeft(find.byKey(const ValueKey('profile-more-Action 3'))).dy, + ); + expect( + tester.getTopLeft(find.byKey(const ValueKey('profile-more-Action 4'))).dy, + greaterThan( + tester + .getTopLeft(find.byKey(const ValueKey('profile-more-Action 0'))) + .dy, + ), + ); + expect( + tester + .getTopLeft(find.byKey(const ValueKey('profile-more-Block account'))) + .dy, + greaterThan( + tester + .getTopLeft(find.byKey(const ValueKey('profile-more-Action 4'))) + .dy, + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('narrow large-text sheet scrolls to every action', + (tester) async { + await mount(tester, width: 280, height: 480, textScale: 2, dark: true); + expect(find.byType(SingleChildScrollView), findsOneWidget); + await tester.drag( + find.byType(SingleChildScrollView), const Offset(0, -550)); + await tester.pumpAndSettle(); + expect(find.text('不看TA的动态内容'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('two-line privacy label is visible without reserved blank space', + (tester) async { + await mount(tester); + final label = find.text('不看TA的动态内容'); + final tile = find.byKey(const ValueKey('profile-more-不看TA的动态内容')); + final cancel = find.byKey(const ValueKey('profile-more-cancel')); + expect(tester.getBottomLeft(label).dy, + lessThan(tester.getBottomLeft(tile).dy)); + expect( + tester.getTopLeft(cancel).dy - tester.getBottomLeft(tile).dy, + lessThan(48), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('tile preserves its action and has no splash', (tester) async { + FlutterContextMenuItem? selected; + await mount(tester, onSelected: (item) => selected = item); + final tile = find.byKey(const ValueKey('profile-more-Action 0')); + final ink = tester.widget(tile); + expect(ink.splashFactory, NoSplash.splashFactory); + await tester.tap(tile); + expect(selected?.label, 'Action 0'); + expect(tester.takeException(), isNull); + }); + + testWidgets('cancel is a separate reachable action', (tester) async { + var dismissed = false; + await mount(tester, onCancel: () => dismissed = true); + await tester.tap(find.byKey(const ValueKey('profile-more-cancel'))); + expect(dismissed, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('bottom sheet wrapper honors the custom top radius', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const Scaffold( + body: BottomSheetWrapperWidget( + topRadius: Radius.circular(28), + child: SizedBox(height: 200), + ), + ); + }), + )); + final clip = tester.widget( + find.descendant( + of: find.byType(BottomSheetWrapperWidget), + matching: find.byType(ClipRRect), + ), + ); + final radius = clip.borderRadius as BorderRadius; + expect(radius.topLeft, const Radius.circular(28)); + expect(radius.topRight, const Radius.circular(28)); + }); +} diff --git a/test/widgets/profile/profile_overview_card_test.dart b/test/widgets/profile/profile_overview_card_test.dart new file mode 100644 index 00000000..bdc70ed0 --- /dev/null +++ b/test/widgets/profile/profile_overview_card_test.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Widgets/Design/loftify_surfaces.dart'; +import 'package:loftify/Widgets/Profile/profile_overview_card.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/profile_overview_card', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('four long profile statistics stay bounded and clickable', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var tapCount = 0; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: Center( + child: SizedBox( + width: 288, + child: ProfileOverviewCard( + statistics: [ + ProfileStatisticData( + title: 'Following accounts', + count: 123456, + onTap: () => tapCount++, + ), + const ProfileStatisticData( + title: 'Followers', + count: 987654, + ), + const ProfileStatisticData( + title: 'Total popularity', + count: 13579, + ), + const ProfileStatisticData( + title: 'Supporters', + count: null, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + + final card = find.byType(ProfileOverviewCard); + expect(card, findsOneWidget); + expect( + find.descendant(of: card, matching: find.byType(InkWell)), + findsNWidgets(4), + ); + final surface = tester.widget( + find.descendant(of: card, matching: find.byType(LoftifyCard)), + ); + expect(surface.radius, 14); + expect(find.text('123 K', findRichText: true), findsOneWidget); + expect(find.text('988 K', findRichText: true), findsOneWidget); + expect(find.text('13.6 K', findRichText: true), findsOneWidget); + + await tester.tap(find.text('Following accounts')); + await tester.pump(); + expect(tapCount, 1); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/profile/supporter_list_item_test.dart b/test/widgets/profile/supporter_list_item_test.dart new file mode 100644 index 00000000..79cdb247 --- /dev/null +++ b/test/widgets/profile/supporter_list_item_test.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Profile/supporter_list_item.dart'; + +void main() { + setUpAll(() async { + final directory = Directory('build/test_hive/supporter_list_item'); + await directory.create(recursive: true); + Hive.init(directory.absolute.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + Future mount( + WidgetTester tester, { + double width = 390, + double height = 844, + double textScale = 1, + bool dark = false, + VoidCallback? onTap, + }) async { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + theme: LoftifyTheme.build(dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(textScale), + ), + child: Scaffold( + body: Column( + children: [ + LoftifySupporterListItem( + blogId: 42, + avatarUrl: '', + name: '很长的支持者昵称,不应该把贡献数挤出屏幕', + blogName: 'supporter_42', + intro: '摄影、绘画和文字。这里是一段较长的个人介绍,应允许两行。', + score: 123456789, + onTap: onTap ?? () {}, + ), + ], + ), + ), + )), + )); + await tester.pump(); + } + + testWidgets('phone row keeps identity and score together', (tester) async { + await mount(tester); + expect(find.text('ID: supporter_42'), findsOneWidget); + expect(find.text('123456789'), findsOneWidget); + final row = find.byKey(const ValueKey('loftify-supporter-row-42')); + expect(tester.getSize(row).height, lessThan(180)); + final ink = tester.widget(find + .descendant( + of: row, + matching: find.byType(InkWell), + ) + .first); + expect(ink.splashFactory, NoSplash.splashFactory); + expect(tester.takeException(), isNull); + }); + + testWidgets('narrow dark large-text row reflows without clipping', + (tester) async { + await mount(tester, width: 280, height: 480, textScale: 2, dark: true); + final row = find.byKey(const ValueKey('loftify-supporter-row-42')); + expect(tester.getTopRight(row).dx, lessThanOrEqualTo(280)); + expect(find.text('123456789'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('whole supporter item opens the author', (tester) async { + var tapped = false; + await mount(tester, onTap: () => tapped = true); + await tester.tap(find.byKey(const ValueKey('loftify-supporter-row-42'))); + expect(tapped, isTrue); + }); +} diff --git a/test/widgets/profile/user_list_item_test.dart b/test/widgets/profile/user_list_item_test.dart new file mode 100644 index 00000000..3dda3f9e --- /dev/null +++ b/test/widgets/profile/user_list_item_test.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Models/account_response.dart'; +import 'package:loftify/Models/user_response.dart'; +import 'package:loftify/Widgets/Design/loftify_controls.dart'; +import 'package:loftify/Widgets/Item/loftify_item_builder.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final hiveDirectory = Directory( + '${Directory.current.path}/build/test_hive/user_list_item', + ); + await hiveDirectory.create(recursive: true); + Hive.init(hiveDirectory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('relation card keeps long identity content bounded on phones', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final item = FollowingUserItem( + blogId: 7, + blogInfo: FullBlogInfo.fromJson({ + 'blogId': 7, + 'blogName': 'a_very_long_user_identifier_for_layout_verification', + 'blogNickName': 'A very long creator nickname that must not overflow', + 'bigAvaImg': '', + 'homePageUrl': '', + 'imageDigitStamp': false, + 'imageProtected': false, + 'imageStamp': false, + 'isOriginalAuthor': false, + 'selfIntro': + 'A long two-line introduction used to verify that the relation card remains bounded.', + }), + follower: true, + following: true, + followTime: 0, + hotCount: 0, + id: 1, + lastPublishTime: 0, + lastVisitTime: 0, + responseCount: 0, + score: 0, + specialFollow: false, + userId: 7, + ); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + chewieProvider.setRootContext(context); + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: const TextScaler.linear(2), + ), + child: Builder( + builder: (context) => Scaffold( + body: Padding( + padding: const EdgeInsets.all(8), + child: LoftifyItemBuilder.buildFollowerOrFollowingItem( + context, + 0, + item, + ), + ), + ), + ), + ); + }, + ), + ), + ); + + final row = find.byKey(const ValueKey('loftify-relation-row-7')); + expect(row, findsOneWidget); + expect(find.byType(LoftifyCompactToggleButton), findsOneWidget); + expect( + tester.getSize(find.byType(LoftifyCompactToggleButton)).height, + greaterThanOrEqualTo(48), + ); + expect( + tester.getSize(row).height, + greaterThan(150), + ); + expect(find.textContaining('ID:'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/refresh/easy_refresh_extent_shrink_test.dart b/test/widgets/refresh/easy_refresh_extent_shrink_test.dart new file mode 100644 index 00000000..1cba80ce --- /dev/null +++ b/test/widgets/refresh/easy_refresh_extent_shrink_test.dart @@ -0,0 +1,59 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'refresh physics allows a ballistic move back after content extent shrinks', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final controller = ScrollController(); + addTearDown(controller.dispose); + var itemCount = 10; + late StateSetter rebuild; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return EasyRefresh( + header: const LottieCupertinoHeader( + maxOverOffset: 84, + indicator: SizedBox.square(dimension: 40), + ), + footer: const LottieCupertinoFooter( + maxOverOffset: 76, + indicator: SizedBox.square(dimension: 36), + ), + onRefresh: () async {}, + child: ListView.builder( + controller: controller, + itemExtent: 100, + itemCount: itemCount, + itemBuilder: (context, index) => Text('Item $index'), + ), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + controller.jumpTo(387); + rebuild(() => itemCount = 8); + await tester.pump(); + + controller.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + await tester.pumpAndSettle(); + + expect(controller.offset, closeTo(0, 0.1)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/search_collection_rows_test.dart b/test/widgets/search_collection_rows_test.dart new file mode 100644 index 00000000..e42c5943 --- /dev/null +++ b/test/widgets/search_collection_rows_test.dart @@ -0,0 +1,103 @@ +import 'dart:io'; +import 'package:hive/hive.dart'; +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Models/collection_response.dart'; +import 'package:loftify/Models/recommend_response.dart'; +import 'package:loftify/Widgets/Item/loftify_item_builder.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +class _Collection extends Fake implements Collection { + @override + String get name => 'A long collection of creative works'; + @override + String get coverUrl => ''; + @override + int get postCount => 123456789; + @override + int get lastPublishTime => 1700000000000; + @override + List get tags => + ['Photography and creative illustration', 'last-tag']; +} + +class _Grain extends Fake implements GrainInfo { + @override + String get name => 'A long selection of creative works'; + @override + String get coverUrl => ''; + @override + int get postCount => 123456789; + @override + int get updateTime => 1700000000000; + @override + List get tags => + ['Photography and creative illustration', 'last-tag']; +} + +void main() { + setUpAll(() async { + final dir = Directory('build/test_hive/search_collection_rows'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + }); + for (final grain in [false, true]) { + for (final locale in [ + const Locale('en'), + const Locale('zh'), + const Locale('zh', 'TW') + ]) { + for (final width in [280.0, 720.0]) { + for (final scale in [1.0, 2.0]) { + testWidgets( + 'search collection grain=$grain $locale width=$width scale=$scale', + (tester) async { + tester.view.physicalSize = Size(width, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var taps = 0; + await tester.pumpWidget(MaterialApp( + locale: locale, + theme: + ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates + ], + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: SingleChildScrollView( + child: grain + ? LoftifyItemBuilder.buildGrainRow( + context, _Grain(), onTap: () => taps++) + : LoftifyItemBuilder.buildCollectionRow( + context, _Collection(), + onTap: () => taps++))); + }), + )); + await tester.pump(); + expect(tester.takeException(), isNull); + final name = find.text(grain ? _Grain().name : _Collection().name); + await tester.tap(name); + expect(taps, 1); + final tags = find.text('#last-tag'); + await tester.ensureVisible(tags); + await tester.pump(); + expect(tags.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + } + } + } +} diff --git a/test/widgets/suit/custom_bg_avatar_detail_layout_test.dart b/test/widgets/suit/custom_bg_avatar_detail_layout_test.dart new file mode 100644 index 00000000..fa22e22b --- /dev/null +++ b/test/widgets/suit/custom_bg_avatar_detail_layout_test.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/BottomSheet/custom_bg_avatar_detail_bottom_sheet.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/custom_bg_avatar_detail_layout', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('detail panel keeps footer reachable on a short large-text phone', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 568)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + theme: LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ), + home: MediaQuery( + data: const MediaQueryData( + size: Size(320, 568), + padding: EdgeInsets.only(top: 24), + textScaler: TextScaler.linear(2), + ), + child: const Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: CustomBgAvatarDetailPanel( + title: 'Decoration details with a long localized title', + body: Column( + children: [ + SizedBox(height: 700), + Text('End of preview details'), + ], + ), + footer: SizedBox(height: 150, child: Text('Footer actions')), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final panel = find.byKey( + const ValueKey('custom-bg-avatar-detail-panel'), + ); + expect(tester.getSize(panel).height, lessThanOrEqualTo(544)); + expect(find.text('Footer actions'), findsOneWidget); + expect(find.byKey(const ValueKey('loftify-panel-handle')), findsOneWidget); + expect( + find.byKey( + const ValueKey('custom-bg-avatar-detail-scrolling-footer'), + ), + findsOneWidget, + ); + + await tester.drag( + find.byKey(const ValueKey('custom-bg-avatar-detail-scroll')), + const Offset(0, -600), + ); + await tester.pump(); + expect(find.text('End of preview details'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + test('real detail sheet uses responsive Loftify buttons', () { + final source = File( + 'lib/Widgets/BottomSheet/custom_bg_avatar_detail_bottom_sheet.dart', + ).readAsStringSync(); + expect(source, contains('CustomBgAvatarDetailPanel(')); + expect(source, contains("LoftifyButton(")); + expect(source, contains('custom-bg-avatar-detail-actions-vertical')); + expect(source, contains('custom-bg-avatar-detail-actions-horizontal')); + }); +} diff --git a/test/widgets/tag/tag_detail_components_test.dart b/test/widgets/tag/tag_detail_components_test.dart new file mode 100644 index 00000000..90e645d8 --- /dev/null +++ b/test/widgets/tag/tag_detail_components_test.dart @@ -0,0 +1,238 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Widgets/Design/loftify_controls.dart'; +import 'package:loftify/Widgets/Tag/tag_detail_components.dart'; + +void main() { + setUpAll(() async { + final directory = Directory( + '${Directory.current.path}/build/test_hive/tag_detail_components', + ); + await directory.create(recursive: true); + Hive.init(directory.path); + if (!Hive.isBoxOpen(ChewieHiveUtil.settingsBox)) { + await Hive.openBox(ChewieHiveUtil.settingsBox); + } + }); + + testWidgets('tag hero stacks actions safely on narrow large-text layouts', + (tester) async { + var subscriptionTaps = 0; + await tester.pumpWidget( + _TestApp( + width: 320, + textScaler: const TextScaler.linear(2), + child: LoftifyTagHero( + tag: 'A deliberately long localized discovery tag', + metrics: const [ + LoftifyTagMetric('Top discovery topic', emphasized: true), + LoftifyTagMetric('4.5 million views'), + ], + subscribed: false, + subscribeLabel: 'Subscribe to this tag', + subscribedLabel: 'Subscribed', + onSubscriptionPressed: () => subscriptionTaps++, + ), + ), + ); + await tester.pump(); + + final hero = find.byKey(const ValueKey('loftify-tag-hero')); + expect(hero, findsOneWidget); + expect(tester.getSize(hero).width, 320); + expect(tester.getSize(hero).height, greaterThan(150)); + expect(find.text('Top discovery topic'), findsOneWidget); + + await tester.tap(find.text('Subscribe to this tag')); + await tester.pump(); + expect(subscriptionTaps, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('unsubscribed and subscribed actions keep restrained states', + (tester) async { + await tester.pumpWidget( + _TestApp( + child: LoftifyTagHero( + tag: 'Photography', + metrics: const [], + subscribed: false, + subscribeLabel: 'Subscribe', + subscribedLabel: 'Subscribed', + onSubscriptionPressed: _noop, + ), + ), + ); + await tester.pump(); + + final lightDesign = LoftifyTheme.build( + ChewieThemeColorData.defaultLightThemes.first, + ).extension()!; + final availableDecoration = _buttonDecoration(tester, 'Subscribe'); + expect(availableDecoration.color, Colors.transparent); + expect( + (availableDecoration.border! as Border).top.color, + lightDesign.colors.accent.withValues(alpha: 0.52), + ); + + await tester.pumpWidget( + _TestApp( + child: LoftifyTagHero( + tag: 'Photography', + metrics: const [], + subscribed: true, + subscribeLabel: 'Subscribe', + subscribedLabel: 'Subscribed', + onSubscriptionPressed: _noop, + ), + ), + ); + await tester.pump(); + final subscribedDecoration = _buttonDecoration(tester, 'Subscribed'); + expect(subscribedDecoration.color, lightDesign.colors.surfaceRaised); + expect( + (subscribedDecoration.border! as Border).top.color, + lightDesign.colors.outlineStrong, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('discovery entrances preserve the compact illustration ratio', + (tester) async { + var taps = 0; + await tester.pumpWidget( + _TestApp( + width: 240, + textScaler: const TextScaler.linear(2), + child: Align( + child: LoftifyTagDiscoveryCard( + title: 'Related collections', + description: + 'A long localized description that needs two complete lines', + illustration: const ColoredBox(color: Colors.blue), + onTap: () => taps++, + ), + ), + ), + ); + await tester.pump(); + + final card = find.byKey( + const ValueKey('loftify-tag-discovery-card-Related collections'), + ); + final size = tester.getSize(card); + expect(size.height, inInclusiveRange(70, 96)); + expect( + size.aspectRatio, + moreOrLessEquals( + LoftifyTagDiscoveryCard.illustrationAspectRatio, + epsilon: 0.01, + ), + ); + final surface = tester.widget( + find.descendant(of: card, matching: find.byType(AnimatedContainer)).first, + ); + final decoration = surface.decoration! as BoxDecoration; + expect(decoration.color, Colors.transparent); + expect(decoration.border, isNull); + expect(decoration.boxShadow, isEmpty); + await tester.tap(card); + await tester.pump(); + expect(taps, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('discovery entrance stays compact and semantic in dark mode', + (tester) async { + await tester.pumpWidget( + _TestApp( + dark: true, + child: Align( + child: LoftifyTagDiscoveryCard( + title: 'Related dress', + description: '12 available looks', + illustration: const ColoredBox(color: Colors.indigo), + onTap: _noop, + ), + ), + ), + ); + await tester.pump(); + + final card = find.byKey( + const ValueKey('loftify-tag-discovery-card-Related dress'), + ); + final size = tester.getSize(card); + expect(size.height, LoftifyTagDiscoveryCard.minimumHeight); + expect(size.width, lessThan(190)); + expect(find.text('Related dress'), findsOneWidget); + expect(find.text('12 available looks'), findsOneWidget); + final buttonSemantics = tester + .widgetList( + find.descendant(of: card, matching: find.byType(Semantics)), + ) + .where((widget) => widget.properties.button == true); + expect(buttonSemantics, hasLength(1)); + expect(buttonSemantics.single.properties.enabled, isTrue); + expect(tester.takeException(), isNull); + }); +} + +BoxDecoration _buttonDecoration(WidgetTester tester, String label) { + return tester + .widget( + find + .descendant( + of: find.widgetWithText(LoftifyCompactToggleButton, label), + matching: find.byType(AnimatedContainer), + ) + .first, + ) + .decoration! as BoxDecoration; +} + +void _noop() {} + +class _TestApp extends StatelessWidget { + const _TestApp({ + required this.child, + this.width = 390, + this.textScaler = TextScaler.noScaling, + this.dark = false, + }); + + final Widget child; + final double width; + final TextScaler textScaler; + final bool dark; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: LoftifyTheme.build( + dark + ? ChewieThemeColorData.defaultDarkThemes.first + : ChewieThemeColorData.defaultLightThemes.first, + ), + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 800), + textScaler: textScaler, + ), + child: Scaffold( + body: SingleChildScrollView( + child: Align( + alignment: Alignment.topCenter, + child: SizedBox(width: width, child: child), + ), + ), + ), + ), + ); + } +} diff --git a/third-party/chewie/build/.last_build_id b/third-party/chewie/build/.last_build_id deleted file mode 100644 index cbcc3ebd..00000000 --- a/third-party/chewie/build/.last_build_id +++ /dev/null @@ -1 +0,0 @@ -ae30582a806dc4248fd237072391b5a0 \ No newline at end of file diff --git a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/.filecache b/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/.filecache deleted file mode 100644 index 0da217d8..00000000 --- a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/.filecache +++ /dev/null @@ -1 +0,0 @@ -{"version":2,"files":[{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_ja.dart","hash":"1cdd14bf20284f061afd3eda8f376cf3"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_ja.arb","hash":"e5dfa012bb93f9eeeffdb17e75598c76"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_zh.dart","hash":"a53767897a677440a1fd65db5d97fc50"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\l10n.yaml","hash":"7fcd25ea5a1b31991b66ccd8d5aaad01"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh.arb","hash":"b2ffcfa59377fd2c342eea700847c05d"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_en.dart","hash":"c1d5448f4c82d7740c7efff6ab23fd7f"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations.dart","hash":"326203f0715bc25d91d4c19ff1f8f4cc"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh_TW.arb","hash":"e594aa62e96b81e5eb8abdbcf8f88dea"},{"path":"C:\\Users\\ruida\\Documents\\ProgramData\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","hash":"b62161681a787d895266e36070173455"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh_CN.arb","hash":"49aa8735c4a22d9b4ae6e6ec98fcc449"},{"path":"D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_en.arb","hash":"d5e7a6966f654d84623dd8e7b596b0ba"}]} \ No newline at end of file diff --git a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_l10n_inputs_and_outputs.json b/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_l10n_inputs_and_outputs.json deleted file mode 100644 index d9c9f977..00000000 --- a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_l10n_inputs_and_outputs.json +++ /dev/null @@ -1 +0,0 @@ -{"inputs":["D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_en.arb","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_ja.arb","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh.arb","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh_CN.arb","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh_TW.arb"],"outputs":["D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_en.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_ja.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_zh.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations.dart"]} \ No newline at end of file diff --git a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_localizations.d b/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_localizations.d deleted file mode 100644 index 18941b9e..00000000 --- a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_localizations.d +++ /dev/null @@ -1 +0,0 @@ - D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_en.dart D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_ja.dart D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_zh.dart D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations.dart: D:\\Repositories\\Readar\\third-party\\chewie\\l10n.yaml D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_en.arb D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_ja.arb D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh.arb D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh_CN.arb D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh_TW.arb \ No newline at end of file diff --git a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_localizations.stamp b/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_localizations.stamp deleted file mode 100644 index 835adda7..00000000 --- a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/gen_localizations.stamp +++ /dev/null @@ -1 +0,0 @@ -{"inputs":["C:\\Users\\ruida\\Documents\\ProgramData\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","D:\\Repositories\\Readar\\third-party\\chewie\\l10n.yaml","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_en.arb","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_ja.arb","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh.arb","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh_CN.arb","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/l10n\\intl_zh_TW.arb"],"outputs":["D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_en.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_ja.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_zh.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations.dart"]} \ No newline at end of file diff --git a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/outputs.json b/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/outputs.json deleted file mode 100644 index 0f87722d..00000000 --- a/third-party/chewie/build/ae30582a806dc4248fd237072391b5a0/outputs.json +++ /dev/null @@ -1 +0,0 @@ -["D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_en.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_ja.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations_zh.dart","D:\\Repositories\\Readar\\third-party\\chewie\\lib/src/generated\\chewie_localizations.dart"] \ No newline at end of file diff --git a/third-party/chewie/lib/awesome_chewie.dart b/third-party/chewie/lib/awesome_chewie.dart index f1e69652..3e4a6d58 100644 --- a/third-party/chewie/lib/awesome_chewie.dart +++ b/third-party/chewie/lib/awesome_chewie.dart @@ -10,6 +10,8 @@ export 'src/Providers/chewie_provider.dart'; export 'src/Resources/colors.dart'; export 'src/Resources/dimens.dart'; export 'src/Resources/fonts.dart'; +export 'src/Resources/icon_theme.dart'; +export 'src/Resources/chewie_icons.dart'; export 'src/Resources/styles.dart'; export 'src/Resources/theme.dart'; export 'src/Resources/theme_color_data.dart'; @@ -28,7 +30,6 @@ export 'src/Utils/General/responsive_util.dart'; export 'src/Utils/General/string_util.dart'; export 'src/Utils/General/web_util.dart'; export 'src/Utils/General/time_util.dart'; -export 'src/Utils/General/url_preview_helper.dart'; export 'src/Utils/ilogger.dart'; export 'src/Utils/iprint.dart'; export 'src/Utils/itoast.dart'; diff --git a/third-party/chewie/lib/src/Models/selection_item_model.dart b/third-party/chewie/lib/src/Models/selection_item_model.dart index 5b882d49..3459d56c 100644 --- a/third-party/chewie/lib/src/Models/selection_item_model.dart +++ b/third-party/chewie/lib/src/Models/selection_item_model.dart @@ -15,13 +15,11 @@ class SelectionItemModel with DropdownMixin { bool operator ==(Object other) { if (identical(this, other)) return true; - return other is SelectionItemModel && - other.key == key && - other.value == value; + return other is SelectionItemModel && other.value == value; } @override - int get hashCode => super.hashCode + key.hashCode + value.hashCode; + int get hashCode => value?.hashCode ?? 0; @override String get display => key; diff --git a/third-party/chewie/lib/src/Providers/chewie_provider.dart b/third-party/chewie/lib/src/Providers/chewie_provider.dart index cda69d8b..db0790d8 100644 --- a/third-party/chewie/lib/src/Providers/chewie_provider.dart +++ b/third-party/chewie/lib/src/Providers/chewie_provider.dart @@ -7,9 +7,51 @@ ChewieProvider chewieProvider = ChewieProvider(); bool haveMigratedToSupportDirectory = false; +enum ChewieStateViewType { loading, empty, error } + +@immutable +class ChewieStateViewConfig { + const ChewieStateViewConfig({ + required this.type, + required this.text, + this.actionLabel, + this.onAction, + this.icon, + this.size = 30, + this.showText = true, + this.forceDark = false, + this.background, + this.physics, + this.shrinkWrap = true, + this.scrollController, + this.topPadding = 0, + this.bottomPadding = 0, + }); + + final ChewieStateViewType type; + final String text; + final String? actionLabel; + final VoidCallback? onAction; + final IconData? icon; + final double size; + final bool showText; + final bool forceDark; + final Color? background; + final ScrollPhysics? physics; + final bool shrinkWrap; + final ScrollController? scrollController; + final double topPadding; + final double bottomPadding; +} + +typedef ChewieStateWidgetBuilder = Widget Function( + BuildContext context, + ChewieStateViewConfig config, +); + class ChewieProvider with ChangeNotifier { static const Size defaultWindowSize = Size(1280, 720); - static const Size minimumWindowSize = Size(1280, 640); + static const Size minimumWindowSize = Size(800, 640); RouteObserver routeObserver = RouteObserver(); @@ -19,6 +61,11 @@ class ChewieProvider with ChangeNotifier { BuildContext get globalNavigatorContext => globalNavigatorKey.currentContext!; + BuildContext navigatorContextOf(BuildContext context) { + if (Navigator.maybeOf(context) != null) return context; + return globalNavigatorState?.context ?? context; + } + late BuildContext _rootContext; BuildContext get rootContext => _rootContext; @@ -79,6 +126,13 @@ class ChewieProvider with ChangeNotifier { Widget Function(double size, bool forceDark) loadingWidgetBuilder = (size, forceDark) => const Center(child: CircularProgressIndicator()); + /// Optional product-level state renderer. + /// + /// The component package retains its standalone fallbacks, while host apps + /// can provide one semantic visual language for loading, empty and error + /// states without replacing every call site. + ChewieStateWidgetBuilder? stateWidgetBuilder; + bool _enableLandscapeInTablet = ChewieHiveUtil.getBool(ChewieHiveUtil.enableLandscapeInTabletKey); @@ -117,11 +171,11 @@ class ChewieProvider with ChangeNotifier { ]; } - int? _fontSize = ChewieHiveUtil.getFontSize(); + int _fontSize = ChewieHiveUtil.getFontSize(); - int? get fontSize => _fontSize; + int get fontSize => _fontSize; - set fontSize(int? value) { + set fontSize(int value) { if (value != _fontSize) { _fontSize = value; notifyListeners(); diff --git a/third-party/chewie/lib/src/Resources/chewie_icons.dart b/third-party/chewie/lib/src/Resources/chewie_icons.dart new file mode 100644 index 00000000..b893dfa9 --- /dev/null +++ b/third-party/chewie/lib/src/Resources/chewie_icons.dart @@ -0,0 +1,51 @@ +import 'package:flutter/widgets.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +/// Semantic Lucide glyphs shared by reusable Chewie components. +/// +/// Names describe meaning instead of visual variants. Persistent selected +/// states keep the same glyph and express emphasis through color or a +/// container; action pairs may use different glyphs when their meaning changes. +abstract final class ChewieIcons { + static const IconData back = LucideIcons.arrowLeft; + static const IconData previous = LucideIcons.chevronLeft; + static const IconData next = LucideIcons.chevronRight; + static const IconData expand = LucideIcons.chevronDown; + static const IconData collapse = LucideIcons.chevronUp; + static const IconData arrowUp = LucideIcons.arrowUp; + static const IconData arrowDown = LucideIcons.arrowDown; + static const IconData arrowLeft = LucideIcons.arrowLeft; + static const IconData arrowRight = LucideIcons.arrowRight; + + static const IconData add = LucideIcons.plus; + static const IconData remove = LucideIcons.minus; + static const IconData close = LucideIcons.x; + static const IconData check = LucideIcons.check; + static const IconData copy = LucideIcons.copy; + static const IconData copyDone = LucideIcons.copyCheck; + static const IconData more = LucideIcons.ellipsisVertical; + static const IconData refresh = LucideIcons.refreshCw; + static const IconData retry = LucideIcons.refreshCw; + static const IconData search = LucideIcons.search; + static const IconData share = LucideIcons.share2; + static const IconData openExternal = LucideIcons.externalLink; + + static const IconData info = LucideIcons.info; + static const IconData success = LucideIcons.circleCheck; + static const IconData warning = LucideIcons.triangleAlert; + static const IconData error = LucideIcons.circleX; + static const IconData imageUnavailable = LucideIcons.imageOff; + static const IconData inbox = LucideIcons.inbox; + static const IconData archive = LucideIcons.archive; + static const IconData star = LucideIcons.star; + static const IconData starHalf = LucideIcons.starHalf; + + static const IconData pin = LucideIcons.pin; + static const IconData minimizeWindow = LucideIcons.minus; + static const IconData maximizeWindow = LucideIcons.maximize2; + static const IconData restoreWindow = LucideIcons.minimize2; + static const IconData closeWindow = LucideIcons.x; + static const IconData square = LucideIcons.square; + static const IconData alarm = LucideIcons.alarmClock; + static const IconData time = LucideIcons.clock3; +} diff --git a/third-party/chewie/lib/src/Resources/colors.dart b/third-party/chewie/lib/src/Resources/colors.dart index b1b17cf3..3cb030c9 100644 --- a/third-party/chewie/lib/src/Resources/colors.dart +++ b/third-party/chewie/lib/src/Resources/colors.dart @@ -147,11 +147,10 @@ extension HexColor on Color { return hexString.toColor(); } - String toHex({bool leadingHashSign = true}) => '${leadingHashSign ? '#' : ''}' - '${a.toInt().toRadixString(16).padLeft(2, '0')}' - '${r.toInt().toRadixString(16).padLeft(2, '0')}' - '${g.toInt().toRadixString(16).padLeft(2, '0')}' - '${b.toInt().toRadixString(16).padLeft(2, '0')}'; + String toHex({bool leadingHashSign = true}) { + final value = toARGB32().toRadixString(16).padLeft(8, '0'); + return '${leadingHashSign ? '#' : ''}$value'; + } } extension HexColorString on String { diff --git a/third-party/chewie/lib/src/Resources/fonts.dart b/third-party/chewie/lib/src/Resources/fonts.dart index 91584881..9e02f36c 100644 --- a/third-party/chewie/lib/src/Resources/fonts.dart +++ b/third-party/chewie/lib/src/Resources/fonts.dart @@ -111,10 +111,19 @@ class CustomFont { } static List getAllFonts() { - return List.from(defaultFonts)..addAll(ChewieHiveUtil.getCustomFonts()); + final fonts = [...defaultFonts]; + // Some Windows analyzer paths normalize this library's `Resources` + // segment differently from package imports. Keep the package boundary + // dynamic so the same runtime CustomFont objects are not treated as two + // unrelated static types solely because of path casing. + final dynamic customFonts = ChewieHiveUtil.getCustomFonts(); + for (final dynamic font in customFonts as Iterable) { + fonts.add(font); + } + return fonts; } - static dynamic getCurrentFont() { + static CustomFont getCurrentFont() { dynamic fontFamily = ChewieHiveUtil.get(ChewieHiveUtil.fontFamilyKey, defaultValue: 0); List allFonts = getAllFonts(); @@ -168,55 +177,67 @@ class CustomFont { return file.existsSync(); } - static downloadFont({ + static Future downloadFont({ BuildContext? context, bool showToast = true, Function(bool)? onFinished, Function(double)? onReceiveProgress, CustomFont? customFont, }) async { - customFont ??= getCurrentFont(); - if (customFont != Default) { - await FontUtil.url( - fontFamily: customFont!.fontFamily, - url: customFont.fontUrl, - ).load(onReceiveProgress: onReceiveProgress).then((value) { - onFinished?.call(value); - if (showToast && context != null) { - if (value == true) { - IToast.showTop(chewieLocalizations.fontFamlyLoadSuccess); - } else { - IToast.showTop(chewieLocalizations.fontFamlyLoadFailed); - } - } - }); - } else { + final font = customFont ?? getCurrentFont(); + if (font == Default) { onFinished?.call(true); - return Future(() => true); + return true; + } + + final bool value; + if (defaultFonts.contains(font)) { + value = await FontUtil.url( + fontFamily: font.fontFamily, + url: font.fontUrl, + ).load(onReceiveProgress: onReceiveProgress); + } else { + value = await FontUtil.file( + fontFamily: font.fontFamily, + filepath: join(await FileUtil.getFontDir(), font.fontUrl), + ).load(onReceiveProgress: onReceiveProgress); + } + onFinished?.call(value); + if (showToast && context != null) { + IToast.showTop( + value + ? chewieLocalizations.fontFamlyLoadSuccess + : chewieLocalizations.fontFamlyLoadFailed, + ); } + return value; } - static void loadFont( + static Future loadFont( BuildContext context, CustomFont item, { bool autoRestartApp = false, }) async { var dialog = showProgressDialog(chewieLocalizations.alreadyDownload); - await ChewieHiveUtil.put(ChewieHiveUtil.fontFamilyKey, item.fontFamily); - await downloadFont( + final loaded = await downloadFont( context: context, showToast: false, - onFinished: (value) { - dialog.dismiss(); - chewieProvider.darkTheme = chewieProvider.darkTheme; - chewieProvider.lightTheme = chewieProvider.lightTheme; - if (autoRestartApp) { - ResponsiveUtil.restartApp(context); - } - }, onReceiveProgress: (progress) { dialog.updateProgress(progress: progress); }, + customFont: item, ); + dialog.dismiss(); + if (!loaded) { + IToast.showTop(chewieLocalizations.fontFamlyLoadFailed); + return false; + } + await ChewieHiveUtil.put(ChewieHiveUtil.fontFamilyKey, item.fontFamily); + chewieProvider.darkTheme = chewieProvider.darkTheme; + chewieProvider.lightTheme = chewieProvider.lightTheme; + if (autoRestartApp && context.mounted) { + ResponsiveUtil.restartApp(context); + } + return true; } } diff --git a/third-party/chewie/lib/src/Resources/icon_theme.dart b/third-party/chewie/lib/src/Resources/icon_theme.dart new file mode 100644 index 00000000..4c34574e --- /dev/null +++ b/third-party/chewie/lib/src/Resources/icon_theme.dart @@ -0,0 +1,101 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// Shared optical and interaction measurements for Lucide interface icons. +@immutable +class ChewieIconThemeData extends ThemeExtension { + const ChewieIconThemeData({ + this.smallSize = 16, + this.regularSize = 20, + this.largeSize = 24, + this.minimumTapTarget = 44, + this.cornerRadius = 10, + this.disabledOpacity = 0.38, + this.hoverOpacity = 0.08, + this.focusOpacity = 0.10, + this.pressedOpacity = 0.12, + this.selectedContainerOpacity = 0.12, + }) : assert(smallSize > 0), + assert(regularSize > 0), + assert(largeSize > 0), + assert(minimumTapTarget >= 44), + assert(cornerRadius >= 0), + assert(disabledOpacity >= 0 && disabledOpacity <= 1), + assert(hoverOpacity >= 0 && hoverOpacity <= 1), + assert(focusOpacity >= 0 && focusOpacity <= 1), + assert(pressedOpacity >= 0 && pressedOpacity <= 1), + assert( + selectedContainerOpacity >= 0 && selectedContainerOpacity <= 1, + ); + + static const ChewieIconThemeData standard = ChewieIconThemeData(); + + final double smallSize; + final double regularSize; + final double largeSize; + final double minimumTapTarget; + final double cornerRadius; + final double disabledOpacity; + final double hoverOpacity; + final double focusOpacity; + final double pressedOpacity; + final double selectedContainerOpacity; + + static ChewieIconThemeData of(BuildContext context) { + return Theme.of(context).extension() ?? standard; + } + + @override + ChewieIconThemeData copyWith({ + double? smallSize, + double? regularSize, + double? largeSize, + double? minimumTapTarget, + double? cornerRadius, + double? disabledOpacity, + double? hoverOpacity, + double? focusOpacity, + double? pressedOpacity, + double? selectedContainerOpacity, + }) { + return ChewieIconThemeData( + smallSize: smallSize ?? this.smallSize, + regularSize: regularSize ?? this.regularSize, + largeSize: largeSize ?? this.largeSize, + minimumTapTarget: minimumTapTarget ?? this.minimumTapTarget, + cornerRadius: cornerRadius ?? this.cornerRadius, + disabledOpacity: disabledOpacity ?? this.disabledOpacity, + hoverOpacity: hoverOpacity ?? this.hoverOpacity, + focusOpacity: focusOpacity ?? this.focusOpacity, + pressedOpacity: pressedOpacity ?? this.pressedOpacity, + selectedContainerOpacity: + selectedContainerOpacity ?? this.selectedContainerOpacity, + ); + } + + @override + ChewieIconThemeData lerp( + covariant ChewieIconThemeData? other, + double t, + ) { + if (other == null) return this; + return ChewieIconThemeData( + smallSize: lerpDouble(smallSize, other.smallSize, t)!, + regularSize: lerpDouble(regularSize, other.regularSize, t)!, + largeSize: lerpDouble(largeSize, other.largeSize, t)!, + minimumTapTarget: + lerpDouble(minimumTapTarget, other.minimumTapTarget, t)!, + cornerRadius: lerpDouble(cornerRadius, other.cornerRadius, t)!, + disabledOpacity: lerpDouble(disabledOpacity, other.disabledOpacity, t)!, + hoverOpacity: lerpDouble(hoverOpacity, other.hoverOpacity, t)!, + focusOpacity: lerpDouble(focusOpacity, other.focusOpacity, t)!, + pressedOpacity: lerpDouble(pressedOpacity, other.pressedOpacity, t)!, + selectedContainerOpacity: lerpDouble( + selectedContainerOpacity, + other.selectedContainerOpacity, + t, + )!, + ); + } +} diff --git a/third-party/chewie/lib/src/Resources/theme.dart b/third-party/chewie/lib/src/Resources/theme.dart index df27f3ff..4d947317 100644 --- a/third-party/chewie/lib/src/Resources/theme.dart +++ b/third-party/chewie/lib/src/Resources/theme.dart @@ -9,6 +9,9 @@ class ChewieTheme { static ThemeData getTheme({required bool isDarkMode}) { return ThemeData( + extensions: const >[ + ChewieIconThemeData.standard, + ], brightness: isDarkMode ? Brightness.dark : Brightness.light, primaryColor: isDarkMode ? ChewieColors.defaultPrimaryColorDark @@ -16,9 +19,6 @@ class ChewieTheme { hintColor: isDarkMode ? ChewieColors.defaultPrimaryColorDark : ChewieColors.defaultPrimaryColor, - indicatorColor: isDarkMode - ? ChewieColors.defaultPrimaryColorDark - : ChewieColors.defaultPrimaryColor, scaffoldBackgroundColor: isDarkMode ? ChewieColors.backgroundDark : ChewieColors.background, canvasColor: isDarkMode @@ -97,6 +97,12 @@ class ChewieTheme { ? ChewieColors.appBarBackgroundDark : ChewieColors.appBarBackground, ), + tabBarTheme: TabBarThemeData( + indicatorColor: isDarkMode + ? ChewieColors.defaultPrimaryColorDark + : ChewieColors.defaultPrimaryColor, + splashFactory: NoSplash.splashFactory, + ), ); } @@ -138,7 +144,7 @@ class ChewieTheme { static BoxDecoration get defaultDecoration { return BoxDecoration( color: ChewieTheme.canvasColor, - border: ChewieTheme.border, + border: ChewieTheme.responsiveBorder, boxShadow: ChewieTheme.defaultBoxShadow, borderRadius: ChewieDimens.defaultBorderRadius, ); @@ -174,6 +180,9 @@ class ChewieTheme { static Border get border => borderWithWidth(0.8); + static Border? get responsiveBorder => + ResponsiveUtil.isLandscapeLayout() ? ChewieTheme.border : null; + static Border get divider => dividerWithWidth(1); static Border get topBorder => Border(top: borderSide); @@ -197,13 +206,13 @@ class ChewieTheme { static getBackground(BuildContext context) { return ChewieUtils.currentBrightness(context) == Brightness.light - ? canvasColor + ? scaffoldBackgroundColor : scaffoldBackgroundColor; } static Color getForeground(BuildContext context) { return ChewieUtils.currentBrightness(context) == Brightness.light - ? scaffoldBackgroundColor + ? canvasColor : canvasColor; } @@ -293,7 +302,9 @@ class ChewieTheme { static Color get barrierColor => ResponsiveUtil.isLandscapeLayout() ? ChewieTheme.scaffoldBackgroundColor.withValues(alpha: 0.7) - : Colors.black54; + : const Color(0x59000000); + + static const Duration animationDuration = Duration(milliseconds: 500); static Color get iconColor => Theme.of(chewieProvider.rootContext).iconTheme.color!; diff --git a/third-party/chewie/lib/src/Resources/theme_color_data.dart b/third-party/chewie/lib/src/Resources/theme_color_data.dart index a7f6f564..ff15874b 100644 --- a/third-party/chewie/lib/src/Resources/theme_color_data.dart +++ b/third-party/chewie/lib/src/Resources/theme_color_data.dart @@ -116,6 +116,13 @@ class ChewieThemeColorData { } } + /// The full-page surface used by Scaffold and navigation chrome. + /// + /// Light themes keep their configurable canvas/card colors for visual + /// hierarchy, while the viewport itself stays consistently white. + Color get effectivePageBackgroundColor => + isDarkMode ? scaffoldBackgroundColor : Colors.white; + ChewieThemeColorData({ this.isDarkMode = false, required this.id, @@ -166,11 +173,11 @@ class ChewieThemeColorData { canvasColor: const Color(0xFFF7F8F9), scaffoldBackgroundColor: const Color(0xFFFFFFFF), cardColor: const Color(0xFFF1F1F1), - primaryColor: const Color(0xFF455A64), - cursorColor: const Color(0xFF455A64), - indicatorColor: const Color(0xFF455A64), - textSelectionColor: const Color(0xFF455A64).withAlpha(70), - textSelectionHandleColor: const Color(0xFF455A64), + primaryColor: const Color(0xFF14C2BB), + cursorColor: const Color(0xFF14C2BB), + indicatorColor: const Color(0xFF14C2BB), + textSelectionColor: const Color(0xFF14C2BB).withAlpha(70), + textSelectionHandleColor: const Color(0xFF14C2BB), hintColor: const Color(0xFF1F1F1F), iconColor: const Color(0xFF333333), hoverColor: const Color(0x33C8C8C8), @@ -182,14 +189,14 @@ class ChewieThemeColorData { appBarShadowColor: const Color(0xFFF6F6F6), appBarBackgroundColor: const Color(0xFFF7F8F9), appBarSurfaceTintColor: const Color(0xFFF7F8F9), - textColor: const Color(0xFF212121), - textLightGreyColor: const Color(0xFF757575), + textColor: const Color(0xFF333333), + textLightGreyColor: const Color(0xFFB0B8C0), textDarkGreyColor: const Color(0xFF71767B), - buttonPrimaryColor: const Color(0xFF455A64), + buttonPrimaryColor: const Color(0xFF14C2BB), buttonSecondaryColor: const Color(0xFFF2F2F2), buttonHoverColor: const Color(0xFFF2F2F2), buttonLightHoverColor: const Color(0xFFECECED), - buttonDisabledColor: const Color(0xFF96BBFA), + buttonDisabledColor: const Color(0xFFD4D4D5), scrollBarThumbColor: const Color(0xFFC4C4C4), scrollBarThumbHoverColor: const Color(0xFFB5B5B5), scrollBarTrackColor: const Color(0xFFF0F0F0), @@ -223,11 +230,11 @@ class ChewieThemeColorData { textColor: const Color(0xFF2D2D2D), textLightGreyColor: const Color(0xFF9E9E9E), textDarkGreyColor: const Color(0xFF636363), - buttonPrimaryColor: const Color(0xFF1976D2), + buttonPrimaryColor: const Color(0xFF3A3A3A), buttonSecondaryColor: const Color(0xFFE0E0E0), - buttonHoverColor: const Color(0xFF2196F3), - buttonLightHoverColor: const Color(0xFF90CAF9), - buttonDisabledColor: const Color(0xFFB0BEC5), + buttonHoverColor: const Color(0xFF5D5D5D), + buttonLightHoverColor: const Color(0xFF979797), + buttonDisabledColor: const Color(0xFFDADADA), scrollBarThumbColor: const Color(0xFFC1C1C1), scrollBarThumbHoverColor: const Color(0xFF9E9E9E), scrollBarTrackColor: const Color(0xFFF5F5F5), @@ -278,19 +285,20 @@ class ChewieThemeColorData { static List defaultDarkThemes = [ ChewieThemeColorData( + isDarkMode: true, id: "PureBlack", name: "极简黑", canvasColor: const Color(0xFF212121), scaffoldBackgroundColor: const Color(0xFF171717), cardColor: const Color(0xFF252525), - primaryColor: const Color(0xFF90A4AE), - cursorColor: const Color(0xFF90A4AE), - indicatorColor: const Color(0xFF90A4AE), - textSelectionColor: const Color(0xFF90A4AE).withAlpha(70), - textSelectionHandleColor: const Color(0xFF90A4AE), + primaryColor: const Color(0xFF14C2BB), + cursorColor: const Color(0xFF14C2BB), + indicatorColor: const Color(0xFF14C2BB), + textSelectionColor: const Color(0xFF14C2BB).withAlpha(70), + textSelectionHandleColor: const Color(0xFF14C2BB), hintColor: const Color(0xFFE8E8E8), textColor: const Color(0xFFE0E0E0), - textLightGreyColor: const Color(0xFFAAAAAA), + textLightGreyColor: const Color(0xFFA0A0A0), textDarkGreyColor: const Color(0xFF5C5C5C), iconColor: const Color(0xFFCACACA), hoverColor: const Color(0x44383838), @@ -302,7 +310,7 @@ class ChewieThemeColorData { appBarShadowColor: const Color(0xFF1F1F1F), appBarBackgroundColor: const Color(0xFF212121), appBarSurfaceTintColor: const Color(0xFF212121), - buttonPrimaryColor: const Color(0xFF90A4AE), + buttonPrimaryColor: const Color(0xFF14C2BB), buttonSecondaryColor: const Color(0xFF333333), buttonDisabledColor: const Color(0xFF4A4A4A), buttonHoverColor: const Color(0xFF333333), @@ -316,44 +324,46 @@ class ChewieThemeColorData { errorColor: const Color(0xFFCF6679), ), ChewieThemeColorData( + isDarkMode: true, id: "BlueIron", name: "蓝铁", - scaffoldBackgroundColor: const Color(0xFF1D2733), - canvasColor: const Color(0xFF242E39), - cardColor: const Color(0xFF2E3A45), - primaryColor: const Color(0xFF14C2BB), - hintColor: const Color(0xFF14C2BB), - cursorColor: const Color(0xFF14C2BB), - indicatorColor: const Color(0xFF14C2BB), - textSelectionColor: const Color(0xFF14C2BB).withAlpha(70), - textSelectionHandleColor: const Color(0xFF14C2BB), - textColor: const Color(0xFFB8B8B8), - textLightGreyColor: const Color(0xFF94A3B8), - textDarkGreyColor: const Color(0xFF5C677D), - iconColor: const Color(0xFFB8B8B8), + scaffoldBackgroundColor: const Color(0xFF1A2332), + canvasColor: const Color(0xFF212D3B), + cardColor: const Color(0xFF2A3847), + primaryColor: const Color(0xFF4FC3C3), + hintColor: const Color(0xFF4FC3C3), + cursorColor: const Color(0xFF4FC3C3), + indicatorColor: const Color(0xFF4FC3C3), + textSelectionColor: const Color(0xFF4FC3C3).withAlpha(70), + textSelectionHandleColor: const Color(0xFF4FC3C3), + textColor: const Color(0xFFDCE4EC), + textLightGreyColor: const Color(0xFFA8B8C8), + textDarkGreyColor: const Color(0xFF7A8FA3), + iconColor: const Color(0xFFC5D0DB), hoverColor: const Color(0x22C8C8C8), splashColor: const Color(0x22CCCCCC), highlightColor: const Color(0x22CFCFCF), - shadowColor: const Color(0xFF1B2530), - appBarShadowColor: const Color(0xFF1B2530), - appBarBackgroundColor: const Color(0xFF242E39), - appBarSurfaceTintColor: const Color(0xFF242E39), - buttonPrimaryColor: const Color(0xFFF2F2F2), - buttonSecondaryColor: const Color(0xFF333333), - buttonHoverColor: const Color(0xFF333333), - buttonLightHoverColor: const Color(0xFF2C2C2C), - buttonDisabledColor: const Color(0xFF4A4A4A), - dividerColor: const Color(0xFF2D3743), - borderColor: const Color(0xFF2D3743), - scrollBarThumbColor: const Color(0xFF5A5A5A), - scrollBarThumbHoverColor: const Color(0xFF242E39), - scrollBarTrackColor: const Color(0xFF2D3743), - scrollBarTrackHoverColor: const Color(0xFF404040), - successColor: const Color(0xFF81C784), - warningColor: const Color(0xFFFFA726), - errorColor: const Color(0xFFCF6679), + shadowColor: const Color(0xFF131C28), + appBarShadowColor: const Color(0xFF131C28), + appBarBackgroundColor: const Color(0xFF212D3B), + appBarSurfaceTintColor: const Color(0xFF212D3B), + buttonPrimaryColor: const Color(0xFFE8EDF2), + buttonSecondaryColor: const Color(0xFF2E3D4D), + buttonHoverColor: const Color(0xFF2E3D4D), + buttonLightHoverColor: const Color(0xFF283545), + buttonDisabledColor: const Color(0xFF3A4A5A), + dividerColor: const Color(0xFF2C3A4A), + borderColor: const Color(0xFF2C3A4A), + scrollBarThumbColor: const Color(0xFF4A5A6A), + scrollBarThumbHoverColor: const Color(0xFF5A6A7A), + scrollBarTrackColor: const Color(0xFF253040), + scrollBarTrackHoverColor: const Color(0xFF2E3D4D), + successColor: const Color(0xFF6ECF8E), + warningColor: const Color(0xFFFFB74D), + errorColor: const Color(0xFFEF7B7B), ), ChewieThemeColorData( + isDarkMode: true, id: "GithubDark", name: "Github深色", canvasColor: const Color(0xFF0d1117), @@ -394,6 +404,26 @@ class ChewieThemeColorData { ]; ThemeData toThemeData() { + final pageBackgroundColor = effectivePageBackgroundColor; + final brightness = isDarkMode ? Brightness.dark : Brightness.light; + final onPrimary = ColorUtil.getContrastColor(primaryColor); + final colorScheme = ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: brightness, + ).copyWith( + primary: primaryColor, + onPrimary: onPrimary, + secondary: primaryColor, + onSecondary: onPrimary, + surface: pageBackgroundColor, + onSurface: textColor, + error: errorColor, + onError: ColorUtil.getContrastColor(errorColor), + outline: borderColor, + shadow: shadowColor, + surfaceTint: primaryColor, + ); + TextStyle displayLarge = TextStyle( fontWeight: FontWeight.w400, fontSize: 57, @@ -504,15 +534,15 @@ class ChewieThemeColorData { ); return ThemeData( - fontFamily: CustomFont.getCurrentFont().fontFamily, - fontFamilyFallback: const [ - "SNPro-Regular", + extensions: const >[ + ChewieIconThemeData.standard, ], - brightness: isDarkMode ? Brightness.dark : Brightness.light, + fontFamily: CustomFont.getCurrentFont().fontFamily, + brightness: brightness, + colorScheme: colorScheme, primaryColor: primaryColor, hintColor: hintColor, - indicatorColor: indicatorColor, - scaffoldBackgroundColor: scaffoldBackgroundColor, + scaffoldBackgroundColor: pageBackgroundColor, canvasColor: canvasColor, dividerColor: dividerColor, shadowColor: shadowColor, @@ -597,7 +627,17 @@ class ChewieThemeColorData { backgroundColor: appBarBackgroundColor, surfaceTintColor: appBarSurfaceTintColor, ), - tabBarTheme: const TabBarTheme( + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: pageBackgroundColor, + elevation: 0, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: pageBackgroundColor, + surfaceTintColor: Colors.transparent, + elevation: 0, + ), + tabBarTheme: TabBarThemeData( + indicatorColor: indicatorColor, splashFactory: NoSplash.splashFactory, ), buttonTheme: ButtonThemeData( @@ -636,6 +676,11 @@ class ChewieThemeColorData { hintStyle: bodySmall, errorStyle: TextStyle(color: errorColor), ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: primaryColor, + linearTrackColor: dividerColor, + circularTrackColor: dividerColor, + ), ); } @@ -682,6 +727,103 @@ class ChewieThemeColorData { 'errorColor': errorColor.toHex(), }; + static String generateId() => + 'custom_${DateTime.now().millisecondsSinceEpoch}'; + + ChewieThemeColorData copyWith({ + bool? isDarkMode, + String? id, + String? name, + String? description, + Color? primaryColor, + Color? canvasColor, + Color? scaffoldBackgroundColor, + Color? cardColor, + Color? hintColor, + Color? indicatorColor, + Color? hoverColor, + Color? splashColor, + Color? highlightColor, + Color? shadowColor, + Color? iconColor, + Color? appBarBackgroundColor, + Color? appBarSurfaceTintColor, + Color? appBarShadowColor, + double? appBarElevation, + double? appBarScrollUnderElevation, + Color? textColor, + Color? textLightGreyColor, + Color? textDarkGreyColor, + Color? buttonPrimaryColor, + Color? buttonSecondaryColor, + Color? buttonDisabledColor, + Color? buttonHoverColor, + Color? buttonLightHoverColor, + Color? textSelectionColor, + Color? textSelectionHandleColor, + Color? cursorColor, + Color? dividerColor, + Color? borderColor, + Color? scrollBarThumbColor, + Color? scrollBarThumbHoverColor, + Color? scrollBarTrackColor, + Color? scrollBarTrackHoverColor, + Color? successColor, + Color? warningColor, + Color? errorColor, + }) { + return ChewieThemeColorData( + isDarkMode: isDarkMode ?? this.isDarkMode, + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + primaryColor: primaryColor ?? this.primaryColor, + canvasColor: canvasColor ?? this.canvasColor, + scaffoldBackgroundColor: + scaffoldBackgroundColor ?? this.scaffoldBackgroundColor, + cardColor: cardColor ?? this.cardColor, + hintColor: hintColor ?? this.hintColor, + indicatorColor: indicatorColor ?? this.indicatorColor, + hoverColor: hoverColor ?? this.hoverColor, + splashColor: splashColor ?? this.splashColor, + highlightColor: highlightColor ?? this.highlightColor, + shadowColor: shadowColor ?? this.shadowColor, + iconColor: iconColor ?? this.iconColor, + appBarBackgroundColor: + appBarBackgroundColor ?? this.appBarBackgroundColor, + appBarSurfaceTintColor: + appBarSurfaceTintColor ?? this.appBarSurfaceTintColor, + appBarShadowColor: appBarShadowColor ?? this.appBarShadowColor, + appBarElevation: appBarElevation ?? this.appBarElevation, + appBarScrollUnderElevation: + appBarScrollUnderElevation ?? this.appBarScrollUnderElevation, + textColor: textColor ?? this.textColor, + textLightGreyColor: textLightGreyColor ?? this.textLightGreyColor, + textDarkGreyColor: textDarkGreyColor ?? this.textDarkGreyColor, + buttonPrimaryColor: buttonPrimaryColor ?? this.buttonPrimaryColor, + buttonSecondaryColor: buttonSecondaryColor ?? this.buttonSecondaryColor, + buttonDisabledColor: buttonDisabledColor ?? this.buttonDisabledColor, + buttonHoverColor: buttonHoverColor ?? this.buttonHoverColor, + buttonLightHoverColor: + buttonLightHoverColor ?? this.buttonLightHoverColor, + textSelectionColor: textSelectionColor ?? this.textSelectionColor, + textSelectionHandleColor: + textSelectionHandleColor ?? this.textSelectionHandleColor, + cursorColor: cursorColor ?? this.cursorColor, + dividerColor: dividerColor ?? this.dividerColor, + borderColor: borderColor ?? this.borderColor, + scrollBarThumbColor: scrollBarThumbColor ?? this.scrollBarThumbColor, + scrollBarThumbHoverColor: + scrollBarThumbHoverColor ?? this.scrollBarThumbHoverColor, + scrollBarTrackColor: scrollBarTrackColor ?? this.scrollBarTrackColor, + scrollBarTrackHoverColor: + scrollBarTrackHoverColor ?? this.scrollBarTrackHoverColor, + successColor: successColor ?? this.successColor, + warningColor: warningColor ?? this.warningColor, + errorColor: errorColor ?? this.errorColor, + ); + } + factory ChewieThemeColorData.fromJson(Map map) { return ChewieThemeColorData( id: map['id'] ?? "", diff --git a/third-party/chewie/lib/src/Screens/webview_screen.dart b/third-party/chewie/lib/src/Screens/webview_screen.dart index 09a0b902..6c31fe3e 100644 --- a/third-party/chewie/lib/src/Screens/webview_screen.dart +++ b/third-party/chewie/lib/src/Screens/webview_screen.dart @@ -72,28 +72,28 @@ class _WebviewScreenState extends BaseDynamicState entries: [ FlutterContextMenuItem( chewieLocalizations.refresh, - iconData: Icons.refresh_rounded, + iconData: ChewieIcons.refresh, onPressed: () async { webViewController?.reload(); }, ), FlutterContextMenuItem( chewieLocalizations.copyLink, - iconData: Icons.copy_rounded, + iconData: ChewieIcons.copy, onPressed: () { ChewieUtils.copy(context, widget.url); }, ), FlutterContextMenuItem( chewieLocalizations.openWithBrowser, - iconData: Icons.open_in_browser_rounded, + iconData: ChewieIcons.openExternal, onPressed: () { UriUtil.openExternal(widget.url); }, ), FlutterContextMenuItem( chewieLocalizations.shareToOtherApps, - iconData: Icons.share_rounded, + iconData: ChewieIcons.share, onPressed: () { UriUtil.share(widget.url); }, @@ -124,12 +124,9 @@ class _WebviewScreenState extends BaseDynamicState DialogNavigatorHelper.responsivePopPage(); }, actions: [ - CircleIconButton( - icon: Icon( - LucideIcons.ellipsisVertical, - color: ChewieTheme.iconColor, - ), - onTap: () { + ChewieIconButton( + icon: LucideIcons.ellipsisVertical, + onPressed: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); }, @@ -139,7 +136,6 @@ class _WebviewScreenState extends BaseDynamicState ToolButton( context: context, icon: LucideIcons.ellipsisVertical, - buttonSize: const Size(32, 32), onPressed: () { BottomSheetBuilder.showContextMenu( context, _buildMoreButtons()); diff --git a/third-party/chewie/lib/src/Utils/General/color_util.dart b/third-party/chewie/lib/src/Utils/General/color_util.dart index af4db3fd..4453229d 100644 --- a/third-party/chewie/lib/src/Utils/General/color_util.dart +++ b/third-party/chewie/lib/src/Utils/General/color_util.dart @@ -21,7 +21,10 @@ class ColorUtil { } static Color getContrastColor(Color color) { - return color.computeLuminance() > 0.5 ? Colors.black : Colors.white; + final luminance = color.computeLuminance(); + final blackContrast = (luminance + 0.05) / 0.05; + final whiteContrast = 1.05 / (luminance + 0.05); + return blackContrast >= whiteContrast ? Colors.black : Colors.white; } static isDark(BuildContext context) { diff --git a/third-party/chewie/lib/src/Utils/General/responsive_util.dart b/third-party/chewie/lib/src/Utils/General/responsive_util.dart index 11774f07..45ba1202 100644 --- a/third-party/chewie/lib/src/Utils/General/responsive_util.dart +++ b/third-party/chewie/lib/src/Utils/General/responsive_util.dart @@ -25,6 +25,11 @@ enum LinuxOSType { } class ResponsiveUtil { + static const double tabletShortestSideThreshold = 600; + static const double tabletLongestSideThreshold = 900; + + static bool? _portraitLockEnabled; + static String osVersion = ""; static String buildNumber = ""; static String version = ""; @@ -58,7 +63,8 @@ class ResponsiveUtil { deviceName = await getDeviceName(); deviceDescription = await getDeviceDescription(); osVersion = await getOSVersion(); - print("Platform: $platformName, Version: $osVersion, Build Number: $buildNumber, App Name: $appName, Package Name: $packageName, Device Name: $deviceName, Device Description: $deviceDescription"); + print( + "Platform: $platformName, Version: $osVersion, Build Number: $buildNumber, App Name: $appName, Package Name: $packageName, Device Name: $deviceName, Device Description: $deviceDescription"); } static Future getOSVersion() async { @@ -171,33 +177,39 @@ class ResponsiveUtil { (Platform.isMacOS || Platform.isWindows || Platform.isLinux); } - static checkSizeCondition() { - double shortestThreshold = 600; - double longestThreshold = 900; - double longestSide = - MediaQuery.sizeOf(chewieProvider.rootContext).longestSide; - double shortestSide = - MediaQuery.sizeOf(chewieProvider.rootContext).shortestSide; - bool sizeCondition = - longestSide >= longestThreshold && shortestSide >= shortestThreshold; - if (!sizeCondition) { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - } + static bool isTabletSize(Size size) { + return size.longestSide >= tabletLongestSideThreshold && + size.shortestSide >= tabletShortestSideThreshold; + } + + static Future checkSizeCondition({Size? logicalSize}) async { + if (!isMobile()) return; + + final size = logicalSize ?? MediaQuery.sizeOf(chewieProvider.rootContext); + final shouldLockPortrait = !isTabletSize(size); + if (_portraitLockEnabled == shouldLockPortrait) return; + + await SystemChrome.setPreferredOrientations( + shouldLockPortrait + ? const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ] + : const [], + ); + _portraitLockEnabled = shouldLockPortrait; + } + + /// Re-applies the app's normal phone/tablet orientation policy after a + /// temporary media fullscreen override. + static Future restoreOrientationPolicy({Size? logicalSize}) async { + _portraitLockEnabled = null; + await checkSizeCondition(logicalSize: logicalSize); } static bool isTablet() { - double shortestThreshold = 600; - double longestThreshold = 900; - double longestSide = - MediaQuery.sizeOf(chewieProvider.rootContext).longestSide; - double shortestSide = - MediaQuery.sizeOf(chewieProvider.rootContext).shortestSide; - bool sizeCondition = - longestSide >= longestThreshold && shortestSide >= shortestThreshold; - return !kIsWeb && (Platform.isIOS || Platform.isAndroid) && sizeCondition; + final size = MediaQuery.sizeOf(chewieProvider.rootContext); + return isMobile() && isTabletSize(size); } static bool isLandscapeTablet() { diff --git a/third-party/chewie/lib/src/Utils/General/web_util.dart b/third-party/chewie/lib/src/Utils/General/web_util.dart index fc5adcf2..f551b964 100644 --- a/third-party/chewie/lib/src/Utils/General/web_util.dart +++ b/third-party/chewie/lib/src/Utils/General/web_util.dart @@ -21,21 +21,19 @@ class FaviconRequest { class WebUtil { static Uri getBaseUrl(dynamic url) { - Uri uri; - - if (url is String) { - uri = Uri.parse(url); - } else if (url is Uri) { - uri = url; - } else { + final Uri? uri = url is Uri + ? url + : url is String + ? Uri.tryParse(url) + : null; + if (uri == null || uri.scheme.isEmpty || uri.host.isEmpty) { return Uri(); } - - final scheme = uri.scheme; - final host = uri.host; - final port = uri.hasPort ? ':${uri.port}' : ''; - - return Uri.parse('$scheme://$host$port'); + return Uri( + scheme: uri.scheme, + host: uri.host, + port: uri.hasPort ? uri.port : null, + ); } static bool isHashOnlyLink(String value) { @@ -122,7 +120,6 @@ class WebUtil { if (await validateFavicon(fallback)) { return fallback; } - } catch (e) { return null; } diff --git a/third-party/chewie/lib/src/Utils/System/file_util.dart b/third-party/chewie/lib/src/Utils/System/file_util.dart index 6431c9ec..a45a5c60 100644 --- a/third-party/chewie/lib/src/Utils/System/file_util.dart +++ b/third-party/chewie/lib/src/Utils/System/file_util.dart @@ -20,7 +20,25 @@ import 'package:win32/win32.dart'; enum WindowsVersion { installed, portable } +typedef SaveImageDelegate = Future Function( + BuildContext context, + String imageUrl, { + bool showToast, + String? fileName, +}); + +typedef SaveVideoDelegate = Future Function( + BuildContext context, + String videoUrl, { + bool showToast, + String? fileName, + Function(int, int)? onReceiveProgress, +}); + class FileUtil { + static SaveImageDelegate? saveImageDelegate; + static SaveVideoDelegate? saveVideoDelegate; + static Future pickFiles({ String? dialogTitle, String? initialDirectory, @@ -241,7 +259,6 @@ class FileUtil { static Future migrationDataToSupportDirectory() async { try { - Hive.defaultDirectory = await FileUtil.getHiveDir(); bool haveMigratedToSupportDirectoryFromHive = ChewieHiveUtil.getBool( ChewieHiveUtil.haveMigratedToSupportDirectoryKey, defaultValue: false); @@ -476,6 +493,15 @@ class FileUtil { bool showToast = true, String? fileName, }) async { + final delegate = saveImageDelegate; + if (delegate != null) { + return delegate( + context, + imageUrl, + showToast: showToast, + fileName: fileName, + ); + } try { CachedNetworkImage image = CachedNetworkImage( imageUrl: imageUrl, @@ -594,6 +620,16 @@ class FileUtil { String? fileName, Function(int, int)? onReceiveProgress, }) async { + final delegate = saveVideoDelegate; + if (delegate != null) { + return delegate( + context, + videoUrl, + showToast: showToast, + fileName: fileName, + onReceiveProgress: onReceiveProgress, + ); + } try { var appDocDir = await getTemporaryDirectory(); String savePath = appDocDir.path + extractFileNameFromUrl(videoUrl); diff --git a/third-party/chewie/lib/src/Utils/System/hive_util.dart b/third-party/chewie/lib/src/Utils/System/hive_util.dart index f07286ff..ffb63a58 100644 --- a/third-party/chewie/lib/src/Utils/System/hive_util.dart +++ b/third-party/chewie/lib/src/Utils/System/hive_util.dart @@ -64,9 +64,9 @@ class ChewieHiveUtil { } static initBox() async { - Hive.box( - name: ChewieHiveUtil.settingsBox, - directory: await FileUtil.getApplicationDir(), + await Hive.openBox( + ChewieHiveUtil.settingsBox, + path: await FileUtil.getApplicationDir(), ); } @@ -163,11 +163,11 @@ class ChewieHiveUtil { } } - static int? getFontSize() { - return 2; + static int getFontSize() { + return ChewieHiveUtil.getInt(ChewieHiveUtil.fontSizeKey, defaultValue: 2); } - static void setFontSize(int? fontSize) { + static void setFontSize(int fontSize) { ChewieHiveUtil.put(ChewieHiveUtil.fontSizeKey, fontSize); } @@ -183,7 +183,7 @@ class ChewieHiveUtil { static int getLightThemeIndex() { int index = ChewieHiveUtil.getInt(ChewieHiveUtil.lightThemeIndexKey, defaultValue: 0); - if (index > ChewieThemeColorData.defaultLightThemes.length) { + if (index >= ChewieThemeColorData.defaultLightThemes.length) { String? json = ChewieHiveUtil.getString(ChewieHiveUtil.customLightThemeListKey); if (json == null || json.isEmpty) { @@ -191,7 +191,7 @@ class ChewieHiveUtil { return 0; } else { List list = jsonDecode(json); - if (index > + if (index >= ChewieThemeColorData.defaultLightThemes.length + list.length) { setLightTheme(0); return 0; @@ -208,7 +208,7 @@ class ChewieHiveUtil { static int getDarkThemeIndex() { int index = ChewieHiveUtil.getInt(ChewieHiveUtil.darkThemeIndexKey, defaultValue: 0); - if (index > ChewieThemeColorData.defaultDarkThemes.length) { + if (index >= ChewieThemeColorData.defaultDarkThemes.length) { String? json = ChewieHiveUtil.getString(ChewieHiveUtil.customDarkThemeListKey); if (json == null || json.isEmpty) { @@ -216,7 +216,7 @@ class ChewieHiveUtil { return 0; } else { List list = jsonDecode(json); - if (index > + if (index >= ChewieThemeColorData.defaultDarkThemes.length + list.length) { setDarkTheme(0); return 0; @@ -230,56 +230,91 @@ class ChewieHiveUtil { } } + static ChewieThemeColorData _applyPrimaryColorOverride( + ChewieThemeColorData theme, bool isDark) { + final key = isDark + ? customDarkThemePrimaryColorKey + : customLightThemePrimaryColorKey; + String? hex = ChewieHiveUtil.getString(key, autoCreate: false); + if (hex == null || hex.isEmpty) return theme; + final color = HexColor.fromHex(hex); + // Flutter's normalized Color channels previously got serialized with + // `toInt()`, producing values such as #01010000. Those legacy values are + // unrecoverable, so discard the broken override and restore the selected + // theme's own accent instead of rendering an almost transparent UI. + if ((color.toARGB32() >>> 24) <= 1) { + ChewieHiveUtil.delete(key); + if (isDark) { + setDarkThemePrimaryColorIndex(0); + } else { + setLightThemePrimaryColorIndex(0); + } + return theme; + } + return theme.copyWith( + primaryColor: color, + indicatorColor: color, + cursorColor: color, + textSelectionColor: color.withAlpha(70), + textSelectionHandleColor: color, + buttonPrimaryColor: color, + ); + } + static ChewieThemeColorData getLightTheme() { int index = ChewieHiveUtil.getInt(ChewieHiveUtil.lightThemeIndexKey, defaultValue: 0); - if (index > ChewieThemeColorData.defaultLightThemes.length) { + ChewieThemeColorData theme; + if (index >= ChewieThemeColorData.defaultLightThemes.length) { String? json = ChewieHiveUtil.getString(ChewieHiveUtil.customLightThemeListKey); if (json == null || json.isEmpty) { setLightTheme(0); - return ChewieThemeColorData.defaultLightThemes[0]; + theme = ChewieThemeColorData.defaultLightThemes[0]; } else { List list = jsonDecode(json); - if (index > + if (index >= ChewieThemeColorData.defaultLightThemes.length + list.length) { setLightTheme(0); - return ChewieThemeColorData.defaultLightThemes[0]; + theme = ChewieThemeColorData.defaultLightThemes[0]; } else { - return ChewieThemeColorData.fromJson( + theme = ChewieThemeColorData.fromJson( list[index - ChewieThemeColorData.defaultLightThemes.length]); } } } else { - return ChewieThemeColorData.defaultLightThemes[ChewieUtils.patchEnum( + theme = ChewieThemeColorData.defaultLightThemes[ChewieUtils.patchEnum( index, ChewieThemeColorData.defaultLightThemes.length)]; } + return _applyPrimaryColorOverride(theme, false); } static ChewieThemeColorData getDarkTheme() { int index = ChewieHiveUtil.getInt(ChewieHiveUtil.darkThemeIndexKey, defaultValue: 0); - if (index > ChewieThemeColorData.defaultDarkThemes.length) { + ChewieThemeColorData theme; + if (index >= ChewieThemeColorData.defaultDarkThemes.length) { String? json = ChewieHiveUtil.getString(ChewieHiveUtil.customDarkThemeListKey); if (json == null || json.isEmpty) { setDarkTheme(0); - return ChewieThemeColorData.defaultDarkThemes[0]; + theme = ChewieThemeColorData.defaultDarkThemes[0]; } else { List list = jsonDecode(json); - if (index > + if (index >= ChewieThemeColorData.defaultDarkThemes.length + list.length) { setDarkTheme(0); - return ChewieThemeColorData.defaultDarkThemes[0]; + theme = ChewieThemeColorData.defaultDarkThemes[0]; } else { - return ChewieThemeColorData.fromJson( + theme = ChewieThemeColorData.fromJson( list[index - ChewieThemeColorData.defaultDarkThemes.length]); } } } else { - return ChewieThemeColorData.defaultDarkThemes[ChewieUtils.patchEnum( + theme = ChewieThemeColorData.defaultDarkThemes[ChewieUtils.patchEnum( index, ChewieThemeColorData.defaultDarkThemes.length)]; } + return _applyPrimaryColorOverride(theme, true); } static void setLightTheme(int index) => @@ -288,6 +323,84 @@ class ChewieHiveUtil { static void setDarkTheme(int index) => ChewieHiveUtil.put(ChewieHiveUtil.darkThemeIndexKey, index); + static List getCustomLightThemes() { + String? json = + ChewieHiveUtil.getString(ChewieHiveUtil.customLightThemeListKey); + if (json == null || json.isEmpty) return []; + List list = jsonDecode(json); + return list.map((e) => ChewieThemeColorData.fromJson(e)).toList(); + } + + static List getCustomDarkThemes() { + String? json = + ChewieHiveUtil.getString(ChewieHiveUtil.customDarkThemeListKey); + if (json == null || json.isEmpty) return []; + List list = jsonDecode(json); + return list.map((e) => ChewieThemeColorData.fromJson(e)).toList(); + } + + static void setCustomLightThemes(List themes) { + ChewieHiveUtil.put(ChewieHiveUtil.customLightThemeListKey, + jsonEncode(themes.map((e) => e.toJson()).toList())); + } + + static void setCustomDarkThemes(List themes) { + ChewieHiveUtil.put(ChewieHiveUtil.customDarkThemeListKey, + jsonEncode(themes.map((e) => e.toJson()).toList())); + } + + static Color? getCustomLightPrimaryColor() { + String? hex = ChewieHiveUtil.getString( + ChewieHiveUtil.customLightThemePrimaryColorKey, + autoCreate: false); + if (hex == null || hex.isEmpty) return null; + return HexColor.fromHex(hex); + } + + static void setCustomLightPrimaryColor(Color? color) { + if (color == null) { + ChewieHiveUtil.delete(ChewieHiveUtil.customLightThemePrimaryColorKey); + } else { + ChewieHiveUtil.put( + ChewieHiveUtil.customLightThemePrimaryColorKey, color.toHex()); + } + } + + static Color? getCustomDarkPrimaryColor() { + String? hex = ChewieHiveUtil.getString( + ChewieHiveUtil.customDarkThemePrimaryColorKey, + autoCreate: false); + if (hex == null || hex.isEmpty) return null; + return HexColor.fromHex(hex); + } + + static void setCustomDarkPrimaryColor(Color? color) { + if (color == null) { + ChewieHiveUtil.delete(ChewieHiveUtil.customDarkThemePrimaryColorKey); + } else { + ChewieHiveUtil.put( + ChewieHiveUtil.customDarkThemePrimaryColorKey, color.toHex()); + } + } + + static int getLightThemePrimaryColorIndex() { + return ChewieHiveUtil.getInt(ChewieHiveUtil.lightThemePrimaryColorIndexKey, + defaultValue: 0); + } + + static void setLightThemePrimaryColorIndex(int index) { + ChewieHiveUtil.put(ChewieHiveUtil.lightThemePrimaryColorIndexKey, index); + } + + static int getDarkThemePrimaryColorIndex() { + return ChewieHiveUtil.getInt(ChewieHiveUtil.darkThemePrimaryColorIndexKey, + defaultValue: 0); + } + + static void setDarkThemePrimaryColorIndex(int index) { + ChewieHiveUtil.put(ChewieHiveUtil.darkThemePrimaryColorIndexKey, index); + } + static List getSortableItems( String key, List defaultValue, @@ -310,7 +423,7 @@ class ChewieHiveUtil { String boxName = ChewieHiveUtil.settingsBox, int defaultValue = 0, }) { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); if (!box.containsKey(key)) { put(key, defaultValue, boxName: boxName); } @@ -322,7 +435,7 @@ class ChewieHiveUtil { String boxName = ChewieHiveUtil.settingsBox, int defaultValue = 0, }) { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); if (!box.containsKey(key)) { put(key, defaultValue, boxName: boxName); } @@ -334,7 +447,7 @@ class ChewieHiveUtil { String boxName = ChewieHiveUtil.settingsBox, bool defaultValue = true, }) { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); if (!box.containsKey(key)) { put(key, defaultValue, boxName: boxName); } @@ -347,7 +460,7 @@ class ChewieHiveUtil { bool autoCreate = true, String? defaultValue, }) { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); if (!box.containsKey(key)) { if (!autoCreate) { return null; @@ -361,7 +474,7 @@ class ChewieHiveUtil { String key, { String boxName = ChewieHiveUtil.settingsBox, }) { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); Map res = {}; if (box.get(key) != null) { res = Map.from(box.get(key)); @@ -375,7 +488,7 @@ class ChewieHiveUtil { bool autoCreate = true, List defaultValue = const [], }) { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); if (!box.containsKey(key)) { if (!autoCreate) { return null; @@ -406,7 +519,7 @@ class ChewieHiveUtil { dynamic value, { String boxName = ChewieHiveUtil.settingsBox, }) async { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); return box.put(key, value); } @@ -414,7 +527,7 @@ class ChewieHiveUtil { String key, { String boxName = ChewieHiveUtil.settingsBox, }) async { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); box.delete(key); } @@ -422,7 +535,7 @@ class ChewieHiveUtil { String key, { String boxName = ChewieHiveUtil.settingsBox, }) { - final Box box = Hive.box(name: boxName); + final Box box = Hive.box(boxName); return box.containsKey(key); } } diff --git a/third-party/chewie/lib/src/Utils/System/locale_util.dart b/third-party/chewie/lib/src/Utils/System/locale_util.dart index 32f10ac7..30dfd6e1 100644 --- a/third-party/chewie/lib/src/Utils/System/locale_util.dart +++ b/third-party/chewie/lib/src/Utils/System/locale_util.dart @@ -3,20 +3,21 @@ import 'package:flutter/material.dart'; import 'package:tuple/tuple.dart'; class LocaleUtil with ChangeNotifier { - static List> localeLabels = >[ - Tuple2(chewieLocalizations.followSystem, null), - const Tuple2("Deutsch", Locale("de")), - const Tuple2("English", Locale("en")), - const Tuple2("Español", Locale("es")), - const Tuple2("Français", Locale("fr")), - const Tuple2("hrvatski", Locale("hr")), - const Tuple2("Português do Brasil", Locale("pt")), - const Tuple2("Türkçe", Locale("tr")), - const Tuple2("Українська", Locale("uk")), - const Tuple2("简体中文", Locale("zh", "CN")), - const Tuple2("繁體中文", Locale("zh", "TW")), - const Tuple2("日本語", Locale("ja")), - ]; + static List> get localeLabels => + >[ + Tuple2(chewieLocalizations.followSystem, null), + const Tuple2("Deutsch", Locale("de")), + const Tuple2("English", Locale("en")), + const Tuple2("Español", Locale("es")), + const Tuple2("Français", Locale("fr")), + const Tuple2("hrvatski", Locale("hr")), + const Tuple2("Português do Brasil", Locale("pt")), + const Tuple2("Türkçe", Locale("tr")), + const Tuple2("Українська", Locale("uk")), + const Tuple2("简体中文", Locale("zh", "CN")), + const Tuple2("繁體中文", Locale("zh", "TW")), + const Tuple2("日本語", Locale("ja")), + ]; static Tuple2? getTuple(Locale? locale) { if (locale == null) { diff --git a/third-party/chewie/lib/src/Utils/System/route_util.dart b/third-party/chewie/lib/src/Utils/System/route_util.dart index dd0322f6..70ab42c8 100644 --- a/third-party/chewie/lib/src/Utils/System/route_util.dart +++ b/third-party/chewie/lib/src/Utils/System/route_util.dart @@ -13,9 +13,11 @@ class RouteUtil { Function(dynamic)? onThen, bool popAll = false, }) { - return Navigator.push( - context, MaterialPageRoute(builder: (context) => page)) - .then(onThen ?? (_) => {}); + final route = MaterialPageRoute(builder: (context) => page); + final future = popAll + ? Navigator.pushAndRemoveUntil(context, route, (_) => false) + : Navigator.push(context, route); + return future.then(onThen ?? (_) => {}); } static pushCupertinoRoute( @@ -43,14 +45,21 @@ class RouteUtil { } static pushPanelCupertinoRoute(BuildContext context, Widget page) { - chewieProvider.panelScreenState?.pushPage(page); + final panelScreenState = chewieProvider.panelScreenState; + if (panelScreenState != null) { + return panelScreenState.pushPage(page); + } + return pushCupertinoRoute(context, page); } static getFadeRoute( Widget page, { Duration? duration, + bool opaque = true, }) { return PageRouteBuilder( + opaque: opaque, + barrierColor: opaque ? null : Colors.transparent, transitionDuration: duration ?? const Duration(milliseconds: 300), pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { @@ -70,11 +79,13 @@ class RouteUtil { Widget page, { Function(dynamic)? onThen, bool popAll = false, + bool opaque = true, }) { - return Navigator.push( - context, - getFadeRoute(page), - ).then(onThen ?? (_) => {}); + final route = getFadeRoute(page, opaque: opaque); + final future = popAll + ? Navigator.pushAndRemoveUntil(context, route, (_) => false) + : Navigator.push(context, route); + return future.then(onThen ?? (_) => {}); } static pushDialogRoute( @@ -89,6 +100,7 @@ class RouteUtil { bool useFade = false, bool popAll = false, bool animation = true, + bool opaque = true, }) { if (ResponsiveUtil.isLandscapeLayout()) { if (DialogNavigatorHelper.isMounted()) { @@ -108,7 +120,13 @@ class RouteUtil { } } else { if (useFade) { - pushFadeRoute(context, page, onThen: onThen, popAll: popAll); + pushFadeRoute( + context, + page, + onThen: onThen, + popAll: popAll, + opaque: opaque, + ); } else { pushCupertinoRoute(context, page, onThen: onThen, popAll: popAll); } diff --git a/third-party/chewie/lib/src/Utils/System/uri_util.dart b/third-party/chewie/lib/src/Utils/System/uri_util.dart index 88045427..2e0d566b 100644 --- a/third-party/chewie/lib/src/Utils/System/uri_util.dart +++ b/third-party/chewie/lib/src/Utils/System/uri_util.dart @@ -251,16 +251,21 @@ class UriUtil { } static Future openExternal(String url) async { - await launchUrl( - Uri.parse(url), - mode: LaunchMode.externalNonBrowserApplication, - ); + final uri = Uri.parse(url); + try { + if (await launchUrl( + uri, + mode: LaunchMode.externalNonBrowserApplication, + )) { + return; + } + } catch (_) { + // No app claimed the link; let the system fall back to a browser. + } + await launchUrl(uri, mode: LaunchMode.externalApplication); } static Future openExternalUri(WebUri uri) async { - await launchUrl( - uri, - mode: LaunchMode.externalNonBrowserApplication, - ); + await openExternal(uri.toString()); } } diff --git a/third-party/chewie/lib/src/Utils/itoast.dart b/third-party/chewie/lib/src/Utils/itoast.dart index c243ba0b..ce05ba38 100644 --- a/third-party/chewie/lib/src/Utils/itoast.dart +++ b/third-party/chewie/lib/src/Utils/itoast.dart @@ -1,9 +1,19 @@ +import 'dart:async'; + import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:local_notifier/local_notifier.dart'; class IToast { + static OverlayEntry? _toastEntry; + static Timer? _toastTimer; + static final GlobalKey<_MobileToastState> _toastKey = + GlobalKey<_MobileToastState>(); + static final ValueNotifier<_ToastContent> _toastContent = + ValueNotifier(const _ToastContent('')); + static FToast? show( String text, { Icon? icon, @@ -11,30 +21,43 @@ class IToast { int seconds = 2, ToastGravity gravity = ToastGravity.TOP, }) { - if (ResponsiveUtil.isLandscapeLayout()) { + if (ResponsiveUtil.isDesktop()) { NotificationManager().show( chewieProvider.rootContext, text, + overlayState: chewieProvider.globalNavigatorState?.overlay, description: decription, duration: Duration(seconds: seconds), style: NotificationStyle(icon: icon?.icon, iconColor: icon?.color), ); } else { - FToast toast = FToast().init(chewieProvider.rootContext); - toast.showToast( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - decoration: ChewieTheme.defaultDecoration, - child: Text( - text, - textAlign: TextAlign.center, - style: ChewieTheme.bodyMedium, - ), - ), + final overlay = chewieProvider.globalNavigatorState?.overlay; + if (overlay == null || !overlay.mounted) return null; + _toastTimer?.cancel(); + _toastContent.value = _ToastContent( + text, + icon: icon, + description: decription, gravity: gravity, - toastDuration: Duration(seconds: seconds), ); - return toast; + if (_toastEntry == null) { + _toastEntry = OverlayEntry( + builder: (context) => _MobileToast( + key: _toastKey, + content: _toastContent, + onDismissed: () { + _toastEntry?.remove(); + _toastEntry = null; + }, + ), + ); + overlay.insert(_toastEntry!); + } else { + _toastKey.currentState?.show(); + } + _toastTimer = Timer(Duration(seconds: seconds), () { + _toastKey.currentState?.dismiss(); + }); } return null; } @@ -93,3 +116,156 @@ class IToast { return notification; } } + +class _ToastContent { + const _ToastContent( + this.text, { + this.icon, + this.description, + this.gravity = ToastGravity.TOP, + }); + + final String text; + final Icon? icon; + final String? description; + final ToastGravity gravity; +} + +class _MobileToast extends StatefulWidget { + const _MobileToast({ + super.key, + required this.content, + required this.onDismissed, + }); + + final ValueListenable<_ToastContent> content; + final VoidCallback onDismissed; + + @override + State<_MobileToast> createState() => _MobileToastState(); +} + +class _MobileToastState extends State<_MobileToast> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + reverseDuration: const Duration(milliseconds: 140), + )..forward(); + + void show() => _controller.forward(); + + Future dismiss() async { + await _controller.reverse(); + if (mounted && _controller.isDismissed) widget.onDismissed(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: IgnorePointer( + child: SafeArea( + minimum: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: ValueListenableBuilder<_ToastContent>( + valueListenable: widget.content, + builder: (context, value, _) { + final bottom = value.gravity == ToastGravity.BOTTOM; + final theme = Theme.of(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final foreground = theme.colorScheme.onSurface; + return Align( + alignment: + bottom ? Alignment.bottomCenter : Alignment.topCenter, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final progress = reduceMotion + ? 1.0 + : Curves.easeOutCubic.transform(_controller.value); + return Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset(0, (bottom ? 10 : -10) * (1 - progress)), + child: child, + ), + ); + }, + child: Semantics( + liveRegion: true, + child: Material( + color: theme.scaffoldBackgroundColor, + elevation: 0, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + color: theme.dividerColor, + width: 0.8, + ), + borderRadius: BorderRadius.circular(16), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (value.icon != null) ...[ + IconTheme( + data: IconThemeData( + size: 18, + color: theme.colorScheme.primary, + ), + child: value.icon!, + ), + const SizedBox(width: 10), + ], + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value.text, + style: + theme.textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + if (value.description?.isNotEmpty ?? false) + Text( + value.description!, + style: + theme.textTheme.bodySmall?.copyWith( + color: theme + .colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/third-party/chewie/lib/src/Utils/utils.dart b/third-party/chewie/lib/src/Utils/utils.dart index 81b36bab..c560082f 100644 --- a/third-party/chewie/lib/src/Utils/utils.dart +++ b/third-party/chewie/lib/src/Utils/utils.dart @@ -4,17 +4,14 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_windowmanager/flutter_windowmanager.dart'; -import 'package:intl/intl.dart'; import 'package:local_auth/error_codes.dart' as auth_error; import 'package:local_auth/local_auth.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:screen_protector/screen_protector.dart'; -import 'package:uuid/uuid.dart'; import 'package:window_manager/window_manager.dart'; class ChewieUtils { - static Future setSafeMode(bool enabled) async { if (ResponsiveUtil.isMobile()) { if (enabled) { @@ -32,8 +29,6 @@ class ChewieUtils { await ScreenProtector.protectDataLeakageWithColor( ChewieTheme.scaffoldBackgroundColor); if (ResponsiveUtil.isAndroid()) { - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle(statusBarColor: Colors.white)); FlutterWindowManager.addFlags(FlutterWindowManager.FLAG_SECURE); FlutterWindowManager.addFlags(FlutterWindowManager.FLAG_BLUR_BEHIND); } @@ -177,6 +172,7 @@ class ChewieUtils { if (showLoading) { CustomLoadingDialog.dismissLoading(); } + if (!context.mounted) return; if (releases.isEmpty) { if (showFailedToast) { IToast.showTop( diff --git a/third-party/chewie/lib/src/Widgets/Basic/hero_photo_view_screen.dart b/third-party/chewie/lib/src/Widgets/Basic/hero_photo_view_screen.dart index 19da67b7..03c036c0 100644 --- a/third-party/chewie/lib/src/Widgets/Basic/hero_photo_view_screen.dart +++ b/third-party/chewie/lib/src/Widgets/Basic/hero_photo_view_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:awesome_chewie/awesome_chewie.dart'; @@ -8,10 +9,17 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import 'photo_view_dismiss_metrics.dart'; + enum DownloadState { none, loading, succeed, failed } +String resolvePhotoViewOriginalUrl(String url) { + final imageViewIndex = url.indexOf('?imageView'); + return imageViewIndex < 0 ? url : url.substring(0, imageViewIndex); +} + class HeroPhotoViewScreen extends StatefulWidget { - const HeroPhotoViewScreen({ + HeroPhotoViewScreen({ super.key, required this.imageUrls, this.initialScale = PhotoViewComputedScale.contained, @@ -26,7 +34,7 @@ class HeroPhotoViewScreen extends StatefulWidget { this.tagSuffix, this.mainColors, this.onDownloadSuccess, - }); + }) : assert(imageUrls.isNotEmpty); final String? title; final String? tagPrefix; @@ -49,10 +57,9 @@ class HeroPhotoViewScreen extends StatefulWidget { enum UrlType { string, photoLink, illust } class HeroPhotoViewScreenState extends State - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { - @override - bool get wantKeepAlive => true; + with TickerProviderStateMixin { late final List imageUrls; + late final List _heroUrls; late final List captions; late final dynamic initialScale; late final dynamic minScale; @@ -66,17 +73,44 @@ class HeroPhotoViewScreenState extends State DownloadState allDownloadState = DownloadState.none; late PageController _pageController; final List _viewControllers = []; + final List _scaleStateControllers = []; + late final List _imageRetryTokens; + late final AnimationController _dismissAnimationController; + final Set _prefetchedImageUrls = {}; + final Set _activePointers = {}; + Timer? _downloadRecoveryTimer; + Timer? _allDownloadRecoveryTimer; + VelocityTracker? _dismissVelocityTracker; + Offset? _dismissStartPosition; + int? _dismissPointer; + double _dismissOffset = 0; + bool _dismissCandidate = false; + bool _isDraggingToDismiss = false; + bool _isDismissing = false; + double _dismissAnimationStart = 0; + double _dismissAnimationEnd = 0; @override void initState() { super.initState(); + _dismissAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + )..addListener(_handleDismissAnimationTick); setDownloadState(DownloadState.none, recover: false); setAllDownloadState(DownloadState.none, recover: false); - imageUrls = widget.imageUrls; + _heroUrls = List.unmodifiable(widget.imageUrls); + imageUrls = List.unmodifiable( + widget.imageUrls.map(resolvePhotoViewOriginalUrl), + ); _viewControllers.addAll(List.generate(imageUrls.length, (index) { return PhotoViewController(); })); - captions = widget.captions ?? []; + _scaleStateControllers.addAll(List.generate(imageUrls.length, (index) { + return PhotoViewScaleStateController(); + })); + _imageRetryTokens = List.filled(imageUrls.length, 0); + captions = List.unmodifiable(widget.captions ?? const []); minScale = widget.minScale; maxScale = widget.maxScale; initialScale = widget.initialScale; @@ -86,119 +120,382 @@ class HeroPhotoViewScreenState extends State if (widget.mainColors != null && widget.mainColors!.length >= imageUrls.length && ChewieHiveUtil.getBool(ChewieHiveUtil.followMainColorKey)) { - mainColors = widget.mainColors!; + mainColors = List.unmodifiable(widget.mainColors!); } else { mainColors = List.filled(imageUrls.length, Colors.black); if (widget.useMainColor && ChewieHiveUtil.getBool(ChewieHiveUtil.followMainColorKey)) { ColorUtil.getMainColors( context, - imageUrls.map((e) => getUrl(imageUrls.indexOf(e))).toList(), + List.generate(imageUrls.length, getUrl), ).then((value) { - if (mounted) setState(() {}); - mainColors = value; + if (!mounted) return; + setState(() { + mainColors = List.generate( + imageUrls.length, + (index) => index < value.length ? value[index] : Colors.black, + ); + }); }); } } updateCurrentUrl(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _prefetchAround(currentIndex); + }); + } + + @override + void dispose() { + _downloadRecoveryTimer?.cancel(); + _allDownloadRecoveryTimer?.cancel(); + _dismissAnimationController.dispose(); + _pageController.dispose(); + for (final controller in _viewControllers) { + controller.dispose(); + } + for (final controller in _scaleStateControllers) { + controller.dispose(); + } + super.dispose(); } @override Widget build(BuildContext context) { - super.build(context); - return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(56), - child: Stack( - children: [ - _buildAppBar(), - if (ResponsiveUtil.isDesktop()) const WindowMoveHandle(), - ], - ), + final viewportHeight = MediaQuery.sizeOf(context).height; + final dismissProgress = PhotoViewDismissMetrics.progress( + _dismissOffset, + viewportHeight, + ); + final backgroundColor = ColorUtil.getDarkColor(mainColors[currentIndex]); + + return ColoredBox( + color: backgroundColor.withValues( + alpha: PhotoViewDismissMetrics.backgroundOpacity(dismissProgress), ), - extendBody: true, - extendBodyBehindAppBar: true, - backgroundColor: Colors.black, - body: Stack( - alignment: Alignment.center, - children: [ - imageUrls.length == 1 ? _buildSinglePage() : _buildMultiplePage(), - if (getCaption(currentIndex).isNotEmpty) - Positioned( - bottom: 60, - child: Center( - child: TranslucentTag( - text: getCaption(currentIndex), - borderRadius: 8, - opacity: 0.4, - fontSizeDelta: 3, - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - ), + child: Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(56), + child: IgnorePointer( + ignoring: dismissProgress > 0.4, + child: Opacity( + opacity: PhotoViewDismissMetrics.appBarOpacity(dismissProgress), + child: Stack( + children: [ + _buildAppBar(), + if (ResponsiveUtil.isDesktop()) const WindowMoveHandle(), + ], ), ), - if (imageUrls.length > 1 && ResponsiveUtil.isDesktop()) - Positioned( - left: 16, - child: Container( - width: 40, - height: 40, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: currentIndex == 0 - ? Colors.black.withValues(alpha: 0.1) - : Colors.black.withValues(alpha: 0.4), - borderRadius: ChewieDimens.defaultBorderRadius, - ), - child: GestureDetector( - onTap: () { - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut); - }, - child: MouseRegion( - cursor: currentIndex == 0 - ? SystemMouseCursors.forbidden - : SystemMouseCursors.click, - child: const Icon( - LucideIcons.chevronLeft, - color: Colors.white, - ), - ), + ), + ), + extendBody: true, + extendBodyBehindAppBar: true, + backgroundColor: Colors.transparent, + body: Transform.translate( + offset: Offset(0, _dismissOffset), + child: Transform.scale( + scale: PhotoViewDismissMetrics.scale(dismissProgress), + child: Opacity( + opacity: PhotoViewDismissMetrics.contentOpacity(dismissProgress), + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, + child: Stack( + alignment: Alignment.center, + children: [ + if (imageUrls.length == 1) + _buildSinglePage(0) + else + Stack( + children: [ + HeroMode( + enabled: _dismissOffset <= 0, + child: Opacity( + opacity: _dismissOffset <= 0 ? 1 : 0, + child: _buildMultiplePage(), + ), + ), + if (_dismissOffset > 0) + _buildSinglePage(currentIndex), + ], + ), + if (getCaption(currentIndex).isNotEmpty) + Positioned( + bottom: 60, + child: Center( + child: TranslucentTag( + text: getCaption(currentIndex), + borderRadius: 8, + opacity: 0.4, + fontSizeDelta: 3, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + ), + ), + ), + if (imageUrls.length > 1 && ResponsiveUtil.isDesktop()) + _buildDesktopPageButton(isPrevious: true), + if (imageUrls.length > 1 && ResponsiveUtil.isDesktop()) + _buildDesktopPageButton(isPrevious: false), + ], ), ), ), - if (imageUrls.length > 1 && ResponsiveUtil.isDesktop()) - Positioned( - right: 16, - child: Container( - width: 40, - height: 40, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: currentIndex == imageUrls.length - 1 - ? Colors.black.withValues(alpha: 0.1) - : Colors.black.withValues(alpha: 0.4), - borderRadius: ChewieDimens.defaultBorderRadius, - ), - child: GestureDetector( - onTap: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut); - }, - child: MouseRegion( - cursor: currentIndex == imageUrls.length - 1 - ? SystemMouseCursors.forbidden - : SystemMouseCursors.click, - child: const Icon( - LucideIcons.chevronRight, - color: Colors.white, - ), - ), - ), - ), + ), + ), + ), + ); + } + + Widget _buildDesktopPageButton({required bool isPrevious}) { + final disabled = + isPrevious ? currentIndex == 0 : currentIndex == imageUrls.length - 1; + return Positioned( + left: isPrevious ? 16 : null, + right: isPrevious ? null : 16, + child: Container( + width: 40, + height: 40, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: disabled ? 0.1 : 0.4), + borderRadius: ChewieDimens.defaultBorderRadius, + ), + child: GestureDetector( + onTap: disabled + ? null + : () { + final targetPage = currentIndex + (isPrevious ? -1 : 1); + _pageController.animateToPage( + targetPage, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + child: MouseRegion( + cursor: + disabled ? SystemMouseCursors.basic : SystemMouseCursors.click, + child: Icon( + isPrevious ? LucideIcons.chevronLeft : LucideIcons.chevronRight, + color: Colors.white, ), + ), + ), + ), + ); + } + + bool get _canDragCurrentImage { + final scaleState = _scaleStateControllers[currentIndex].scaleState; + final isAtBaseScale = scaleState == PhotoViewScaleState.initial || + scaleState == PhotoViewScaleState.zoomedOut; + return isAtBaseScale && + _viewControllers[currentIndex].position.distanceSquared <= 9; + } + + bool _supportsDragDismiss(PointerDownEvent event) { + return event.kind == PointerDeviceKind.touch || + event.kind == PointerDeviceKind.stylus || + event.kind == PointerDeviceKind.invertedStylus; + } + + void _handlePointerDown(PointerDownEvent event) { + _activePointers.add(event.pointer); + if (_isDismissing || !_supportsDragDismiss(event)) return; + if (_activePointers.length > 1) { + _cancelDismissGesture(immediate: true); + return; + } + if (!_canDragCurrentImage) return; + + _dismissAnimationController.stop(); + _dismissPointer = event.pointer; + _dismissStartPosition = event.position; + _dismissVelocityTracker = VelocityTracker.withKind(event.kind) + ..addPosition(event.timeStamp, event.position); + _dismissCandidate = true; + _isDraggingToDismiss = false; + } + + void _handlePointerMove(PointerMoveEvent event) { + if (!_dismissCandidate || + event.pointer != _dismissPointer || + _activePointers.length != 1) { + return; + } + final startPosition = _dismissStartPosition; + if (startPosition == null) return; + + _dismissVelocityTracker?.addPosition(event.timeStamp, event.position); + final delta = event.position - startPosition; + if (!_isDraggingToDismiss) { + if (delta.distance < 10) return; + if (delta.dy <= 0 || delta.dy < delta.dx.abs() * 1.25) { + _dismissCandidate = false; + return; + } + _isDraggingToDismiss = true; + } + + final nextOffset = max(0.0, delta.dy); + if (nextOffset == _dismissOffset || !mounted) return; + setState(() => _dismissOffset = nextOffset); + } + + void _handlePointerUp(PointerUpEvent event) { + if (event.pointer == _dismissPointer) { + _dismissVelocityTracker?.addPosition(event.timeStamp, event.position); + final velocity = + _dismissVelocityTracker?.getVelocity().pixelsPerSecond.dy ?? 0; + if (_isDraggingToDismiss) { + _finishDismissGesture(velocity); + } else { + _clearDismissPointer(); + } + } + _activePointers.remove(event.pointer); + } + + void _handlePointerCancel(PointerCancelEvent event) { + if (event.pointer == _dismissPointer) { + _cancelDismissGesture(); + } + _activePointers.remove(event.pointer); + } + + void _finishDismissGesture(double velocity) { + final viewportHeight = MediaQuery.sizeOf(context).height; + final shouldDismiss = PhotoViewDismissMetrics.shouldDismiss( + distance: _dismissOffset, + velocity: velocity, + viewportHeight: viewportHeight, + ); + _clearDismissPointer(); + if (shouldDismiss) { + _isDismissing = true; + _animateDismissOffset(viewportHeight * 1.08); + } else { + _animateDismissOffset(0); + } + } + + void _cancelDismissGesture({bool immediate = false}) { + _clearDismissPointer(); + if (_dismissOffset <= 0) return; + if (immediate) { + _dismissAnimationController.stop(); + if (mounted) setState(() => _dismissOffset = 0); + } else { + _animateDismissOffset(0); + } + } + + void _clearDismissPointer() { + _dismissPointer = null; + _dismissStartPosition = null; + _dismissVelocityTracker = null; + _dismissCandidate = false; + _isDraggingToDismiss = false; + } + + void _animateDismissOffset(double target) { + _dismissAnimationStart = _dismissOffset; + _dismissAnimationEnd = target; + _dismissAnimationController + ..duration = Duration( + milliseconds: _isDismissing ? 220 : 300, + ) + ..forward(from: 0); + } + + void _handleDismissAnimationTick() { + if (!mounted) return; + final curve = _isDismissing ? Curves.easeInCubic : Curves.easeOutBack; + final value = curve.transform(_dismissAnimationController.value); + setState(() { + _dismissOffset = _dismissAnimationStart + + ((_dismissAnimationEnd - _dismissAnimationStart) * value); + }); + if (_dismissAnimationController.isCompleted && _isDismissing) { + _isDismissing = false; + _popViewer(); + } + } + + void _popViewer() { + if (!mounted) return; + final route = ModalRoute.of(context); + unawaited(Navigator.maybePop(context).then((didPop) async { + if (!mounted) return; + if (didPop) await Future.delayed(const Duration(milliseconds: 450)); + if (mounted && (route?.isCurrent ?? false)) { + setState(() => _dismissOffset = 0); + } + })); + chewieProvider.panelScreenState?.updateStatusBar(); + } + + CachedNetworkImageProvider _imageProvider(int index) { + final url = getUrl(index); + final retryToken = _imageRetryTokens[index]; + return CachedNetworkImageProvider( + url, + cacheKey: retryToken == 0 ? url : '$url#retry-$retryToken', + ); + } + + Future _retryImage(int index) async { + await _imageProvider(index).evict(); + if (!mounted) return; + setState(() => _imageRetryTokens[index]++); + } + + void _prefetchAround(int index) { + if (imageUrls.length < 2) return; + for (final candidate in [index - 1, index + 1]) { + if (candidate < 0 || candidate >= imageUrls.length) continue; + final url = getUrl(candidate); + if (!_prefetchedImageUrls.add(url)) continue; + unawaited(_warmImageFile(url)); + } + } + + Future _warmImageFile(String url) async { + try { + await CachedNetworkImageProvider.defaultCacheManager.getSingleFile(url); + } catch (_) { + _prefetchedImageUrls.remove(url); + } + } + + Widget _buildImageError(int index) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.imageOff, + color: Colors.white70, + size: 38, + ), + const SizedBox(height: 12), + Text( + chewieLocalizations.loadFailed, + style: ChewieTheme.bodyMedium.apply(color: Colors.white70), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => _retryImage(index), + icon: const Icon(LucideIcons.refreshCw, size: 16), + label: Text(chewieLocalizations.retry), + style: TextButton.styleFrom(foregroundColor: Colors.white), + ), ], ), ); @@ -223,7 +520,8 @@ class HeroPhotoViewScreenState extends State } PointerSignalEventListener get onPointerSignal => (event) { - if (event is PointerScrollEvent && + if (!_isDismissing && + event is PointerScrollEvent && currentIndex >= 0 && currentIndex < imageUrls.length) { final delta = event.scrollDelta.dy; @@ -233,7 +531,7 @@ class HeroPhotoViewScreenState extends State } }; - Widget _buildSinglePage() { + Widget _buildSinglePage(int index) { return Container( constraints: BoxConstraints.expand( height: MediaQuery.sizeOf(context).height, @@ -241,24 +539,27 @@ class HeroPhotoViewScreenState extends State child: Listener( onPointerSignal: onPointerSignal, child: PhotoView( - controller: _viewControllers[0], - imageProvider: CachedNetworkImageProvider(currentUrl), + controller: _viewControllers[index], + scaleStateController: _scaleStateControllers[index], + imageProvider: _imageProvider(index), initialScale: getPreferedScale(currentUrl), minScale: minScale, maxScale: maxScale, - backgroundDecoration: BoxDecoration( - color: ColorUtil.getDarkColor(mainColors[currentIndex])), + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + gaplessPlayback: true, + filterQuality: FilterQuality.high, heroAttributes: PhotoViewHeroAttributes( tag: ChewieUtils.getHeroTag( tagSuffix: widget.tagSuffix, tagPrefix: widget.tagPrefix, - url: currentUrl, + url: _heroUrls[index], ), ), loadingBuilder: (context, event) => _buildLoading( event, index: currentIndex, ), + errorBuilder: (context, error, stackTrace) => _buildImageError(index), ), ), ); @@ -270,14 +571,17 @@ class HeroPhotoViewScreenState extends State child: PhotoViewGallery.builder( scrollPhysics: const ClampingScrollPhysics(), pageController: _pageController, - backgroundDecoration: BoxDecoration( - color: ColorUtil.getDarkColor(mainColors[currentIndex])), + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + wantKeepAlive: true, + gaplessPlayback: true, + allowImplicitScrolling: true, loadingBuilder: (context, event) => _buildLoading(event, index: currentIndex), builder: (BuildContext context, int index) { return PhotoViewGalleryPageOptions( controller: _viewControllers[index], - imageProvider: CachedNetworkImageProvider(getUrl(index)), + scaleStateController: _scaleStateControllers[index], + imageProvider: _imageProvider(index), initialScale: getPreferedScale(imageUrls[index]), minScale: minScale, maxScale: maxScale, @@ -285,13 +589,12 @@ class HeroPhotoViewScreenState extends State tag: ChewieUtils.getHeroTag( tagSuffix: widget.tagSuffix, tagPrefix: widget.tagPrefix, - url: getUrl(index), + url: _heroUrls[index], ), ), filterQuality: FilterQuality.high, - // onTapDown: (_, __, ___) { - // Navigator.pop(context); - // }, + errorBuilder: (context, error, stackTrace) => + _buildImageError(index), ); }, itemCount: imageUrls.length, @@ -304,12 +607,14 @@ class HeroPhotoViewScreenState extends State updateCurrentUrl(); }); setDownloadState(DownloadState.none, recover: false); + _prefetchAround(index); }, ), ); } void setDownloadState(DownloadState state, {bool recover = true}) { + _downloadRecoveryTimer?.cancel(); switch (state) { case DownloadState.none: downloadIcon = @@ -344,13 +649,14 @@ class HeroPhotoViewScreenState extends State downloadState = state; if (mounted) setState(() {}); if (recover) { - Future.delayed(const Duration(seconds: 2), () { - setDownloadState(DownloadState.none, recover: false); + _downloadRecoveryTimer = Timer(const Duration(seconds: 2), () { + if (mounted) setDownloadState(DownloadState.none, recover: false); }); } } void setAllDownloadState(DownloadState state, {bool recover = true}) { + _allDownloadRecoveryTimer?.cancel(); switch (state) { case DownloadState.none: allDownloadIcon = const Icon( @@ -388,8 +694,8 @@ class HeroPhotoViewScreenState extends State allDownloadState = state; if (mounted) setState(() {}); if (recover) { - Future.delayed(const Duration(seconds: 2), () { - setAllDownloadState(DownloadState.none, recover: false); + _allDownloadRecoveryTimer = Timer(const Duration(seconds: 2), () { + if (mounted) setAllDownloadState(DownloadState.none, recover: false); }); } } @@ -402,10 +708,7 @@ class HeroPhotoViewScreenState extends State leadingIcon: ResponsiveUtil.isLandscapeLayout() ? null : LucideIcons.arrowLeft, leadingColor: Colors.white, - onLeadingTap: () { - Navigator.pop(context); - chewieProvider.panelScreenState?.updateStatusBar(); - }, + onLeadingTap: _popViewer, title: imageUrls.length > 1 ? Text( "${currentIndex + 1}/${imageUrls.length}", @@ -453,6 +756,7 @@ class HeroPhotoViewScreenState extends State context, currentUrl, ).then((res) { + if (!mounted) return; if (res) { widget.onDownloadSuccess?.call(); setDownloadState(DownloadState.succeed); @@ -478,6 +782,7 @@ class HeroPhotoViewScreenState extends State context, imageUrls, ).then((res) { + if (!mounted) return; if (res) { widget.onDownloadSuccess?.call(); setAllDownloadState(DownloadState.succeed); @@ -497,6 +802,7 @@ class HeroPhotoViewScreenState extends State const Icon(LucideIcons.x, color: Colors.white, size: 20), onPressed: () { DialogNavigatorHelper.popPage(); + chewieProvider.panelScreenState?.updateStatusBar(); }, ), ], diff --git a/third-party/chewie/lib/src/Widgets/Basic/item_builder.dart b/third-party/chewie/lib/src/Widgets/Basic/item_builder.dart index 1f216b34..b9e7afad 100644 --- a/third-party/chewie/lib/src/Widgets/Basic/item_builder.dart +++ b/third-party/chewie/lib/src/Widgets/Basic/item_builder.dart @@ -28,9 +28,11 @@ class ChewieItemBuilder { bool showBack = true, Color? backgroundColor, double titleLeftMargin = 5, - bool showBorder = true, + bool showBorder = false, Function()? onTapBack, Widget? overrideBody, + List landscapeActions = const [], + @Deprecated('Use landscapeActions instead.') List desktopActions = const [], List actions = const [], }) { @@ -44,6 +46,7 @@ class ChewieItemBuilder { showBorder: showBorder, onTapBack: onTapBack, actions: actions, + landscapeActions: landscapeActions, desktopActions: desktopActions, ) : null, @@ -75,6 +78,8 @@ class ChewieItemBuilder { bool showLoading = true, double? width, double? height, + int? memCacheWidth, + int? memCacheHeight, double? placeholderHeight, Color? placeholderBackground, double topPadding = 0, @@ -88,6 +93,8 @@ class ChewieItemBuilder { width: width, simpleError: simpleError, height: height, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, placeholderHeight: placeholderHeight, placeholderBackground: placeholderBackground, topPadding: topPadding, @@ -104,6 +111,8 @@ class ChewieItemBuilder { bool showLoading = true, double? width, double? height, + int? memCacheWidth, + int? memCacheHeight, Color? placeholderBackground, double topPadding = 0, double bottomPadding = 0, @@ -120,6 +129,7 @@ class ChewieItemBuilder { showClose: false, fullScreen: true, useFade: true, + opaque: false, HeroPhotoViewScreen( tagPrefix: tagPrefix, tagSuffix: tagSuffix, @@ -139,6 +149,8 @@ class ChewieItemBuilder { imageUrl: imageUrl, width: width, height: height, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, showLoading: showLoading, bottomPadding: bottomPadding, topPadding: topPadding, @@ -256,8 +268,10 @@ class ChewieItemBuilder { controller: controller, buttons: buttons, buttonBuilder: (selected, label, context, onTap, __) { - return SizedBox( - width: constraintWidth ? 80 : null, + return ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraintWidth ? 80 : 0, + ), child: RoundIconTextButton( height: 36, text: label, diff --git a/third-party/chewie/lib/src/Widgets/Basic/photo_view_dismiss_metrics.dart b/third-party/chewie/lib/src/Widgets/Basic/photo_view_dismiss_metrics.dart new file mode 100644 index 00000000..41190e1e --- /dev/null +++ b/third-party/chewie/lib/src/Widgets/Basic/photo_view_dismiss_metrics.dart @@ -0,0 +1,48 @@ +import 'dart:math' as math; + +/// Pure calculations shared by the photo viewer gesture and its regression +/// tests. Keeping the thresholds here makes the interaction independent from +/// the display refresh rate. +class PhotoViewDismissMetrics { + const PhotoViewDismissMetrics._(); + + static const double minimumDismissDistance = 96; + static const double minimumFlingDistance = 72; + static const double dismissVelocity = 900; + static const double minimumScale = 0.86; + + static double threshold(double viewportHeight) { + return math.max(minimumDismissDistance, viewportHeight * 0.14); + } + + static double progress(double distance, double viewportHeight) { + if (viewportHeight <= 0) return 0; + return (distance / (viewportHeight * 0.34)).clamp(0.0, 1.0); + } + + static double scale(double progress) { + final normalized = progress.clamp(0.0, 1.0); + return 1 - ((1 - minimumScale) * normalized); + } + + static double backgroundOpacity(double progress) { + return 1 - progress.clamp(0.0, 1.0); + } + + static double contentOpacity(double progress) { + return 1 - (0.12 * progress.clamp(0.0, 1.0)); + } + + static double appBarOpacity(double progress) { + return (1 - progress.clamp(0.0, 1.0) * 2.5).clamp(0.0, 1.0); + } + + static bool shouldDismiss({ + required double distance, + required double velocity, + required double viewportHeight, + }) { + return distance >= threshold(viewportHeight) || + (distance >= minimumFlingDistance && velocity >= dismissVelocity); + } +} diff --git a/third-party/chewie/lib/src/Widgets/BottomSheet/bottom_sheet_builder.dart b/third-party/chewie/lib/src/Widgets/BottomSheet/bottom_sheet_builder.dart index e1e08c24..d4a3e47c 100644 --- a/third-party/chewie/lib/src/Widgets/BottomSheet/bottom_sheet_builder.dart +++ b/third-party/chewie/lib/src/Widgets/BottomSheet/bottom_sheet_builder.dart @@ -14,7 +14,8 @@ class BottomSheetBuilder { return showBottomSheet( responsive: true, context, - (context) => ContextMenuBottomSheet(menu: menu)); + (context) => ContextMenuBottomSheet(menu: menu), + topRadius: const Radius.circular(20)); } } @@ -29,23 +30,27 @@ class BottomSheetBuilder { bool responsive = false, Color? backgroundColor, double? preferMinWidth, + Radius? topRadius, }) { + final navigatorContext = chewieProvider.navigatorContextOf(context); bool isLandScape = ResponsiveUtil.isWideDevice(); preferMinWidth ??= responsive && isLandScape ? 450 : null; if (responsive && isLandScape) { return showGeneralDialog( - context: context, + context: navigatorContext, barrierDismissible: true, barrierColor: ChewieTheme.barrierColor, barrierLabel: - MaterialLocalizations.of(context).modalBarrierDismissLabel, - transitionDuration: const Duration(milliseconds: 300), + MaterialLocalizations.of(navigatorContext).modalBarrierDismissLabel, + transitionDuration: ChewieTheme.animationDuration, pageBuilder: (context, anim1, anim2) => const SizedBox.shrink(), transitionBuilder: (context, animation, secondaryAnimation, child) { return DialogAnimation( animation: animation, child: BottomSheetWrapperWidget( preferMinWidth: preferMinWidth, + useVerticalMargin: true, + topRadius: topRadius ?? ChewieDimens.defaultRadius, child: builder(context), ), ); @@ -53,20 +58,47 @@ class BottomSheetBuilder { ); } else { return showCustomModalBottomSheet( - context: context, + context: navigatorContext, elevation: 0, enableDrag: enableDrag, barrierColor: ChewieTheme.barrierColor, + duration: ChewieTheme.animationDuration, backgroundColor: backgroundColor ?? ChewieTheme.canvasColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: topRadius ?? ChewieDimens.defaultRadius, + ), ), builder: builder, containerWidget: (_, animation, child) => BottomSheetWrapperWidget( preferMinWidth: preferMinWidth, + topRadius: topRadius ?? ChewieDimens.defaultRadius, child: child, ), ); } } + + static Future showListBottomSheet( + BuildContext context, + WidgetBuilder builder, { + Color? backgroundColor, + ShapeBorder shape = const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + }) { + final navigatorContext = chewieProvider.navigatorContextOf(context); + return showCustomModalBottomSheet( + context: navigatorContext, + elevation: 0, + backgroundColor: + backgroundColor ?? Theme.of(navigatorContext).canvasColor, + shape: shape, + builder: builder, + containerWidget: (_, animation, child) => BottomSheetWrapperWidget( + topRadius: const Radius.circular(20), + child: child, + ), + ); + } } diff --git a/third-party/chewie/lib/src/Widgets/BottomSheet/bottom_sheet_wrapper_widget.dart b/third-party/chewie/lib/src/Widgets/BottomSheet/bottom_sheet_wrapper_widget.dart index cfb36d9b..af8dc8d0 100644 --- a/third-party/chewie/lib/src/Widgets/BottomSheet/bottom_sheet_wrapper_widget.dart +++ b/third-party/chewie/lib/src/Widgets/BottomSheet/bottom_sheet_wrapper_widget.dart @@ -8,12 +8,14 @@ class BottomSheetWrapperWidget extends StatelessWidget { final Widget child; final double? preferMinWidth; final bool useVerticalMargin; + final Radius topRadius; const BottomSheetWrapperWidget({ super.key, required this.child, this.preferMinWidth, this.useVerticalMargin = false, + this.topRadius = ChewieDimens.defaultRadius, }); @override @@ -22,14 +24,50 @@ class BottomSheetWrapperWidget extends StatelessWidget { double width = MediaQuery.sizeOf(context).width - 60; double height = MediaQuery.sizeOf(context).height - 60; double preferWidth = min(width, preferMinWidth ?? 540); - double preferHeight = min(width, 500); + double preferHeight = min(height, 500); + final panel = ClipRRect( + borderRadius: BorderRadius.vertical( + top: topRadius, + bottom: useVerticalMargin || isLandScape ? topRadius : Radius.zero, + ), + child: ColoredBox( + color: ChewieTheme.scaffoldBackgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: child), + if (!isLandScape && + MediaQuery.of(context).viewInsets.bottom <= 0 && + MediaQuery.of(context).viewPadding.bottom > 0) + SizedBox(height: MediaQuery.of(context).viewPadding.bottom), + ], + ), + ), + ); + + if (useVerticalMargin) { + return BackdropFilter( + filter: ResponsiveUtil.isDesktop() + ? ImageFilter.blur(sigmaX: 2, sigmaY: 2) + : ImageFilter.blur(sigmaX: 0, sigmaY: 0), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: preferWidth, + maxWidth: preferWidth, + maxHeight: preferHeight, + ), + child: panel, + ), + ), + ); + } + double preferHorizontalMargin = isLandScape ? width > preferWidth ? (width - preferWidth) / 2 : 0 : 0; - double preferVerticalMargin = - height > preferHeight ? (height - preferHeight) / 2 : 0; return BackdropFilter( filter: ResponsiveUtil.isDesktop() ? ImageFilter.blur(sigmaX: 2, sigmaY: 2) @@ -38,14 +76,9 @@ class BottomSheetWrapperWidget extends StatelessWidget { margin: EdgeInsets.only( left: preferHorizontalMargin, right: preferHorizontalMargin, - top: useVerticalMargin - ? preferVerticalMargin - : ResponsiveUtil.isLandscapeLayout() - ? 0 - : 100, - bottom: useVerticalMargin ? preferVerticalMargin : 0, + top: ResponsiveUtil.isLandscapeLayout() ? 0 : 100, ), - child: child, + child: panel, ), ); } diff --git a/third-party/chewie/lib/src/Widgets/BottomSheet/context_menu_bottom_sheet.dart b/third-party/chewie/lib/src/Widgets/BottomSheet/context_menu_bottom_sheet.dart index 64b627d9..f530f061 100644 --- a/third-party/chewie/lib/src/Widgets/BottomSheet/context_menu_bottom_sheet.dart +++ b/third-party/chewie/lib/src/Widgets/BottomSheet/context_menu_bottom_sheet.dart @@ -13,6 +13,8 @@ * If not, see . */ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:awesome_chewie/awesome_chewie.dart'; @@ -35,124 +37,223 @@ class ContextMenuBottomSheetState extends State { super.initState(); } - Radius radius = ChewieDimens.defaultRadius; + Radius radius = const Radius.circular(20); @override Widget build(BuildContext context) { - return Wrap( - runAlignment: WrapAlignment.center, - children: [ - Container( - decoration: BoxDecoration( - color: ChewieTheme.scaffoldBackgroundColor, - borderRadius: BorderRadius.vertical( - top: radius, - bottom: ResponsiveUtil.isWideDevice() ? radius : Radius.zero, - ), - border: ChewieTheme.border, - boxShadow: ChewieTheme.defaultBoxShadow, + final mediaQuery = MediaQuery.of(context); + final entries = widget.menu.entries.toList(); + final firstHeader = entries.isNotEmpty && entries.first is MenuHeader + ? entries.first as MenuHeader + : null; + final listEntries = firstHeader == null ? entries : entries.skip(1); + final obscuredBottom = max( + mediaQuery.viewPadding.bottom, + mediaQuery.viewInsets.bottom, + ); + final availableHeight = + mediaQuery.size.height - mediaQuery.padding.top - obscuredBottom - 24; + + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: max(0, availableHeight)), + child: Container( + decoration: BoxDecoration( + color: ChewieTheme.scaffoldBackgroundColor, + borderRadius: BorderRadius.vertical( + top: radius, + bottom: ResponsiveUtil.isWideDevice() ? radius : Radius.zero, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (!ResponsiveUtil.isWideDevice()) - Stack( - alignment: Alignment.center, - children: [ - const SizedBox(height: 40), - Container( - width: 50, - height: 5, - decoration: BoxDecoration( - color: ChewieTheme.dividerColor, - borderRadius: BorderRadius.circular(2.5), - ), + border: ChewieTheme.responsiveBorder, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!ResponsiveUtil.isWideDevice()) + SizedBox( + height: 24, + child: Center( + child: Container( + key: const ValueKey('context-menu-handle'), + width: 36, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.46), + borderRadius: BorderRadius.circular(2), ), + ), + ), + ), + if (firstHeader != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 14), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + firstHeader.disableUppercase + ? firstHeader.text + : firstHeader.text.toUpperCase(), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Flexible( + child: SingleChildScrollView( + padding: + EdgeInsets.fromLTRB(16, firstHeader == null ? 8 : 0, 16, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var config in listEntries) _buildConfigItem(config), ], ), - for (var config in widget.menu.entries) - _buildConfigItem( - config as FlutterContextMenuItem, - config == widget.menu.entries.first, - config == widget.menu.entries.last, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: SizedBox( + width: double.infinity, + height: 44, + child: TextButton( + onPressed: () => Navigator.of(context).maybePop(), + style: TextButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.onSurfaceVariant, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: + Text(MaterialLocalizations.of(context).cancelButtonLabel), ), - ], - ), + ), + ), + ], ), - ], + ), ); } - _buildConfigItem( - FlutterContextMenuItem? config, [ - bool isFirst = false, - bool isLast = false, - ]) { + Widget _buildConfigItem(ContextMenuEntry config) { + if (config is MenuHeader) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + config.disableUppercase ? config.text : config.text.toUpperCase(), + style: ChewieTheme.labelMedium.apply( + color: ChewieTheme.textLightGreyColor, + ), + ), + ), + ); + } + if (config is MenuDivider) { + final thickness = config.thickness ?? 0.6; + return Container( + height: config.height ?? 13, + margin: EdgeInsetsDirectional.only( + start: config.indent ?? 8, + end: config.endIndent ?? 8, + ), + alignment: Alignment.center, + child: Container( + height: thickness, + decoration: BoxDecoration( + color: config.color ?? ChewieTheme.dividerColor, + borderRadius: BorderRadius.circular(thickness), + ), + ), + ); + } + if (config is! FlutterContextMenuItem) { + return const SizedBox.shrink(); + } + Color? textColor; - if (config == null || config.type == MenuItemType.divider) { - return const MyDivider(width: 1.5, vertical: 12, horizontal: 16); + if (config.type == MenuItemType.divider) { + return const MyDivider(width: 0.6, vertical: 6, horizontal: 8); } else { + Color iconColor = ChewieTheme.primaryColor; switch (config.status) { case MenuItemStatus.success: textColor = ChewieTheme.successColor; + iconColor = ChewieTheme.successColor; break; case MenuItemStatus.warning: textColor = ChewieTheme.warningColor; + iconColor = ChewieTheme.warningColor; break; case MenuItemStatus.error: textColor = ChewieTheme.errorColor; + iconColor = ChewieTheme.errorColor; break; default: textColor = null; + iconColor = ChewieTheme.primaryColor; break; } - var borderRadius = BorderRadius.vertical( - top: isFirst - ? ResponsiveUtil.isWideDevice() - ? radius - : Radius.zero - : Radius.zero, - bottom: isLast - ? ResponsiveUtil.isWideDevice() - ? radius - : Radius.zero - : Radius.zero, - ); - return Material( - color: ChewieTheme.scaffoldBackgroundColor, - borderRadius: borderRadius, - child: InkWell( - borderRadius: borderRadius, - onTap: () { - Navigator.of(context).pop(); - config.onPressed?.call(); - }, - child: Container( - decoration: BoxDecoration(borderRadius: borderRadius), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - child: Row( - children: [ - if (config.type != MenuItemType.checkbox && - config.iconData != null) ...[ - Icon(config.iconData, size: 24, color: textColor), - const SizedBox(width: 10), + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + Navigator.of(context).pop(); + config.onPressed?.call(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + if (config.type != MenuItemType.checkbox && + config.iconData != null) ...[ + _buildIcon(config.iconData!, iconColor), + const SizedBox(width: 12), + ], + if (config.type == MenuItemType.checkbox) ...[ + config.checked + ? _buildIcon(ChewieIcons.check, iconColor) + : const SizedBox(width: 34, height: 34), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + config.label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: textColor, + ), + ), + ), ], - if (config.type == MenuItemType.checkbox && config.checked) - Icon(Icons.check_rounded, size: 20, color: textColor), - if (config.type == MenuItemType.checkbox && !config.checked) - const SizedBox(width: 20, height: 20), - if (config.iconData != null) const SizedBox(width: 10), - Text( - config.label, - style: ChewieTheme.bodyLarge.apply(color: textColor), - ), - ], + ), ), ), ), ); } } + + Widget _buildIcon(IconData icon, Color color) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withAlpha(30), + borderRadius: BorderRadius.circular(9), + ), + child: Icon(icon, size: 18, color: color), + ); + } } diff --git a/third-party/chewie/lib/src/Widgets/BottomSheet/input_bottom_sheet.dart b/third-party/chewie/lib/src/Widgets/BottomSheet/input_bottom_sheet.dart index c53e6168..624c5b33 100644 --- a/third-party/chewie/lib/src/Widgets/BottomSheet/input_bottom_sheet.dart +++ b/third-party/chewie/lib/src/Widgets/BottomSheet/input_bottom_sheet.dart @@ -39,6 +39,7 @@ class InputBottomSheet extends StatefulWidget { this.leadingConfig, this.tailingConfig, this.style, + this.buttonText, }); final String? hint; @@ -56,6 +57,7 @@ class InputBottomSheet extends StatefulWidget { final FocusNode? focusNode; final List inputFormatters; final bool preventPop; + final String? buttonText; final InputItemLeadingTailingConfig? leadingConfig; final InputItemLeadingTailingConfig? tailingConfig; @@ -79,8 +81,8 @@ class InputBottomSheetState extends State { widget.validateAsyncController?.doPop = () { Navigator.of(context).pop(); }; - Future.delayed(const Duration(milliseconds: 200), () { - if (mounted) FocusScope.of(context).requestFocus(_focusNode); + Future.delayed(ChewieTheme.animationDuration, () { + if (mounted) _focusNode.requestFocus(); }); } @@ -88,9 +90,8 @@ class InputBottomSheetState extends State { @override Widget build(BuildContext context) { - return AnimatedPadding( - padding: MediaQuery.of(context).viewInsets, - duration: const Duration(milliseconds: 100), + return Padding( + padding: MediaQuery.viewInsetsOf(context), child: Wrap( runAlignment: WrapAlignment.center, children: [ @@ -101,7 +102,7 @@ class InputBottomSheetState extends State { top: radius, bottom: ResponsiveUtil.isWideDevice() ? radius : Radius.zero), color: ChewieTheme.scaffoldBackgroundColor, - border: ChewieTheme.border, + border: ChewieTheme.responsiveBorder, boxShadow: ChewieTheme.defaultBoxShadow, ), child: Column( @@ -204,9 +205,11 @@ class InputBottomSheetState extends State { Expanded( child: RoundIconTextButton( height: 48, - background: ChewieTheme.primaryColor, - color: ChewieTheme.primaryButtonColor, - text: chewieLocalizations.confirm, + background: CustomDialogColors.readableActionFill( + ChewieTheme.primaryColor, + ), + color: Colors.white, + text: widget.buttonText ?? chewieLocalizations.confirm, onPressed: processConfirm, fontSizeDelta: 2, ), diff --git a/third-party/chewie/lib/src/Widgets/BottomSheet/star_bottom_sheet.dart b/third-party/chewie/lib/src/Widgets/BottomSheet/star_bottom_sheet.dart index 8cd00551..cdb75208 100644 --- a/third-party/chewie/lib/src/Widgets/BottomSheet/star_bottom_sheet.dart +++ b/third-party/chewie/lib/src/Widgets/BottomSheet/star_bottom_sheet.dart @@ -33,7 +33,7 @@ class StarBottomSheetState extends State { borderRadius: BorderRadius.vertical( top: radius, bottom: ResponsiveUtil.isWideDevice() ? radius : Radius.zero), - border: ChewieTheme.border, + border: ChewieTheme.responsiveBorder, boxShadow: ChewieTheme.defaultBoxShadow, ), child: Column( @@ -102,10 +102,10 @@ class StarBottomSheetState extends State { (index) { return ClickableGestureDetector( child: Icon( - index < currentStar - ? Icons.star_rate_rounded - : Icons.star_border_purple500_rounded, - color: Colors.yellow, + ChewieIcons.star, + color: index < currentStar + ? Colors.amber + : Theme.of(context).colorScheme.outline, size: 40, ), onTap: () { diff --git a/third-party/chewie/lib/src/Widgets/Button/chewie_icon_button.dart b/third-party/chewie/lib/src/Widgets/Button/chewie_icon_button.dart new file mode 100644 index 00000000..66fd1c46 --- /dev/null +++ b/third-party/chewie/lib/src/Widgets/Button/chewie_icon_button.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; + +import '../../Resources/icon_theme.dart'; +import '../General/chewie_icon.dart'; + +enum ChewieIconButtonStyle { + plain, + soft, + outlined, +} + +class ChewieIconButtonVisualScope extends InheritedWidget { + const ChewieIconButtonVisualScope({ + super.key, + required this.visualSize, + required this.maximumIconSize, + required super.child, + }); + + static const double appBarVisualSize = 28; + static const double appBarIconSize = 18; + + final double visualSize; + final double maximumIconSize; + + static ChewieIconButtonVisualScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(ChewieIconButtonVisualScope oldWidget) => + visualSize != oldWidget.visualSize || + maximumIconSize != oldWidget.maximumIconSize; +} + +/// The shared Lucide button primitive used by app bars, menus and inline tools. +class ChewieIconButton extends StatelessWidget { + const ChewieIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.onLongPress, + this.tooltip, + this.semanticLabel, + this.selected = false, + this.style = ChewieIconButtonStyle.plain, + this.iconSize, + this.tapTargetSize, + this.visualSize, + this.foregroundColor, + this.selectedColor, + this.backgroundColor, + this.selectedBackgroundColor, + this.borderColor, + this.cornerRadius, + this.opticalOffset = Offset.zero, + }); + + final IconData icon; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + final String? tooltip; + final String? semanticLabel; + final bool selected; + final ChewieIconButtonStyle style; + final double? iconSize; + final double? tapTargetSize; + final double? visualSize; + final Color? foregroundColor; + final Color? selectedColor; + final Color? backgroundColor; + final Color? selectedBackgroundColor; + final Color? borderColor; + final double? cornerRadius; + final Offset opticalOffset; + + @override + Widget build(BuildContext context) { + final specification = ChewieIconThemeData.of(context); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final highContrast = MediaQuery.highContrastOf(context); + final visualScope = ChewieIconButtonVisualScope.maybeOf(context); + final disabledOpacity = highContrast && specification.disabledOpacity < 0.5 + ? 0.5 + : specification.disabledOpacity; + final effectiveTarget = + (tapTargetSize ?? specification.minimumTapTarget).clamp( + specification.minimumTapTarget, + double.infinity, + ); + final requestedIconSize = iconSize ?? specification.regularSize; + final effectiveIconSize = visualScope == null + ? requestedIconSize + : requestedIconSize.clamp(0, visualScope.maximumIconSize).toDouble(); + final effectiveVisualSize = + (visualSize ?? visualScope?.visualSize ?? effectiveTarget) + .clamp( + effectiveIconSize, + effectiveTarget, + ) + .toDouble(); + final baseForeground = foregroundColor ?? + IconTheme.of(context).color ?? + colorScheme.onSurfaceVariant; + final activeForeground = selectedColor ?? colorScheme.primary; + final effectiveBackground = _background( + context, + specification, + selected, + highContrast, + ); + final effectiveBorder = style == ChewieIconButtonStyle.outlined + ? borderColor ?? colorScheme.outlineVariant + : highContrast && selected + ? activeForeground + : Colors.transparent; + final visualBackground = onPressed == null + ? effectiveBackground.withValues( + alpha: effectiveBackground.a * disabledOpacity, + ) + : effectiveBackground; + final visualDecoration = BoxDecoration( + color: visualBackground, + shape: cornerRadius == null ? BoxShape.circle : BoxShape.rectangle, + borderRadius: + cornerRadius == null ? null : BorderRadius.circular(cornerRadius!), + border: effectiveBorder.a == 0 + ? null + : Border.all( + color: effectiveBorder, + width: highContrast ? 1.2 : 0.8, + ), + ); + + Widget buildVisualIcon(Color color) { + return SizedBox.square( + key: const ValueKey('chewie-icon-button-visual'), + dimension: effectiveVisualSize, + child: DecoratedBox( + decoration: visualDecoration, + child: Center( + child: ChewieIcon( + icon, + size: effectiveIconSize, + color: color, + enabled: onPressed != null, + opticalOffset: opticalOffset, + ), + ), + ), + ); + } + + final button = IconButton( + tooltip: tooltip, + onPressed: onPressed, + isSelected: selected, + iconSize: effectiveIconSize, + padding: EdgeInsets.zero, + constraints: BoxConstraints.tightFor( + width: effectiveTarget, + height: effectiveTarget, + ), + style: ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.standard, + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return baseForeground.withValues( + alpha: baseForeground.a * disabledOpacity, + ); + } + return selected ? activeForeground : baseForeground; + }), + backgroundColor: WidgetStateProperty.resolveWith((states) { + return Colors.transparent; + }), + overlayColor: WidgetStateProperty.resolveWith((states) { + final overlayBase = selected ? activeForeground : baseForeground; + if (states.contains(WidgetState.pressed)) { + return overlayBase.withValues(alpha: specification.pressedOpacity); + } + if (states.contains(WidgetState.focused)) { + return overlayBase.withValues(alpha: specification.focusOpacity); + } + if (states.contains(WidgetState.hovered)) { + return overlayBase.withValues(alpha: specification.hoverOpacity); + } + return Colors.transparent; + }), + side: const WidgetStatePropertyAll( + BorderSide.none, + ), + shape: WidgetStatePropertyAll( + cornerRadius == null + ? const CircleBorder() + : RoundedRectangleBorder( + borderRadius: BorderRadius.circular(cornerRadius!), + ), + ), + ), + icon: buildVisualIcon(selected ? activeForeground : baseForeground), + selectedIcon: buildVisualIcon(activeForeground), + ); + + final label = semanticLabel ?? tooltip; + final accessibleButton = label == null + ? button + : Semantics( + container: true, + button: true, + enabled: onPressed != null, + selected: selected, + label: label, + onTap: onPressed, + onLongPress: onLongPress, + excludeSemantics: true, + child: button, + ); + if (onLongPress == null) return accessibleButton; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: onLongPress, + child: accessibleButton, + ); + } + + Color _background( + BuildContext context, + ChewieIconThemeData specification, + bool isSelected, + bool highContrast, + ) { + final colorScheme = Theme.of(context).colorScheme; + if (isSelected) { + return selectedBackgroundColor ?? + colorScheme.primary.withValues( + alpha: highContrast && specification.selectedContainerOpacity < 0.2 + ? 0.2 + : specification.selectedContainerOpacity, + ); + } + if (backgroundColor != null) return backgroundColor!; + return switch (style) { + ChewieIconButtonStyle.plain => Colors.transparent, + ChewieIconButtonStyle.soft => + colorScheme.surfaceContainerHighest.withValues( + alpha: highContrast ? 0.72 : 0.58, + ), + ChewieIconButtonStyle.outlined => Colors.transparent, + }; + } +} diff --git a/third-party/chewie/lib/src/Widgets/Button/circle_icon_button.dart b/third-party/chewie/lib/src/Widgets/Button/circle_icon_button.dart index 33442214..3e72f871 100644 --- a/third-party/chewie/lib/src/Widgets/Button/circle_icon_button.dart +++ b/third-party/chewie/lib/src/Widgets/Button/circle_icon_button.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:awesome_chewie/awesome_chewie.dart'; class CircleIconButton extends StatefulWidget { + final BuildContext? context; final dynamic icon; final Function()? onTap; final Function()? onLongPress; @@ -13,6 +14,7 @@ class CircleIconButton extends StatefulWidget { const CircleIconButton({ super.key, + this.context, required this.icon, required this.onTap, this.onLongPress, @@ -80,7 +82,7 @@ class BlankIconButtonState extends State { maintainState: true, maintainSize: true, child: CircleIconButton( - icon: Icon(Icons.more_vert_rounded, color: ChewieTheme.iconColor), + icon: ChewieIcon(ChewieIcons.more, color: ChewieTheme.iconColor), onTap: () {}, ), ); diff --git a/third-party/chewie/lib/src/Widgets/Button/index.dart b/third-party/chewie/lib/src/Widgets/Button/index.dart index d9eef7a6..39a69900 100644 --- a/third-party/chewie/lib/src/Widgets/Button/index.dart +++ b/third-party/chewie/lib/src/Widgets/Button/index.dart @@ -14,6 +14,7 @@ */ export 'circle_icon_button.dart'; +export 'chewie_icon_button.dart'; export 'hover_icon_button.dart'; export 'outlined_button.dart'; export 'round_icon_button.dart'; diff --git a/third-party/chewie/lib/src/Widgets/Button/round_icon_text_button.dart b/third-party/chewie/lib/src/Widgets/Button/round_icon_text_button.dart index c5bcce8d..b10561e3 100644 --- a/third-party/chewie/lib/src/Widgets/Button/round_icon_text_button.dart +++ b/third-party/chewie/lib/src/Widgets/Button/round_icon_text_button.dart @@ -19,12 +19,14 @@ class RoundIconTextButton extends StatelessWidget { final double spacing; final Border? border; final bool disabled; + final Widget? trailing; const RoundIconTextButton({ super.key, this.text, this.tooltip, - this.onPressed, + Function()? onPressed, + Function()? onTap, this.background, this.icon, this.padding = const EdgeInsets.symmetric(horizontal: 24), @@ -38,7 +40,8 @@ class RoundIconTextButton extends StatelessWidget { this.minHeight = 32, this.border, this.disabled = false, - }); + this.trailing, + }) : onPressed = onPressed ?? onTap; @override Widget build(BuildContext context) { @@ -96,6 +99,10 @@ class RoundIconTextButton extends StatelessWidget { ), ), ), + if (trailing != null) ...[ + SizedBox(width: spacing), + trailing!, + ], ], ), ), diff --git a/third-party/chewie/lib/src/Widgets/Button/toggle_icon_button.dart b/third-party/chewie/lib/src/Widgets/Button/toggle_icon_button.dart index 7b00e1dd..46bd54ce 100644 --- a/third-party/chewie/lib/src/Widgets/Button/toggle_icon_button.dart +++ b/third-party/chewie/lib/src/Widgets/Button/toggle_icon_button.dart @@ -15,8 +15,8 @@ class ToggleIconButton extends StatefulWidget { this.tooltip, this.onPressed, this.resetDuration = const Duration(seconds: 2), - this.iconA = const Icon(Icons.access_alarm), - this.iconB = const Icon(Icons.access_time), + this.iconA = const Icon(ChewieIcons.alarm), + this.iconB = const Icon(ChewieIcons.time), }); @override diff --git a/third-party/chewie/lib/src/Widgets/Button/window_button.dart b/third-party/chewie/lib/src/Widgets/Button/window_button.dart index 247e37c5..8117d63e 100644 --- a/third-party/chewie/lib/src/Widgets/Button/window_button.dart +++ b/third-party/chewie/lib/src/Widgets/Button/window_button.dart @@ -53,8 +53,10 @@ class WindowButtonColors { this.mouseDown = mouseDown ?? defaultWindowButtonColors.mouseDown; this.iconNormal = iconNormal ?? defaultWindowButtonColors.iconNormal; this.iconSelected = iconSelected ?? defaultWindowButtonColors.iconSelected; - this.iconMouseOver = iconMouseOver ?? defaultWindowButtonColors.iconMouseOver; - this.iconMouseDown = iconMouseDown ?? defaultWindowButtonColors.iconMouseDown; + this.iconMouseOver = + iconMouseOver ?? defaultWindowButtonColors.iconMouseOver; + this.iconMouseDown = + iconMouseDown ?? defaultWindowButtonColors.iconMouseDown; } WindowButtonColors copyWith({ @@ -278,14 +280,18 @@ class ToolButton extends WindowButton { colors: colors ?? ChewieColors.getNormalButtonColors(context), borderRadius: borderRadius ?? ChewieDimens.borderRadius8, iconBuilder: iconBuilder ?? - (buttonContext) => Transform.rotate( - angle: rotateTurns ?? 0, - child: Icon( - selected ? selectedIcon ?? icon : icon, - color: buttonContext.iconColor, - size: iconSize, - ), + (buttonContext) { + final effectiveIcon = selected ? selectedIcon ?? icon : icon; + if (effectiveIcon == null) return emptyWidget; + return Transform.rotate( + angle: rotateTurns ?? 0, + child: ChewieIcon( + effectiveIcon, + color: buttonContext.iconColor, + size: iconSize, ), + ); + }, ); static Widget dynamicButton({ @@ -328,8 +334,8 @@ class StayOnTopWindowButton extends WindowButton { enableAnimation: false, padding: EdgeInsets.zero, rotateTurns: rotateTurns ?? 0, - iconBuilder: (buttonContext) => Icon( - rotateTurns == 0 ? Icons.push_pin_rounded : Icons.push_pin_outlined, + iconBuilder: (buttonContext) => ChewieIcon( + ChewieIcons.pin, color: buttonContext.iconColor, size: iconSize, ), @@ -346,7 +352,8 @@ class MinimizeWindowButton extends WindowButton { }) : super( enableAnimation: animate ?? false, padding: EdgeInsets.zero, - iconBuilder: (buttonContext) => Icon(Icons.horizontal_rule_rounded, + iconBuilder: (buttonContext) => ChewieIcon( + ChewieIcons.minimizeWindow, color: buttonContext.iconColor), onPressed: onPressed ?? () => windowManager.minimize()); } @@ -361,8 +368,8 @@ class MaximizeWindowButton extends WindowButton { }) : super( padding: EdgeInsets.zero, enableAnimation: animate ?? false, - iconBuilder: (buttonContext) => Icon( - Icons.check_box_outline_blank_rounded, + iconBuilder: (buttonContext) => ChewieIcon( + ChewieIcons.maximizeWindow, size: 19, color: buttonContext.iconColor), onPressed: onPressed ?? () => ResponsiveUtil.maximizeOrRestore()); @@ -378,7 +385,8 @@ class RestoreWindowButton extends WindowButton { }) : super( padding: EdgeInsets.zero, enableAnimation: animate ?? false, - iconBuilder: (buttonContext) => Icon(Icons.fullscreen_exit_rounded, + iconBuilder: (buttonContext) => ChewieIcon( + ChewieIcons.restoreWindow, color: buttonContext.iconColor), onPressed: onPressed ?? () => ResponsiveUtil.maximizeOrRestore()); } @@ -402,7 +410,7 @@ class CloseWindowButton extends WindowButton { colors: colors ?? _defaultCloseButtonColors, padding: EdgeInsets.zero, enableAnimation: animate ?? false, - iconBuilder: (buttonContext) => Icon(Icons.close_rounded, + iconBuilder: (buttonContext) => ChewieIcon(ChewieIcons.closeWindow, size: iconSize, color: buttonContext.iconColor), onPressed: onPressed ?? () => windowManager.close()); } diff --git a/third-party/chewie/lib/src/Widgets/Component/auto_image.dart b/third-party/chewie/lib/src/Widgets/Component/auto_image.dart index 06357d53..50c6ca0e 100644 --- a/third-party/chewie/lib/src/Widgets/Component/auto_image.dart +++ b/third-party/chewie/lib/src/Widgets/Component/auto_image.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; import 'package:flutter/cupertino.dart'; @@ -15,6 +17,8 @@ class AutoImage extends StatefulWidget { final bool showLoading; final double? width; final double? height; + final int? memCacheWidth; + final int? memCacheHeight; final double? placeholderHeight; final Color? placeholderBackground; final double topPadding; @@ -29,6 +33,8 @@ class AutoImage extends StatefulWidget { this.showLoading = true, this.width, this.height, + this.memCacheWidth, + this.memCacheHeight, this.placeholderHeight, this.placeholderBackground, this.topPadding = 0, @@ -43,7 +49,8 @@ class AutoImage extends StatefulWidget { class _AutoImageState extends State { late Future _imageFuture; - static final Map _svgCheckCache = {}; + static const _maxSvgCheckCacheEntries = 200; + static final LinkedHashMap _svgCheckCache = LinkedHashMap(); @override void initState() { @@ -51,6 +58,26 @@ class _AutoImageState extends State { _imageFuture = _loadImage(); } + @override + void didUpdateWidget(covariant AutoImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl || + oldWidget.baseUrl != widget.baseUrl || + oldWidget.fit != widget.fit || + oldWidget.width != widget.width || + oldWidget.height != widget.height || + oldWidget.memCacheWidth != widget.memCacheWidth || + oldWidget.memCacheHeight != widget.memCacheHeight || + oldWidget.showLoading != widget.showLoading || + oldWidget.placeholderHeight != widget.placeholderHeight || + oldWidget.placeholderBackground != widget.placeholderBackground || + oldWidget.topPadding != widget.topPadding || + oldWidget.bottomPadding != widget.bottomPadding || + oldWidget.simpleError != widget.simpleError) { + _imageFuture = _loadImage(); + } + } + bool hasCommonImageExtension(String url) { try { final uri = Uri.parse(url); @@ -90,23 +117,22 @@ class _AutoImageState extends State { } } - final lowerUrl = fullUrl.toLowerCase(); - const commonImageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.gif']; - bool useCachedImage = - commonImageExtensions.any((ext) => lowerUrl.endsWith(ext)); - if (useCachedImage) { + if (hasCommonImageExtension(fullUrl)) { return _buildCachedImage(); } bool isSvg = false; if (_svgCheckCache.containsKey(fullUrl)) { - isSvg = _svgCheckCache[fullUrl]!; + isSvg = _svgCheckCache.remove(fullUrl)!; + _svgCheckCache[fullUrl] = isSvg; } else { try { - final response = await http.head(Uri.parse(fullUrl)); + final response = await http + .head(Uri.parse(fullUrl)) + .timeout(const Duration(seconds: 5)); final contentType = response.headers['content-type']; isSvg = contentType != null && contentType.contains('image/svg+xml'); - _svgCheckCache[fullUrl] = isSvg; + _rememberSvgType(fullUrl, isSvg); } catch (e) { isSvg = false; } @@ -138,6 +164,14 @@ class _AutoImageState extends State { return _buildCachedImage(); } + void _rememberSvgType(String url, bool isSvg) { + _svgCheckCache.remove(url); + _svgCheckCache[url] = isSvg; + while (_svgCheckCache.length > _maxSvgCheckCacheEntries) { + _svgCheckCache.remove(_svgCheckCache.keys.first); + } + } + Widget _buildCachedImage() { return MyCachedNetworkImage( imageUrl: widget.imageUrl, @@ -146,6 +180,8 @@ class _AutoImageState extends State { width: widget.width, simpleError: widget.simpleError, height: widget.height, + memCacheWidth: widget.memCacheWidth, + memCacheHeight: widget.memCacheHeight, placeholderHeight: widget.placeholderHeight, placeholderBackground: widget.placeholderBackground, topPadding: widget.topPadding, diff --git a/third-party/chewie/lib/src/Widgets/Component/custom_html_widget.dart b/third-party/chewie/lib/src/Widgets/Component/custom_html_widget.dart index 47a9ae13..ed862800 100644 --- a/third-party/chewie/lib/src/Widgets/Component/custom_html_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Component/custom_html_widget.dart @@ -16,7 +16,6 @@ import 'package:awesome_chewie/src/Widgets/Tile/expandable_item.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_math_fork/flutter_math.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:html/dom.dart' as dom; @@ -56,6 +55,7 @@ class CustomHtmlWidget extends StatefulWidget { this.contextMenuItemsBuilder, this.maxHeaderLevel = 5, this.detectLanguage, + this.selectable = true, }); final int maxHeaderLevel; @@ -71,6 +71,7 @@ class CustomHtmlWidget extends StatefulWidget { final Function(Anchor anchor)? onAnchorTap; final Function(String hashtag)? onHashtagTap; final LanguageDetector? detectLanguage; + final bool selectable; final String? url; final List Function(SelectableRegionState, String?)? contextMenuItemsBuilder; @@ -96,6 +97,16 @@ class CustomHtmlWidgetState extends State { // } double verticalMargin = 10; + FocusNode? _selectionFocusNode; + + FocusNode get selectionFocusNode => + _selectionFocusNode ??= FocusNode(debugLabel: 'CustomHtmlWidget'); + + @override + void dispose() { + _selectionFocusNode?.dispose(); + super.dispose(); + } LanguageDetector? get detectLanguage => widget.detectLanguage ?? HtmlUtil.detectLanguage; @@ -171,11 +182,13 @@ class CustomHtmlWidgetState extends State { } _buildHtmlWidget(String content) { + final renderedContent = _render(content); + if (!widget.selectable) return renderedContent; return SelectableAreaWrapper( - focusNode: FocusNode(), + focusNode: selectionFocusNode, // selectionNotifier: widget.selectionNotifier, contextMenuItemsBuilder: widget.contextMenuItemsBuilder, - child: _render(content), + child: renderedContent, ); } @@ -360,7 +373,8 @@ class CustomHtmlWidgetState extends State { // 'text-decoration': 'underline', // 'text-decoration-style': 'dashed', // 'text-decoration-color': 'red', - 'background-color': ColorUtil.isDark(context)?'#2a2a2a':'#e0f7fa', + 'background-color': + ColorUtil.isDark(context) ? '#2a2a2a' : '#e0f7fa', }; } if (e.id == "title") { @@ -460,60 +474,6 @@ class CustomHtmlWidgetState extends State { ChewieTheme.bodyMedium.apply(fontSizeDelta: 2), ), ); - bool isInline = element.localName == 'inlinetex'; - var texWidget = ClickableGestureDetector( - onTap: () { - dom.Element tmp = dom.Element.tag('blockquote'); - tmp.children.add(dom.Element.tag('p')..text = element.text); - DialogBuilder.showInfoDialog( - context, - title: "公式详情", - messageChild: Container( - margin: EdgeInsets.symmetric(vertical: verticalMargin), - child: Math.tex( - element.text, - mathStyle: MathStyle.display, - textStyle: style?.apply(fontSizeDelta: 2) ?? - ChewieTheme.bodyMedium.apply(fontSizeDelta: 2), - ), - ), - ); - }, - child: Wrap( - children: [ - Container( - color: Colors.transparent, - constraints: const BoxConstraints( - maxWidth: 1000, minHeight: 5, maxHeight: 100, minWidth: 5), - margin: isInline - ? const EdgeInsets.symmetric(horizontal: 2) - : EdgeInsets.symmetric( - vertical: verticalMargin, horizontal: 10), - child: Math.tex( - element.text, - mathStyle: MathStyle.text, - textStyle: style, - onErrorFallback: (err) => Container( - color: Colors.red, - child: Text( - err.messageWithType, - style: const TextStyle( - color: Colors.yellow, - ), - ), - ), - options: MathOptions( - color: style?.color ?? ChewieTheme.bodyMedium.color!, - fontSize: style?.fontSize ?? 16 - 1, - ), - ), - ), - ], - ), - ); - return InlineCustomWidget( - child: isInline ? texWidget : Center(child: texWidget), - ); } Widget _renderDetailsWithSummary( @@ -1145,6 +1105,7 @@ class CustomHtmlWidgetState extends State { showClose: false, fullScreen: true, useFade: true, + opaque: false, barrierDismissible: false, animation: false, HeroPhotoViewScreen( diff --git a/third-party/chewie/lib/src/Widgets/Component/custom_markdown_widget.dart b/third-party/chewie/lib/src/Widgets/Component/custom_markdown_widget.dart index 15d53b2e..1ca7931f 100644 --- a/third-party/chewie/lib/src/Widgets/Component/custom_markdown_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Component/custom_markdown_widget.dart @@ -172,12 +172,12 @@ class _CodeWrapperWidgetState extends State { ToggleIconButton( tooltip: chewieLocalizations.copy, iconA: Icon( - CupertinoIcons.square_on_square, + ChewieIcons.copy, size: 18, color: ChewieTheme.iconColor, ), iconB: Icon( - CupertinoIcons.checkmark_alt, + ChewieIcons.check, size: 18, color: ChewieTheme.iconColor, ), diff --git a/third-party/chewie/lib/src/Widgets/Component/loading_icon.dart b/third-party/chewie/lib/src/Widgets/Component/loading_icon.dart index 2469ffb8..09b78611 100644 --- a/third-party/chewie/lib/src/Widgets/Component/loading_icon.dart +++ b/third-party/chewie/lib/src/Widgets/Component/loading_icon.dart @@ -116,12 +116,12 @@ class LoadingIconState extends State ); case LoadingStatus.failedAndLoading: return Icon( - Icons.error_outline_rounded, + ChewieIcons.error, color: ChewieTheme.errorColor, ); case LoadingStatus.failed: return Icon( - Icons.error_outline_rounded, + ChewieIcons.error, color: ChewieTheme.errorColor, ); } diff --git a/third-party/chewie/lib/src/Widgets/Component/my_cached_network_image.dart b/third-party/chewie/lib/src/Widgets/Component/my_cached_network_image.dart index 497ce414..407e7a13 100644 --- a/third-party/chewie/lib/src/Widgets/Component/my_cached_network_image.dart +++ b/third-party/chewie/lib/src/Widgets/Component/my_cached_network_image.dart @@ -13,6 +13,8 @@ * If not, see . */ +import 'dart:async'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; @@ -25,6 +27,8 @@ class MyCachedNetworkImage extends StatefulWidget { this.baseUrl, this.width, this.height, + this.memCacheWidth, + this.memCacheHeight, this.placeholderHeight, this.fit, this.showLoading = false, @@ -38,6 +42,8 @@ class MyCachedNetworkImage extends StatefulWidget { final String? baseUrl; final double? width; final double? height; + final int? memCacheWidth; + final int? memCacheHeight; final double? placeholderHeight; final BoxFit? fit; final bool showLoading; @@ -54,68 +60,147 @@ class _MyCachedNetworkImageState extends State { final _rebuildValueNotifier = ValueNotifier(''); final int _maxRetries = 3; int _currentRetries = 0; + Timer? _retryTimer; String get _imageUrl => HtmlUtil.unscape(widget.imageUrl); - void _retryLoadImage() { + void _scheduleRetry() { + if (!mounted || + _currentRetries >= _maxRetries || + (_retryTimer?.isActive ?? false)) { + return; + } + _currentRetries++; ILogger.debug( - 'Retrying to load image: $_imageUrl, attempt: ${_currentRetries + 1}'); - if (_currentRetries < _maxRetries) { - setState(() { + 'Retrying to load image: $_imageUrl, attempt: $_currentRetries'); + _retryTimer = Timer( + Duration(milliseconds: 300 * _currentRetries), + () { + if (!mounted) return; _rebuildValueNotifier.value = MockUtil.getRandomString(); - _currentRetries++; - }); + }, + ); + } + + void _retryManually() { + if (!mounted) return; + _retryTimer?.cancel(); + _currentRetries = 0; + _rebuildValueNotifier.value = MockUtil.getRandomString(); + } + + @override + void didUpdateWidget(covariant MyCachedNetworkImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl || + oldWidget.baseUrl != widget.baseUrl) { + _retryTimer?.cancel(); + _currentRetries = 0; + _rebuildValueNotifier.value = ''; } } + @override + void dispose() { + _retryTimer?.cancel(); + _rebuildValueNotifier.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _rebuildValueNotifier, - builder: (context, value, child) { - return CachedNetworkImage( - key: value.isEmpty ? null : ValueKey(value), - imageUrl: _imageUrl, - fit: widget.fit, - width: widget.width, - height: widget.height, - filterQuality: FilterQuality.high, - httpHeaders: { - 'Referer': widget.baseUrl ?? '', - "User-Agent": - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0', - }, - placeholder: widget.showLoading - ? (context, url) => LoadingWidget( - topPadding: widget.placeholderHeight != null - ? (widget.placeholderHeight! - 20) / 2 - : widget.topPadding, - bottomPadding: widget.placeholderHeight != null - ? (widget.placeholderHeight! - 20) / 2 - : widget.bottomPadding, - showText: false, - size: 40, - background: widget.placeholderBackground, - ) - : (context, url) => Container( - color: widget.placeholderBackground ?? - Theme.of(context).cardColor, - width: widget.width, - height: widget.placeholderHeight ?? widget.height, - ), - errorWidget: widget.simpleError - ? _simpleErrorWidget - : _currentRetries < _maxRetries - ? _defaultErrorWidget - : _maxRetryReachedWidget, - errorListener: (error) { - _retryLoadImage(); + return LayoutBuilder( + builder: (context, constraints) { + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + // Supplying both dimensions to ResizeImage can decode the bitmap into + // the box aspect ratio before BoxFit is applied. Prefer width (the + // bounded axis for feeds and grids), or height when width is unknown, + // so the source aspect ratio is always preserved. + final cacheWidth = _validCacheDimension(widget.memCacheWidth) ?? + _cacheDimension( + widget.width, + constraints.hasBoundedWidth ? constraints.maxWidth : null, + pixelRatio, + ); + final cacheHeight = cacheWidth == null + ? _validCacheDimension(widget.memCacheHeight) ?? + _cacheDimension( + widget.height, + constraints.hasBoundedHeight ? constraints.maxHeight : null, + pixelRatio, + ) + : null; + return ValueListenableBuilder( + valueListenable: _rebuildValueNotifier, + builder: (context, value, child) { + return CachedNetworkImage( + key: value.isEmpty ? null : ValueKey(value), + imageUrl: _imageUrl, + fit: widget.fit, + width: widget.width, + height: widget.height, + memCacheWidth: cacheWidth, + memCacheHeight: cacheHeight, + filterQuality: FilterQuality.medium, + httpHeaders: { + 'Referer': widget.baseUrl ?? '', + "User-Agent": + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0', + }, + placeholder: widget.showLoading + ? (context, url) => LoadingWidget( + topPadding: widget.placeholderHeight != null + ? (widget.placeholderHeight! - 20) / 2 + : widget.topPadding, + bottomPadding: widget.placeholderHeight != null + ? (widget.placeholderHeight! - 20) / 2 + : widget.bottomPadding, + showText: false, + size: 40, + background: widget.placeholderBackground, + ) + : (context, url) => Container( + color: widget.placeholderBackground ?? + Theme.of(context).cardColor, + width: widget.width, + height: widget.placeholderHeight ?? widget.height, + ), + errorWidget: widget.simpleError + ? _simpleErrorWidget + : _currentRetries < _maxRetries + ? _defaultErrorWidget + : _maxRetryReachedWidget, + errorListener: (error) { + _scheduleRetry(); + }, + ); }, ); }, ); } + int? _cacheDimension( + double? requestedLogicalSize, + double? constrainedLogicalSize, + double pixelRatio, + ) { + final logicalSize = requestedLogicalSize?.isFinite == true + ? requestedLogicalSize + : constrainedLogicalSize?.isFinite == true + ? constrainedLogicalSize + : null; + if (logicalSize == null || logicalSize <= 0 || pixelRatio <= 0) { + return null; + } + return (logicalSize * pixelRatio).ceil().clamp(1, 4096); + } + + int? _validCacheDimension(int? dimension) { + if (dimension == null || dimension <= 0) return null; + return dimension.clamp(1, 4096); + } + Widget _simpleErrorWidget(BuildContext context, String url, dynamic error) { return Container( constraints: const BoxConstraints( @@ -125,8 +210,8 @@ class _MyCachedNetworkImageState extends State { maxHeight: 40, ), child: CircleIconButton( - icon: const Icon(Icons.refresh_rounded), - onTap: _retryLoadImage, + icon: const ChewieIcon(ChewieIcons.retry), + onTap: _retryManually, ), ); } @@ -138,10 +223,10 @@ class _MyCachedNetworkImageState extends State { mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error), + ChewieIcon(ChewieIcons.error), const SizedBox(height: 8), RoundIconTextButton( - onPressed: _retryLoadImage, + onPressed: _retryManually, text: '重新加载', padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), ), @@ -158,7 +243,7 @@ class _MyCachedNetworkImageState extends State { mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error), + const ChewieIcon(ChewieIcons.error), SizedBox(height: 8), Text( '无法加载图片,请稍后重试', diff --git a/third-party/chewie/lib/src/Widgets/Component/star_rating.dart b/third-party/chewie/lib/src/Widgets/Component/star_rating.dart index 75178004..cb47c043 100644 --- a/third-party/chewie/lib/src/Widgets/Component/star_rating.dart +++ b/third-party/chewie/lib/src/Widgets/Component/star_rating.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:awesome_chewie/awesome_chewie.dart'; class StarRating extends StatefulWidget { final int starCount; @@ -39,15 +40,15 @@ class StarRatingState extends State { } Widget _buildStar(BuildContext context, int index) { - IconData iconData = Icons.star_border; + IconData iconData = ChewieIcons.star; Color iconColor = widget.borderColor; if (index < _currentRating.floor()) { - iconData = Icons.star; + iconData = ChewieIcons.star; iconColor = widget.color; } else if (index == _currentRating.floor() && _currentRating - _currentRating.floor() > 0.0) { - iconData = Icons.star_half; + iconData = ChewieIcons.starHalf; iconColor = widget.color; } @@ -68,7 +69,7 @@ class StarRatingState extends State { margin: widget.showRTL ? EdgeInsets.only(left: widget.spacing) : EdgeInsets.only(right: widget.spacing), - child: Icon( + child: ChewieIcon( iconData, color: iconColor, size: widget.size, diff --git a/third-party/chewie/lib/src/Widgets/Dialog/components/colors.dart b/third-party/chewie/lib/src/Widgets/Dialog/components/colors.dart index 9c365ca3..5ee1c4dd 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/components/colors.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/components/colors.dart @@ -33,8 +33,8 @@ class CustomDialogColors { ///

Hex Code: #707070

static Color defaultTextColor = const Color(0xFF707070); - static getBgColor(BuildContext context, CustomDialogType customDialogType, - Color? defaultColor) { + static Color getBgColor(BuildContext context, + CustomDialogType customDialogType, Color? defaultColor) { return customDialogType == CustomDialogType.normal ? ChewieTheme.primaryColor : customDialogType == CustomDialogType.success @@ -43,6 +43,16 @@ class CustomDialogColors { ? CustomDialogColors.warning : customDialogType == CustomDialogType.error ? CustomDialogColors.error - : defaultColor; + : (defaultColor ?? ChewieTheme.primaryColor); + } + + /// Keep filled action labels white without sacrificing text contrast on + /// lighter accent colors (notably yellow themes). + static Color readableActionFill(Color base) { + var fill = base; + while (1.05 / (fill.computeLuminance() + 0.05) < 4.5) { + fill = Color.lerp(fill, Colors.black, 0.08)!; + } + return fill; } } diff --git a/third-party/chewie/lib/src/Widgets/Dialog/custom_dialog.dart b/third-party/chewie/lib/src/Widgets/Dialog/custom_dialog.dart index 4806a3be..d6c42cfb 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/custom_dialog.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/custom_dialog.dart @@ -29,7 +29,7 @@ class CustomInfoDialog { barrierColor: ChewieTheme.barrierColor, barrierLabel: "", barrierDismissible: barrierDismissible, - context: context, + context: chewieProvider.navigatorContextOf(context), pageBuilder: (context, animation, secondaryAnimation) => const SizedBox.shrink(), transitionBuilder: (context, animation, secondaryAnimation, _) => @@ -78,10 +78,10 @@ class CustomInfoDialog { }) => showGeneralDialog( barrierDismissible: barrierDismissible, - context: context, + context: chewieProvider.navigatorContextOf(context), barrierLabel: '', barrierColor: ChewieTheme.barrierColor, - transitionDuration: const Duration(milliseconds: 400), + transitionDuration: ChewieTheme.animationDuration, transitionBuilder: (context, animation, secondaryAnimation, child) { return CustomDialogAnimations.fromBottom( animation, @@ -136,7 +136,7 @@ class CustomConfirmDialog { barrierColor: ChewieTheme.barrierColor, barrierDismissible: barrierDismissible, barrierLabel: "", - context: context, + context: chewieProvider.navigatorContextOf(context), pageBuilder: (context, animation, secondaryAnimation) => const SizedBox.shrink(), transitionBuilder: (context, animation, secondaryAnimation, _) => @@ -185,10 +185,10 @@ class CustomConfirmDialog { }) => showGeneralDialog( barrierDismissible: barrierDismissible, - context: context, + context: chewieProvider.navigatorContextOf(context), barrierLabel: '', barrierColor: ChewieTheme.barrierColor, - transitionDuration: const Duration(milliseconds: 400), + transitionDuration: ChewieTheme.animationDuration, transitionBuilder: (context, animation, secondaryAnimation, child) { return CustomDialogAnimations.fromBottom( animation, @@ -229,7 +229,8 @@ class CustomLoadingDialog { barrierColor: ChewieTheme.barrierColor, barrierDismissible: barrierDismissible, barrierLabel: "", - context: chewieProvider.rootContext, + context: chewieProvider.globalNavigatorState?.context ?? + chewieProvider.rootContext, pageBuilder: (context, animation, secondaryAnimation) => const SizedBox.shrink(), transitionBuilder: (context, animation, secondaryAnimation, _) => @@ -245,6 +246,9 @@ class CustomLoadingDialog { } static Future dismissLoading() async { - return Future.sync(() => Navigator.pop(chewieProvider.rootContext)); + return Future.sync(() => Navigator.pop( + chewieProvider.globalNavigatorState?.context ?? + chewieProvider.rootContext, + )); } } diff --git a/third-party/chewie/lib/src/Widgets/Dialog/dialog_builder.dart b/third-party/chewie/lib/src/Widgets/Dialog/dialog_builder.dart index 90ace932..7a56baa2 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/dialog_builder.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/dialog_builder.dart @@ -148,10 +148,10 @@ class DialogBuilder { }) { showGeneralDialog( barrierDismissible: barrierDismissible, - context: context, + context: chewieProvider.navigatorContextOf(context), barrierLabel: '', barrierColor: ChewieTheme.barrierColor, - transitionDuration: const Duration(milliseconds: 300), + transitionDuration: ChewieTheme.animationDuration, transitionBuilder: (context, animation, secondaryAnimation, _) { return DialogAnimation( animation: animation, diff --git a/third-party/chewie/lib/src/Widgets/Dialog/widgets/custom_confirm_dialog_widget.dart b/third-party/chewie/lib/src/Widgets/Dialog/widgets/custom_confirm_dialog_widget.dart index 0bffc299..96b1dcc1 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/widgets/custom_confirm_dialog_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/widgets/custom_confirm_dialog_widget.dart @@ -138,8 +138,7 @@ class _CustomConfirmDialogWidgetState Expanded( flex: 1, child: RoundIconTextButton( - color: widget.buttonTextColor ?? - ChewieTheme.primaryButtonColor, + color: widget.buttonTextColor ?? Colors.white, fontSizeDelta: 2, height: 48, onPressed: () { @@ -147,10 +146,12 @@ class _CustomConfirmDialogWidgetState widget.onTapConfirm.call(); }, text: widget.confirmButtonText, - background: CustomDialogColors.getBgColor( - context, - widget.customDialogType, - widget.color ?? ChewieTheme.primaryColor, + background: CustomDialogColors.readableActionFill( + CustomDialogColors.getBgColor( + context, + widget.customDialogType, + widget.color ?? ChewieTheme.primaryColor, + ), ), ), ), diff --git a/third-party/chewie/lib/src/Widgets/Dialog/widgets/dialog_wrapper_widget.dart b/third-party/chewie/lib/src/Widgets/Dialog/widgets/dialog_wrapper_widget.dart index a78f63b0..828d0743 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/widgets/dialog_wrapper_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/widgets/dialog_wrapper_widget.dart @@ -31,15 +31,14 @@ class DialogWrapperWidgetState extends State with SingleTickerProviderStateMixin { bool canNavigatorPop = true; + // Kept for Loftify's root lock/unlock flow. + void popAll() { + if (mounted) Navigator.of(context).pop(); + } + late AnimationController _shakingController; late Animation _shakingAnimation; - late double dialogWidth; - late double dialogHeight; - - late double defaultWidth; - late double defaultHeight; - BorderRadius borderRadius = ChewieDimens.defaultBorderRadius; @override @@ -65,11 +64,6 @@ class DialogWrapperWidgetState extends State .chain(CurveTween(curve: Curves.easeInOut)), weight: 1), ]).animate(_shakingController); - - defaultWidth = widget.preferMinWidth ?? 1000; - defaultHeight = widget.preferMinHeight ?? 720; - dialogWidth = defaultWidth; - dialogHeight = defaultHeight; } @override @@ -84,27 +78,21 @@ class DialogWrapperWidgetState extends State } } - void _resetSizeToDefault() { - setState(() { - dialogWidth = defaultWidth; - dialogHeight = defaultHeight; - }); - } - @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.sizeOf(context).width; - final screenHeight = MediaQuery.sizeOf(context).height; - - final maxDialogWidth = screenWidth - 60; - final maxDialogHeight = screenHeight - 60; - - final preferWidth = min(maxDialogWidth, dialogWidth); - final preferHeight = min(maxDialogHeight, dialogHeight); - - final horizontalMargin = max((screenWidth - preferWidth) / 2, 40.0); - final verticalMargin = max((screenHeight - preferHeight) / 2, 40.0); - + double width = MediaQuery.sizeOf(context).width - 60; + double height = MediaQuery.sizeOf(context).height - 60; + double preferWidth = min(width, widget.preferMinWidth ?? 800); + double preferHeight = min(height, widget.preferMinHeight ?? 720); + double preferHorizontalMargin = + width > preferWidth ? (width - preferWidth) / 2 : 0; + double preferVerticalMargin = + height > preferHeight ? (height - preferHeight) / 2 : 0; + preferHorizontalMargin = max(preferHorizontalMargin, 20); + preferVerticalMargin = max(preferVerticalMargin, 80); + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + final adjustedBottomMargin = + max(preferVerticalMargin - keyboardHeight, 20.0); return BackdropFilter( filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), child: GestureDetector( @@ -130,23 +118,24 @@ class DialogWrapperWidgetState extends State color: widget.barrierDismissible ? null : Colors.transparent, padding: widget.fullScreen ? EdgeInsets.zero - : EdgeInsets.symmetric( - horizontal: horizontalMargin, - vertical: verticalMargin, + : EdgeInsets.only( + left: preferHorizontalMargin, + right: preferHorizontalMargin, + top: preferVerticalMargin, + bottom: adjustedBottomMargin, ), child: GestureDetector( onTap: () {}, child: Container( - width: widget.fullScreen ? null : preferWidth, - height: widget.fullScreen ? null : preferHeight, decoration: BoxDecoration( borderRadius: borderRadius, boxShadow: ChewieTheme.defaultBoxShadow, border: widget.fullScreen ? null : ChewieTheme.border, ), child: ClipRRect( - borderRadius: - widget.fullScreen ? BorderRadius.zero : borderRadius, + borderRadius: widget.fullScreen + ? BorderRadius.circular(0) + : borderRadius, child: Stack( children: [ Navigator( @@ -174,36 +163,6 @@ class DialogWrapperWidgetState extends State }, ), ), - if (!widget.fullScreen) - Positioned( - right: 0, - bottom: 0, - child: MouseRegion( - cursor: SystemMouseCursors.resizeUpLeft, - child: GestureDetector( - onPanUpdate: (details) { - setState(() { - dialogWidth = max( - 300, dialogWidth + details.delta.dx); - dialogHeight = max( - 200, dialogHeight + details.delta.dy); - }); - }, - onDoubleTap: _resetSizeToDefault, - child: RotatedBox( - quarterTurns: 3, - child: Container( - padding: const EdgeInsets.all(4), - child: const Icon( - LucideIcons.axis3d, - size: 18, - color: Colors.grey, - ), - ), - ), - ), - ), - ), ], ), ), diff --git a/third-party/chewie/lib/src/Widgets/Dialog/widgets/progress_dialog_widget.dart b/third-party/chewie/lib/src/Widgets/Dialog/widgets/progress_dialog_widget.dart index 48e07ca6..89ce42c4 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/widgets/progress_dialog_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/widgets/progress_dialog_widget.dart @@ -105,7 +105,8 @@ class ProgressDialog { void dismiss() { if (_dialogIsOpen) { - Navigator.pop(chewieProvider.rootContext); + Navigator.pop(chewieProvider.globalNavigatorState?.context ?? + chewieProvider.rootContext); _dialogIsOpen = false; } } @@ -125,7 +126,8 @@ class ProgressDialog { barrierColor: ChewieTheme.barrierColor, barrierDismissible: barrierDismissible, barrierLabel: "", - context: chewieProvider.rootContext, + context: chewieProvider.globalNavigatorState?.context ?? + chewieProvider.rootContext, pageBuilder: (context, animation, secondaryAnimation) => const SizedBox.shrink(), transitionBuilder: (context, animation, secondaryAnimation, _) => diff --git a/third-party/chewie/lib/src/Widgets/General/appbar_wrapper.dart b/third-party/chewie/lib/src/Widgets/General/appbar_wrapper.dart index 5319bf76..ee6d61fa 100644 --- a/third-party/chewie/lib/src/Widgets/General/appbar_wrapper.dart +++ b/third-party/chewie/lib/src/Widgets/General/appbar_wrapper.dart @@ -17,6 +17,7 @@ class AppBarWrapper extends StatelessWidget implements PreferredSizeWidget { final double titleRightMargin; final List? actions; final SystemUiOverlayStyle? systemOverlayStyle; + final bool primary; const AppBarWrapper({ super.key, @@ -33,6 +34,7 @@ class AppBarWrapper extends StatelessWidget implements PreferredSizeWidget { this.titleRightMargin = 0, this.actions, this.systemOverlayStyle, + this.primary = true, }); @override @@ -41,6 +43,11 @@ class AppBarWrapper extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { bool showLeading = (leadingIcon != null || leadingWidget != null); + final effectiveBackgroundColor = backgroundColor ?? + Theme.of(context).appBarTheme.backgroundColor ?? + Theme.of(context).scaffoldBackgroundColor; + final effectiveSystemOverlayStyle = systemOverlayStyle ?? + systemUiOverlayStyleForColor(context, effectiveBackgroundColor); var finalTitleWidget = Container( margin: EdgeInsets.only( @@ -53,38 +60,79 @@ class AppBarWrapper extends StatelessWidget implements PreferredSizeWidget { var finalLeadingWidget = Container( margin: EdgeInsets.only(left: leftSpacing), child: leadingWidget ?? - CircleIconButton( - icon: - Icon(leadingIcon, color: leadingColor ?? ChewieTheme.iconColor), - onTap: onLeadingTap, - ), + (leadingIcon == null + ? const SizedBox.shrink() + : ChewieIconButton( + icon: leadingIcon!, + foregroundColor: leadingColor ?? ChewieTheme.iconColor, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: onLeadingTap, + )), ); - return MyAppBar( - backgroundColor: - backgroundColor ?? Theme.of(context).appBarTheme.backgroundColor!, - elevation: 0, - centerTitle: centerTitle, - systemOverlayStyle: systemOverlayStyle ?? - (ChewieTheme.isDarkMode - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark), - scrolledUnderElevation: 0, - automaticallyImplyLeading: false, - leadingWidth: showLeading ? 56.0 : 0.0, - leading: showLeading ? finalLeadingWidget : null, - title: centerTitle ? Center(child: finalTitleWidget) : finalTitleWidget, - actions: [ - ...?actions, - if (rightSpacing > 0) SizedBox(width: rightSpacing), - ], + return ChewieIconButtonVisualScope( + visualSize: ChewieIconButtonVisualScope.appBarVisualSize, + maximumIconSize: ChewieIconButtonVisualScope.appBarIconSize, + child: MyAppBar( + backgroundColor: effectiveBackgroundColor, + elevation: 0, + centerTitle: centerTitle, + systemOverlayStyle: effectiveSystemOverlayStyle, + primary: primary, + scrolledUnderElevation: 0, + automaticallyImplyLeading: false, + leadingWidth: showLeading ? 52.0 : 0.0, + leading: showLeading ? finalLeadingWidget : null, + title: centerTitle ? Center(child: finalTitleWidget) : finalTitleWidget, + actions: [ + ...?actions, + if (rightSpacing > 0) SizedBox(width: rightSpacing), + ], + ), + ); + } + + static SystemUiOverlayStyle systemUiOverlayStyleForColor( + BuildContext context, + Color backgroundColor, { + bool includeNavigationBar = false, + }) { + final opaqueBackground = Color.alphaBlend( + backgroundColor, + Theme.of(context).scaffoldBackgroundColor, + ); + return systemUiOverlayStyleForBrightness( + ThemeData.estimateBrightnessForColor(opaqueBackground), + includeNavigationBar: includeNavigationBar, + ); + } + + static SystemUiOverlayStyle systemUiOverlayStyleForBrightness( + Brightness backgroundBrightness, { + bool includeNavigationBar = false, + }) { + final preset = backgroundBrightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark; + return SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarBrightness: preset.statusBarBrightness, + statusBarIconBrightness: preset.statusBarIconBrightness, + systemStatusBarContrastEnforced: false, + systemNavigationBarColor: + includeNavigationBar ? Colors.transparent : null, + systemNavigationBarDividerColor: + includeNavigationBar ? Colors.transparent : null, + systemNavigationBarIconBrightness: + includeNavigationBar ? preset.statusBarIconBrightness : null, + systemNavigationBarContrastEnforced: includeNavigationBar ? false : null, ); } static PreferredSizeWidget simple({ String title = "", Key? key, - IconData leadingIcon = Icons.arrow_back_rounded, + IconData leadingIcon = ChewieIcons.back, List? actions, required BuildContext context, bool transparent = false, diff --git a/third-party/chewie/lib/src/Widgets/General/chewie_icon.dart b/third-party/chewie/lib/src/Widgets/General/chewie_icon.dart new file mode 100644 index 00000000..508c0481 --- /dev/null +++ b/third-party/chewie/lib/src/Widgets/General/chewie_icon.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../../Resources/icon_theme.dart'; + +/// A theme-aware interface icon with one optical size and disabled-state rule. +class ChewieIcon extends StatelessWidget { + const ChewieIcon( + this.icon, { + super.key, + this.size, + this.color, + this.enabled = true, + this.semanticLabel, + this.textDirection, + this.shadows, + this.opticalOffset = Offset.zero, + }); + + final IconData icon; + final double? size; + final Color? color; + final bool enabled; + final String? semanticLabel; + final TextDirection? textDirection; + final List? shadows; + + /// Allows a rare glyph-specific optical correction without changing layout. + final Offset opticalOffset; + + @override + Widget build(BuildContext context) { + final specification = ChewieIconThemeData.of(context); + final iconTheme = IconTheme.of(context); + final baseColor = + color ?? iconTheme.color ?? Theme.of(context).colorScheme.onSurface; + final disabledOpacity = MediaQuery.highContrastOf(context) && + specification.disabledOpacity < 0.5 + ? 0.5 + : specification.disabledOpacity; + final effectiveColor = enabled + ? baseColor + : baseColor.withValues( + alpha: baseColor.a * disabledOpacity, + ); + final effectiveSize = size ?? specification.regularSize; + final child = Icon( + icon, + size: effectiveSize, + color: effectiveColor, + semanticLabel: semanticLabel, + textDirection: textDirection, + shadows: shadows, + ); + if (opticalOffset == Offset.zero) return child; + return Transform.translate(offset: opticalOffset, child: child); + } +} diff --git a/third-party/chewie/lib/src/Widgets/General/clickable_gesture_detector.dart b/third-party/chewie/lib/src/Widgets/General/clickable_gesture_detector.dart index d2f6608e..1b807099 100644 --- a/third-party/chewie/lib/src/Widgets/General/clickable_gesture_detector.dart +++ b/third-party/chewie/lib/src/Widgets/General/clickable_gesture_detector.dart @@ -8,6 +8,7 @@ class ClickableGestureDetector extends StatelessWidget { final GestureLongPressStartCallback? onLongPressStart; final GestureLongPressEndCallback? onLongPressEnd; final bool clickable; + final HitTestBehavior behavior; const ClickableGestureDetector({ super.key, @@ -18,6 +19,7 @@ class ClickableGestureDetector extends StatelessWidget { this.onLongPressStart, this.onLongPressEnd, this.clickable = true, + this.behavior = HitTestBehavior.opaque, }); @override @@ -25,6 +27,7 @@ class ClickableGestureDetector extends StatelessWidget { return MouseRegion( cursor: clickable ? SystemMouseCursors.click : SystemMouseCursors.basic, child: GestureDetector( + behavior: behavior, onTap: onTap, onLongPress: onLongPress, onSecondaryTap: onSecondaryTap, diff --git a/third-party/chewie/lib/src/Widgets/General/dropdown_wrapper.dart b/third-party/chewie/lib/src/Widgets/General/dropdown_wrapper.dart index 34c6ce4c..52defd79 100644 --- a/third-party/chewie/lib/src/Widgets/General/dropdown_wrapper.dart +++ b/third-party/chewie/lib/src/Widgets/General/dropdown_wrapper.dart @@ -47,7 +47,7 @@ class DropdownWrapper extends StatelessWidget { closedBorder: ChewieTheme.borderWithWidth(1), closedBorderRadius: ChewieDimens.borderRadius8, listItemDecoration: ListItemDecoration( - selectedIconColor: ChewieTheme.successColor, + selectedIconColor: ChewieTheme.primaryColor, splashColor: ChewieTheme.splashColor, highlightColor: ChewieTheme.highlightColor, selectedColor: ChewieTheme.hoverColor, diff --git a/third-party/chewie/lib/src/Widgets/General/empty_placeholder.dart b/third-party/chewie/lib/src/Widgets/General/empty_placeholder.dart index cba9f2cd..2ebb936b 100644 --- a/third-party/chewie/lib/src/Widgets/General/empty_placeholder.dart +++ b/third-party/chewie/lib/src/Widgets/General/empty_placeholder.dart @@ -29,6 +29,24 @@ class EmptyPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { + final stateBuilder = chewieProvider.stateWidgetBuilder; + if (stateBuilder != null) { + return stateBuilder( + context, + ChewieStateViewConfig( + type: ChewieStateViewType.empty, + text: text, + onAction: onTap, + icon: icon, + size: size, + physics: physics, + shrinkWrap: shrinkWrap, + scrollController: scrollController, + topPadding: topPadding, + bottomPadding: bottomPadding, + ), + ); + } return ListView( physics: physics, shrinkWrap: shrinkWrap, @@ -52,9 +70,58 @@ class EmptyPlaceholder extends StatelessWidget { textAlign: TextAlign.center, style: ChewieTheme.labelLarge, ), + if (onTap != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: TextButton( + onPressed: onTap, + child: Text(chewieLocalizations.retry), + ), + ), ], ), + SizedBox(height: bottomPadding), ], ); } } + +/// Empty state for a [CustomScrollView] that keeps a single scroll chain. +/// +/// [EmptyPlaceholder] is backed by a [ListView], so placing it directly in a +/// non-scrolling [SliverFillRemaining] asks a viewport for intrinsic dimensions +/// and can break the complete sliver layout. This wrapper gives it a finite +/// viewport and disables the inner gesture handler. +class SliverEmptyPlaceholder extends StatelessWidget { + final String text; + final double height; + final double topPadding; + final double size; + final IconData? icon; + + const SliverEmptyPlaceholder({ + super.key, + required this.text, + this.height = 220, + this.topPadding = 64, + this.size = 30, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: SizedBox( + height: height, + child: EmptyPlaceholder( + text: text, + icon: icon, + size: size, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: false, + topPadding: topPadding, + ), + ), + ); + } +} diff --git a/third-party/chewie/lib/src/Widgets/General/error_widget.dart b/third-party/chewie/lib/src/Widgets/General/error_widget.dart index 53ceb16d..eb947ad0 100644 --- a/third-party/chewie/lib/src/Widgets/General/error_widget.dart +++ b/third-party/chewie/lib/src/Widgets/General/error_widget.dart @@ -20,6 +20,19 @@ class CustomErrorWidget extends StatefulWidget { class CustomErrorWidgetState extends State { @override Widget build(BuildContext context) { + final stateBuilder = chewieProvider.stateWidgetBuilder; + if (stateBuilder != null) { + return stateBuilder( + context, + ChewieStateViewConfig( + type: ChewieStateViewType.error, + text: widget.text ?? chewieLocalizations.loadFailed, + actionLabel: widget.buttonText ?? chewieLocalizations.retry, + onAction: widget.onTap, + icon: ChewieIcons.error, + ), + ); + } return Container( alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 20), diff --git a/third-party/chewie/lib/src/Widgets/General/index.dart b/third-party/chewie/lib/src/Widgets/General/index.dart index 88d6c35f..17571f2d 100644 --- a/third-party/chewie/lib/src/Widgets/General/index.dart +++ b/third-party/chewie/lib/src/Widgets/General/index.dart @@ -17,6 +17,7 @@ export 'animated_sidebar_container.dart'; export 'appbar_wrapper.dart'; export 'clickable_gesture_detector.dart'; export 'clickable_wrapper.dart'; +export 'chewie_icon.dart'; export 'dropdown_wrapper.dart'; export 'empty_placeholder.dart'; export 'error_widget.dart'; @@ -28,4 +29,4 @@ export 'selectable_area_wrapper.dart'; export 'sliver_appbar_wrapper.dart'; export 'tab_bar_wrapper.dart'; export 'tooltip_wrapper.dart'; -export 'subordinate_scroll_controller.dart'; \ No newline at end of file +export 'subordinate_scroll_controller.dart'; diff --git a/third-party/chewie/lib/src/Widgets/General/loading_widget.dart b/third-party/chewie/lib/src/Widgets/General/loading_widget.dart index 4fbd00b1..766eb3d4 100644 --- a/third-party/chewie/lib/src/Widgets/General/loading_widget.dart +++ b/third-party/chewie/lib/src/Widgets/General/loading_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show Colors; import 'package:awesome_chewie/awesome_chewie.dart'; class LoadingWidget extends StatefulWidget { @@ -28,10 +29,26 @@ class LoadingWidget extends StatefulWidget { class LoadingWidgetState extends State { @override Widget build(BuildContext context) { + final stateBuilder = chewieProvider.stateWidgetBuilder; + if (stateBuilder != null) { + return stateBuilder( + context, + ChewieStateViewConfig( + type: ChewieStateViewType.loading, + text: widget.text ?? chewieLocalizations.loading, + size: widget.size, + showText: widget.showText, + forceDark: widget.forceDark, + background: widget.background, + topPadding: widget.topPadding, + bottomPadding: widget.bottomPadding, + ), + ); + } return Center( child: Container( width: double.infinity, - color: widget.background ?? ChewieTheme.cardColor.withAlpha(127), + color: widget.background ?? Colors.transparent, padding: EdgeInsets.only( top: widget.topPadding, bottom: widget.bottomPadding), child: Column( diff --git a/third-party/chewie/lib/src/Widgets/General/my_divider.dart b/third-party/chewie/lib/src/Widgets/General/my_divider.dart index 926ce65b..2309ab05 100644 --- a/third-party/chewie/lib/src/Widgets/General/my_divider.dart +++ b/third-party/chewie/lib/src/Widgets/General/my_divider.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:awesome_chewie/awesome_chewie.dart'; -class MyDivider extends StatelessWidget { +class MyDivider extends StatefulWidget { final double vertical; final double horizontal; final double? width; @@ -15,15 +14,31 @@ class MyDivider extends StatelessWidget { this.margin, }); + @override + State createState() => _MyDividerState(); +} + +class _MyDividerState extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + Theme.of(context); + } + @override Widget build(BuildContext context) { + Theme.of(context); + return Container( - margin: margin ?? - EdgeInsets.symmetric(vertical: vertical, horizontal: horizontal), - height: width ?? 0.5, + margin: widget.margin ?? + EdgeInsets.symmetric( + vertical: widget.vertical, + horizontal: widget.horizontal, + ), + height: widget.width ?? 0.5, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - color: ChewieTheme.dividerColor, + color: Theme.of(context).dividerColor, ), ); } diff --git a/third-party/chewie/lib/src/Widgets/General/number_field.dart b/third-party/chewie/lib/src/Widgets/General/number_field.dart index d2da39d4..303e6590 100644 --- a/third-party/chewie/lib/src/Widgets/General/number_field.dart +++ b/third-party/chewie/lib/src/Widgets/General/number_field.dart @@ -186,7 +186,7 @@ class NumberFieldState extends State { borderRadius: const BorderRadius.horizontal(left: Radius.circular(6)), ), - child: const Icon(Icons.remove_rounded, size: 16), + child: const ChewieIcon(ChewieIcons.remove, size: 16), ), ), SizedBox( @@ -266,7 +266,7 @@ class NumberFieldState extends State { borderRadius: const BorderRadius.horizontal(right: Radius.circular(6)), ), - child: const Icon(Icons.add_rounded, size: 16), + child: const ChewieIcon(ChewieIcons.add, size: 16), ), ), ], diff --git a/third-party/chewie/lib/src/Widgets/General/responsive_app_bar.dart b/third-party/chewie/lib/src/Widgets/General/responsive_app_bar.dart index 9f449bc4..1de98595 100644 --- a/third-party/chewie/lib/src/Widgets/General/responsive_app_bar.dart +++ b/third-party/chewie/lib/src/Widgets/General/responsive_app_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:awesome_chewie/awesome_chewie.dart'; @@ -6,6 +7,7 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; final Widget? titleWidget; final bool showBack; + final IconData leadingIcon; final Function()? onTapBack; final bool? showBorder; final Widget? bottomWidget; @@ -15,15 +17,21 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { final double titleLeftMargin; final double rightSpacing; final List actions; + final List landscapeActions; + @Deprecated('Use landscapeActions instead.') final List desktopActions; final double height; final double? borderWidth; + final BuildContext? context; + final SystemUiOverlayStyle? systemOverlayStyle; const ResponsiveAppBar({ super.key, + this.context, this.title = "", this.titleWidget, this.showBack = false, + this.leadingIcon = ChewieIcons.back, this.onTapBack, this.showBorder, this.bottomWidget, @@ -32,29 +40,51 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { this.centerTitle = false, this.titleLeftMargin = 5, this.rightSpacing = 8, + this.landscapeActions = const [], this.desktopActions = const [], this.actions = const [], this.height = 48, this.borderWidth, + this.systemOverlayStyle, }); @override Widget build(BuildContext context) { final bool isLandscape = ResponsiveUtil.isLandscapeLayout(); + void handleBack() { + if (onTapBack != null) { + onTapBack!(); + return; + } + final panelScreenState = chewieProvider.panelScreenState; + if (panelScreenState != null) { + panelScreenState.popPage(); + return; + } + final navigator = Navigator.maybeOf(context); + if (navigator?.canPop() ?? false) { + navigator!.pop(); + } + } + final Widget titleContent = Container( margin: EdgeInsets.only(left: titleLeftMargin), - child: titleWidget ?? Text(title, style: ChewieTheme.titleLarge), + child: titleWidget ?? + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChewieTheme.titleLarge, + ), ); final PreferredSize topWidget = PreferredSize( preferredSize: Size.fromHeight(height), child: Container( + key: const ValueKey('responsive-app-bar-surface'), height: height, decoration: BoxDecoration( color: backgroundColor ?? ChewieTheme.appBarBackgroundColor, - border: (showBorder ?? true) - ? ChewieTheme.bottomDividerWithWidth(borderWidth) - : null, ), child: isLandscape ? Stack( @@ -68,20 +98,18 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { if (showBack) Container( margin: const EdgeInsets.only(left: 8), - child: ToolButton( - context: context, - onPressed: onTapBack ?? - () => chewieProvider.panelScreenState - ?.popPage(), - iconBuilder: (_) => const Icon( - Icons.arrow_back_rounded, - size: 22), + child: ChewieIconButton( + icon: leadingIcon, + onPressed: handleBack, + iconSize: 20, + tooltip: MaterialLocalizations.of(context) + .backButtonTooltip, ), ), - titleContent, - const Spacer(), + Expanded(child: titleContent), ...[ ...desktopActions, + ...landscapeActions, const SizedBox(width: 44), ], ], @@ -90,12 +118,13 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { ], ) : AppBarWrapper( + primary: false, centerTitle: centerTitle, - leadingIcon: showBack ? Icons.arrow_back_rounded : null, - onLeadingTap: onTapBack ?? - () => chewieProvider.panelScreenState?.popPage(), + leadingIcon: showBack ? leadingIcon : null, + onLeadingTap: handleBack, backgroundColor: backgroundColor ?? ChewieTheme.scaffoldBackgroundColor, + systemOverlayStyle: systemOverlayStyle, titleLeftMargin: titleLeftMargin, rightSpacing: rightSpacing, title: titleWidget != null @@ -105,6 +134,8 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { ) : Text( title, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: ChewieTheme.titleMedium.apply(fontWeightDelta: 2), ), @@ -113,19 +144,38 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { ), ); - return SafeArea( - top: !ResponsiveUtil.isLandscapeLayout(), - child: bottomWidget != null && bottomHeight != null - ? PreferredSize( - preferredSize: Size.fromHeight(height + bottomHeight!), - child: Column( - children: [ - topWidget, - bottomWidget!, - ], - ), - ) - : topWidget, + final effectiveBackgroundColor = backgroundColor ?? + (isLandscape + ? ChewieTheme.appBarBackgroundColor + : ChewieTheme.scaffoldBackgroundColor); + final effectiveSystemOverlayStyle = systemOverlayStyle ?? + AppBarWrapper.systemUiOverlayStyleForColor( + context, + effectiveBackgroundColor, + ); + return ChewieIconButtonVisualScope( + visualSize: ChewieIconButtonVisualScope.appBarVisualSize, + maximumIconSize: ChewieIconButtonVisualScope.appBarIconSize, + child: AnnotatedRegion( + value: effectiveSystemOverlayStyle, + child: ColoredBox( + color: effectiveBackgroundColor, + child: SafeArea( + top: ResponsiveUtil.isMobile(), + child: bottomWidget != null && bottomHeight != null + ? PreferredSize( + preferredSize: Size.fromHeight(height + bottomHeight!), + child: Column( + children: [ + topWidget, + bottomWidget!, + ], + ), + ) + : topWidget, + ), + ), + ), ); } diff --git a/third-party/chewie/lib/src/Widgets/General/sliver_appbar_wrapper.dart b/third-party/chewie/lib/src/Widgets/General/sliver_appbar_wrapper.dart index 7d884b6f..52882cee 100644 --- a/third-party/chewie/lib/src/Widgets/General/sliver_appbar_wrapper.dart +++ b/third-party/chewie/lib/src/Widgets/General/sliver_appbar_wrapper.dart @@ -19,6 +19,7 @@ class SliverAppBarWrapper extends StatelessWidget { final double leftSpacing; final double rightSpacing; final SystemUiOverlayStyle? systemOverlayStyle; + final VoidCallback? onBack; const SliverAppBarWrapper({ super.key, @@ -35,43 +36,62 @@ class SliverAppBarWrapper extends StatelessWidget { this.leftSpacing = 8, this.rightSpacing = 8, this.systemOverlayStyle, + this.onBack, }); @override Widget build(BuildContext context) { bool showLeading = !ResponsiveUtil.isLandscapeLayout(); + final backgroundColor = Theme.of(context).appBarTheme.backgroundColor ?? + Theme.of(context).scaffoldBackgroundColor; + final effectiveSystemOverlayStyle = systemOverlayStyle ?? + (backgroundWidget != null + ? AppBarWrapper.systemUiOverlayStyleForBrightness(Brightness.dark) + : AppBarWrapper.systemUiOverlayStyleForColor( + context, + backgroundColor, + )); + final leadingColor = backgroundWidget != null + ? Colors.white + : Theme.of(context).iconTheme.color; var finalTitleWidget = Container( margin: EdgeInsets.only(left: titleLeftMargin), child: title, ); var leading = Container( margin: EdgeInsets.only(left: leftSpacing), - child: CircleIconButton( - icon: const Icon(Icons.arrow_back_rounded, color: Colors.white), - onTap: () => Navigator.pop(context), + child: ChewieIconButton( + icon: ChewieIcons.back, + foregroundColor: leadingColor, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: onBack ?? () => Navigator.pop(context), ), ); - return MySliverAppBar( - systemOverlayStyle: systemOverlayStyle, - expandedHeight: expandedHeight, - collapsedHeight: collapsedHeight ?? - max(100, kToolbarHeight + MediaQuery.of(context).padding.top), - pinned: true, - leadingWidth: showLeading ? 56 : 0, - leading: showLeading ? leading : null, - automaticallyImplyLeading: false, - backgroundWidget: backgroundWidget, - title: centerTitle ? Center(child: finalTitleWidget) : finalTitleWidget, - elevation: 0, - scrolledUnderElevation: 0, - backgroundColor: Theme.of(context).appBarTheme.backgroundColor, - flexibleSpace: flexibleSpace, - bottom: bottom, - actions: [ - if (actions != null) ...?actions, - SizedBox(width: rightSpacing), - ], + return ChewieIconButtonVisualScope( + visualSize: ChewieIconButtonVisualScope.appBarVisualSize, + maximumIconSize: ChewieIconButtonVisualScope.appBarIconSize, + child: MySliverAppBar( + systemOverlayStyle: effectiveSystemOverlayStyle, + expandedHeight: expandedHeight, + collapsedHeight: collapsedHeight ?? + max(100, kToolbarHeight + MediaQuery.of(context).padding.top), + pinned: true, + leadingWidth: showLeading ? 52 : 0, + leading: showLeading ? leading : null, + automaticallyImplyLeading: false, + backgroundWidget: backgroundWidget, + title: centerTitle ? Center(child: finalTitleWidget) : finalTitleWidget, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: backgroundColor, + flexibleSpace: flexibleSpace, + bottom: bottom, + actions: [ + if (actions != null) ...?actions, + SizedBox(width: rightSpacing), + ], + ), ); } } diff --git a/third-party/chewie/lib/src/Widgets/General/tab_bar_wrapper.dart b/third-party/chewie/lib/src/Widgets/General/tab_bar_wrapper.dart index 9febb2bf..a915bfb9 100644 --- a/third-party/chewie/lib/src/Widgets/General/tab_bar_wrapper.dart +++ b/third-party/chewie/lib/src/Widgets/General/tab_bar_wrapper.dart @@ -12,7 +12,7 @@ class TabBarWrapper extends StatelessWidget implements PreferredSizeWidget { final bool showBorder; final Color? background; final double? width; - final bool forceUnscrollable; + final bool? isScrollable; const TabBarWrapper({ super.key, @@ -26,7 +26,7 @@ class TabBarWrapper extends StatelessWidget implements PreferredSizeWidget { this.showBorder = false, this.background, this.width, - this.forceUnscrollable = false, + this.isScrollable, }); @override @@ -34,49 +34,55 @@ class TabBarWrapper extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { - bool scrollable = false; - if (MediaQuery.of(context).orientation == Orientation.landscape) { - scrollable = true; - } else { - scrollable = tabs.length > 3; - } - scrollable = forceUnscrollable ? false : scrollable; + final theme = Theme.of(context); + final textScaler = MediaQuery.textScalerOf(context); + final scrollable = isScrollable ?? + tabs.length > 3 || + textScaler.scale(theme.textTheme.titleMedium?.fontSize ?? 14) > 18; + final titleMedium = theme.textTheme.titleMedium ?? ChewieTheme.titleMedium; + final selectedColor = titleMedium.color ?? theme.colorScheme.onSurface; + final unselectedColor = theme.colorScheme.onSurfaceVariant; + final primaryColor = theme.colorScheme.primary; - var titleMedium = ChewieTheme.titleMedium; - - return Container( + return SizedBox( height: height, width: width, - padding: containerPadding, - decoration: BoxDecoration( - color: background ?? ChewieTheme.canvasColor, - border: showBorder - ? const Border(bottom: BorderSide(color: Colors.grey, width: 0.5)) - : null, - ), - child: TabBar( - controller: tabController, - overlayColor: WidgetStateProperty.all(Colors.transparent), - tabs: tabs, - labelPadding: labelPadding, - indicatorSize: TabBarIndicatorSize.tab, - dividerHeight: 0, - padding: tabBarPadding, - isScrollable: scrollable, - tabAlignment: scrollable ? TabAlignment.start : null, - physics: const BouncingScrollPhysics(), - labelStyle: titleMedium.copyWith( - fontWeight: FontWeight.w600, - color: const Color(0xFFF1F1F1), - ), - unselectedLabelStyle: titleMedium.copyWith(color: Colors.grey), - indicator: UnderlinedTabIndicator( - borderColor: ChewieTheme.primaryColor, - indicatorBottom: 4, - indicatorWidth: 12, - borderWidth: 3, + child: Material( + color: background ?? theme.scaffoldBackgroundColor, + child: Container( + padding: containerPadding, + child: TabBar( + controller: tabController, + tabs: tabs, + labelPadding: labelPadding, + indicatorSize: TabBarIndicatorSize.tab, + dividerHeight: 0, + padding: tabBarPadding, + isScrollable: scrollable, + tabAlignment: scrollable ? TabAlignment.start : TabAlignment.fill, + physics: const BouncingScrollPhysics(), + enableFeedback: true, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.all(Colors.transparent), + labelColor: selectedColor, + unselectedLabelColor: unselectedColor, + labelStyle: titleMedium.copyWith( + fontWeight: FontWeight.w600, + color: selectedColor, + ), + unselectedLabelStyle: titleMedium.copyWith( + fontWeight: FontWeight.w400, + color: unselectedColor, + ), + indicator: UnderlinedTabIndicator( + borderColor: primaryColor, + indicatorBottom: 4, + indicatorWidth: 12, + borderWidth: 3, + ), + onTap: onTap, + ), ), - onTap: onTap, ), ); } diff --git a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/custom_dropdown.dart b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/custom_dropdown.dart index dddab136..f1e800d6 100644 --- a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/custom_dropdown.dart +++ b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/custom_dropdown.dart @@ -2,6 +2,9 @@ library animated_custom_dropdown; import 'dart:async'; +import 'package:awesome_chewie/src/Providers/chewie_provider.dart'; +import 'package:awesome_chewie/src/Resources/chewie_icons.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -43,12 +46,6 @@ final Border _defaultErrorBorder = Border.all( width: 1.5, ); -const _defaultErrorStyle = TextStyle( - color: _defaultErrorColor, - fontSize: 14, - height: 0.5, -); - class CustomDropdown extends StatefulWidget { /// The list of items user can select. final List? items; @@ -399,13 +396,13 @@ class CustomDropdown extends StatefulWidget { assert( initialItems == null || initialItems.isEmpty || - initialItems.any((e) => items!.contains(e)), + initialItems.every((e) => items!.contains(e)), 'Initial items must match with the items in the items list.', ), assert( multiSelectController == null || multiSelectController.value.isEmpty || - multiSelectController.value.any((e) => items!.contains(e)), + multiSelectController.value.every((e) => items!.contains(e)), 'Controller value must match with one of the item in items list.', ), _searchType = null, @@ -467,13 +464,13 @@ class CustomDropdown extends StatefulWidget { assert( initialItems == null || initialItems.isEmpty || - initialItems.any((e) => items!.contains(e)), + initialItems.every((e) => items!.contains(e)), 'Initial items must match with the items in the items list.', ), assert( multiSelectController == null || multiSelectController.value.isEmpty || - multiSelectController.value.any((e) => items!.contains(e)), + multiSelectController.value.every((e) => items!.contains(e)), 'Controller value must match with one of the item in items list.', ), _searchType = _SearchType.onListData, @@ -547,65 +544,166 @@ class _CustomDropdownState late SingleSelectController selectedItemNotifier; late MultiSelectController selectedItemsNotifier; FormFieldState<(T?, List)>? _formFieldState; + bool _refreshingEquivalentCandidate = false; + + T? _canonicalItem(T? candidate) { + if (candidate == null || widget.items == null) return candidate; + for (final item in widget.items!) { + if (item == candidate) return item; + } + return candidate; + } + + List _canonicalItems(List candidates) { + return candidates.map((candidate) => _canonicalItem(candidate)!).toList(); + } + + bool _sameValues(List? first, List? second) { + if (identical(first, second)) return true; + if (first == null || second == null || first.length != second.length) { + return false; + } + for (var index = 0; index < first.length; index++) { + if (first[index] != second[index]) return false; + } + return true; + } + + bool _sameInstances(List first, List second) { + if (first.length != second.length) return false; + for (var index = 0; index < first.length; index++) { + if (!identical(first[index], second[index])) return false; + } + return true; + } + + void _handleSelectedItemChanged() { + if (!_refreshingEquivalentCandidate) { + widget.onChanged?.call(selectedItemNotifier.value); + } + _formFieldState?.didChange((selectedItemNotifier.value, [])); + if (widget.validateOnChange) { + _formFieldState?.validate(); + } + } + + void _handleSelectedItemsChanged() { + if (!_refreshingEquivalentCandidate) { + widget.onListChanged?.call(selectedItemsNotifier.value); + } + _formFieldState?.didChange((null, selectedItemsNotifier.value)); + if (widget.validateOnChange) { + _formFieldState?.validate(); + } + } + + void _refreshSelectedItem(T? nextItem) { + _refreshingEquivalentCandidate = true; + try { + selectedItemNotifier.replaceEquivalent(nextItem); + } finally { + _refreshingEquivalentCandidate = false; + } + } + + void _refreshSelectedItems(List nextItems) { + _refreshingEquivalentCandidate = true; + try { + selectedItemsNotifier.value = nextItems; + } finally { + _refreshingEquivalentCandidate = false; + } + } @override void initState() { super.initState(); - selectedItemNotifier = - widget.controller ?? SingleSelectController(widget.initialItem); + selectedItemNotifier = widget.controller ?? + SingleSelectController(_canonicalItem(widget.initialItem)); selectedItemsNotifier = widget.multiSelectController ?? - MultiSelectController(widget.initialItems ?? []); - - selectedItemNotifier.addListener(() { - widget.onChanged?.call(selectedItemNotifier.value); - _formFieldState?.didChange((selectedItemNotifier.value, [])); - if (widget.validateOnChange) { - _formFieldState?.validate(); - } - }); + MultiSelectController(_canonicalItems(widget.initialItems ?? [])); - selectedItemsNotifier.addListener(() { - widget.onListChanged?.call(selectedItemsNotifier.value); - _formFieldState?.didChange((null, selectedItemsNotifier.value)); - if (widget.validateOnChange) { - _formFieldState?.validate(); - } - }); + selectedItemNotifier.addListener(_handleSelectedItemChanged); + selectedItemsNotifier.addListener(_handleSelectedItemsChanged); } @override void didUpdateWidget(covariant CustomDropdown oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.initialItem != oldWidget.initialItem && - selectedItemNotifier.value != widget.initialItem) { - SchedulerBinding.instance.addPostFrameCallback((_) { - selectedItemNotifier.value = widget.initialItem; - }); + final singleControllerChanged = widget.controller != oldWidget.controller; + if (singleControllerChanged) { + selectedItemNotifier.removeListener(_handleSelectedItemChanged); + if (oldWidget.controller == null) selectedItemNotifier.dispose(); + selectedItemNotifier = widget.controller ?? + SingleSelectController(_canonicalItem(widget.initialItem)); + selectedItemNotifier.addListener(_handleSelectedItemChanged); } - if (widget.initialItems != oldWidget.initialItems && - selectedItemsNotifier.value != widget.initialItems) { - SchedulerBinding.instance.addPostFrameCallback((_) { - selectedItemsNotifier.value = widget.initialItems ?? []; - }); + final multiControllerChanged = + widget.multiSelectController != oldWidget.multiSelectController; + if (multiControllerChanged) { + selectedItemsNotifier.removeListener(_handleSelectedItemsChanged); + if (oldWidget.multiSelectController == null) { + selectedItemsNotifier.dispose(); + } + selectedItemsNotifier = widget.multiSelectController ?? + MultiSelectController(_canonicalItems(widget.initialItems ?? [])); + selectedItemsNotifier.addListener(_handleSelectedItemsChanged); } - if (widget.controller != oldWidget.controller && - widget.controller != null) { - selectedItemNotifier = widget.controller!; + final nextInitialItem = _canonicalItem(widget.initialItem); + final initialValueChanged = widget.initialItem != oldWidget.initialItem; + final equivalentCandidateChanged = widget.initialItem != null && + selectedItemNotifier.value == nextInitialItem && + !identical(selectedItemNotifier.value, nextInitialItem) && + !identical(widget.initialItem, oldWidget.initialItem); + if (!singleControllerChanged && + widget.controller == null && + (initialValueChanged || equivalentCandidateChanged)) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted || widget.controller != null) return; + final currentInitialItem = _canonicalItem(widget.initialItem); + if (!identical(selectedItemNotifier.value, currentInitialItem)) { + if (selectedItemNotifier.value == currentInitialItem) { + _refreshSelectedItem(currentInitialItem); + } else { + selectedItemNotifier.value = currentInitialItem; + } + } + }); } - if (widget.multiSelectController != oldWidget.multiSelectController && - widget.multiSelectController != null) { - selectedItemsNotifier = widget.multiSelectController!; + final nextInitialItems = _canonicalItems(widget.initialItems ?? []); + final initialValuesChanged = + !_sameValues(widget.initialItems, oldWidget.initialItems); + final equivalentCandidatesChanged = widget.initialItems != null && + _sameValues(selectedItemsNotifier.value, nextInitialItems) && + !_sameInstances(selectedItemsNotifier.value, nextInitialItems) && + !identical(widget.initialItems, oldWidget.initialItems); + if (!multiControllerChanged && + widget.multiSelectController == null && + (initialValuesChanged || equivalentCandidatesChanged)) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted || widget.multiSelectController != null) return; + final currentInitialItems = _canonicalItems(widget.initialItems ?? []); + if (!_sameInstances(selectedItemsNotifier.value, currentInitialItems)) { + if (_sameValues(selectedItemsNotifier.value, currentInitialItems)) { + _refreshSelectedItems(currentInitialItems); + } else { + selectedItemsNotifier.value = currentInitialItems; + } + } + }); } } @override void dispose() { + selectedItemNotifier.removeListener(_handleSelectedItemChanged); + selectedItemsNotifier.removeListener(_handleSelectedItemsChanged); if (widget.controller == null) { selectedItemNotifier.dispose(); } @@ -647,7 +745,6 @@ class _CustomDropdownState _formFieldState = formFieldState; return Container( decoration: const BoxDecoration( - // errorStyle: decoration?.errorStyle ?? _defaultErrorStyle, // errorText: formFieldState.errorText, border: null, // contentPadding: EdgeInsets.zero, diff --git a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/models/controllers.dart b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/models/controllers.dart index 9cf7b3c1..4f0c0b24 100644 --- a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/models/controllers.dart +++ b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/models/controllers.dart @@ -1,7 +1,25 @@ part of '../custom_dropdown.dart'; -class SingleSelectController extends ValueNotifier { - SingleSelectController(super._value); +class SingleSelectController extends ChangeNotifier + implements ValueListenable { + T? _value; + + SingleSelectController(this._value); + + @override + T? get value => _value; + + set value(T? newValue) { + if (_value == newValue) return; + _value = newValue; + notifyListeners(); + } + + void replaceEquivalent(T? newValue) { + if (identical(_value, newValue)) return; + _value = newValue; + notifyListeners(); + } void clear() { value = null; diff --git a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_field.dart b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_field.dart index 1ad86560..ff69a52b 100644 --- a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_field.dart +++ b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_field.dart @@ -2,7 +2,7 @@ part of '../custom_dropdown.dart'; // overlay icon const _defaultOverlayIconDown = Icon( - Icons.keyboard_arrow_down_rounded, + ChewieIcons.expand, size: 20, ); @@ -93,22 +93,18 @@ class _DropDownFieldState } Widget defaultShowSelectedBuilder({T? oneItem, List? itemList}) { - return Container( - // margin: const EdgeInsets.only(left: 16), - // alignment: Alignment.center, - child: Text( - itemList != null - ? itemList.join(', ') - : (oneItem as DropdownMixin).display, - maxLines: widget.maxLines, - overflow: TextOverflow.ellipsis, - style: widget.showSelectedStyle ?? - TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: widget.enabled ? null : Colors.black.withValues(alpha: .5), - ), - ), + return Text( + itemList != null + ? itemList.join(', ') + : (oneItem as DropdownMixin).display, + maxLines: widget.maxLines, + overflow: TextOverflow.ellipsis, + style: widget.showSelectedStyle ?? + TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: widget.enabled ? null : Colors.black.withValues(alpha: .5), + ), ); } @@ -164,42 +160,56 @@ class _DropDownFieldState @override Widget build(BuildContext context) { - return GestureDetector( - onTap: widget.onTap, - child: Container( - padding: widget.headerPadding ?? _defaultHeaderPadding, - decoration: BoxDecoration( - color: widget.fillColor ?? - (widget.enabled - ? CustomDropdownDecoration._defaultFillColor - : CustomDropdownDecoration._defaultFillColor - .withValues(alpha: .5)), - border: widget.border, - borderRadius: widget.borderRadius ?? _defaultBorderRadius, - boxShadow: widget.shadow, - ), - child: Row( - children: [ - if (widget.prefixIcon != null) ...[ - widget.prefixIcon!, - const SizedBox(width: 12), - ], - Expanded( - child: switch (widget.dropdownType) { - _DropdownType.singleSelect => selectedItem != null - ? showSelectedBuilder(context) - : hintBuilder(context), - _DropdownType.multipleSelect => selectedItems.isNotEmpty - ? multiSelectShowSelectedBuilder(context) - : hintBuilder(context), - }, + final borderRadius = widget.borderRadius ?? _defaultBorderRadius; + final fillColor = widget.fillColor ?? + (widget.enabled + ? CustomDropdownDecoration._defaultFillColor + : CustomDropdownDecoration._defaultFillColor.withValues(alpha: .5)); + + return Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + boxShadow: widget.shadow, + ), + child: Material( + color: fillColor, + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: Ink( + decoration: BoxDecoration( + border: widget.border, + borderRadius: borderRadius, + ), + child: InkWell( + onTap: widget.enabled ? widget.onTap : null, + borderRadius: borderRadius, + child: Padding( + padding: widget.headerPadding ?? _defaultHeaderPadding, + child: Row( + children: [ + if (widget.prefixIcon != null) ...[ + widget.prefixIcon!, + const SizedBox(width: 12), + ], + Expanded( + child: switch (widget.dropdownType) { + _DropdownType.singleSelect => selectedItem != null + ? showSelectedBuilder(context) + : hintBuilder(context), + _DropdownType.multipleSelect => selectedItems.isNotEmpty + ? multiSelectShowSelectedBuilder(context) + : hintBuilder(context), + }, + ), + const SizedBox(width: 12), + widget.suffixIcon ?? + (widget.enabled + ? _defaultOverlayIconDown + : const SizedBox.shrink()), + ], + ), ), - const SizedBox(width: 12), - widget.suffixIcon ?? - (widget.enabled - ? _defaultOverlayIconDown - : const SizedBox.shrink()), - ], + ), ), ), ); diff --git a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/dropdown_overlay.dart b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/dropdown_overlay.dart index 16fac9f4..a281a6c5 100644 --- a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/dropdown_overlay.dart +++ b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/dropdown_overlay.dart @@ -1,7 +1,7 @@ part of '../../custom_dropdown.dart'; const _defaultOverlayIconUp = Icon( - Icons.keyboard_arrow_up_rounded, + ChewieIcons.collapse, size: 20, ); @@ -121,6 +121,19 @@ class _DropdownOverlayState bool isSelected, VoidCallback onItemSelect, ) { + final selectedIconColor = + widget.decoration?.listItemDecoration?.selectedIconColor ?? + Theme.of(context).colorScheme.primary; + final selectedIconShape = + widget.decoration?.listItemDecoration?.selectedIconShape ?? + RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)); + final selectedIconBorder = + widget.decoration?.listItemDecoration?.selectedIconBorder ?? + BorderSide( + color: isSelected + ? selectedIconColor + : Theme.of(context).colorScheme.outlineVariant, + ); return Row( children: [ Expanded( @@ -135,18 +148,27 @@ class _DropdownOverlayState (widget.dropdownType == _DropdownType.singleSelect && isSelected)) Padding( padding: const EdgeInsetsDirectional.only(start: 12.0), - child: Checkbox( - onChanged: (_) => onItemSelect(), - value: isSelected, - activeColor: - widget.decoration?.listItemDecoration?.selectedIconColor, - splashRadius: 0, - side: widget.decoration?.listItemDecoration?.selectedIconBorder, - shape: widget.decoration?.listItemDecoration?.selectedIconShape, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: const VisualDensity( - horizontal: VisualDensity.minimumDensity, - vertical: VisualDensity.minimumDensity, + child: IgnorePointer( + child: SizedBox.square( + key: ValueKey('dropdown-selection-${result.selection}'), + dimension: 22, + child: Material( + animationDuration: const Duration(milliseconds: 160), + color: isSelected ? selectedIconColor : Colors.transparent, + shape: selectedIconShape.copyWith(side: selectedIconBorder), + clipBehavior: Clip.antiAlias, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + child: isSelected + ? const Icon( + ChewieIcons.check, + key: ValueKey('selected'), + size: 15, + color: Colors.white, + ) + : const SizedBox.shrink(key: ValueKey('unselected')), + ), + ), ), ), ), @@ -196,13 +218,27 @@ class _DropdownOverlayState super.initState(); scrollController = widget.itemsScrollCtrl ?? ScrollController(); WidgetsBinding.instance.addPostFrameCallback((_) { - final render1 = key1.currentContext?.findRenderObject() as RenderBox; - final render2 = key2.currentContext?.findRenderObject() as RenderBox; - final screenHeight = MediaQuery.of(context).size.height; - double y = render1.localToGlobal(Offset.zero).dy; - if (screenHeight - y < render2.size.height) { - displayOverlayBottom = false; - setState(() {}); + if (!mounted) return; + final render1 = key1.currentContext?.findRenderObject(); + final render2 = key2.currentContext?.findRenderObject(); + if (render1 is! RenderBox || + render2 is! RenderBox || + !render1.attached || + !render2.attached) { + return; + } + + final mediaQuery = MediaQuery.of(context); + final y = render1.localToGlobal(Offset.zero).dy; + final availableBelow = mediaQuery.size.height - + mediaQuery.padding.bottom - + mediaQuery.viewInsets.bottom - + y; + final availableAbove = y - mediaQuery.padding.top; + final shouldDisplayBelow = availableBelow >= render2.size.height || + availableBelow >= availableAbove; + if (displayOverlayBottom != shouldDisplayBelow) { + setState(() => displayOverlayBottom = shouldDisplayBelow); } }); @@ -359,11 +395,13 @@ class _DropdownOverlayState mainAxisSize: MainAxisSize.min, children: [ if (!widget.hideSelectedFieldWhenOpen) - GestureDetector( - behavior: HitTestBehavior.opaque, + InkWell( onTap: () { setState(() => displayOverly = false); }, + borderRadius: + decoration?.expandedBorderRadius ?? + _defaultBorderRadius, child: Padding( padding: widget.headerPadding ?? _defaultHeaderPadding, @@ -402,11 +440,13 @@ class _DropdownOverlayState decoration?.searchFieldDecoration, ) else - GestureDetector( - behavior: HitTestBehavior.opaque, + InkWell( onTap: () { setState(() => displayOverly = false); }, + borderRadius: + decoration?.expandedBorderRadius ?? + _defaultBorderRadius, child: Padding( padding: const EdgeInsetsDirectional.only( top: 12.0, @@ -462,11 +502,13 @@ class _DropdownOverlayState decoration?.searchFieldDecoration, ) else - GestureDetector( - behavior: HitTestBehavior.opaque, + InkWell( onTap: () { setState(() => displayOverly = false); }, + borderRadius: + decoration?.expandedBorderRadius ?? + _defaultBorderRadius, child: Padding( padding: const EdgeInsetsDirectional.only( top: 12.0, @@ -513,17 +555,15 @@ class _DropdownOverlayState ), if (isSearchRequestLoading) widget.searchRequestLoadingIndicator ?? - const Padding( - padding: EdgeInsets.symmetric( + Padding( + padding: const EdgeInsets.symmetric( vertical: 20.0, ), child: Center( - child: SizedBox( - width: 25, - height: 25, - child: CircularProgressIndicator( - strokeWidth: 3, - ), + child: + chewieProvider.loadingWidgetBuilder( + 25, + false, ), ), ) diff --git a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/widgets/items_list.dart b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/widgets/items_list.dart index f574e5a6..06857db0 100644 --- a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/widgets/items_list.dart +++ b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/widgets/items_list.dart @@ -41,25 +41,32 @@ class _ItemsList extends StatelessWidget { !excludeSelected && selectedItem == items[index], _DropdownType.multipleSelect => selectedItems.contains(items[index]) }; - return Material( - color: Colors.transparent, - child: InkWell( - splashColor: decoration?.splashColor ?? - ListItemDecoration._defaultSplashColor, - highlightColor: decoration?.highlightColor ?? - ListItemDecoration._defaultHighlightColor, - onTap: () => onItemSelect(items[index]), - child: Ink( - color: selected - ? (decoration?.selectedColor ?? - ListItemDecoration._defaultSelectedColor) - : Colors.transparent, - padding: listItemPadding, - child: listItemBuilder( - context, - items[index], - selected, - () => onItemSelect(items[index]), + return Semantics( + selected: selected, + button: true, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Material( + color: Colors.transparent, + child: InkWell( + splashColor: decoration?.splashColor ?? + ListItemDecoration._defaultSplashColor, + highlightColor: decoration?.highlightColor ?? + ListItemDecoration._defaultHighlightColor, + onTap: () => onItemSelect(items[index]), + child: Ink( + color: selected + ? (decoration?.selectedColor ?? + ListItemDecoration._defaultSelectedColor) + : Colors.transparent, + padding: listItemPadding, + child: listItemBuilder( + context, + items[index], + selected, + () => onItemSelect(items[index]), + ), + ), ), ), ), diff --git a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/widgets/search_field.dart b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/widgets/search_field.dart index 548cae8a..e86fc8e0 100644 --- a/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/widgets/search_field.dart +++ b/third-party/chewie/lib/src/Widgets/Module/AnimatedCustomDropdown/widgets/dropdown_overlay/widgets/search_field.dart @@ -144,11 +144,11 @@ class _SearchFieldState extends State<_SearchField> { hintText: widget.searchHintText, hintStyle: widget.decoration?.hintStyle, prefixIcon: widget.decoration?.prefixIcon ?? - const Icon(Icons.search_rounded, size: 20), + const Icon(ChewieIcons.search, size: 20), suffixIcon: widget.decoration?.suffixIcon?.call(onClear) ?? GestureDetector( onTap: onClear, - child: const Icon(Icons.close_rounded, size: 20), + child: const Icon(ChewieIcons.close, size: 20), ), border: widget.decoration?.border ?? OutlineInputBorder( diff --git a/third-party/chewie/lib/src/Widgets/Module/Draggable/programmatic_expansion_tile.dart b/third-party/chewie/lib/src/Widgets/Module/Draggable/programmatic_expansion_tile.dart index 0cb09155..9bec14aa 100644 --- a/third-party/chewie/lib/src/Widgets/Module/Draggable/programmatic_expansion_tile.dart +++ b/third-party/chewie/lib/src/Widgets/Module/Draggable/programmatic_expansion_tile.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:awesome_chewie/src/Resources/chewie_icons.dart'; const Duration _kExpand = Duration(milliseconds: 200); @@ -213,7 +214,7 @@ class ProgrammaticExpansionTileState extends State trailing: widget.trailing ?? RotationTransition( turns: _iconTurns, - child: const Icon(Icons.expand_more), + child: const Icon(ChewieIcons.expand), ), ), ), diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/easy_refresh.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/easy_refresh.dart index e716880a..15b00edc 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/easy_refresh.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/easy_refresh.dart @@ -11,6 +11,7 @@ import 'package:flutter/physics.dart' as physics; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:awesome_chewie/src/Resources/chewie_icons.dart'; import 'src/painter/paths_painter.dart'; diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/controller/controller.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/controller/controller.dart index 0e790ca1..e900e465 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/controller/controller.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/controller/controller.dart @@ -35,6 +35,7 @@ class EasyRefreshController { Duration? duration = const Duration(milliseconds: 200), Curve curve = Curves.linear, ScrollController? scrollController, + bool jumpToEdge = true, bool force = false, }) async { await _state?._callRefresh( @@ -42,6 +43,7 @@ class EasyRefreshController { duration: duration, curve: curve, scrollController: scrollController, + jumpToEdge: jumpToEdge, force: force, ); } diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/easy_refresh.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/easy_refresh.dart index 34074c1c..1e799ca1 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/easy_refresh.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/easy_refresh.dart @@ -338,19 +338,38 @@ class _EasyRefreshState extends State // Refresh on start. if (widget.refreshOnStart && widget.onRefresh != null) { _isRefreshOnStart = true; - Future(() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _callRefresh( - overOffset: widget.callRefreshOverOffset, - duration: null, - ); - }); - }); + _scheduleInitialRefresh(); } _initData(); widget.controller?._bind(this); } + void _scheduleInitialRefresh([int attempt = 0]) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final controller = widget.scrollController; + if (controller != null && !controller.hasClients) { + if (attempt < 8) { + _scheduleInitialRefresh(attempt + 1); + WidgetsBinding.instance.scheduleFrame(); + } else { + _isRefreshOnStart = false; + _headerNotifier._update(indicator: _header, task: _onRefresh); + } + return; + } + _callRefresh( + overOffset: widget.callRefreshOverOffset, + // A clamping header needs its reveal animation to establish the + // offset; assigning it instantly can be cleared by nested metrics. + duration: _header.clamping ? const Duration(milliseconds: 200) : null, + // A nested inner jump to zero also scrolls its outer floating toolbar + // away. The initial refresh starts at the edge already. + jumpToEdge: !_header.clamping, + ); + }); + } + @override void didUpdateWidget(covariant EasyRefresh oldWidget) { super.didUpdateWidget(oldWidget); @@ -476,6 +495,7 @@ class _EasyRefreshState extends State Duration? duration, Curve curve = Curves.linear, ScrollController? scrollController, + bool jumpToEdge = true, bool force = false, }) { return _headerNotifier.callTask( @@ -483,7 +503,9 @@ class _EasyRefreshState extends State duration: duration, curve: curve, scrollController: scrollController ?? widget.scrollController, + jumpToEdge: jumpToEdge, force: force, + triggerHaptic: !_isRefreshOnStart, ); } @@ -629,10 +651,27 @@ class _EasyRefreshState extends State child: widget.child!, ); } - return _InheritedEasyRefresh( + final content = _InheritedEasyRefresh( data: _data, child: child, ); + // In clamping mode the header owns the reveal distance rather than the + // scroll position. Move the content by that same distance so it cannot + // cover the indicator, including inside a NestedScrollView. + return AnimatedBuilder( + animation: _headerNotifier, + child: content, + builder: (context, child) { + return Transform.translate( + // Keep the wrapper mounted even at zero. Switching between child + // and Transform would remount the nested Scrollable at every pull + // threshold crossing, resetting its position and flashing the feed. + offset: + Offset(0, _headerNotifier.clamping ? _headerNotifier.offset : 0), + child: child, + ); + }, + ); } @override diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/notifier/indicator_notifier.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/notifier/indicator_notifier.dart index 47679c53..64436fa2 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/notifier/indicator_notifier.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/notifier/indicator_notifier.dart @@ -28,6 +28,10 @@ abstract class IndicatorNotifier extends ChangeNotifier { /// Wait for the task to complete. bool _waitTaskResult; + int _taskStartCount = 0; + + bool _programmaticCallInProgress = false; + /// Mounted on EasyRefresh. bool _mounted = false; @@ -433,8 +437,10 @@ abstract class IndicatorNotifier extends ChangeNotifier { Duration? duration, Curve curve = Curves.linear, ScrollController? scrollController, + bool jumpToEdge = true, bool force = false, - }) { + bool triggerHaptic = true, + }) async { if (!_mounted) { return Future.value(); } @@ -447,13 +453,31 @@ abstract class IndicatorNotifier extends ChangeNotifier { _mode = IndicatorMode.inactive; _processing = false; } - return animateToOffset( - offset: actualTriggerOffset + overOffset, - mode: IndicatorMode.ready, - duration: duration, - curve: curve, - scrollController: scrollController, - ); + final taskStartCount = _taskStartCount; + _programmaticCallInProgress = true; + try { + await animateToOffset( + offset: actualTriggerOffset + overOffset, + mode: IndicatorMode.ready, + duration: duration, + curve: curve, + scrollController: scrollController, + jumpToEdge: jumpToEdge, + ); + // A nested position may not emit a pointer release. Start the task once + // the header is visible, unless the animation already started it. + if (_taskStartCount != taskStartCount) { + if (hapticFeedback && triggerHaptic) HapticFeedback.mediumImpact(); + return; + } + if (_offset > 0 && !modeLocked && !noMoreLocked && _canProcess) { + if (hapticFeedback && triggerHaptic) HapticFeedback.mediumImpact(); + _setMode(IndicatorMode.processing); + _onTask(); + } + } finally { + _programmaticCallInProgress = false; + } } /// Animation listener for [clamping]. @@ -567,7 +591,9 @@ abstract class IndicatorNotifier extends ChangeNotifier { return; } // Haptic feedback - if (hapticFeedback && userOffsetNotifier.value) { + if (hapticFeedback && + userOffsetNotifier.value && + !_programmaticCallInProgress) { if (_indicator.triggerWhenReach) { if (_mode == IndicatorMode.processing && oldMode == IndicatorMode.drag) { @@ -642,8 +668,9 @@ abstract class IndicatorNotifier extends ChangeNotifier { } } } else if (_mode == IndicatorMode.done && offset > 0) { - _setMode(IndicatorMode.inactive); - // The state does not change until the end + // A clamping header is still closing. Re-arming it here starts a + // second refresh while the same pull animation is rebounding. + if (!clamping) _setMode(IndicatorMode.inactive); return; } else if (_offset == 0) { if (!(_mode == IndicatorMode.ready && !userOffsetNotifier.value)) { @@ -743,6 +770,7 @@ abstract class IndicatorNotifier extends ChangeNotifier { if (!(_canProcess && !_processing && _task != null)) { return; } + _taskStartCount++; _processing = true; if (_waitTaskResult) { try { @@ -785,6 +813,17 @@ abstract class IndicatorNotifier extends ChangeNotifier { /// Reset ballistic. /// Trigger [_ERScrollPhysics.createBallisticSimulation]. void _resetBallistic() { + if (clamping && _offset > 0) { + // The ready spring may still be settling at the trigger distance when + // a fast refresh finishes. Replace it with the closing spring instead + // of leaving the indicator held open indefinitely. + _clampingAnimationController!.stop(canceled: true); + final simulation = createBallisticSimulation(position, 0); + if (simulation != null) { + _clampingAnimationController!.animateWith(simulation); + } + return; + } ScrollActivityDelegate? delegate; double velocity = 0; if (_position is ScrollPosition) { @@ -1031,7 +1070,13 @@ class HeaderNotifier extends IndicatorNotifier { ScrollController? scrollController, }) async { try { - if (scrollController == null && _position is! ScrollPosition) { + if (scrollController != null) { + if (!scrollController.hasClients) return; + // Nested scroll views can expose only ScrollMetrics to the indicator + // until the first user drag. A caller-provided controller is the + // authoritative position for programmatic refreshes in that case. + position = scrollController.positions.first; + } else if (_position is! ScrollPosition) { return; } } catch (_) { @@ -1068,6 +1113,7 @@ class HeaderNotifier extends IndicatorNotifier { } else { (_position as ScrollPosition).jumpTo(scrollTo); } + _updateBySimulation(position, 0); } else { userOffsetNotifier.value = true; if (scrollController != null) { @@ -1078,7 +1124,10 @@ class HeaderNotifier extends IndicatorNotifier { .animateTo(scrollTo, duration: duration, curve: curve); } userOffsetNotifier.value = false; - notifyListeners(); + // A programmatic overscroll has no pointer-up event. Finalize it as + // a released simulation so the ready indicator actually starts the + // refresh task instead of remaining animated forever. + _updateBySimulation(position, 0); } } } @@ -1194,7 +1243,10 @@ class FooterNotifier extends IndicatorNotifier { ScrollController? scrollController, }) async { try { - if (scrollController == null && _position is! ScrollPosition) { + if (scrollController != null) { + if (!scrollController.hasClients) return; + position = scrollController.positions.first; + } else if (_position is! ScrollPosition) { return; } } catch (_) { @@ -1231,6 +1283,7 @@ class FooterNotifier extends IndicatorNotifier { } else { (_position as ScrollPosition).jumpTo(scrollTo); } + _updateBySimulation(position, 0); } else { userOffsetNotifier.value = true; if (scrollController != null) { @@ -1241,7 +1294,7 @@ class FooterNotifier extends IndicatorNotifier { .animateTo(scrollTo, duration: duration, curve: curve); } userOffsetNotifier.value = false; - notifyListeners(); + _updateBySimulation(position, 0); } } } diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/physics/scroll_physics.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/physics/scroll_physics.dart index 166a89ca..5dc7db51 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/physics/scroll_physics.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/physics/scroll_physics.dart @@ -192,6 +192,26 @@ class _ERScrollPhysics extends BouncingScrollPhysics { return total + absDelta; } + /// Flutter requires a boundary result to have the same direction as the + /// proposed movement and never be larger than that movement. A scrollable's + /// content extent can shrink while an existing ballistic simulation is + /// still outside the newly calculated maximum (for example when an overlay + /// navigation bar changes the viewport). In that state the old hard-limit + /// formula can return the whole distance from [value] to the new boundary, + /// which is larger than this frame's delta and triggers a framework error. + /// + /// Movement back towards the valid range must remain fully allowed. Movement + /// farther outside the range is limited to at most the proposed delta. + static double _validBoundaryResult( + ScrollMetrics position, + double value, + double result, + ) { + final delta = value - position.pixels; + if (delta == 0 || result == 0 || result.sign != delta.sign) return 0; + return result.abs() > delta.abs() ? delta : result; + } + @override double applyBoundaryConditions(ScrollMetrics position, double value) { if (headerNotifier._axis != position.axis || @@ -234,8 +254,12 @@ class _ERScrollPhysics extends BouncingScrollPhysics { value < -headerNotifier.actualMaxOverOffset) { _updateIndicatorOffset( position, -headerNotifier.actualMaxOverOffset, value); - return (value + headerNotifier.actualMaxOverOffset) - - position.minScrollExtent; + return _validBoundaryResult( + position, + value, + (value + headerNotifier.actualMaxOverOffset) - + position.minScrollExtent, + ); } // hit top over if (!(headerNotifier.hitOver || headerNotifier.modeLocked) && @@ -328,8 +352,12 @@ class _ERScrollPhysics extends BouncingScrollPhysics { position, position.maxScrollExtent + footerNotifier.actualMaxOverOffset, value); - return (value - footerNotifier.actualMaxOverOffset) - - position.maxScrollExtent; + return _validBoundaryResult( + position, + value, + (value - footerNotifier.actualMaxOverOffset) - + position.maxScrollExtent, + ); } // hit bottom over if (!(footerNotifier.hitOver || footerNotifier.modeLocked) && diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/bezier/bezier_indicator.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/bezier/bezier_indicator.dart index 92db5272..abc5553d 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/bezier/bezier_indicator.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/bezier/bezier_indicator.dart @@ -298,7 +298,7 @@ class _BezierIndicatorState extends State<_BezierIndicator> child: Center( child: widget.noMoreWidget ?? Icon( - Icons.inbox_outlined, + ChewieIcons.inbox, color: _foregroundColor, size: 32, ), diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/classic/classic_indicator.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/classic/classic_indicator.dart index 022319ba..f6014476 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/classic/classic_indicator.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/classic/classic_indicator.dart @@ -281,7 +281,7 @@ class _ClassicIndicatorState extends State<_ClassicIndicator> icon = SizedBox( child: widget.noMoreIcon ?? const Icon( - Icons.inbox_outlined, + ChewieIcons.inbox, ), ); } else if (_mode == IndicatorMode.processing || @@ -305,7 +305,7 @@ class _ClassicIndicatorState extends State<_ClassicIndicator> icon = SizedBox( child: widget.failedIcon ?? const Icon( - Icons.error_outline, + ChewieIcons.error, ), ); } else { @@ -315,7 +315,7 @@ class _ClassicIndicatorState extends State<_ClassicIndicator> Transform.rotate( angle: _axis == Axis.vertical ? 0 : -math.pi / 2, child: const Icon( - Icons.done, + ChewieIcons.check, ), ), ); @@ -325,11 +325,15 @@ class _ClassicIndicatorState extends State<_ClassicIndicator> icon = SizedBox( child: Transform.rotate( angle: -math.pi * _iconAnimationController.value, - child: Icon(widget.reverse - ? (_axis == Axis.vertical ? Icons.arrow_upward : Icons.arrow_back) - : (_axis == Axis.vertical - ? Icons.arrow_downward - : Icons.arrow_forward)), + child: Icon( + widget.reverse + ? (_axis == Axis.vertical + ? ChewieIcons.arrowUp + : ChewieIcons.arrowLeft) + : (_axis == Axis.vertical + ? ChewieIcons.arrowDown + : ChewieIcons.arrowRight), + ), ), ); } diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/cupertino/cupertino_indicator.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/cupertino/cupertino_indicator.dart index 695f3840..aaa5563c 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/cupertino/cupertino_indicator.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/cupertino/cupertino_indicator.dart @@ -149,7 +149,7 @@ class _CupertinoIndicatorState extends State<_CupertinoIndicator> child: widget.emptyWidget!, ) : Icon( - CupertinoIcons.archivebox, + ChewieIcons.archive, key: const ValueKey('noMore'), color: widget.foregroundColor, ) diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/material/material_indicator.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/material/material_indicator.dart index ac02d42d..895188be 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/material/material_indicator.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/styles/material/material_indicator.dart @@ -178,7 +178,7 @@ class _MaterialIndicatorState extends State<_MaterialIndicator> { ), if (_mode == IndicatorMode.inactive && _result == IndicatorResult.noMore) - widget.noMoreIcon ?? const Icon(Icons.inbox_outlined), + widget.noMoreIcon ?? const Icon(ChewieIcons.inbox), ], ), ); diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/components/menu_divider.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/components/menu_divider.dart index 56e2c0f9..700d02e8 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/components/menu_divider.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/components/menu_divider.dart @@ -41,11 +41,22 @@ final class MenuDivider extends ContextMenuEntry { @override Widget builder(BuildContext context, ContextMenuState menuState) { - return buildDivider( - context, - width: 0.5, - vertical: 6, - horizontal: 4, + final effectiveThickness = thickness ?? 0.6; + final effectiveHeight = height ?? 13; + return Container( + height: effectiveHeight, + margin: EdgeInsetsDirectional.only( + start: indent ?? 8, + end: endIndent ?? 8, + ), + alignment: Alignment.center, + child: Container( + height: effectiveThickness, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(effectiveThickness), + color: color ?? Theme.of(context).dividerColor, + ), + ), ); } diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/components/menu_item.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/components/menu_item.dart index 082e50c4..ef7dbc3a 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/components/menu_item.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/components/menu_item.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:awesome_chewie/src/Resources/chewie_icons.dart'; import '../core/models/context_menu_entry.dart'; import '../core/models/context_menu_item.dart'; @@ -49,22 +50,22 @@ class MenuItemStyle { EdgeInsets? padding, TextStyle? textStyle, TextStyle? shortcutTextStyle, - double disabledOpacity = 0.7, - double radius = 8, + double? disabledOpacity, + double? radius, Border? focusedBorder, }) { return MenuItemStyle( backgroundColor: backgroundColor ?? this.backgroundColor, normalColor: normalColor ?? this.normalColor, - normalIconColor: normalColor ?? this.normalIconColor, + normalIconColor: normalIconColor ?? this.normalIconColor, successColor: successColor ?? this.successColor, warningColor: warningColor ?? this.warningColor, errorColor: errorColor ?? this.errorColor, padding: padding ?? this.padding, textStyle: textStyle ?? this.textStyle, shortcutTextStyle: shortcutTextStyle ?? this.shortcutTextStyle, - disabledOpacity: disabledOpacity, - radius: radius, + disabledOpacity: disabledOpacity ?? this.disabledOpacity, + radius: radius ?? this.radius, focusedBorder: focusedBorder ?? this.focusedBorder, ); } @@ -130,7 +131,7 @@ final class FlutterContextMenuItem extends BaseContextMenuItem { required this.checked, this.status = MenuItemStatus.normal, }) : type = MenuItemType.checkbox, - iconData = Icons.check_rounded; + iconData = ChewieIcons.check; FlutterContextMenuItem.divider() : label = '', @@ -191,10 +192,14 @@ final class FlutterContextMenuItem extends BaseContextMenuItem { TextStyle labelTextStyle = mStyle.textStyle!; labelTextStyle = labelTextStyle.copyWith(color: textColor); - final normalTextColor = labelTextStyle.color!.withValues(alpha: 0.8); - final normalIconColor = iconColor!.withValues(alpha: 0.8); - final foregroundColor = isFocused ? labelTextStyle.color : normalTextColor; - final foregroundIconColor = isFocused ? iconColor : normalIconColor; + final effectiveTextColor = + labelTextStyle.color ?? theme.colorScheme.onSurface; + final effectiveIconColor = iconColor ?? effectiveTextColor; + final normalTextColor = effectiveTextColor.withValues(alpha: 0.8); + final normalIconColor = effectiveIconColor.withValues(alpha: 0.8); + final foregroundColor = isFocused ? effectiveTextColor : normalTextColor; + final foregroundIconColor = + isFocused ? effectiveIconColor : normalIconColor; final Border? focusedBorder = isFocused ? mStyle.focusedBorder : null; @@ -223,7 +228,7 @@ final class FlutterContextMenuItem extends BaseContextMenuItem { ), if (type == MenuItemType.checkbox && checked) Icon( - Icons.check_rounded, + ChewieIcons.check, size: 20, color: foregroundColor, ), @@ -242,7 +247,7 @@ final class FlutterContextMenuItem extends BaseContextMenuItem { Align( alignment: AlignmentDirectional.centerStart, child: Icon( - isSubmenuItem ? Icons.arrow_right : null, + isSubmenuItem ? ChewieIcons.next : null, size: 16.0, color: foregroundColor, ), diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/models/context_menu.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/models/context_menu.dart index 652a417d..d2fa32ca 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/models/context_menu.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/models/context_menu.dart @@ -77,6 +77,8 @@ class FlutterContextMenu { double? maxWidth, Clip? clipBehavior, BoxDecoration? boxDecoration, + FocusNode? focusNode, + Map? shortcuts, }) { return FlutterContextMenu( position: position ?? this.position, @@ -86,6 +88,8 @@ class FlutterContextMenu { maxWidth: maxWidth ?? this.maxWidth, clipBehavior: clipBehavior ?? this.clipBehavior, boxDecoration: boxDecoration ?? this.boxDecoration, + focusNode: focusNode ?? this.focusNode, + shortcuts: shortcuts ?? this.shortcuts, ); } } diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart index 2277a92a..31983943 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart @@ -15,8 +15,18 @@ import '../utils/extensions.dart'; AlignmentGeometry spawnAlignment, bool isSubmenu, ) { - final screenSize = MediaQuery.of(context).size; - final safeScreenRect = (Offset.zero & screenSize).deflate(8.0); + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + final obscuredBottom = max( + mediaQuery.padding.bottom, + mediaQuery.viewInsets.bottom, + ); + final safeScreenRect = Rect.fromLTRB( + mediaQuery.padding.left + 8, + mediaQuery.padding.top + 8, + screenSize.width - mediaQuery.padding.right - 8, + screenSize.height - obscuredBottom - 8, + ); final menuRect = context.getWidgetBounds()!; AlignmentGeometry nextSpawnAlignment = spawnAlignment; @@ -25,9 +35,11 @@ import '../utils/extensions.dart'; double x = menuRect.left; double y = menuRect.top; - bool isWidthExceed() => x + menuRect.width > screenSize.width || x < 0; + bool isWidthExceed() => + x + menuRect.width > safeScreenRect.right || x < safeScreenRect.left; - bool isHeightExceed() => y + menuRect.height > screenSize.height || y < 0; + bool isHeightExceed() => + y + menuRect.height > safeScreenRect.bottom || y < safeScreenRect.top; Rect currentRect() => Offset(x, y) & menuRect.size; @@ -88,7 +100,14 @@ import '../utils/extensions.dart'; } } } else if (!isSubmenu) { - x = max(safeScreenRect.left, menuRect.left - menuRect.width); + final maxLeft = max( + safeScreenRect.left, + safeScreenRect.right - menuRect.width, + ); + x = (menuRect.left - menuRect.width).clamp( + safeScreenRect.left, + maxLeft, + ); } } @@ -97,7 +116,14 @@ import '../utils/extensions.dart'; y = max(safeScreenRect.top, safeScreenRect.bottom - menuRect.height - menu.padding.top); } else if (!isSubmenu) { - y = max(safeScreenRect.top, menuRect.top - menuRect.height); + final maxTop = max( + safeScreenRect.top, + safeScreenRect.bottom - menuRect.height, + ); + y = (menuRect.top - menuRect.height).clamp( + safeScreenRect.top, + maxTop, + ); } } diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_region.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_region.dart index 937bba0a..85874ce2 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_region.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_region.dart @@ -31,10 +31,13 @@ class ContextMenuRegion extends StatelessWidget { mousePosition = event.position; }, child: GestureDetector( - onLongPress: - enableOnLongPress ? () => _showMenu(context, mousePosition) : null, + onLongPress: enable && enableOnLongPress + ? () => _showMenu(context, mousePosition) + : null, onSecondaryTap: enable ? () => _showMenu(context, mousePosition) : null, - onTap: showOnClicked ? () => _showMenu(context, mousePosition) : null, + onTap: enable && showOnClicked + ? () => _showMenu(context, mousePosition) + : null, child: child, ), ); diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_widget.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_widget.dart index 6d387697..7b21e66c 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_widget.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'context_menu_provider.dart'; @@ -71,6 +73,12 @@ class ContextMenuWidget extends StatelessWidget { borderRadius: state.borderRadius ?? BorderRadius.circular(8.0), ); + final mediaQuery = MediaQuery.of(context); + final safeWidth = max( + 0.0, + mediaQuery.size.width - mediaQuery.padding.horizontal - 16, + ); + return TweenAnimationBuilder( tween: Tween( begin: 0.8, @@ -83,7 +91,9 @@ class ContextMenuWidget extends StatelessWidget { scale: value, child: Container( padding: state.padding, - // constraints: BoxConstraints(maxWidth: state.maxWidth), + constraints: BoxConstraints( + maxWidth: min(state.maxWidth, safeWidth), + ), clipBehavior: state.clipBehavior, decoration: state.boxDecoration ?? boxDecoration, child: Material( diff --git a/third-party/chewie/lib/src/Widgets/Module/Hidable/scroll_to_hide.dart b/third-party/chewie/lib/src/Widgets/Module/Hidable/scroll_to_hide.dart index 09f36a1d..cf197a6c 100644 --- a/third-party/chewie/lib/src/Widgets/Module/Hidable/scroll_to_hide.dart +++ b/third-party/chewie/lib/src/Widgets/Module/Hidable/scroll_to_hide.dart @@ -35,7 +35,7 @@ class ScrollToHide extends StatefulWidget { /// Creates a `ScrollToHide` widget. /// /// The [child], [scrollController], and [height] parameters are required. - /// The [duration] parameter is optional and defaults to 300 milliseconds. + /// The [duration] parameter is optional and defaults to 260 milliseconds. /// /// The [child] is the widget that you want to hide/show based on the scroll direction. /// @@ -47,24 +47,32 @@ class ScrollToHide extends StatefulWidget { super.key, required this.child, required this.scrollController, - this.duration = const Duration(milliseconds: 300), + this.duration = const Duration(milliseconds: 260), required this.hideDirection, this.width, this.enabled = true, this.height, this.controller, + this.showCurve = Curves.easeOutCubic, + this.hideCurve = Curves.easeInCubic, + this.hiddenScale = 0.96, + this.hiddenOffset = 0.18, }) : scrollControllers = const []; const ScrollToHide.multi({ super.key, required this.child, - this.duration = const Duration(milliseconds: 300), + this.duration = const Duration(milliseconds: 260), required this.hideDirection, this.width, this.enabled = true, this.height, this.controller, this.scrollControllers = const [], + this.showCurve = Curves.easeOutCubic, + this.hideCurve = Curves.easeInCubic, + this.hiddenScale = 0.96, + this.hiddenOffset = 0.18, }) : scrollController = null; final ScrollToHideController? controller; @@ -83,6 +91,16 @@ class ScrollToHide extends StatefulWidget { /// The duration of the animation when the child widget is hidden or shown. final Duration duration; + /// Curves are direction-specific so a returning control can settle softly + /// while a leaving control gets out of the way without lingering. + final Curve showCurve; + final Curve hideCurve; + + /// Small coordinated scale/translation values keep floating controls from + /// looking as if their height was abruptly clipped to zero. + final double hiddenScale; + final double hiddenOffset; + /// The initial height of the child widget. When the widget is hidden, its height will be animated to 0. final double? height; @@ -96,8 +114,11 @@ class ScrollToHide extends StatefulWidget { State createState() => ScrollToHideState(); } -class ScrollToHideState extends State { +class ScrollToHideState extends State + with SingleTickerProviderStateMixin { bool isShown = true; + late final AnimationController _visibilityController; + final Map _controllerListeners = {}; List get _allScrollControllers => [ if (widget.scrollController != null) widget.scrollController!, @@ -106,90 +127,182 @@ class ScrollToHideState extends State { @override void initState() { - for (final controller in _allScrollControllers) { - controller.addListener(listen); - } + super.initState(); + _visibilityController = AnimationController( + vsync: this, + duration: widget.duration, + value: 1, + ); + _replaceScrollControllers(_allScrollControllers); widget.controller?.doShow = show; widget.controller?.doHide = hide; - super.initState(); } @override void dispose() { - for (final controller in _allScrollControllers) { - controller.removeListener(() {}); + _replaceScrollControllers(const []); + if (widget.controller?.doShow == show) { + widget.controller?.doShow = null; + } + if (widget.controller?.doHide == hide) { + widget.controller?.doHide = null; } + _visibilityController.dispose(); super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_reduceMotion) { + _visibilityController.value = isShown ? 1 : 0; + } + } + @override void didUpdateWidget(ScrollToHide oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.scrollController != widget.scrollController) { - widget.scrollController?.addListener(listen); + if (oldWidget.duration != widget.duration) { + _visibilityController.duration = widget.duration; } - if (oldWidget.scrollControllers != widget.scrollControllers) { - for (final controller in oldWidget.scrollControllers) { - controller.removeListener(listen); + final newControllers = _controllersFor(widget); + final controllersChanged = _replaceScrollControllers(newControllers); + if (oldWidget.controller != widget.controller) { + if (oldWidget.controller?.doShow == show) { + oldWidget.controller?.doShow = null; } - for (final controller in widget.scrollControllers) { - controller.addListener(listen); + if (oldWidget.controller?.doHide == hide) { + oldWidget.controller?.doHide = null; + } + widget.controller?.doShow = show; + widget.controller?.doHide = hide; + } + if (oldWidget.enabled && !widget.enabled) show(); + if (controllersChanged) show(); + } + + List _controllersFor(ScrollToHide target) => [ + if (target.scrollController != null) target.scrollController!, + ...target.scrollControllers, + ]; + + bool _replaceScrollControllers(List controllers) { + final uniqueControllers = []; + for (final controller in controllers) { + if (!uniqueControllers.contains(controller)) { + uniqueControllers.add(controller); } } + + var changed = false; + for (final controller in _controllerListeners.keys.toList()) { + if (uniqueControllers.contains(controller)) continue; + controller.removeListener(_controllerListeners.remove(controller)!); + changed = true; + } + for (final controller in uniqueControllers) { + if (_controllerListeners.containsKey(controller)) continue; + void listener() => _handleScroll(controller); + _controllerListeners[controller] = listener; + controller.addListener(listener); + changed = true; + } + return changed; + } + + bool get _reduceMotion { + final mediaQuery = MediaQuery.maybeOf(context); + return mediaQuery?.disableAnimations == true || + mediaQuery?.accessibleNavigation == true; } @override Widget build(BuildContext context) { - return AnimatedOpacity( - duration: widget.duration, - opacity: isShown ? 1.0 : 0.0, - child: AnimatedContainer( - duration: widget.duration, - height: widget.hideDirection == Axis.vertical - ? (isShown ? widget.height : 0) - : widget.height, - width: widget.hideDirection == Axis.horizontal - ? (isShown ? widget.width : 0) - : widget.width, - curve: Curves.linear, - clipBehavior: Clip.none, - child: Wrap( - children: [ - widget.child, - ], - ), - ), + final vertical = widget.hideDirection == Axis.vertical; + return AnimatedBuilder( + key: const ValueKey('scroll-to-hide-transition'), + animation: _visibilityController, + builder: (context, child) { + final progress = _visibilityController.value; + final scale = widget.hiddenScale + (1 - widget.hiddenScale) * progress; + final translation = (1 - progress) * widget.hiddenOffset; + final constrainedChild = SizedBox( + height: vertical ? widget.height : null, + width: vertical ? null : widget.width, + child: Opacity( + key: const ValueKey('scroll-to-hide-opacity'), + opacity: progress, + child: FractionalTranslation( + translation: + vertical ? Offset(0, translation) : Offset(translation, 0), + child: Transform.scale( + scale: scale, + alignment: + vertical ? Alignment.bottomCenter : Alignment.centerRight, + child: child, + ), + ), + ), + ); + return IgnorePointer( + ignoring: progress < 0.02, + child: ClipRect( + child: Align( + alignment: + vertical ? Alignment.bottomCenter : Alignment.centerRight, + heightFactor: vertical ? progress : null, + widthFactor: vertical ? null : progress, + child: constrainedChild, + ), + ), + ); + }, + child: widget.child, ); } /// Shows the child widget if it is currently hidden. void show() { - if (!isShown && mounted) { - setState(() => isShown = true); - } + _setVisibility(true); } /// Hides the child widget if it is currently shown. void hide() { - if (isShown && mounted) { - setState( - () => isShown = false, - ); + _setVisibility(false); + } + + void _setVisibility(bool shown) { + if (!mounted || isShown == shown) return; + isShown = shown; + final target = shown ? 1.0 : 0.0; + if (_reduceMotion) { + _visibilityController.value = target; + return; } + _visibilityController.animateTo( + target, + duration: widget.duration, + curve: shown ? widget.showCurve : widget.hideCurve, + ); } - void listen() { - if (widget.enabled) { - for (final controller in _allScrollControllers) { - if (controller.hasClients) { - final direction = controller.position.userScrollDirection; - if (direction == ScrollDirection.forward) { - show(); - } else if (direction == ScrollDirection.reverse) { - hide(); - } - } - } + void _handleScroll(ScrollController controller) { + if (!widget.enabled || !controller.hasClients) return; + final positions = controller.positions.toList(growable: false); + if (positions.isEmpty) return; + final position = positions.lastWhere( + (position) => position.userScrollDirection != ScrollDirection.idle, + orElse: () => positions.last, + ); + if (position.pixels <= position.minScrollExtent + 0.5) { + show(); + return; + } + final direction = position.userScrollDirection; + if (direction == ScrollDirection.forward) { + show(); + } else if (direction == ScrollDirection.reverse) { + hide(); } } } diff --git a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_activity_indicator.dart b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_activity_indicator.dart index f89fbcfb..a074d231 100644 --- a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_activity_indicator.dart +++ b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_activity_indicator.dart @@ -2,7 +2,7 @@ part of 'lottie_cupertino_refresh.dart'; const double _kDefaultIndicatorRadius = 10.0; -class _CustomActivityIndicator extends StatefulWidget { +class _CustomActivityIndicator extends StatelessWidget { const _CustomActivityIndicator({ super.key, this.color, @@ -13,6 +13,7 @@ class _CustomActivityIndicator extends StatefulWidget { progress = 1.0; const _CustomActivityIndicator.partiallyRevealed({ + super.key, this.color, this.radius = _kDefaultIndicatorRadius, this.progress = 1.0, @@ -47,51 +48,12 @@ class _CustomActivityIndicator extends StatefulWidget { final Widget indicator; - @override - State<_CustomActivityIndicator> createState() => - _CustomActivityIndicatorState(); -} - -class _CustomActivityIndicatorState extends State<_CustomActivityIndicator> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(seconds: 1), - vsync: this, - ); - - if (widget.animating) { - _controller.repeat(); - } - } - - @override - void didUpdateWidget(_CustomActivityIndicator oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.animating != oldWidget.animating) { - if (widget.animating) { - _controller.repeat(); - } else { - _controller.stop(); - } - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { return SizedBox( - height: widget.radius * 2, - width: widget.radius * 2, - child: widget.indicator); + height: radius * 2, + width: radius * 2, + child: indicator, + ); } } diff --git a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_footer.dart b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_footer.dart index a235c4d6..52423ad0 100644 --- a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_footer.dart +++ b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_footer.dart @@ -21,26 +21,26 @@ class LottieCupertinoFooter extends Footer { const LottieCupertinoFooter({ this.key, - super.triggerOffset = 60, + super.triggerOffset = 52, super.clamping = false, - super.position = IndicatorPosition.behind, - super.processedDuration = Duration.zero, + super.position = IndicatorPosition.above, + super.processedDuration = const Duration(milliseconds: 180), super.spring, super.readySpringBuilder, - super.springRebound, + super.springRebound = true, FrictionFactor? frictionFactor, super.safeArea, - super.infiniteOffset = 60, + super.infiniteOffset = 240, super.hitOver, super.infiniteHitOver, super.hapticFeedback, super.triggerWhenRelease, - super.maxOverOffset, + super.maxOverOffset = 76, this.foregroundColor, this.backgroundColor, this.emptyWidget, required this.indicator, - this.radius, + this.radius = 18, }) : super( frictionFactor: frictionFactor ?? (infiniteOffset == null ? kCustomCupertinoFrictionFactor : null), diff --git a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_header.dart b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_header.dart index 83be9eb6..84058c61 100644 --- a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_header.dart +++ b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_header.dart @@ -19,15 +19,20 @@ class LottieCupertinoHeader extends Header { final Widget indicator; + /// Visual translation applied after the safe-area offset is resolved. + /// Useful for scrollables that intentionally render beneath an overlay + /// header while keeping their refresh animation below that overlay. + final double indicatorOffset; + const LottieCupertinoHeader({ this.key, - super.triggerOffset = 60, + super.triggerOffset = 56, super.clamping = false, - super.position = IndicatorPosition.behind, - super.processedDuration = Duration.zero, + super.position = IndicatorPosition.above, + super.processedDuration = const Duration(milliseconds: 180), super.spring, super.readySpringBuilder, - super.springRebound = false, + super.springRebound = true, FrictionFactor? frictionFactor, super.safeArea, super.infiniteOffset, @@ -35,12 +40,13 @@ class LottieCupertinoHeader extends Header { super.infiniteHitOver, super.hapticFeedback, super.triggerWhenRelease, - super.maxOverOffset, + super.maxOverOffset = 84, this.foregroundColor, this.backgroundColor, this.emptyWidget, required this.indicator, - this.radius, + this.radius = 20, + this.indicatorOffset = 0, }) : super( frictionFactor: frictionFactor ?? (infiniteOffset == null ? kCustomCupertinoFrictionFactor : null), @@ -58,6 +64,7 @@ class LottieCupertinoHeader extends Header { emptyWidget: emptyWidget, radius: radius, indicator: indicator, + indicatorOffset: indicatorOffset, ); } } diff --git a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_indicator.dart b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_indicator.dart index 9273f4cc..c2b8ad06 100644 --- a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_indicator.dart +++ b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/custom_indicator.dart @@ -32,6 +32,8 @@ class _CustomIndicator extends StatefulWidget { final Widget indicator; + final double indicatorOffset; + const _CustomIndicator({ super.key, required this.state, @@ -40,6 +42,7 @@ class _CustomIndicator extends StatefulWidget { this.backgroundColor, this.emptyWidget, required this.indicator, + this.indicatorOffset = 0, this.radius, }); @@ -77,25 +80,18 @@ class _CustomIndicatorState extends State<_CustomIndicator> } Widget _buildIndicator() { - final scale = (_offset / _actualTriggerOffset).clamp(0.01, 0.99); + final progress = + (_offset / math.max(1, _actualTriggerOffset)).clamp(0.0, 1.0); Widget indicator; switch (_mode) { case IndicatorMode.drag: case IndicatorMode.armed: - const Curve opacityCurve = Interval( - 0.0, - 0.8, - curve: Curves.easeInOut, - ); - indicator = Opacity( + indicator = _CustomActivityIndicator.partiallyRevealed( key: const ValueKey('indicatorArmed'), - opacity: opacityCurve.transform(scale), - child: _CustomActivityIndicator.partiallyRevealed( - radius: _radius, - progress: scale, - color: widget.foregroundColor, - indicator: widget.indicator, - ), + radius: _radius, + progress: progress, + color: widget.foregroundColor, + indicator: widget.indicator, ); break; case IndicatorMode.ready: @@ -112,7 +108,7 @@ class _CustomIndicatorState extends State<_CustomIndicator> case IndicatorMode.done: indicator = _CustomActivityIndicator( key: const ValueKey('indicatorDone'), - radius: _radius * scale, + radius: _radius, color: widget.foregroundColor, animating: true, indicator: widget.indicator, @@ -125,8 +121,8 @@ class _CustomIndicatorState extends State<_CustomIndicator> break; } return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 100), + duration: const Duration(milliseconds: 180), + reverseDuration: const Duration(milliseconds: 120), child: widget.state.result == IndicatorResult.noMore ? widget.emptyWidget != null ? SizedBox( @@ -134,7 +130,7 @@ class _CustomIndicatorState extends State<_CustomIndicator> child: widget.emptyWidget!, ) : Icon( - CupertinoIcons.archivebox, + ChewieIcons.archive, key: const ValueKey('noMoreDefault'), color: widget.foregroundColor, ) @@ -152,25 +148,63 @@ class _CustomIndicatorState extends State<_CustomIndicator> offset = _actualTriggerOffset; } return Stack( + key: const ValueKey('refresh-indicator-viewport'), alignment: Alignment.center, + clipBehavior: widget.indicatorOffset == 0 ? Clip.hardEdge : Clip.none, children: [ SizedBox( height: _axis == Axis.vertical ? offset : double.infinity, width: _axis == Axis.vertical ? double.infinity : offset, ), // Indicator. - Positioned( - top: 0, - left: 0, - right: _axis == Axis.vertical ? 0 : null, - bottom: _axis == Axis.vertical ? null : 0, - child: Container( - alignment: Alignment.center, - height: - _axis == Axis.vertical ? _actualTriggerOffset : double.infinity, - width: - _axis == Axis.vertical ? double.infinity : _actualTriggerOffset, - child: _buildIndicator(), + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + // Use the laid-out reveal region, not the notifier's prospective + // scroll offset: the latter can lead the viewport during a drag. + final revealExtent = _axis == Axis.vertical + ? constraints.maxHeight + : constraints.maxWidth; + final availableExtent = math.max( + 0.0, + revealExtent - 2 * widget.indicatorOffset.abs(), + ); + // With an unclamped scrollable, the content boundary is driven + // by the scroll position. A reported zero is meaningful: the + // header can update one frame before the list actually moves. + // Never fall back to the header extent in that frame. + final notifier = widget.state.notifier; + final visibleExtent = notifier.clamping + ? availableExtent + : math.min( + availableExtent, + math.max( + 0.0, + notifier.calculateOffsetWithPixels( + notifier.position, + notifier.position.pixels, + ), + ), + ); + final pullScale = + (visibleExtent * 0.9 / (_radius * 2)).clamp(0.0, 1.0); + return ColoredBox( + color: widget.backgroundColor ?? const Color(0x00000000), + child: Transform.translate( + offset: _axis == Axis.vertical + ? Offset(0, widget.indicatorOffset) + : Offset(widget.indicatorOffset, 0), + child: Align( + alignment: Alignment.center, + child: Transform.scale( + key: const ValueKey('indicatorPullScale'), + scale: pullScale, + child: _buildIndicator(), + ), + ), + ), + ); + }, ), ), ], diff --git a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/lottie_cupertino_refresh.dart b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/lottie_cupertino_refresh.dart index 4f164911..0981a56b 100644 --- a/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/lottie_cupertino_refresh.dart +++ b/third-party/chewie/lib/src/Widgets/Module/LottieCupertinoRefresh/lottie_cupertino_refresh.dart @@ -3,6 +3,7 @@ library lottie_cupertino_refresh; import 'dart:math' as math; import 'package:flutter/cupertino.dart'; +import 'package:awesome_chewie/src/Resources/chewie_icons.dart'; import 'package:awesome_chewie/src/Widgets/Module/EasyRefresh/easy_refresh.dart'; diff --git a/third-party/chewie/lib/src/Widgets/Module/PhotoView/src/photo_view_default_widgets.dart b/third-party/chewie/lib/src/Widgets/Module/PhotoView/src/photo_view_default_widgets.dart index 9d59430f..e6c45047 100644 --- a/third-party/chewie/lib/src/Widgets/Module/PhotoView/src/photo_view_default_widgets.dart +++ b/third-party/chewie/lib/src/Widgets/Module/PhotoView/src/photo_view_default_widgets.dart @@ -1,3 +1,5 @@ +import 'package:awesome_chewie/src/Providers/chewie_provider.dart'; +import 'package:awesome_chewie/src/Resources/chewie_icons.dart'; import 'package:flutter/material.dart'; class PhotoViewDefaultError extends StatelessWidget { @@ -12,7 +14,7 @@ class PhotoViewDefaultError extends StatelessWidget { decoration: decoration, child: Center( child: Icon( - Icons.broken_image, + ChewieIcons.imageUnavailable, color: Colors.grey[400], size: 40.0, ), @@ -28,18 +30,8 @@ class PhotoViewDefaultLoading extends StatelessWidget { @override Widget build(BuildContext context) { - final expectedBytes = event?.expectedTotalBytes; - final loadedBytes = event?.cumulativeBytesLoaded; - final value = loadedBytes != null && expectedBytes != null - ? loadedBytes / expectedBytes - : null; - return Center( - child: Container( - width: 20.0, - height: 20.0, - child: CircularProgressIndicator(value: value), - ), + child: chewieProvider.loadingWidgetBuilder(24, true), ); } } diff --git a/third-party/chewie/lib/src/Widgets/Notification/notification_manager.dart b/third-party/chewie/lib/src/Widgets/Notification/notification_manager.dart index c63373ec..4cb7f52c 100644 --- a/third-party/chewie/lib/src/Widgets/Notification/notification_manager.dart +++ b/third-party/chewie/lib/src/Widgets/Notification/notification_manager.dart @@ -24,12 +24,13 @@ class NotificationManager { void show( BuildContext context, String message, { + OverlayState? overlayState, String? description, Duration duration = const Duration(seconds: 3), NotificationStyle? style, NotificationType type = NotificationType.normal, }) { - _ensureOverlay(context); + _ensureOverlay(context, overlayState); if (_queue.length >= maxCount) { _queue.removeAt(0); @@ -50,14 +51,19 @@ class NotificationManager { }); } - void _ensureOverlay(BuildContext context) { + void _ensureOverlay(BuildContext context, OverlayState? overlayState) { if (_overlayEntry != null) return; _overlayEntry = OverlayEntry( builder: (_) => const NotificationOverlayWidget(), ); - Overlay.of(context).insert(_overlayEntry!); + final overlay = overlayState ?? Overlay.maybeOf(context); + if (overlay == null) { + _overlayEntry = null; + return; + } + overlay.insert(_overlayEntry!); } } diff --git a/third-party/chewie/lib/src/Widgets/Scaffold/my_appbar.dart b/third-party/chewie/lib/src/Widgets/Scaffold/my_appbar.dart index b41963ae..e26bedaa 100644 --- a/third-party/chewie/lib/src/Widgets/Scaffold/my_appbar.dart +++ b/third-party/chewie/lib/src/Widgets/Scaffold/my_appbar.dart @@ -813,8 +813,8 @@ class _MyAppBarState extends State { assert(debugCheckHasMaterialLocalizations(context)); final ThemeData theme = Theme.of(context); final IconButtonThemeData iconButtonTheme = IconButtonTheme.of(context); - final AppBarTheme appBarTheme = AppBarTheme.of(context); - final AppBarTheme defaults = theme.useMaterial3 + final AppBarThemeData appBarTheme = AppBarTheme.of(context); + final AppBarThemeData defaults = theme.useMaterial3 ? _AppBarDefaultsM3(context) : _AppBarDefaultsM2(context); final ScaffoldState? scaffold = Scaffold.maybeOf(context); @@ -1210,7 +1210,7 @@ class _RenderAppBarTitleBox extends RenderAligningShiftedBox { } // Hand coded defaults based on Material Design 2. -class _AppBarDefaultsM2 extends AppBarTheme { +class _AppBarDefaultsM2 extends AppBarThemeData { _AppBarDefaultsM2(this.context) : super( elevation: 4.0, @@ -1249,7 +1249,7 @@ class _AppBarDefaultsM2 extends AppBarTheme { // Design token database by the script: // dev/tools/gen_defaults/bin/gen_defaults.dart. -class _AppBarDefaultsM3 extends AppBarTheme { +class _AppBarDefaultsM3 extends AppBarThemeData { _AppBarDefaultsM3(this.context) : super( elevation: 0.0, @@ -2485,8 +2485,8 @@ class _ScrollUnderFlexibleSpace extends StatelessWidget { @override Widget build(BuildContext context) { - late final AppBarTheme appBarTheme = AppBarTheme.of(context); - late final AppBarTheme defaults = Theme.of(context).useMaterial3 + late final AppBarThemeData appBarTheme = AppBarTheme.of(context); + late final AppBarThemeData defaults = Theme.of(context).useMaterial3 ? _AppBarDefaultsM3(context) : _AppBarDefaultsM2(context); final FlexibleSpaceBarSettings settings = diff --git a/third-party/chewie/lib/src/Widgets/Scaffold/my_popupmenu.dart b/third-party/chewie/lib/src/Widgets/Scaffold/my_popupmenu.dart index 3b9f5a03..b5fd6a50 100644 --- a/third-party/chewie/lib/src/Widgets/Scaffold/my_popupmenu.dart +++ b/third-party/chewie/lib/src/Widgets/Scaffold/my_popupmenu.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; +import 'package:awesome_chewie/src/Resources/chewie_icons.dart'; const Duration _kMenuDuration = Duration(milliseconds: 300); const double _kMenuCloseIntervalEnd = 2.0 / 3.0; @@ -526,7 +527,9 @@ class _CheckedPopupMenuItemState titleTextStyle: effectiveLabelTextStyle?.resolve(states), leading: FadeTransition( opacity: _opacity, - child: Icon(_controller.isDismissed ? null : Icons.done), + child: Icon( + _controller.isDismissed ? null : ChewieIcons.check, + ), ), title: widget.child, ), diff --git a/third-party/chewie/lib/src/Widgets/Scaffold/my_tooltip.dart b/third-party/chewie/lib/src/Widgets/Scaffold/my_tooltip.dart index 2157f318..8f595241 100644 --- a/third-party/chewie/lib/src/Widgets/Scaffold/my_tooltip.dart +++ b/third-party/chewie/lib/src/Widgets/Scaffold/my_tooltip.dart @@ -439,8 +439,7 @@ class MyTooltip extends StatefulWidget { /// /// This class can be used to programmatically show the Tooltip, see the /// [ensureTooltipVisible] method. -class MyTooltipState extends State - with SingleTickerProviderStateMixin { +class MyTooltipState extends State with TickerProviderStateMixin { static const double _defaultVerticalOffset = 16.0; static const bool _defaultPreferBelow = true; static const EdgeInsetsGeometry _defaultMargin = EdgeInsets.zero; @@ -517,6 +516,22 @@ class MyTooltipState extends State final Set _activeHoveringPointerDevices = {}; AnimationStatus _animationStatus = AnimationStatus.dismissed; + bool _isActive = true; + bool _globalPointerRouteRegistered = false; + + void _addGlobalPointerRoute() { + if (_globalPointerRouteRegistered) return; + GestureBinding.instance.pointerRouter + .addGlobalRoute(_handleGlobalPointerEvent); + _globalPointerRouteRegistered = true; + } + + void _removeGlobalPointerRoute() { + if (!_globalPointerRouteRegistered) return; + GestureBinding.instance.pointerRouter + .removeGlobalRoute(_handleGlobalPointerEvent); + _globalPointerRouteRegistered = false; + } void _handleStatusChanged(AnimationStatus status) { assert(mounted); @@ -624,7 +639,7 @@ class MyTooltipState extends State // For PointerDownEvents, this method will be called after _handlePointerDown. void _handleGlobalPointerEvent(PointerEvent event) { - assert(mounted); + if (!mounted || !_isActive) return; if (_tapRecognizer?.primaryPointer == event.pointer || _longPressRecognizer?.primaryPointer == event.pointer) { // This is a pointer of interest specified by the trigger mode, since it's @@ -637,7 +652,7 @@ class MyTooltipState extends State // callback (_handleTapToDismiss) will not be called. return; } - if ((_timer == null && _controller.isDismissed) || + if ((_timer == null && (_backingController?.isDismissed ?? true)) || event is! PointerDownEvent) { return; } @@ -765,8 +780,21 @@ class MyTooltipState extends State // Listen to global pointer events so that we can hide a tooltip immediately // if some other control is clicked on. Pointer events are dispatched to // global routes **after** other routes. - GestureBinding.instance.pointerRouter - .addGlobalRoute(_handleGlobalPointerEvent); + _addGlobalPointerRoute(); + } + + @override + void activate() { + super.activate(); + _isActive = true; + _addGlobalPointerRoute(); + } + + @override + void deactivate() { + _isActive = false; + _removeGlobalPointerRoute(); + super.deactivate(); } @override @@ -854,11 +882,13 @@ class MyTooltipState extends State }; final TooltipThemeData tooltipTheme = _tooltipTheme; + final defaultConstraints = BoxConstraints( + minHeight: widget.height ?? _getDefaultTooltipHeight(), + ); final _TooltipOverlay overlayChild = _TooltipOverlay( maxWidth: widget.maxWidth, richMessage: widget.richMessage ?? TextSpan(text: widget.message), - height: - widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight(), + constraints: tooltipTheme.constraints ?? defaultConstraints, padding: widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding(), margin: widget.margin ?? tooltipTheme.margin ?? _defaultMargin, onEnter: _handleMouseEnter, @@ -885,8 +915,8 @@ class MyTooltipState extends State @override void dispose() { - GestureBinding.instance.pointerRouter - .removeGlobalRoute(_handleGlobalPointerEvent); + _isActive = false; + _removeGlobalPointerRoute(); MyTooltip._openedTooltips.remove(this); // _longPressRecognizer.dispose() and _tapRecognizer.dispose() may call // their registered onCancel callbacks if there's a gesture in progress. @@ -995,7 +1025,7 @@ class _TooltipPositionDelegate extends SingleChildLayoutDelegate { class _TooltipOverlay extends StatelessWidget { const _TooltipOverlay({ - required this.height, + required this.constraints, required this.richMessage, this.padding, this.margin, @@ -1013,7 +1043,7 @@ class _TooltipOverlay extends StatelessWidget { }); final InlineSpan richMessage; - final double height; + final BoxConstraints constraints; final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? margin; final Decoration? decoration; @@ -1054,8 +1084,9 @@ class _TooltipOverlay extends StatelessWidget { Container( decoration: mDecoration, padding: padding ?? const EdgeInsets.all(8), - constraints: - BoxConstraints(maxWidth: maxWidth ?? double.infinity), + constraints: constraints.enforce( + BoxConstraints(maxWidth: maxWidth ?? double.infinity), + ), margin: margin, child: Text.rich( richMessage, diff --git a/third-party/chewie/lib/src/Widgets/Scaffold/sliver_appbar_delegate.dart b/third-party/chewie/lib/src/Widgets/Scaffold/sliver_appbar_delegate.dart index 02b18f3c..4f108efd 100644 --- a/third-party/chewie/lib/src/Widgets/Scaffold/sliver_appbar_delegate.dart +++ b/third-party/chewie/lib/src/Widgets/Scaffold/sliver_appbar_delegate.dart @@ -28,22 +28,15 @@ class SliverAppBarDelegate extends SliverPersistentHeaderDelegate { color: background ?? ChewieTheme.getBackground(context), borderRadius: BorderRadius.vertical(top: Radius.circular(radius)), ), - child: Theme( - data: ThemeData( - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - tabBarTheme: const TabBarTheme( - splashFactory: NoSplash.splashFactory, - ), - ), - child: tabBar, - ), + child: tabBar, ); } @override bool shouldRebuild(SliverAppBarDelegate oldDelegate) { - return false; + return oldDelegate.tabBar != tabBar || + oldDelegate.radius != radius || + oldDelegate.background != background; } } diff --git a/third-party/chewie/lib/src/Widgets/Tile/checkbox_item.dart b/third-party/chewie/lib/src/Widgets/Tile/checkbox_item.dart index 1f7d2826..9c40a9d7 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/checkbox_item.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/checkbox_item.dart @@ -5,6 +5,8 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:awesome_chewie/awesome_chewie.dart'; class CheckboxItem extends SearchableStatefulWidget { + // Retained for Loftify source compatibility. + final BuildContext? context; final double radius; final bool roundTop; final bool roundBottom; @@ -17,9 +19,12 @@ class CheckboxItem extends SearchableStatefulWidget { final double trailingLeftMargin; final double padding; final bool disabled; + final bool ink; const CheckboxItem({ super.key, + this.context, + this.ink = true, this.radius = 8, this.roundTop = false, this.roundBottom = false, @@ -44,6 +49,7 @@ class CheckboxItem extends SearchableStatefulWidget { SearchConfig? searchConfig, }) { return CheckboxItem( + context: context, searchConfig: searchConfig ?? this.searchConfig, searchText: searchText ?? this.searchText, title: title, @@ -60,6 +66,7 @@ class CheckboxItem extends SearchableStatefulWidget { trailingLeftMargin: trailingLeftMargin, padding: padding, disabled: disabled, + ink: ink, ); } @@ -83,31 +90,32 @@ class CheckboxItemState extends SearchableState { if (!shouldShow) return const SizedBox.shrink(); return InkAnimation( borderRadius: _borderRadius, - ink: false, - child: Column( - children: [ - Padding( - padding: EdgeInsets.only( - top: _effectivePadding, - bottom: _effectivePadding, - left: 6, - right: 4, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: _buildRowChildren(), - ), - ), - // _buildBottomDivider(), - ], + ink: widget.ink, + color: ChewieTheme.canvasColor, + onTap: widget.disabled + ? null + : () { + HapticFeedback.lightImpact(); + widget.onTap?.call(); + }, + child: Padding( + padding: EdgeInsets.only( + top: _effectivePadding, + bottom: _effectivePadding, + left: 10, + right: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: _buildRowChildren(), + ), ), ); } List _buildRowChildren() { return [ - if (widget.showLeading) - Icon(widget.leading, size: 20, color: widget.leadingColor), + if (widget.showLeading) _buildLeadingIcon(), const SizedBox(width: 5), Expanded(child: _buildTextContent()), SizedBox(width: widget.trailingLeftMargin), @@ -115,6 +123,19 @@ class CheckboxItemState extends SearchableState { ]; } + Widget _buildLeadingIcon() { + final color = widget.leadingColor ?? ChewieTheme.primaryColor; + return Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: color.withAlpha(25), + borderRadius: BorderRadius.circular(7), + ), + child: Icon(widget.leading, size: 15, color: color), + ); + } + Widget _buildTextContent() { final titleStyle = ChewieTheme.titleMedium.apply(color: widget.titleColor); final descStyle = ChewieTheme.bodySmall; @@ -173,14 +194,4 @@ class CheckboxItemState extends SearchableState { ), ); } - -// Widget _buildBottomDivider() { -// return Container( -// height: 0, -// margin: const EdgeInsets.symmetric(horizontal: 10), -// decoration: BoxDecoration( -// border: widget.roundBottom ? null : ChewieTheme.bottomDivider, -// ), -// ); -// } } diff --git a/third-party/chewie/lib/src/Widgets/Tile/entry_item.dart b/third-party/chewie/lib/src/Widgets/Tile/entry_item.dart index 0aa7f967..0f911575 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/entry_item.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/entry_item.dart @@ -2,7 +2,50 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; +const double _settingSectionTopSpacing = 10; + +BorderRadius _settingSectionBorderRadius(BuildContext context) { + final shape = Theme.of(context).cardTheme.shape; + if (shape is RoundedRectangleBorder) { + return shape.borderRadius.resolve(Directionality.of(context)); + } + return BorderRadius.circular(14); +} + +TextStyle _settingSectionTitleStyle(BuildContext context) { + final theme = Theme.of(context); + return (theme.textTheme.labelMedium!); +} + +Widget _settingSectionDivider(BuildContext context) { + return Divider( + height: 0.6, + thickness: 0.6, + indent: 16, + endIndent: 16, + color: Theme.of(context).dividerColor, + ); +} + +WidgetStateProperty _settingSectionOverlay(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + return WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return color.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.focused)) { + return color.withValues(alpha: 0.06); + } + if (states.contains(WidgetState.hovered)) { + return color.withValues(alpha: 0.04); + } + return Colors.transparent; + }); +} + class EntryItem extends SearchableStatefulWidget { + // Retained for source compatibility with Loftify's existing item builders. + final BuildContext? context; final double radius; final bool roundTop; final bool roundBottom; @@ -13,10 +56,13 @@ class EntryItem extends SearchableStatefulWidget { final Color? descriptionColor; final CrossAxisAlignment crossAxisAlignment; final IconData leading; + final Widget? leadingWidget; final String tip; final Function()? onTap; final double? paddingVertical; final double? paddingHorizontal; + // Legacy shorthand retained while callers migrate to axis-specific padding. + final double? padding; final double trailingLeftMargin; final bool dividerPadding; final IconData trailing; @@ -27,6 +73,7 @@ class EntryItem extends SearchableStatefulWidget { const EntryItem({ super.key, + this.context, this.radius = 8, this.roundTop = false, this.roundBottom = false, @@ -37,10 +84,12 @@ class EntryItem extends SearchableStatefulWidget { this.descriptionColor, this.crossAxisAlignment = CrossAxisAlignment.start, this.leading = LucideIcons.house, + this.leadingWidget, this.tip = "", this.onTap, this.paddingVertical, this.paddingHorizontal, + this.padding, this.trailingLeftMargin = 5, this.dividerPadding = true, this.trailing = LucideIcons.chevronRight, @@ -66,6 +115,7 @@ class EntryItem extends SearchableStatefulWidget { SearchConfig? searchConfig, }) { return EntryItem( + context: context, searchConfig: searchConfig ?? this.searchConfig, title: title, description: description, @@ -80,66 +130,95 @@ class EntryItem extends SearchableStatefulWidget { descriptionColor: descriptionColor, crossAxisAlignment: crossAxisAlignment, leading: leading, + leadingWidget: leadingWidget, tip: tip, onTap: onTap, paddingVertical: paddingVertical, paddingHorizontal: paddingHorizontal, + padding: padding, trailingLeftMargin: trailingLeftMargin, dividerPadding: dividerPadding, trailing: trailing, tipWidth: tipWidth, + minTipWidth: minTipWidth, tipWidget: tipWidget, + ink: ink, ); } } class EntryItemState extends SearchableState { - double get _paddingVertical => widget.paddingVertical ?? 12; + double get _paddingVertical => widget.paddingVertical ?? widget.padding ?? 14; + + double get _paddingHorizontal => + widget.paddingHorizontal ?? widget.padding ?? 10; - double get _paddingHorizontal => widget.paddingHorizontal ?? 6; + Color get _leadingColor => widget.titleColor ?? ChewieTheme.primaryColor; - BorderRadius get _borderRadius => - const BorderRadius.vertical(top: Radius.zero, bottom: Radius.zero); + BorderRadius get _borderRadius => BorderRadius.vertical( + top: widget.roundTop ? Radius.circular(widget.radius) : Radius.zero, + bottom: + widget.roundBottom ? Radius.circular(widget.radius) : Radius.zero, + ); @override Widget build(BuildContext context) { if (!shouldShow) return const SizedBox.shrink(); return InkAnimation( - color: Colors.transparent, + color: widget.backgroundColor ?? ChewieTheme.canvasColor, ink: widget.ink, borderRadius: _borderRadius, - // onTap: widget.onTap, - child: Column( - children: [ - Container( - padding: EdgeInsets.symmetric( - vertical: _paddingVertical, - horizontal: _paddingHorizontal, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: _buildRowChildren(), - ), - ), - // _buildBottomDivider(), - ], + onTap: widget.onTap, + child: Container( + decoration: BoxDecoration( + borderRadius: _borderRadius, + ), + padding: EdgeInsets.only( + top: _paddingVertical, + bottom: _paddingVertical, + left: _paddingHorizontal, + right: _paddingHorizontal + 6, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: _buildRowChildren(), + ), ), ); } List _buildRowChildren() { + final hasLeading = widget.showLeading || widget.leadingWidget != null; return [ - if (widget.showLeading) Icon(widget.leading, size: 20), - SizedBox(width: widget.showLeading ? 10 : 5), + if (hasLeading) _buildLeadingIcon(), + SizedBox(width: hasLeading ? 10 : 5), Expanded(child: _buildTextContent()), - const SizedBox(width: 50), - if (widget.tipWidget == null) _buildTipWidget(), + if (widget.tipWidget != null) const SizedBox(width: 10), if (widget.tipWidget != null) _buildCustomTipWidget(), - // if (widget.showTrailing) SizedBox(width: widget.trailingLeftMargin), - // if (widget.showTrailing) _buildTrailingIcon(), + if (widget.tipWidget == null) const SizedBox(width: 10), + if (widget.tipWidget == null) _buildTipWidget(), ]; } + Widget _buildLeadingIcon() { + if (widget.leadingWidget != null) { + return Container( + margin: const EdgeInsets.only(left: 4), + child: widget.leadingWidget!, + ); + } + return Container( + width: 28, + height: 28, + margin: const EdgeInsets.only(left: 4), + decoration: BoxDecoration( + color: _leadingColor.withAlpha(25), + borderRadius: BorderRadius.circular(7), + ), + child: ChewieIcon(widget.leading, size: 15, color: _leadingColor), + ); + } + Widget _buildTextContent() { final titleStyle = ChewieTheme.titleMedium.apply(color: widget.titleColor); final descStyle = @@ -180,39 +259,33 @@ class EntryItemState extends SearchableState { } Widget _buildTipWidget() { - return widget.tip.isNotEmpty - ? Container( - constraints: BoxConstraints( - minWidth: widget.minTipWidth, - maxWidth: widget.description.isNotEmpty - ? widget.tipWidth - : widget.tipWidth + 40, - ), - child: RoundIconTextButton( - height: null, - minHeight: 32, - onPressed: widget.onTap, - text: widget.tip, - textStyle: ChewieTheme.bodyMedium - .apply(fontSizeDelta: -1, fontWeightDelta: 2), - padding: const EdgeInsets.symmetric(horizontal: 10), - background: ChewieTheme.canvasColor, - border: ChewieTheme.border, - ), - ) - : RoundIconTextButton( - height: 32, - minHeight: 32, - onPressed: widget.onTap, - icon: Icon( - widget.trailing, - size: 16, - color: ChewieTheme.bodyMedium.color, + if (!widget.showTrailing && widget.tip.isEmpty) { + return const SizedBox.shrink(); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.tip.isNotEmpty) + Flexible( + child: Text( + widget.tip, + style: ChewieTheme.bodyMedium.apply( + color: ChewieTheme.bodySmall.color, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - padding: const EdgeInsets.symmetric(horizontal: 8), - background: ChewieTheme.canvasColor, - border: ChewieTheme.border, - ); + ), + if (widget.tip.isNotEmpty && widget.showTrailing) + const SizedBox(width: 6), + if (widget.showTrailing) + ChewieIcon( + widget.trailing, + size: 16, + color: ChewieTheme.bodySmall.color, + ), + ], + ); } Widget _buildCustomTipWidget() { @@ -226,16 +299,6 @@ class EntryItemState extends SearchableState { child: widget.tipWidget!, ); } - -// Widget _buildBottomDivider() { -// return Container( -// height: 0, -// margin: const EdgeInsets.symmetric(horizontal: 10), -// decoration: BoxDecoration( -// border: widget.roundBottom ? null : ChewieTheme.bottomDivider, -// ), -// ); -// } } class SearchableCaptionItem extends SearchableStatefulWidget { @@ -325,64 +388,80 @@ class SearchableCaptionItemState extends SearchableState @override Widget build(BuildContext context) { if (!shouldShow) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: _toggleExpansion, - child: Container( - color: Colors.transparent, - padding: widget.padding ?? - const EdgeInsets.symmetric(horizontal: 12) - .add(const EdgeInsets.only(top: 20, bottom: 10)), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - widget.title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: ChewieTheme.textDarkGreyColor, - letterSpacing: 0.5, - ), - ), - ), - RotationTransition( - turns: _arrowAnimation, - child: Icon( - LucideIcons.chevronDown, - size: 18, - color: ChewieTheme.textDarkGreyColor, - ), - ), - ], + return Padding( + padding: const EdgeInsets.only(top: _settingSectionTopSpacing), + child: Material( + color: ChewieTheme.canvasColor, + borderRadius: _settingSectionBorderRadius(context), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + ClipRect( + child: SizeTransition( + sizeFactor: _sizeAnimation, + axisAlignment: -1.0, + child: Column(children: _buildChildren()), + ), ), - ), + ], ), - if (widget.showDivider) - Container( - height: 0, - margin: const EdgeInsets.symmetric(horizontal: 10) - .add(const EdgeInsets.only(bottom: 4)), - decoration: BoxDecoration(border: ChewieTheme.bottomDivider), - ), - ClipRect( - child: SizeTransition( - sizeFactor: _sizeAnimation, - axisAlignment: -1.0, - child: Column(children: _buildChildren()), + ), + ); + } + + Widget _buildHeader() { + final radius = _settingSectionBorderRadius(context); + return InkWell( + onTap: _toggleExpansion, + borderRadius: BorderRadius.only( + topLeft: radius.topLeft, + topRight: radius.topRight, + ), + splashFactory: NoSplash.splashFactory, + overlayColor: _settingSectionOverlay(context), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: widget.padding ?? + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + widget.title, + style: _settingSectionTitleStyle(context), + ), + ), + const SizedBox(width: 8), + RotationTransition( + turns: _arrowAnimation, + child: Icon( + LucideIcons.chevronDown, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), ), - ], + ), ); } List _buildChildren() { - return widget.children - .map((child) => _withUpdatedSearchText(child)) - .toList(); + final children = + widget.children.map((child) => _withUpdatedSearchText(child)).toList(); + final result = []; + for (int i = 0; i < children.length; i++) { + if (widget.showDivider) { + result.add(_settingSectionDivider(context)); + } + result.add(children[i]); + } + return result; } SearchableStatefulWidget _withUpdatedSearchText( @@ -395,6 +474,8 @@ class SearchableCaptionItemState extends SearchableState } class CaptionItem extends StatefulWidget { + // Retained for source compatibility with existing Loftify screens. + final BuildContext? context; final EdgeInsetsGeometry? padding; final bool showDivider; final List children; @@ -403,6 +484,7 @@ class CaptionItem extends StatefulWidget { const CaptionItem({ super.key, + this.context, required this.title, this.padding, this.showDivider = true, @@ -458,57 +540,92 @@ class CaptionItemState extends BaseDynamicState @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: _toggleExpansion, - child: Container( - color: Colors.transparent, - padding: widget.padding ?? - const EdgeInsets.symmetric(horizontal: 12) - .add(const EdgeInsets.only(top: 20, bottom: 10)), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - widget.title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: ChewieTheme.textDarkGreyColor, - letterSpacing: 0.5, - ), - ), - ), - RotationTransition( - turns: _arrowAnimation, - child: Icon( - LucideIcons.chevronDown, - size: 20, - color: ChewieTheme.textDarkGreyColor, - ), + if (widget.children.isEmpty) { + return Padding( + padding: widget.padding ?? + const EdgeInsets.fromLTRB( + 16, + _settingSectionTopSpacing, + 16, + 8, + ), + child: Text( + widget.title, + style: _settingSectionTitleStyle(context), + ), + ); + } + return Padding( + padding: const EdgeInsets.only(top: _settingSectionTopSpacing), + child: Material( + color: ChewieTheme.canvasColor, + borderRadius: _settingSectionBorderRadius(context), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + ClipRect( + child: SizeTransition( + sizeFactor: _sizeAnimation, + axisAlignment: -1.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildChildren(), ), - ], + ), ), - ), + ], ), - if (widget.showDivider) - Container( - height: 0, - margin: const EdgeInsets.symmetric(horizontal: 10) - .add(const EdgeInsets.only(bottom: 4)), - decoration: BoxDecoration(border: ChewieTheme.bottomDivider), - ), - ClipRect( - child: SizeTransition( - sizeFactor: _sizeAnimation, - axisAlignment: -1.0, - child: Column(children: widget.children), - ), + ), + ); + } + + Widget _buildHeader() { + final radius = _settingSectionBorderRadius(context); + return InkWell( + onTap: _toggleExpansion, + borderRadius: BorderRadius.only( + topLeft: radius.topLeft, + topRight: radius.topRight, + ), + splashFactory: NoSplash.splashFactory, + overlayColor: _settingSectionOverlay(context), + child: Padding( + padding: widget.padding ?? + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + widget.title, + style: _settingSectionTitleStyle(context), + ), + ), + const SizedBox(width: 8), + RotationTransition( + turns: _arrowAnimation, + child: Icon( + LucideIcons.chevronDown, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), - ], + ), ); } + + List _buildChildren() { + final result = []; + for (int i = 0; i < widget.children.length; i++) { + if (widget.showDivider) { + result.add(_settingSectionDivider(context)); + } + result.add(widget.children[i]); + } + return result; + } } diff --git a/third-party/chewie/lib/src/Widgets/Tile/font_item.dart b/third-party/chewie/lib/src/Widgets/Tile/font_item.dart index d83141bb..bdc75527 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/font_item.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/font_item.dart @@ -1,8 +1,7 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:auto_size_text/auto_size_text.dart'; - import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; -import 'package:awesome_chewie/awesome_chewie.dart'; class FontItem extends StatefulWidget { final CustomFont font; @@ -29,128 +28,186 @@ class FontItem extends StatefulWidget { } class FontItemState extends State { - bool exist = true; + late Future _fontExists; + + @override + void initState() { + super.initState(); + _fontExists = CustomFont.isFontFileExist(widget.font); + } + + @override + void didUpdateWidget(covariant FontItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.font != widget.font) { + _fontExists = CustomFont.isFontFileExist(widget.font); + } + } @override Widget build(BuildContext context) { - return SizedBox( - width: widget.width, - child: Column( - children: [ - Container( - width: widget.width, - height: widget.height, - padding: const EdgeInsets.only(top: 8, left: 10, right: 10), - decoration: BoxDecoration( - color: ChewieTheme.canvasColor, - border: ChewieTheme.border, - borderRadius: ChewieDimens.borderRadius8, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: widget.height - 65, - child: FutureBuilder( - future: Future.sync(() async { - exist = await CustomFont.isFontFileExist(widget.font); - return widget.font; - }), - builder: (context, snapshot) { - return exist - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutoSizeText( - "AaBbCcDd", - style: ChewieTheme.bodyMedium.apply( - fontFamily: widget.font.fontFamily, - letterSpacingDelta: 1, - ), - maxLines: 1, - ), - AutoSizeText( - "AaBbCcDd", - style: ChewieTheme.bodyMedium.apply( - fontWeightDelta: 2, - fontFamily: widget.font.fontFamily, - letterSpacingDelta: 1, - ), - maxLines: 1, - ), - AutoSizeText( - "你好世界", - style: ChewieTheme.bodyMedium.apply( - fontFamily: widget.font.fontFamily, - letterSpacingDelta: 1, - ), - maxLines: 1, - ), - AutoSizeText( - "你好世界", - style: ChewieTheme.bodyMedium.apply( - fontWeightDelta: 2, - fontFamily: widget.font.fontFamily, - letterSpacingDelta: 1, - ), - maxLines: 1, - ), - ], - ) - : Text( - chewieLocalizations.fontFileNotExist, - style: ChewieTheme.bodyMedium.apply( - fontFamily: widget.font.fontFamily, - fontWeightDelta: 0, - ), - ); - }, - ), + final selected = widget.font == widget.currentFont; + final labelHeight = MediaQuery.textScalerOf(context).scale(36); + return Semantics( + container: true, + selected: selected, + button: true, + label: widget.font.intlFontName, + onTap: () => widget.onChanged?.call(widget.font), + child: ClickableGestureDetector( + onTap: () => widget.onChanged?.call(widget.font), + child: SizedBox( + width: widget.width, + child: Column( + children: [ + Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: selected + ? ChewieTheme.primaryColor.withValues(alpha: 0.06) + : ChewieTheme.canvasColor, + border: selected + ? Border.all(color: ChewieTheme.primaryColor, width: 1.5) + : ChewieTheme.border, + borderRadius: BorderRadius.circular(12), ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, + child: Stack( children: [ - Radio( - value: widget.font, - groupValue: widget.currentFont, - onChanged: widget.onChanged, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - fillColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return ChewieTheme.primaryColor; - } else { - return ChewieTheme.bodySmall.color; - } - }), + Padding( + padding: const EdgeInsets.fromLTRB(12, 14, 12, 40), + child: MediaQuery.withNoTextScaling( + child: FutureBuilder( + future: _fontExists, + builder: (context, snapshot) { + return snapshot.data != false + ? Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + AutoSizeText( + "AaBbCcDd", + style: ChewieTheme.bodyMedium.apply( + fontFamily: widget.font.fontFamily, + letterSpacingDelta: 1, + ), + maxLines: 1, + ), + AutoSizeText( + "AaBbCcDd", + style: ChewieTheme.bodyMedium.apply( + fontWeightDelta: 2, + fontFamily: widget.font.fontFamily, + letterSpacingDelta: 1, + ), + maxLines: 1, + ), + AutoSizeText( + "你好世界", + style: ChewieTheme.bodyMedium.apply( + fontFamily: widget.font.fontFamily, + letterSpacingDelta: 1, + ), + maxLines: 1, + ), + AutoSizeText( + "你好世界", + style: ChewieTheme.bodyMedium.apply( + fontWeightDelta: 2, + fontFamily: widget.font.fontFamily, + letterSpacingDelta: 1, + ), + maxLines: 1, + ), + ], + ) + : Text( + chewieLocalizations.fontFileNotExist, + style: ChewieTheme.bodySmall, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ); + }, + ), + ), ), - if (widget.showDelete) const SizedBox(width: 5), + if (selected) + Positioned( + right: 8, + top: 8, + child: DecoratedBox( + decoration: BoxDecoration( + color: ChewieTheme.primaryColor, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(3), + child: Icon( + LucideIcons.check, + size: 12, + color: + ChewieTheme.primaryColor.computeLuminance() > + 0.5 + ? Colors.black + : Colors.white, + ), + ), + ), + ), if (widget.showDelete) - CircleIconButton( - icon: Icon( - LucideIcons.trash2, - color: ChewieTheme.errorColor, - size: 21, + Positioned( + right: 2, + bottom: 2, + child: IconButton( + key: + ValueKey('font-delete-${widget.font.fontFamily}'), + icon: Icon( + LucideIcons.trash2, + color: ChewieTheme.errorColor, + size: 17, + ), + constraints: const BoxConstraints.tightFor( + width: 40, + height: 40, + ), + padding: EdgeInsets.zero, + onPressed: () => widget.onDelete?.call(widget.font), + ), + ), + if (!widget.showDelete) + Positioned( + left: 12, + bottom: 12, + child: Container( + width: 22, + height: 3, + decoration: BoxDecoration( + color: selected + ? ChewieTheme.primaryColor + : ChewieTheme.borderColor, + borderRadius: BorderRadius.circular(3), + ), ), - padding: const EdgeInsets.all(10), - onTap: () { - widget.onDelete?.call(widget.font); - }, ), ], - ) - ], - ), - ), - const SizedBox(height: 8), - Text( - widget.font.intlFontName, - style: ChewieTheme.bodySmall.apply( - fontFamily: widget.font.fontFamily, - ), - textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 6), + SizedBox( + height: labelHeight, + child: Center( + child: Text( + widget.font.intlFontName, + style: ChewieTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ), + ], ), - ], + ), ), ); } @@ -165,7 +222,7 @@ class EmptyFontItem extends StatefulWidget { super.key, this.onTap, this.width = 110, - this.height = 160, + this.height = 154, }); @override @@ -175,36 +232,49 @@ class EmptyFontItem extends StatefulWidget { class EmptyFontItemState extends State { @override Widget build(BuildContext context) { - return SizedBox( - width: widget.width, - child: Column( - children: [ - ClickableGestureDetector( - onTap: widget.onTap, - child: Container( - width: widget.width, - height: widget.height, - padding: - const EdgeInsets.only(top: 5, bottom: 5, left: 10, right: 10), - decoration: BoxDecoration( - color: ChewieTheme.canvasColor, - border: ChewieTheme.border, - borderRadius: ChewieDimens.borderRadius8, + final labelHeight = MediaQuery.textScalerOf(context).scale(36); + return Semantics( + container: true, + button: true, + label: chewieLocalizations.loadFontFamily, + onTap: widget.onTap, + child: ClickableGestureDetector( + onTap: widget.onTap, + child: SizedBox( + width: widget.width, + child: Column( + children: [ + Container( + width: widget.width, + height: widget.height, + padding: const EdgeInsets.only( + top: 5, bottom: 5, left: 10, right: 10), + decoration: BoxDecoration( + color: ChewieTheme.canvasColor, + border: ChewieTheme.border, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + LucideIcons.plus, + size: 30, + color: ChewieTheme.labelSmall.color, + ), ), - child: Icon( - LucideIcons.plus, - size: 40, - color: ChewieTheme.labelSmall.color, + const SizedBox(height: 6), + SizedBox( + height: labelHeight, + child: Center( + child: Text( + chewieLocalizations.loadFontFamily, + style: ChewieTheme.bodySmall, + maxLines: 2, + textAlign: TextAlign.center, + ), + ), ), - ), - ), - const SizedBox(height: 8), - Text( - chewieLocalizations.loadFontFamily, - style: ChewieTheme.bodySmall, - textAlign: TextAlign.center, + ], ), - ], + ), ), ); } diff --git a/third-party/chewie/lib/src/Widgets/Tile/index.dart b/third-party/chewie/lib/src/Widgets/Tile/index.dart index 6d6c1656..ca0aef23 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/index.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/index.dart @@ -25,5 +25,6 @@ export 'input_item.dart'; export 'radio_item.dart'; export 'searchable_stateful_widget.dart'; export 'selection_item.dart'; +export 'selection_indicator.dart'; export 'theme_item.dart'; export 'tip_banner.dart'; diff --git a/third-party/chewie/lib/src/Widgets/Tile/inline_selection_item.dart b/third-party/chewie/lib/src/Widgets/Tile/inline_selection_item.dart index f873a20e..3acc8113 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/inline_selection_item.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/inline_selection_item.dart @@ -49,7 +49,7 @@ class InlineSelectionItem this.trailingLeftMargin = 5, this.dividerIndent = true, this.overlayController, - this.ink = false, + this.ink = true, super.searchText, super.searchConfig, }) : isMultiSelect = false, @@ -78,7 +78,7 @@ class InlineSelectionItem this.trailingLeftMargin = 5, this.dividerIndent = true, this.overlayController, - this.ink = false, + this.ink = true, super.searchText, super.searchConfig, }) : isMultiSelect = true, @@ -111,6 +111,7 @@ class InlineSelectionItem this.isMultiSelect = false, this.selectedItems = const [], this.onListChanged, + super.searchText, super.searchConfig, }); @@ -123,6 +124,8 @@ class InlineSelectionItem SearchConfig? searchConfig, }) { return InlineSelectionItem._internal( + key: key, + searchText: searchText ?? this.searchText, searchConfig: searchConfig ?? this.searchConfig, title: title, items: items, @@ -146,6 +149,8 @@ class InlineSelectionItem trailingLeftMargin: trailingLeftMargin, dividerIndent: dividerIndent, overlayController: overlayController, + ink: ink, + isMultiSelect: isMultiSelect, ); } @@ -171,40 +176,76 @@ class InlineSelectionItemState Widget build(BuildContext context) { if (!shouldShow) return const SizedBox.shrink(); final vertical = widget.paddingVertical ?? 12; - final horizontal = widget.paddingHorizontal ?? 6; + final horizontal = widget.paddingHorizontal ?? 10; return InkAnimation( ink: widget.ink, + color: widget.backgroundColor ?? ChewieTheme.canvasColor, borderRadius: BorderRadius.vertical( top: widget.roundTop ? Radius.circular(widget.radius) : Radius.zero, bottom: widget.roundBottom ? Radius.circular(widget.radius) : Radius.zero, ), - child: Column( - children: [ - Container( + child: LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + final useStackedLayout = + constraints.maxWidth < 380 || textScale > 1.35; + final title = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (widget.showLeading) _buildLeadingIcon(), + SizedBox(width: widget.showLeading ? 10 : 5), + Expanded(child: _buildTitleDescription()), + ], + ); + + return Padding( padding: EdgeInsets.symmetric( - vertical: vertical, horizontal: horizontal), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (widget.showLeading) Icon(widget.leading, size: 20), - SizedBox(width: widget.showLeading ? 10 : 5), - Expanded(child: _buildTitleDescription()), - const SizedBox(width: 50), - SizedBox(width: 180, child: _buildDropdownContainer()), - ], + vertical: vertical, + horizontal: horizontal, ), - ), - // Container( - // height: 0, - // margin: const EdgeInsets.symmetric(horizontal: 10), - // decoration: BoxDecoration( - // border: widget.roundBottom ? null : ChewieTheme.bottomDivider, - // ), - // ), - ], + child: useStackedLayout + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + title, + const SizedBox(height: 10), + Padding( + padding: EdgeInsets.only( + left: widget.showLeading ? 38 : 5, + ), + child: _buildDropdownContainer(), + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: title), + SizedBox(width: widget.trailingLeftMargin), + SizedBox( + width: 180, + child: _buildDropdownContainer(), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildLeadingIcon() { + final color = ChewieTheme.primaryColor; + return Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: color.withAlpha(25), + borderRadius: BorderRadius.circular(7), ), + child: Icon(widget.leading, size: 15, color: color), ); } @@ -259,6 +300,7 @@ class InlineSelectionItemState initialItem: widget.initItem, items: widget.items, excludeSelected: false, + maxlines: 1, onChanged: widget.onChanged, overlayController: _controller, closedHeaderPadding: @@ -281,6 +323,9 @@ class InlineSelectionItemState } CustomDropdownDecoration _dropdownDecoration() { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final radius = BorderRadius.circular(10); return CustomDropdownDecoration( overlayScrollbarThemeData: ChewieTheme.scrollbarTheme, showSelectStyle: @@ -289,16 +334,16 @@ class InlineSelectionItemState hintStyle: ChewieTheme.bodySmall, expandedShadow: ChewieTheme.defaultBoxShadow, expandedBorder: ChewieTheme.border, - expandedBorderRadius: ChewieDimens.borderRadius8, + expandedBorderRadius: radius, closedBorder: ChewieTheme.border, - closedBorderRadius: ChewieDimens.borderRadius8, + closedBorderRadius: radius, listItemDecoration: ListItemDecoration( - splashColor: ChewieTheme.splashColor, - highlightColor: ChewieTheme.highlightColor, - selectedColor: ChewieTheme.hoverColor, - selectedIconColor: ChewieTheme.successColor, + splashColor: Colors.transparent, + highlightColor: colorScheme.primary.withValues(alpha: 0.06), + selectedColor: colorScheme.primaryContainer, + selectedIconColor: colorScheme.primary, ), - closedFillColor: ChewieTheme.canvasColor, + closedFillColor: ChewieTheme.scaffoldBackgroundColor, expandedFillColor: ChewieTheme.scaffoldBackgroundColor, listItemStyle: ChewieTheme.bodyMedium, closedSuffixIcon: diff --git a/third-party/chewie/lib/src/Widgets/Tile/input_item.dart b/third-party/chewie/lib/src/Widgets/Tile/input_item.dart index 9dad8826..ed254376 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/input_item.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/input_item.dart @@ -88,7 +88,7 @@ class InputItemLeadingTailingConfig { this.show = true, this.type = InputItemLeadingTailingType.none, this.onTap, - this.enable=true, + this.enable = true, }); } @@ -402,7 +402,6 @@ class InputItemState extends State { break; case InputItemLeadingTailingType.clear: res = RoundIconTextButton( - height: 36, background: theme.canvasColor, border: ChewieTheme.borderWithWidth(1), padding: const EdgeInsets.symmetric(horizontal: 10), @@ -418,7 +417,6 @@ class InputItemState extends State { break; case InputItemLeadingTailingType.copy: res = RoundIconTextButton( - height: 36, background: theme.canvasColor, border: ChewieTheme.borderWithWidth(1), padding: const EdgeInsets.symmetric(horizontal: 10), @@ -435,7 +433,6 @@ class InputItemState extends State { case InputItemLeadingTailingType.openExternal: res = RoundIconTextButton( background: theme.canvasColor, - height: 36, border: ChewieTheme.borderWithWidth(1), padding: const EdgeInsets.symmetric(horizontal: 10), icon: Icon( @@ -450,7 +447,6 @@ class InputItemState extends State { break; case InputItemLeadingTailingType.addSelection: res = RoundIconTextButton( - height: 36, background: theme.canvasColor, border: ChewieTheme.borderWithWidth(1), padding: const EdgeInsets.symmetric(horizontal: 10), @@ -480,7 +476,6 @@ class InputItemState extends State { break; case InputItemLeadingTailingType.text: res = RoundIconTextButton( - height: 36, disabled: !config.enable, background: theme.canvasColor, border: ChewieTheme.borderWithWidth(1), diff --git a/third-party/chewie/lib/src/Widgets/Tile/selection_indicator.dart b/third-party/chewie/lib/src/Widgets/Tile/selection_indicator.dart new file mode 100644 index 00000000..40c7b6f1 --- /dev/null +++ b/third-party/chewie/lib/src/Widgets/Tile/selection_indicator.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../../Resources/chewie_icons.dart'; + +/// A visual-only selection marker for cards whose whole surface is tappable. +/// +/// Selection semantics and gestures belong on the parent card so this marker +/// never creates a second, smaller interaction target. +class ChewieSelectionIndicator extends StatelessWidget { + const ChewieSelectionIndicator({ + super.key, + required this.selected, + this.selectedColor, + this.unselectedColor, + this.size = 24, + }); + + final bool selected; + final Color? selectedColor; + final Color? unselectedColor; + final double size; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final activeColor = selectedColor ?? colorScheme.primary; + final inactiveColor = unselectedColor ?? colorScheme.outlineVariant; + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + final duration = + reduceMotion ? Duration.zero : const Duration(milliseconds: 160); + + return IgnorePointer( + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? activeColor : Colors.transparent, + border: Border.all( + color: selected ? activeColor : inactiveColor, + ), + ), + child: AnimatedSwitcher( + duration: duration, + child: selected + ? Icon( + ChewieIcons.check, + key: const ValueKey('selected'), + size: size * 0.62, + color: colorScheme.onPrimary, + ) + : const SizedBox.shrink(key: ValueKey('unselected')), + ), + ), + ); + } +} diff --git a/third-party/chewie/lib/src/Widgets/Tile/selection_item.dart b/third-party/chewie/lib/src/Widgets/Tile/selection_item.dart index 07a120b0..0806d03c 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/selection_item.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/selection_item.dart @@ -280,7 +280,7 @@ class SelectionItemState splashColor: Theme.of(context).splashColor, highlightColor: Theme.of(context).highlightColor, selectedColor: Theme.of(context).hoverColor, - selectedIconColor: ChewieTheme.successColor, + selectedIconColor: ChewieTheme.primaryColor, ), searchFieldDecoration: SearchFieldDecoration( hintStyle: @@ -337,7 +337,7 @@ class SelectionItemState splashColor: ChewieTheme.splashColor, highlightColor: ChewieTheme.highlightColor, selectedColor: ChewieTheme.hoverColor, - selectedIconColor: ChewieTheme.successColor, + selectedIconColor: ChewieTheme.primaryColor, ), searchFieldDecoration: SearchFieldDecoration( hintStyle: diff --git a/third-party/chewie/lib/src/Widgets/Tile/theme_item.dart b/third-party/chewie/lib/src/Widgets/Tile/theme_item.dart index 2c1bb5a6..89f97e36 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/theme_item.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/theme_item.dart @@ -1,69 +1,102 @@ -import 'package:flutter/material.dart'; import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; class ThemeItem extends StatefulWidget { final ChewieThemeColorData themeColorData; final int index; final int groupIndex; final Function(int?)? onChanged; + final VoidCallback? onLongPress; const ThemeItem({ - Key? key, + super.key, required this.themeColorData, required this.index, required this.groupIndex, required this.onChanged, - }) : super(key: key); + this.onLongPress, + }); @override - _ThemeItemState createState() => _ThemeItemState(); + State createState() => _ThemeItemState(); } class _ThemeItemState extends State { @override Widget build(BuildContext context) { - return Container( - width: 107.3, - height: 166.4, - margin: EdgeInsets.only(left: widget.index == 0 ? 10 : 0, right: 10), - child: Column( - children: [ - Container( - padding: - const EdgeInsets.only(top: 10, bottom: 0, left: 8, right: 8), - decoration: BoxDecoration( - color: widget.themeColorData.scaffoldBackgroundColor, - borderRadius: const BorderRadius.all(Radius.circular(10)), - border: ChewieTheme.border, - ), - child: Column( - children: [ - _buildCardRow(widget.themeColorData), - const SizedBox(height: 5), - _buildCardRow(widget.themeColorData), - const SizedBox(height: 15), - Radio( - value: widget.index, - groupValue: widget.groupIndex, - onChanged: widget.onChanged, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - fillColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return widget.themeColorData.primaryColor; - } else { - return widget.themeColorData.textLightGreyColor; - } - }), + final textScale = MediaQuery.textScalerOf(context).scale(1); + final responsiveHeight = 136.0 + 16 * (textScale - 1).clamp(0.0, 2.0); + final selected = widget.index == widget.groupIndex; + void selectTheme() => widget.onChanged?.call(widget.index); + return Semantics( + container: true, + excludeSemantics: true, + selected: selected, + button: true, + label: widget.themeColorData.i18nName, + onTap: selectTheme, + onLongPress: widget.onLongPress, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onLongPress: widget.onLongPress, + onTap: selectTheme, + child: Container( + width: 107.3, + height: responsiveHeight, + margin: EdgeInsets.only(left: widget.index == 0 ? 10 : 0, right: 10), + child: Column( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.only( + top: 10, bottom: 0, left: 8, right: 8), + decoration: BoxDecoration( + color: widget.themeColorData.scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(10), + border: selected + ? Border.all( + color: widget.themeColorData.primaryColor, + width: 1.5, + ) + : ChewieTheme.border, + ), + child: Column( + children: [ + _buildCardRow(widget.themeColorData), + const SizedBox(height: 5), + _buildCardRow(widget.themeColorData), + const Spacer(), + Align( + alignment: Alignment.centerRight, + child: Icon( + selected + ? LucideIcons.circleCheck + : LucideIcons.circle, + size: 18, + color: selected + ? widget.themeColorData.primaryColor + : widget.themeColorData.textLightGreyColor, + ), + ), + const SizedBox(height: 6), + ], + ), ), - ], - ), - ), - const SizedBox(height: 8), - Text( - widget.themeColorData.i18nName, - style: ChewieTheme.bodySmall, + ), + const SizedBox(height: 8), + Text( + widget.themeColorData.i18nName, + style: ChewieTheme.bodySmall.copyWith( + fontWeight: selected ? FontWeight.w600 : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - ], + ), ), ); } @@ -75,7 +108,7 @@ class _ThemeItemState extends State { padding: const EdgeInsets.all(5), decoration: BoxDecoration( color: themeColorData.canvasColor, - borderRadius: const BorderRadius.all(Radius.circular(5)), + borderRadius: BorderRadius.circular(5), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -133,46 +166,54 @@ class EmptyThemeItem extends StatefulWidget { }); @override - _EmptyThemeItemState createState() => _EmptyThemeItemState(); + State createState() => _EmptyThemeItemState(); } class _EmptyThemeItemState extends State { @override Widget build(BuildContext context) { - return Container( - width: 107.3, - height: 166.4, - margin: const EdgeInsets.only(right: 10), - child: Column( - children: [ - Container( - width: 107.3, - height: 141.7, - padding: const EdgeInsets.only(left: 8, right: 8), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(10)), - border: ChewieTheme.border, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_rounded, - size: 30, - color: ChewieTheme.titleSmall.color, + final textScale = MediaQuery.textScalerOf(context).scale(1); + final responsiveHeight = 166.4 + 24 * (textScale - 1).clamp(0.0, 2.0); + return Semantics( + container: true, + excludeSemantics: true, + button: true, + label: chewieLocalizations.newTheme, + onTap: widget.onTap, + child: ClickableGestureDetector( + onTap: widget.onTap, + child: Container( + width: 107.3, + height: responsiveHeight, + margin: const EdgeInsets.only(right: 10), + child: Column( + children: [ + Expanded( + child: Container( + width: 107.3, + padding: const EdgeInsets.only( + top: 10, bottom: 0, left: 8, right: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: ChewieTheme.border, + ), + child: Icon( + ChewieIcons.add, + size: 30, + color: ChewieTheme.bodySmall.color, + ), ), - const SizedBox(height: 6), - Text(chewieLocalizations.newTheme, - style: ChewieTheme.titleSmall), - ], - ), - ), - const SizedBox(height: 6), - Text( - "", - style: ChewieTheme.bodySmall, + ), + const SizedBox(height: 8), + Text( + chewieLocalizations.newTheme, + style: ChewieTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - ], + ), ), ); } diff --git a/third-party/chewie/lib/src/Widgets/Tile/tip_banner.dart b/third-party/chewie/lib/src/Widgets/Tile/tip_banner.dart index 262d3369..3fbb901d 100644 --- a/third-party/chewie/lib/src/Widgets/Tile/tip_banner.dart +++ b/third-party/chewie/lib/src/Widgets/Tile/tip_banner.dart @@ -11,6 +11,8 @@ class TipBanner extends StatelessWidget { final Color? backgroundColor; final Color? textColor; final EdgeInsetsGeometry padding; + final Widget? action; + final double actionSpacing; final EdgeInsetsGeometry margin; @@ -22,7 +24,9 @@ class TipBanner extends StatelessWidget { this.backgroundColor, this.textColor, this.padding = const EdgeInsets.all(12), - this.margin = const EdgeInsets.symmetric(horizontal: 8), + this.margin = const EdgeInsets.symmetric(horizontal: 0), + this.action, + this.actionSpacing = 10, }); factory TipBanner.info( @@ -144,23 +148,34 @@ class TipBanner extends StatelessWidget { color: backgroundColor ?? background, borderRadius: ChewieDimens.borderRadius8, ), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon( - customIcon ?? defaultIcons[type], - color: text, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - message, - style: TextStyle( + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + customIcon ?? defaultIcons[type], color: text, - fontSize: 14, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: TextStyle( + color: text, + fontSize: 14, + ), + ), ), - ), + ], ), + if (action != null) ...[ + SizedBox(height: actionSpacing), + Align(alignment: Alignment.centerRight, child: action!), + ], ], ), ); diff --git a/third-party/chewie/pubspec.yaml b/third-party/chewie/pubspec.yaml index a0fe060f..cb8cbcac 100644 --- a/third-party/chewie/pubspec.yaml +++ b/third-party/chewie/pubspec.yaml @@ -16,8 +16,8 @@ dependencies: flutter_animate: ^4.5.2 auto_size_text: ^3.0.0 lottie: 3.3.0 - flutter_widget_from_html: ^0.15.0 - flutter_widget_from_html_core: + flutter_widget_from_html: ^0.17.2 + flutter_widget_from_html_core: ^0.17.2 markdown_widget: path: ../widget/markdown_widget lucide_icons: @@ -25,10 +25,6 @@ dependencies: modal_bottom_sheet: ^3.0.0 markdown: ^7.3.0 highlight: ^0.7.0 - flutter_math_fork: - git: - url: https://github.com/dakshatsuraasa/flutter_math.git - ref: main # 存储/网络 path: logger: ^2.5.0 @@ -52,14 +48,14 @@ dependencies: share_plus: ^10.1.4 url_launcher: ^6.3.0 app_links: ^6.4.0 - flutter_displaymode: ^0.6.0 + flutter_displaymode: ^0.7.0 image_gallery_saver: path: ../tool/image_gallery_saver permission_handler: ^11.3.1 hotkey_manager: ^0.2.3 flutter_inappwebview: 6.1.5 # 工具 - intl: ^0.19.0 + intl: ^0.20.2 html: ^0.15.5+1 uuid: ^4.4.2 tuple: ^2.0.0 @@ -82,9 +78,6 @@ dependencies: decimal: ^3.0.2 equatable: ^2.0.7 -dependency_overrides: - webview_flutter_android: 3.16.1 - dev_dependencies: flutter_test: sdk: flutter @@ -93,4 +86,4 @@ dev_dependencies: json_serializable: ^6.7.1 flutter: - generate: true \ No newline at end of file + generate: true diff --git a/third-party/tool/flutter_windowmanager/android/src/main/java/io/adaptant/labs/flutter_windowmanager/FlutterWindowManagerPlugin.java b/third-party/tool/flutter_windowmanager/android/src/main/java/io/adaptant/labs/flutter_windowmanager/FlutterWindowManagerPlugin.java index ae3a95ca..04a99c9d 100644 --- a/third-party/tool/flutter_windowmanager/android/src/main/java/io/adaptant/labs/flutter_windowmanager/FlutterWindowManagerPlugin.java +++ b/third-party/tool/flutter_windowmanager/android/src/main/java/io/adaptant/labs/flutter_windowmanager/FlutterWindowManagerPlugin.java @@ -14,7 +14,6 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.plugin.common.PluginRegistry.Registrar; /** FlutterWindowManagerPlugin */ public class FlutterWindowManagerPlugin implements MethodCallHandler, FlutterPlugin, ActivityAware { @@ -23,16 +22,6 @@ public class FlutterWindowManagerPlugin implements MethodCallHandler, FlutterPlu @SuppressWarnings("unused") public FlutterWindowManagerPlugin() { } - private FlutterWindowManagerPlugin(Activity activity) { - this.activity = activity; - } - - /** Plugin registration. */ - @Deprecated - public static void registerWith(Registrar registrar) { - new FlutterWindowManagerPlugin(registrar.activity()).registerWith(registrar.messenger()); - } - private void registerWith(BinaryMessenger binaryMessenger) { final MethodChannel channel = new MethodChannel(binaryMessenger, "flutter_windowmanager"); channel.setMethodCallHandler(this); diff --git a/third-party/tool/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt b/third-party/tool/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt index c88c80fc..649bc432 100644 --- a/third-party/tool/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt +++ b/third-party/tool/image_gallery_saver/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt @@ -18,7 +18,6 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result -import io.flutter.plugin.common.PluginRegistry.Registrar import java.io.File import java.io.FileInputStream import java.io.IOException diff --git a/tool/capture_flutter_render.dart b/tool/capture_flutter_render.dart new file mode 100644 index 00000000..0f0e8fdc --- /dev/null +++ b/tool/capture_flutter_render.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:vm_service/vm_service_io.dart'; + +Future main(List arguments) async { + if (arguments.length < 2 || arguments.length > 4) { + stderr.writeln( + 'Usage: dart run tool/capture_flutter_render.dart ' + ' [width] [height]', + ); + exitCode = 64; + return; + } + + final serviceUri = Uri.parse(arguments[0]); + final output = File(arguments[1]); + final width = arguments.length > 2 ? double.parse(arguments[2]) : 430.0; + final height = arguments.length > 3 ? double.parse(arguments[3]) : 940.0; + final service = + await vmServiceConnectUri(flutterRenderWebSocketUri(serviceUri)); + const objectGroup = 'loftify-render-capture'; + + try { + final vm = await service.getVM(); + final isolate = vm.isolates?.where((entry) => entry.id != null).firstOrNull; + if (isolate?.id == null) { + throw StateError('The VM service did not expose a runnable isolate.'); + } + + final rootResponse = await service.callServiceExtension( + 'ext.flutter.inspector.getRootWidget', + isolateId: isolate!.id, + args: const {'objectGroup': objectGroup}, + ); + final root = rootResponse.json?['result']; + final rootId = root is Map ? root['valueId'] as String? : null; + if (rootId == null) { + throw StateError('The Flutter inspector did not expose a root element.'); + } + + final screenshotResponse = await service.callServiceExtension( + 'ext.flutter.inspector.screenshot', + isolateId: isolate.id, + args: { + 'id': rootId, + 'width': '$width', + 'height': '$height', + 'maxPixelRatio': '3.0', + 'debugPaint': 'false', + }, + ); + final encoded = screenshotResponse.json?['result']; + if (encoded is! String || encoded.isEmpty) { + throw StateError('The Flutter inspector returned no screenshot.'); + } + + await output.parent.create(recursive: true); + await output.writeAsBytes(base64Decode(encoded), flush: true); + stdout.writeln(output.absolute.path); + } finally { + try { + final vm = await service.getVM(); + final isolateId = + vm.isolates?.where((entry) => entry.id != null).firstOrNull?.id; + if (isolateId != null) { + await service.callServiceExtension( + 'ext.flutter.inspector.disposeGroup', + isolateId: isolateId, + args: const {'objectGroup': objectGroup}, + ); + } + } catch (_) { + // The screenshot is already safely written; cleanup is best effort. + } + await service.dispose(); + } +} + +String flutterRenderWebSocketUri(Uri serviceUri) { + final path = serviceUri.path.endsWith('/ws') + ? serviceUri.path + : serviceUri.path.endsWith('/') + ? '${serviceUri.path}ws' + : '${serviceUri.path}/ws'; + return serviceUri + .replace( + scheme: switch (serviceUri.scheme) { + 'https' => 'wss', + 'wss' => 'wss', + _ => 'ws', + }, + path: path, + ) + .toString(); +} diff --git a/tools/windows_dll/sqlite3.dll b/tools/windows_dll/sqlite3.dll new file mode 100644 index 00000000..1544c2db Binary files /dev/null and b/tools/windows_dll/sqlite3.dll differ diff --git a/tools/windows_tools/Loftify.iss b/tools/windows_tools/Loftify.iss index 5f04e0bc..73004f19 100644 --- a/tools/windows_tools/Loftify.iss +++ b/tools/windows_tools/Loftify.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "Loftify" -#define MyAppVersion "2.4.2" +#define MyAppVersion "3.0.0" #define MyAppPublisher "Cloudchewie" #define MyAppURL "https://apps.cloudchewie.com/loftify" #define MyAppExeName "Loftify.exe" diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 00dcc55f..0cea4c54 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -39,7 +39,7 @@ add_definitions(-DUNICODE -D_UNICODE) # of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100" /utf-8) target_compile_options(${TARGET} PRIVATE /EHsc) target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 2005eb9a..403fc21d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -22,6 +24,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( @@ -34,6 +38,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ProtocolHandlerWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ProtocolHandlerWindowsPluginCApi")); + RestartAppPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RestartAppPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 56aca232..c22a27e2 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,12 +4,14 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + connectivity_plus flutter_inappwebview_windows hotkey_manager_windows local_auth_windows local_notifier permission_handler_windows protocol_handler_windows + restart_app screen_retriever_windows share_plus tray_manager @@ -18,6 +20,8 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES)