107 changed files with 3185 additions and 915 deletions

No files matched your search

@@ -0,0 +1,28 @@
# ReelShort 客户端深色视觉改造
## 目标
- 深色设为默认且唯一主题,页面、卡片、浮层、播放器和底部导航保持一致。
- 色板收敛到 `AppColors`,主题组件配置收敛到 `AppTheme`。
- 保持现有布局、信息结构、路由、业务逻辑和文案不变。
## 结果
- 背景 `#0B1020`、卡片 `#151B2E`、浮层 `#1C2436`、分割线白色 6%。
- 主文字白色 92%、次文字 64%、禁用 38%。
- 主色 `#FF6B2C`,辅助链接色 `#22D3EE`。
- 卡片 12、按钮/输入框 10、标签 6,阴影关闭,组件使用分割线分层。
- 页面级透明、遮罩、阴影、媒体底、玻璃面和渐变全部映射到 AppColors 语义 token。
- 系统栏统一浅色图标,AppBar 与底部导航使用深色表面。
## 验证
- 主题 token、对比度、系统栏、圆角与关键页 smoke 测试。
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 约束
- 不提交 `pubspec.lock` 漂移。
- 不启停服务、不操作真机。
+173
View File
@@ -730,3 +730,176 @@ $ flutter test --no-pub
解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致;
全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。
`pubspec.lock` 既有改动未提交。
## 2026-10-22 ReelShort 客户端深色视觉改造
- 分支:`feature/dark-theme-client`
- 基线:开发前已有约 43 个未提交视觉文件,本次在原改动上继续收敛,未推倒重来。
- 计划:`docs/plans/2026-10-22-dark-theme-client.md`
### 颜色与主题
- `AppTheme.dark()` 成为默认且唯一主题,系统状态栏/导航栏图标统一浅色。
- 色板统一为背景 `#0B1020`、卡片 `#151B2E`、浮层 `#1C2436`、
divider `#0FFFFFFF`、主色 `#FF6B2C`、辅助链接色 `#22D3EE`。
- 主/次/禁用文字分别使用白色 92% / 64% / 38%。
- 卡片/按钮/输入框/标签圆角统一为 12/10/10/6,卡片阴影关闭。
- 页面和组件内的 `Colors.*`、`Color(0x...)` 已全部映射到 `AppColors`
语义 token;剩余原始色值只在 `app_colors.dart` 中定义。
- 导航、玻璃面板、播放器遮罩、加载轨道、渐变、边框和阴影均改为深色语义。
- 修正全局输入装饰 padding 导致账户性别下拉溢出,保持原布局宽度。
### 验证命令与原始结果
```text
$ git checkout -- pubspec.lock
$ git status --short --branch
## feature/dark-theme-client
$ rg -n -P "Color\(0x|(?<!App)Colors\." lib | rg -v '^lib/core/theme/'
(no output)
$ rg -n "SystemUiOverlayStyle\.dark" lib
(no output)
```
解读:pubspec.lock 漂移已还原;页面/组件层不再直接写颜色,所有状态栏均使用浅色图标。
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
```
```text
$ flutter test --no-pub
00:51 +644: All tests passed!
```
```text
$ flutter build apk --debug
Running Gradle task 'assembleDebug'...
2,058ms
✓ Built build/app/outputs/flutter-apk/app-debug.apk
```
解读:静态分析零告警,644 项测试全部通过,debug APK 构建成功;测试与构建后再次确认
`pubspec.lock` 无改动,未启停服务、未操作真机。
## 2026-10-22 客户端底部导航改为首页/推荐/追剧/我的
- 分支:`feature/client-bottom-nav-4tabs`
- 基线:`develop` / `origin/develop` = `ec3cb8f`
- 计划:`docs/plans/2026-10-22-client-bottom-nav-4tabs.md`
- 实现提交:`91e869d feat(nav): 底部导航改为首页推荐追剧我的`
### 问题
根导航仍为首页/剧场/创作/我的,底栏选中态使用品牌橙。追剧若通过新路由打开,
会叠加第二层底栏并产生返回栈,不符合参考图和单根导航交互。
### 改动
- 新增 `AppRootTab` 统一四个 tab 的顺序、key、图标和 l10n 标签。
- `AppBottomNav` 改为纯黑底、白色选中、`#8A8A8E` 未选中,图标 24、文字 11,
保留 SafeArea 与轻震动。
- 首页 `IndexedStack` 第三槽直接使用 `FavoritesContent`,不再 push 收藏路由。
追剧保留顶部标题区、隐藏返回按钮,只有一个根底栏,不创建嵌套 Scaffold。
- `FavoritesPage` 保留独立 Scaffold 和返回行为,继续供“我的”入口使用。
- 推荐、追剧、我的三个根页面统一调用 `HomeLogic.select` 切换,不进入返回栈。
- 创作页保留,`mine-creator-center` 入口保留;创作不再出现在根底栏。
- 中文使用“追剧”,英文使用 `Home / Recommend / Following / My`,
印尼语使用 `Beranda / Rekomendasi / Daftar Tonton / Saya`。
- `FavoritesContent` 内置顶部 SafeArea,避免嵌入首页后被状态栏遮挡。
### 门禁命令与原始输出
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
```
```text
$ flutter test --no-pub
04:38 +644: All tests passed!
```
导航测试覆盖:四个 tab 数量与顺序、纯白/灰色状态、点击追剧进入
`FavoritesContent`、仅一个 `AppBottomNav`、追剧内部无嵌套 Scaffold、
`Navigator.canPop == false`、创作不再出现在底栏且我的入口保留。
解读:静态分析零告警,644 项测试全部通过;追剧与首页/推荐/我的均在同一根页面
内切换,无叠加导航或返回栈。`pubspec.lock` 未提交,未启动或停止服务,未操作真机。
## 2026-10-22 根导航布局规格对齐
- 分支:`feature/client-bottom-nav-layout`
- 基线:`develop` / `origin/develop` = `667fdc5`
- 实现提交:`81787dc style(nav): 对齐四 Tab 底栏布局规格`
### 改动
- 新增 `AppRootBottomNav` 作为首页、推荐、追剧、我的唯一根导航入口,
三个 Shell 页面不再各自拼装 item。
- 四列使用 `Expanded` 等分,图标在上、文字在下,间距 4。
- 内容高度 56,另由 `SafeArea` 增加底部安全区;图标 26、文字 11、
`letterSpacing = 0`,选中 `w600`、未选中 `w400`。
- 背景纯黑,取消顶部边框;选中 `#FFFFFF`,未选中 `#8E8E93`。
- 首页、推荐、书签、人像继续使用各自的选中实心/未选中描边图标。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found! (ran in 5.2s)
```
```text
$ flutter test
04:43 +645: All tests passed!
```
解读:四等分、56 内容高度、安全区、26 图标、4 间距、精确色值和无分割线
均有测试覆盖;645 项测试全部通过。`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 首页改为三列探索网格
- 分支:`feature/home-grid-layout`
- 基线:`develop` / `origin/develop` = `6dea9d4`
- 实现提交:`dd96792 feat(home): 首页改为三列探索网格`
### 布局与数据
- 首页按参考图改为纯黑探索页:搜索框、皇冠、礼物入口、一级分类、
二级标签与三列封面网格。
- `HomeFeedController` 继续使用 10 条游标分页;支持下拉刷新、触底加载、
空态与失败重试,没有全量拉取。
- 一级固定项为精选/新剧/分类/榜单;古装/社群仅在 `/app/categories`
名称匹配时显示。新剧/分类/榜单复用现有页面。
- 二级标签按分类与题材可用性显示,筛选按钮复用 `FilterPage`。
- 卡片封面 5:6、圆角 8、间距 8;包含真实热剧/新剧/漫剧角标、
播放量、最多两行标题与最多两个题材标签。
- `Drama` 增加 `dramaType` 和可选 `createdAt`;卡片未下发上架时间时
从 Snowflake id 解析,不使用假数据。
- 原 `PlayerPage(homeMode: true)` 保留并迁至推荐 tab,播放器文件与测试未删除。
- 首页顶部中性色、卡片与描边 token 已沉淀到 `AppColors/AppDimens`,
后续 P14 复用。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found! (ran in 2.7s)
```
```text
$ flutter test
02:32 +651: All tests passed!
```
解读:三列网格、分类顺序、动态入口隐藏、角标、卡片信息、分页大小、
下一页追加和卡片进入播放器均有测试覆盖。`home_view.dart` 为 478 行,
其余新增组件均低于 500 行。`pubspec.lock` 未提交,未操作真机。
+10 -8
View File
@@ -53,13 +53,15 @@ import 'core/theme/app_colors.dart';
import 'l10n/app_localizations.dart';
import 'pages/home/home_view.dart';
/// 全局系统栏样式:浅暖导航栏、关闭 Android 对比遮罩。
final SystemUiOverlayStyle appSystemOverlayStyle = SystemUiOverlayStyle.dark
/// 全局系统栏样式:深色底 + 浅色图标,关闭 Android 对比遮罩。
final SystemUiOverlayStyle appSystemOverlayStyle = SystemUiOverlayStyle.light
.copyWith(
statusBarColor: Colors.transparent,
statusBarColor: AppColors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: AppColors.ink,
systemNavigationBarDividerColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
);
@@ -280,9 +282,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
localeResolutionCallback: (deviceLocale, _) =>
resolveAppLocale(deviceLocale),
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.light,
theme: AppTheme.light(),
darkTheme: AppTheme.light(),
themeMode: ThemeMode.dark,
theme: AppTheme.dark(),
darkTheme: AppTheme.dark(),
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
value: appSystemOverlayStyle,
child: child ?? const SizedBox.shrink(),
+12
View File
@@ -86,6 +86,8 @@ class Drama {
this.favoriteCount,
this.commentCount,
this.language = '',
this.dramaType = '',
this.createdAt,
this.detailLoaded = false,
});
@@ -109,6 +111,12 @@ class Drama {
/// 卡片语种(如 zh-CN / id),用于剧场语言筛选。
final String language;
/// 内容类型:ANIME 漫剧,LIVE 真人剧。
final String dramaType;
/// 上架时间;卡片接口未直接下发时可由 Snowflake id 估算。
final DateTime? createdAt;
/// 详情接口是否已经取过完整剧集列表。
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
final bool detailLoaded;
@@ -137,6 +145,8 @@ class Drama {
favoriteCount: _optionalInt(json['favoriteCount']),
commentCount: _optionalInt(json['commentCount']),
language: json['language'] as String? ?? '',
dramaType: json['dramaType'] as String? ?? '',
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
);
}
@@ -157,6 +167,8 @@ class Drama {
favoriteCount: _optionalInt(json['favoriteCount']),
commentCount: _optionalInt(json['commentCount']),
language: json['language'] as String? ?? '',
dramaType: json['dramaType'] as String? ?? '',
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
detailLoaded: true,
episodes: (json['episodes'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
+8 -3
View File
@@ -40,7 +40,7 @@ class WatchApi {
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看分页(服务端已按更新时间倒序)。
/// 最近观看分页:`pageIndex`/`pageSize`,解包 `PageResult`。
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
@@ -49,8 +49,13 @@ class WatchApi {
'/app/watch/recent',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
return PageResult.parse(data, WatchProgress.fromJson);
if (data is! Map) {
return const PageResult();
}
return PageResult.fromJson(
Map<String, dynamic>.from(data),
WatchProgress.fromJson,
);
}
/// 删除某部剧的观看记录(记录页清单条用)。
+100 -43
View File
@@ -2,107 +2,164 @@
import 'package:flutter/material.dart';
/// K28-S 浅色暖橙主题色板。
/// 深色视觉色板。
///
/// 保留既有语义名,避免页面大范围改名;实际颜色统一由本文件控制。
abstract final class AppColors {
/// 主操作、选中态、播放进度。
static const blue = Color(0xFFFF7A1A);
/// 透明与媒体黑底。
static const transparent = Colors.transparent;
static const mediaBlack = Color(0xFF000000);
/// 阴影与遮罩层级。
static const shadowSubtle = Color(0x14000000);
static const shadowSoft = Color(0x33000000);
static const shadow = Color(0x8A000000);
static const shadowStrong = Color(0xE6000000);
static const scrim = Color(0xD1000000);
static const controlScrim = Color(0x80000000);
static const controlSoftScrim = Color(0x42000000);
static const scrubPreviewScrim = Color(0xDE000000);
static const overlaySoft = Color(0x66000000);
static const overlayBar = Color(0x4D000000);
static const overlayMedium = Color(0x99000000);
static const headerScrim = Color(0x59000000);
/// 主操作、选中态、播放进度(品牌橙)。
static const blue = Color(0xFFFF6B2C);
static const primary = blue;
/// 主操作按下态。
static const bluePressed = Color(0xFFFF6A00);
static const bluePressed = Color(0xFFE55A1F);
/// 粉色辅助强调色。
static const blueSoft = Color(0xFFFF6A8A);
/// 链接与次要高亮(禁止大面积铺色)。
static const blueSoft = Color(0xFF22D3EE);
static const secondary = blueSoft;
static const lavender = blueSoft;
/// 金币与奖励。
static const gold = Color(0xFFF3B34C);
static const goldDeep = Color(0xFFD9A23A);
static const bronze = Color(0xFFCD7F32);
static const bronzeDeep = Color(0xFFA9713A);
/// 成功状态。
static const success = Color(0xFF2FA873);
/// 收藏和喜欢使用粉色强调。
static const favorite = secondary;
/// 错误与危险操作。
static const error = Color(0xFFFF6B6B);
/// 收藏与喜欢(非主色铺色,独立强调)。
static const favorite = Color(0xFFFF6A8A);
/// 金币底色上的文字。
static const onGold = Color(0xFF5A3511);
static const onGold = Color(0xFF3D2408);
/// 主色上的文字与图标。
static const onPrimary = Color(0xFFFFFFFF);
/// 页面背景。
static const ink = Color(0xFFFFF8F0);
static const ink = Color(0xFF0B1020);
/// 首页/追剧/我的统一的纯黑内容背景。
static const appBackground = Color(0xFF000000);
/// 中性深灰卡片与控件表面。
static const contentCard = Color(0xFF171717);
static const contentSurface = Color(0xFF1C1C1E);
static const contentChip = Color(0xFF2C2C2E);
static const contentBorder = Color(0x1AFFFFFF);
/// 次级文字与列表 chevron。
static const textSecondary = Color(0xFF8E8E93);
/// 原生开屏与冷启动过渡底色。
static const splashInk = Color(0xFF1F1B16);
static const splashInk = ink;
/// 页面顶部暖色渐变起点。
static const surfaceTop = Color(0xFFFFE9D6);
/// 顶部与页面底同色,靠容器层次区分。
static const surfaceTop = ink;
/// 底部导航底色。
static const bar = Color(0xFFFFFDFB);
static const bar = ink;
/// 卡片轻阴影。
static const cardShadow = Color(0x14FF8A3D);
/// 底部导航专用:纯黑背景、选中白与未选中灰。
static const navBackground = Color(0xFF000000);
static const navSelected = Color(0xFFFFFFFF);
/// 卡片与弹层。
static const card = Color(0xFFFFFFFF);
/// 卡片阴影(深色几乎不用阴影)。
static const cardShadow = Color(0x00000000);
/// 卡片与列表容器。
static const card = Color(0xFF151B2E);
/// 次要卡片表面。
static const cardAlt = Color(0xFFFFF4E9);
static const cardAlt = card;
/// 浮层、输入框和嵌套区域。
static const raised = Color(0xFFFFF1E5);
static const quick = Color(0xFFFFF0E1);
static const input = Color(0xFFFFF4EA);
/// 浮层、弹窗、输入与嵌套区域。
static const raised = Color(0xFF1C2436);
static const quick = raised;
static const input = raised;
/// 评论输入框底色。
static const commentInput = Color(0xFFF5EFE8);
static const goldCard = Color(0xFFFFF4DF);
static const commentInput = raised;
static const goldCard = Color(0xFF2A2418);
/// 正文颜色。
static const cream = Color(0xFF1F1B16);
/// 正文颜色(与主文字同级)。
static const cream = Color(0xEBFFFFFF);
/// 主文字。
static const onSurface = Color(0xFF1F1B16);
/// 主文字 rgba(255,255,255,0.92)。
static const onSurface = Color(0xEBFFFFFF);
/// 次要文字。
static const muted = Color(0xFF8C837B);
/// 次要文字 rgba(255,255,255,0.64)。
static const muted = Color(0xA3FFFFFF);
/// 弱提示与时间。
static const subtle = Color(0xFFB3AAA1);
/// 弱提示与禁用 rgba(255,255,255,0.38)。
static const subtle = Color(0x61FFFFFF);
/// 橙色强调文案。
static const accent = blue;
/// 卡片描边。
static const border = Color(0xFFF0E4D6);
/// 分割线 rgba(255,255,255,0.06)。
static const border = Color(0x0FFFFFFF);
/// 玻璃面板描边。
static const glassBorder = Color(0x66FFFFFF);
static const glassBorder = Color(0x33FFFFFF);
static const glassSurfaceStart = Color(0xEB1C2436);
static const glassSurfaceEnd = Color(0xD1151B2E);
/// 粉色角标。
static const badgePink = Color(0xFFFFE3EC);
static const badgePinkText = Color(0xFFFF6A8A);
/// 角标底与文字。
static const badgePink = Color(0x33FF6A8A);
static const badgePinkText = favorite;
/// 封面锁定数量与视频遮罩。
static const overlayDark = Color(0x33000000);
static const overlayDark = Color(0x66000000);
/// 封面集数胶囊。
static const badgeBeige = Color(0xFFFFF0DF);
static const badgeBeige = raised;
/// 导航未选中。
static const navUnselected = muted;
static const navUnselected = textSecondary;
/// 首页内容角标。
static const badgeHot = Color(0xFFE5484D);
static const badgeNew = Color(0xFF3B82F6);
static const badgeAnime = Color(0xFF8B5CF6);
/// 播放器次要文字。
static const playerMuted = Color(0xFFBBBBBB);
static const playerMuted = Color(0xA3FFFFFF);
/// 播放器控件胶囊与圆形底色。
static const playerOverlayPill = Color(0xB3000000);
/// 进度条未激活轨道。
static const inactiveTrack = Color(0x24FFFFFF);
/// 横幅/封面底部渐变。
static const bannerScrimMid = Color(0x8A0B1020);
static const bannerScrimEnd = Color(0xE6000000);
/// 票券虚线等主色描边。
static const primaryBorder = Color(0x66FF6B2C);
static const outlineDark = border;
static const outlineVariantDark = border;
static const outlineLight = muted;
+32 -15
View File
@@ -9,18 +9,20 @@ abstract final class AppDimens {
static const double spaceXl = 24;
static const double pageHorizontal = 16;
static const double cardRadius = 16;
static const double authCardRadius = 20;
static const double cardRadius = 12;
static const double authCardRadius = 12;
static const double cardPadding = 16;
static const double innerRadius = 12;
static const double innerRadius = 10;
static const double innerPadding = 12;
static const double pillRadius = 999;
static const double glassSmallRadius = 16;
static const double glassRadius = 20;
static const double glassCardRadius = 20;
static const double buttonRadius = 999;
static const double coverRadius = 16;
static const double sheetRadius = 20;
static const double glassSmallRadius = 12;
static const double glassRadius = 12;
static const double glassCardRadius = 12;
static const double buttonRadius = 10;
static const double inputRadius = 10;
static const double chipRadius = 6;
static const double coverRadius = 12;
static const double sheetRadius = 12;
static const double caption = 12;
static const double emptyStateSubtitle = 12.5;
@@ -34,7 +36,8 @@ abstract final class AppDimens {
static const double previewCardTitle = 13;
static const double previewSectionTitle = 14;
static const double chipFont = 10;
static const double navLabel = 12;
static const double navLabel = 11;
static const double navLabelGap = 4;
static const double buttonHeight = 48;
static const double primaryButtonHeight = 52;
@@ -45,8 +48,8 @@ abstract final class AppDimens {
static const double tapTarget = 44;
static const double contentHeaderHeight = 46;
static const double navHeight = 68;
static const double navIcon = 24;
static const double navHeight = 56;
static const double navIcon = 26;
static const double progressHeight = 3;
static const double progressRadius = 2;
static const double progressLineHeight = 3;
@@ -61,13 +64,27 @@ abstract final class AppDimens {
static const double watchPlaceholderHeight = 114;
static const double heroBannerHeight = 240;
static const double heroBannerRadius = 20;
static const double heroBannerRadius = 12;
static const double heroTitle = 28;
static const double heroSubtitle = 14;
static const double cardMeta = 12;
static const double cardViews = 11;
static const double cardTitle = 15;
static const double cardShadowBlur = 18;
static const double cardShadowY = 6;
static const double cardShadowBlur = 0;
static const double cardShadowY = 0;
/// 首页探索网格。
static const double homeGridGap = 8;
static const double homeCardRadius = 8;
static const double homeCardPadding = 8;
static const double homeCardTitle = 13;
static const double homeCardMeta = 10;
static const double homeCardBadge = 9;
static const double homeTagFont = 10;
static const double homeCardBodyExtent = 84;
static const double homeSectionFont = 14;
static const double homeSectionIndicator = 2;
static const double homeIconSlot = 44;
static const double homeHeaderIcon = 22;
}
+140 -24
View File
@@ -6,9 +6,9 @@ import 'package:flutter/services.dart';
import 'app_colors.dart';
import 'app_dimens.dart';
/// K28-S 浅色暖橙主题。
/// 深色视觉主题(默认且唯一)。
abstract final class AppTheme {
/// 页面顶部暖色到米白背景的渐变。
/// 页面背景:深色扁平,靠容器层次区分。
static const LinearGradient pageGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
@@ -16,36 +16,50 @@ abstract final class AppTheme {
stops: [0, 0.24],
);
/// 卡片轻阴影。
/// 卡片阴影关闭,改用底色与分割线。
static const BoxShadow cardShadow = BoxShadow(
color: AppColors.cardShadow,
blurRadius: AppDimens.cardShadowBlur,
offset: Offset(0, AppDimens.cardShadowY),
);
/// 全局浅色暖橙主题。
static ThemeData light() {
const scheme = ColorScheme.light(
static final BorderRadius _buttonRadius = BorderRadius.circular(
AppDimens.buttonRadius,
);
static final BorderRadius _cardRadius = BorderRadius.circular(
AppDimens.cardRadius,
);
static final BorderRadius _inputRadius = BorderRadius.circular(
AppDimens.inputRadius,
);
/// 全局深色主题。
static ThemeData dark() {
const scheme = ColorScheme.dark(
primary: AppColors.blue,
onPrimary: AppColors.onPrimary,
secondary: AppColors.secondary,
onSecondary: AppColors.onPrimary,
primaryContainer: AppColors.surfaceTop,
onSecondary: AppColors.ink,
primaryContainer: AppColors.raised,
onPrimaryContainer: AppColors.onSurface,
secondaryContainer: AppColors.badgePink,
onSecondaryContainer: AppColors.badgePinkText,
surface: AppColors.ink,
onSurface: AppColors.onSurface,
surfaceContainerHighest: AppColors.raised,
outline: AppColors.border,
outlineVariant: AppColors.border,
surfaceTint: AppColors.blue,
surfaceTint: AppColors.transparent,
inversePrimary: AppColors.bluePressed,
error: AppColors.error,
onError: AppColors.onPrimary,
);
final base = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
brightness: Brightness.dark,
colorScheme: scheme,
scaffoldBackgroundColor: AppColors.ink,
canvasColor: AppColors.ink,
fontFamily: 'PlusJakartaSans',
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.blue,
@@ -61,7 +75,23 @@ abstract final class AppTheme {
unselectedLabelColor: AppColors.muted,
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppColors.blue),
style: TextButton.styleFrom(
foregroundColor: AppColors.secondary,
shape: RoundedRectangleBorder(borderRadius: _buttonRadius),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(_buttonColor),
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
elevation: const WidgetStatePropertyAll(0),
minimumSize: const WidgetStatePropertyAll(
Size(0, AppDimens.buttonHeight),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: _buttonRadius),
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
@@ -70,7 +100,9 @@ abstract final class AppTheme {
minimumSize: const WidgetStatePropertyAll(
Size(0, AppDimens.buttonHeight),
),
shape: const WidgetStatePropertyAll(StadiumBorder()),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: _buttonRadius),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
@@ -82,14 +114,46 @@ abstract final class AppTheme {
side: const WidgetStatePropertyAll(
BorderSide(color: AppColors.border),
),
shape: const WidgetStatePropertyAll(StadiumBorder()),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: _buttonRadius),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.input,
hintStyle: const TextStyle(color: AppColors.subtle),
labelStyle: const TextStyle(color: AppColors.muted),
border: OutlineInputBorder(
borderRadius: _inputRadius,
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: _inputRadius,
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: _inputRadius,
borderSide: const BorderSide(color: AppColors.blue),
),
),
chipTheme: ChipThemeData(
backgroundColor: AppColors.raised,
selectedColor: AppColors.blue.withValues(alpha: 0.22),
labelStyle: const TextStyle(color: AppColors.onSurface),
secondaryLabelStyle: const TextStyle(color: AppColors.muted),
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDimens.chipRadius),
),
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.ink,
backgroundColor: AppColors.card,
foregroundColor: AppColors.onSurface,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.dark,
scrolledUnderElevation: 0,
surfaceTintColor: AppColors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.light,
titleTextStyle: TextStyle(
color: AppColors.onSurface,
fontFamily: 'Outfit',
@@ -98,19 +162,47 @@ abstract final class AppTheme {
),
),
iconTheme: const IconThemeData(color: AppColors.onSurface),
dividerTheme: const DividerThemeData(color: AppColors.border),
cardTheme: const CardThemeData(
dividerTheme: const DividerThemeData(
color: AppColors.border,
thickness: 1,
space: 1,
),
cardTheme: CardThemeData(
color: AppColors.card,
elevation: 0,
shadowColor: AppColors.transparent,
surfaceTintColor: AppColors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppDimens.cardRadius)),
side: BorderSide(color: AppColors.border),
borderRadius: _cardRadius,
side: const BorderSide(color: AppColors.border),
),
),
dialogTheme: DialogThemeData(
backgroundColor: AppColors.raised,
surfaceTintColor: AppColors.transparent,
shape: RoundedRectangleBorder(borderRadius: _cardRadius),
titleTextStyle: const TextStyle(
color: AppColors.onSurface,
fontFamily: 'Outfit',
fontSize: AppDimens.sectionTitle,
fontWeight: FontWeight.w700,
),
contentTextStyle: const TextStyle(
color: AppColors.muted,
fontSize: AppDimens.body,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.navBackground,
selectedItemColor: AppColors.navSelected,
unselectedItemColor: AppColors.navUnselected,
elevation: 0,
type: BottomNavigationBarType.fixed,
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Colors.transparent,
modalBackgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
backgroundColor: AppColors.transparent,
modalBackgroundColor: AppColors.transparent,
surfaceTintColor: AppColors.transparent,
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
@@ -124,8 +216,13 @@ abstract final class AppTheme {
),
),
snackBarTheme: const SnackBarThemeData(
backgroundColor: AppColors.onSurface,
contentTextStyle: TextStyle(color: AppColors.onPrimary),
backgroundColor: AppColors.raised,
contentTextStyle: TextStyle(color: AppColors.onSurface),
actionTextColor: AppColors.secondary,
),
listTileTheme: const ListTileThemeData(
iconColor: AppColors.onSurface,
textColor: AppColors.onSurface,
),
);
final textTheme = base.textTheme
@@ -142,10 +239,29 @@ abstract final class AppTheme {
headlineMedium: _display(base.textTheme.headlineMedium),
headlineSmall: _display(base.textTheme.headlineSmall),
titleLarge: _display(base.textTheme.titleLarge),
bodyLarge: base.textTheme.bodyLarge?.copyWith(
color: AppColors.onSurface,
),
bodyMedium: base.textTheme.bodyMedium?.copyWith(
color: AppColors.onSurface,
),
bodySmall: base.textTheme.bodySmall?.copyWith(color: AppColors.muted),
labelLarge: base.textTheme.labelLarge?.copyWith(
color: AppColors.onSurface,
),
labelMedium: base.textTheme.labelMedium?.copyWith(
color: AppColors.muted,
),
labelSmall: base.textTheme.labelSmall?.copyWith(
color: AppColors.subtle,
),
);
return base.copyWith(textTheme: textTheme);
}
/// 兼容旧调用点,统一落到深色主题。
static ThemeData light() => dark();
static TextStyle? _display(TextStyle? style) =>
style?.copyWith(fontFamily: 'Outfit', color: AppColors.onSurface);
+15 -34
View File
@@ -1,7 +1,5 @@
// Author: fruit.com.cn
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
@@ -26,53 +24,32 @@ class AppBottomNavItem {
final bool selected;
}
/// 四槽底部导航:首页 feed 用深色样式,其它页面保持浅色暖橙风格。
/// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。
class AppBottomNav extends StatelessWidget {
const AppBottomNav({
super.key,
required this.items,
this.backgroundColor = AppColors.bar,
this.barKey,
this.dark = false,
});
const AppBottomNav({super.key, required this.items, this.barKey});
final List<AppBottomNavItem> items;
final Color backgroundColor;
final Key? barKey;
final bool dark;
@override
Widget build(BuildContext context) {
final bar = Container(
key: barKey,
height: AppDimens.navHeight,
decoration: BoxDecoration(
color: dark ? Colors.black : backgroundColor.withValues(alpha: 0.95),
border: Border(
top: BorderSide(color: dark ? Colors.white12 : AppColors.border),
),
),
color: AppColors.navBackground,
child: Row(
children: [
for (final item in items)
_NavDestination(
item: item,
unselectedColor: dark ? Colors.white70 : AppColors.navUnselected,
unselectedColor: AppColors.navUnselected,
),
],
),
);
if (dark) {
return ColoredBox(
color: Colors.black,
child: SafeArea(top: false, child: bar),
);
}
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: SafeArea(top: false, child: bar),
),
return ColoredBox(
color: AppColors.navBackground,
child: SafeArea(top: false, child: bar),
);
}
}
@@ -104,18 +81,22 @@ class _NavDestination extends StatelessWidget {
child: Icon(
item.selected ? item.selectedIcon : item.icon,
size: AppDimens.navIcon,
color: item.selected ? AppColors.blue : unselectedColor,
color: item.selected
? AppColors.navSelected
: unselectedColor,
),
),
const SizedBox(height: 3),
const SizedBox(height: AppDimens.navLabelGap),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDimens.navLabel,
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
color: item.selected ? AppColors.blue : unselectedColor,
fontWeight: item.selected ? FontWeight.w600 : FontWeight.w400,
color: item.selected
? AppColors.navSelected
: unselectedColor,
letterSpacing: 0,
),
),
+72
View File
@@ -0,0 +1,72 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import 'app_bottom_nav.dart';
/// 客户端根导航的固定顺序。
enum AppRootTab { home, recommend, following, mine }
extension AppRootTabUi on AppRootTab {
Key get tabKey => switch (this) {
AppRootTab.home => const Key('tab-home'),
AppRootTab.recommend => const Key('tab-recommend'),
AppRootTab.following => const Key('tab-follow'),
AppRootTab.mine => const Key('tab-mine'),
};
IconData get icon => switch (this) {
AppRootTab.home => Icons.home_outlined,
AppRootTab.recommend => Icons.smart_display_outlined,
AppRootTab.following => Icons.bookmark_border_rounded,
AppRootTab.mine => Icons.person_outline_rounded,
};
IconData get selectedIcon => switch (this) {
AppRootTab.home => Icons.home_rounded,
AppRootTab.recommend => Icons.smart_display_rounded,
AppRootTab.following => Icons.bookmark_rounded,
AppRootTab.mine => Icons.person_rounded,
};
String label(AppLocalizations l10n) => switch (this) {
AppRootTab.home => l10n.homeTab,
AppRootTab.recommend => l10n.recommendedTab,
AppRootTab.following => l10n.followingTab,
AppRootTab.mine => l10n.myTab,
};
}
/// 根导航唯一入口,确保四个主页面使用完全一致的底栏配置。
class AppRootBottomNav extends StatelessWidget {
const AppRootBottomNav({
super.key,
required this.selected,
required this.onSelected,
this.barKey,
});
final AppRootTab selected;
final ValueChanged<AppRootTab> onSelected;
final Key? barKey;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AppBottomNav(
barKey: barKey,
items: [
for (final tab in AppRootTab.values)
AppBottomNavItem(
tabKey: tab.tabKey,
icon: tab.icon,
selectedIcon: tab.selectedIcon,
label: tab.label(l10n),
selected: tab == selected,
onTap: () => onSelected(tab),
),
],
);
}
}
+2 -2
View File
@@ -37,7 +37,7 @@ Future<bool> ensureSignedIn(BuildContext context) async {
Text(
l10n.authGateTitle,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w800,
),
@@ -98,7 +98,7 @@ class AuthGatePanel extends StatelessWidget {
l10n.authGateTitle,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w800,
),
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../config/app_config.dart';
import '../config/server_endpoint.dart';
import '../theme/app_colors.dart';
const String _title = '设置服务地址';
const String _save = '保存';
@@ -94,7 +95,7 @@ class _CatalogEndpointDialogState extends State<_CatalogEndpointDialog> {
const Text(
_localNetworkHint,
key: Key('catalog-debug-endpoint-hint'),
style: TextStyle(fontSize: 12, color: Colors.grey),
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
if (_error != null) ...[
const SizedBox(height: 8),
+5 -5
View File
@@ -4,6 +4,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
const Key _toastKey = Key('centered-toast');
const Duration _fadeDuration = Duration(milliseconds: 200);
const Duration _visibleDuration = Duration(seconds: 2);
@@ -11,9 +13,8 @@ const double _toastRadius = 12;
const double _toastMaxWidth = 320;
const double _toastHorizontalInset = 20;
const double _toastVerticalInset = 12;
const double _toastBackgroundOpacity = 0.78;
const TextStyle _toastTextStyle = TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w500,
@@ -108,10 +109,9 @@ class _CenteredToastState extends State<_CenteredToast>
constraints: const BoxConstraints(maxWidth: _toastMaxWidth),
padding: _toastPadding,
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: _toastBackgroundOpacity,
),
color: AppColors.raised,
borderRadius: BorderRadius.circular(_toastRadius),
border: Border.all(color: AppColors.border),
),
child: Text(
widget.message,
+1 -2
View File
@@ -87,12 +87,11 @@ class _EmptyMark extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
return Icon(
icon,
key: const Key('empty-state-icon'),
size: compact ? _compactIconSize : _regularIconSize,
color: dark ? AppColors.gold : AppColors.blue,
color: AppColors.blueSoft,
);
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
/// 首页信息卡与浮层控件使用的浅色玻璃面板。
/// 首页信息卡与浮层控件使用的深色玻璃面板。
class GlassPanel extends StatelessWidget {
const GlassPanel({
super.key,
@@ -38,7 +38,7 @@ class GlassPanel extends StatelessWidget {
const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xE6FFFFFF), Color(0xB3FFFDFB)],
colors: [AppColors.glassSurfaceStart, AppColors.glassSurfaceEnd],
),
),
child: Padding(padding: padding ?? EdgeInsets.zero, child: child),
+1 -1
View File
@@ -16,7 +16,7 @@ class RemoteImage extends StatelessWidget {
this.showFallbackIcon = false,
this.fallbackIconKey,
this.fallbackAspectRatio,
this.fallbackColor = AppColors.bar,
this.fallbackColor = AppColors.raised,
});
final String path;
+15 -10
View File
@@ -16,9 +16,6 @@ const double _iconSize = 22;
const double _iconWeight = 600;
const double _borderWidth = 1;
const double _shadowBlur = 8;
const Color _lightShadowColor = Color(0x14000000);
const Color _darkBackground = Color(0x59000000);
const Color _darkShadowColor = Color(0x33000000);
const Offset _lightShadowOffset = Offset(0, 2);
/// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。
@@ -52,14 +49,14 @@ class AppSubpageCircleButton extends StatelessWidget {
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: dark ? _darkShadowColor : _lightShadowColor,
color: dark ? AppColors.shadowSoft : AppColors.shadowSubtle,
blurRadius: _shadowBlur,
offset: dark ? Offset.zero : _lightShadowOffset,
),
],
),
child: Material(
color: dark ? _darkBackground : AppColors.card,
color: dark ? AppColors.headerScrim : AppColors.card,
shape: CircleBorder(
side: dark
? BorderSide.none
@@ -75,7 +72,7 @@ class AppSubpageCircleButton extends StatelessWidget {
child: Center(
child: Icon(
icon,
color: dark ? Colors.white : AppColors.onSurface,
color: AppColors.onSurface,
size: _iconSize,
weight: _iconWeight,
),
@@ -123,6 +120,7 @@ class AppSubpageHeader extends StatelessWidget {
this.actions,
this.onBack,
this.dark = false,
this.showBack = true,
});
final String title;
@@ -131,11 +129,12 @@ class AppSubpageHeader extends StatelessWidget {
final Widget? actions;
final VoidCallback? onBack;
final bool dark;
final bool showBack;
@override
Widget build(BuildContext context) {
final foreground = dark ? Colors.white : AppColors.onSurface;
final secondary = dark ? Colors.white70 : AppColors.muted;
final foreground = AppColors.onSurface;
final secondary = AppColors.muted;
return SizedBox(
key: const Key('subpage-header'),
height: subtitle == null ? _height : _heightWithSubtitle,
@@ -143,8 +142,14 @@ class AppSubpageHeader extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: _horizontal),
child: Row(
children: [
AppSubpageBackButton(backKey: backKey, dark: dark, onBack: onBack),
const SizedBox(width: _backGap),
if (showBack) ...[
AppSubpageBackButton(
backKey: backKey,
dark: dark,
onBack: onBack,
),
const SizedBox(width: _backGap),
],
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
+19 -2
View File
@@ -162,7 +162,7 @@
"preferences": "Preferences",
"followingTab": "Following",
"animatedTab": "Animated",
"recommendedTab": "Recommended",
"recommendedTab": "Recommend",
"noCategoryDramas": "No dramas in this category yet.",
"allDramas": "All Dramas",
"allLanguages": "All languages",
@@ -697,5 +697,22 @@
"danmakuSettings": "Danmaku settings",
"offlineCache": "Offline cache",
"screenCast": "Cast",
"sleepTimer": "Sleep timer"
"sleepTimer": "Sleep timer",
"homeSearchHint": "Search dramas, actors, genres...",
"homeSectionFeatured": "Featured",
"homeSectionNew": "New",
"homeSectionCategories": "Categories",
"homeSectionRanking": "Charts",
"homeSectionCostume": "Costume",
"homeSectionCommunity": "Community",
"homeTagHot": "Hot",
"homeTagUplifting": "Thrilling",
"homeTagSweet": "Sweet",
"homeTagFaceSlap": "Revenge",
"homeTagAngst": "Angst",
"homeBadgeHot": "Hot",
"homeBadgeNew": "New",
"homeBadgeAnime": "Anime",
"homeGridFilterEmpty": "No dramas match these filters",
"homeGridClearFilter": "Clear filters"
}
+19 -2
View File
@@ -139,7 +139,7 @@
"completed": "Selesai",
"noSavedShows": "Belum ada tontonan di sini.",
"preferences": "Preferensi",
"followingTab": "Mengikuti",
"followingTab": "Daftar Tonton",
"animatedTab": "Animasi",
"recommendedTab": "Rekomendasi",
"noCategoryDramas": "Belum ada drama di kategori ini.",
@@ -654,5 +654,22 @@
"danmakuSettings": "Pengaturan komentar layar",
"offlineCache": "Cache offline",
"screenCast": "Cast",
"sleepTimer": "Timer tidur"
"sleepTimer": "Timer tidur",
"homeSearchHint": "Cari drama, aktor, genre...",
"homeSectionFeatured": "Unggulan",
"homeSectionNew": "Baru",
"homeSectionCategories": "Kategori",
"homeSectionRanking": "Peringkat",
"homeSectionCostume": "Kostum",
"homeSectionCommunity": "Komunitas",
"homeTagHot": "Populer",
"homeTagUplifting": "Membara",
"homeTagSweet": "Manis",
"homeTagFaceSlap": "Balas Dendam",
"homeTagAngst": "Cinta Penuh Luka",
"homeBadgeHot": "Populer",
"homeBadgeNew": "Baru",
"homeBadgeAnime": "Anime",
"homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter",
"homeGridClearFilter": "Hapus filter"
}
+103 -1
View File
@@ -685,7 +685,7 @@ abstract class AppLocalizations {
/// No description provided for @recommendedTab.
///
/// In en, this message translates to:
/// **'Recommended'**
/// **'Recommend'**
String get recommendedTab;
/// No description provided for @noCategoryDramas.
@@ -2997,6 +2997,108 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Sleep timer'**
String get sleepTimer;
/// No description provided for @homeSearchHint.
///
/// In en, this message translates to:
/// **'Search dramas, actors, genres...'**
String get homeSearchHint;
/// No description provided for @homeSectionFeatured.
///
/// In en, this message translates to:
/// **'Featured'**
String get homeSectionFeatured;
/// No description provided for @homeSectionNew.
///
/// In en, this message translates to:
/// **'New'**
String get homeSectionNew;
/// No description provided for @homeSectionCategories.
///
/// In en, this message translates to:
/// **'Categories'**
String get homeSectionCategories;
/// No description provided for @homeSectionRanking.
///
/// In en, this message translates to:
/// **'Charts'**
String get homeSectionRanking;
/// No description provided for @homeSectionCostume.
///
/// In en, this message translates to:
/// **'Costume'**
String get homeSectionCostume;
/// No description provided for @homeSectionCommunity.
///
/// In en, this message translates to:
/// **'Community'**
String get homeSectionCommunity;
/// No description provided for @homeTagHot.
///
/// In en, this message translates to:
/// **'Hot'**
String get homeTagHot;
/// No description provided for @homeTagUplifting.
///
/// In en, this message translates to:
/// **'Thrilling'**
String get homeTagUplifting;
/// No description provided for @homeTagSweet.
///
/// In en, this message translates to:
/// **'Sweet'**
String get homeTagSweet;
/// No description provided for @homeTagFaceSlap.
///
/// In en, this message translates to:
/// **'Revenge'**
String get homeTagFaceSlap;
/// No description provided for @homeTagAngst.
///
/// In en, this message translates to:
/// **'Angst'**
String get homeTagAngst;
/// No description provided for @homeBadgeHot.
///
/// In en, this message translates to:
/// **'Hot'**
String get homeBadgeHot;
/// No description provided for @homeBadgeNew.
///
/// In en, this message translates to:
/// **'New'**
String get homeBadgeNew;
/// No description provided for @homeBadgeAnime.
///
/// In en, this message translates to:
/// **'Anime'**
String get homeBadgeAnime;
/// No description provided for @homeGridFilterEmpty.
///
/// In en, this message translates to:
/// **'No dramas match these filters'**
String get homeGridFilterEmpty;
/// No description provided for @homeGridClearFilter.
///
/// In en, this message translates to:
/// **'Clear filters'**
String get homeGridClearFilter;
}
class _AppLocalizationsDelegate
+52 -1
View File
@@ -329,7 +329,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get animatedTab => 'Animated';
@override
String get recommendedTab => 'Recommended';
String get recommendedTab => 'Recommend';
@override
String get noCategoryDramas => 'No dramas in this category yet.';
@@ -1565,4 +1565,55 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sleepTimer => 'Sleep timer';
@override
String get homeSearchHint => 'Search dramas, actors, genres...';
@override
String get homeSectionFeatured => 'Featured';
@override
String get homeSectionNew => 'New';
@override
String get homeSectionCategories => 'Categories';
@override
String get homeSectionRanking => 'Charts';
@override
String get homeSectionCostume => 'Costume';
@override
String get homeSectionCommunity => 'Community';
@override
String get homeTagHot => 'Hot';
@override
String get homeTagUplifting => 'Thrilling';
@override
String get homeTagSweet => 'Sweet';
@override
String get homeTagFaceSlap => 'Revenge';
@override
String get homeTagAngst => 'Angst';
@override
String get homeBadgeHot => 'Hot';
@override
String get homeBadgeNew => 'New';
@override
String get homeBadgeAnime => 'Anime';
@override
String get homeGridFilterEmpty => 'No dramas match these filters';
@override
String get homeGridClearFilter => 'Clear filters';
}
+52 -1
View File
@@ -323,7 +323,7 @@ class AppLocalizationsId extends AppLocalizations {
String get preferences => 'Preferensi';
@override
String get followingTab => 'Mengikuti';
String get followingTab => 'Daftar Tonton';
@override
String get animatedTab => 'Animasi';
@@ -1565,4 +1565,55 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get sleepTimer => 'Timer tidur';
@override
String get homeSearchHint => 'Cari drama, aktor, genre...';
@override
String get homeSectionFeatured => 'Unggulan';
@override
String get homeSectionNew => 'Baru';
@override
String get homeSectionCategories => 'Kategori';
@override
String get homeSectionRanking => 'Peringkat';
@override
String get homeSectionCostume => 'Kostum';
@override
String get homeSectionCommunity => 'Komunitas';
@override
String get homeTagHot => 'Populer';
@override
String get homeTagUplifting => 'Membara';
@override
String get homeTagSweet => 'Manis';
@override
String get homeTagFaceSlap => 'Balas Dendam';
@override
String get homeTagAngst => 'Cinta Penuh Luka';
@override
String get homeBadgeHot => 'Populer';
@override
String get homeBadgeNew => 'Baru';
@override
String get homeBadgeAnime => 'Anime';
@override
String get homeGridFilterEmpty => 'Tidak ada drama yang cocok dengan filter';
@override
String get homeGridClearFilter => 'Hapus filter';
}
+52 -1
View File
@@ -318,7 +318,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get preferences => '偏好设置';
@override
String get followingTab => '关注';
String get followingTab => '追剧';
@override
String get animatedTab => '漫剧';
@@ -1541,4 +1541,55 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get sleepTimer => '定时关闭';
@override
String get homeSearchHint => '搜索短剧、演员、类型…';
@override
String get homeSectionFeatured => '精选';
@override
String get homeSectionNew => '新剧';
@override
String get homeSectionCategories => '分类';
@override
String get homeSectionRanking => '榜单';
@override
String get homeSectionCostume => '古装';
@override
String get homeSectionCommunity => '社群';
@override
String get homeTagHot => '热门';
@override
String get homeTagUplifting => '爽剧';
@override
String get homeTagSweet => '甜宠';
@override
String get homeTagFaceSlap => '打脸虐渣';
@override
String get homeTagAngst => '虐恋';
@override
String get homeBadgeHot => '热剧';
@override
String get homeBadgeNew => '新剧';
@override
String get homeBadgeAnime => '漫剧';
@override
String get homeGridFilterEmpty => '没有符合筛选条件的短剧';
@override
String get homeGridClearFilter => '清除筛选';
}
+19 -2
View File
@@ -139,7 +139,7 @@
"completed": "已看完",
"noSavedShows": "这里还没有剧集。",
"preferences": "偏好设置",
"followingTab": "关注",
"followingTab": "追剧",
"animatedTab": "漫剧",
"recommendedTab": "推荐",
"noCategoryDramas": "这个分类暂时没有剧集。",
@@ -616,5 +616,22 @@
"danmakuSettings": "弹幕设置",
"offlineCache": "离线缓存",
"screenCast": "投屏",
"sleepTimer": "定时关闭"
"sleepTimer": "定时关闭",
"homeSearchHint": "搜索短剧、演员、类型…",
"homeSectionFeatured": "精选",
"homeSectionNew": "新剧",
"homeSectionCategories": "分类",
"homeSectionRanking": "榜单",
"homeSectionCostume": "古装",
"homeSectionCommunity": "社群",
"homeTagHot": "热门",
"homeTagUplifting": "爽剧",
"homeTagSweet": "甜宠",
"homeTagFaceSlap": "打脸虐渣",
"homeTagAngst": "虐恋",
"homeBadgeHot": "热剧",
"homeBadgeNew": "新剧",
"homeBadgeAnime": "漫剧",
"homeGridFilterEmpty": "没有符合筛选条件的短剧",
"homeGridClearFilter": "清除筛选"
}
@@ -51,7 +51,7 @@ class _AccountDeactivatePageState extends State<AccountDeactivatePage> {
final signedIn = context.watch<AuthController>().isSignedIn;
final canShow = signedIn || _logic.scheduledAt != null;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('account-deactivate-page'),
backKey: const Key('account-deactivate-back'),
+1 -1
View File
@@ -51,7 +51,7 @@ class _AccountDevicesPageState extends State<AccountDevicesPage> {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('account-devices-page'),
backKey: const Key('account-devices-back'),
+1 -1
View File
@@ -52,7 +52,7 @@ class _AccountExportPageState extends State<AccountExportPage> {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('account-export-page'),
backKey: const Key('account-export-back'),
+2 -1
View File
@@ -70,7 +70,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('account-profile-page'),
backKey: const Key('account-profile-back'),
@@ -181,6 +181,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
Widget _genderField(AppLocalizations l10n) => DropdownButtonFormField<String>(
key: const Key('account-gender'),
isExpanded: true,
initialValue: _gender,
decoration: InputDecoration(labelText: l10n.accountGender),
items: [
+1 -1
View File
@@ -268,7 +268,7 @@ class _ModeSegment extends StatelessWidget {
duration: _segmentDuration,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.blue : Colors.transparent,
color: selected ? AppColors.blue : AppColors.transparent,
borderRadius: BorderRadius.circular(18),
),
child: Stack(
+1 -1
View File
@@ -258,7 +258,7 @@ class _AuthPageState extends State<AuthPage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.ink,
+1 -1
View File
@@ -141,7 +141,7 @@ class _AuthModeSwitch extends StatelessWidget {
style: TextButton.styleFrom(
minimumSize: const Size(0, _minTapTarget),
padding: EdgeInsets.zero,
overlayColor: Colors.transparent,
overlayColor: AppColors.transparent,
splashFactory: NoSplash.splashFactory,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
+2 -2
View File
@@ -28,8 +28,8 @@ Future<bool?> showPhoneAuthSheet(BuildContext context, {bool signUp = false}) {
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.62),
backgroundColor: AppColors.transparent,
barrierColor: AppColors.mediaBlack.withValues(alpha: 0.62),
builder: (_) => PhoneAuthSheet(initialSignUp: signUp),
);
}
+5 -5
View File
@@ -13,8 +13,8 @@ const double _arrowSize = 36;
const double _illustrationWidth = 156;
const double _illustrationHeight = 88;
const double _mottoLineWidth = 96;
const List<Color> _aiGradientStart = [Color(0xFFFFE3D0), Color(0xFFFFC9AC)];
const List<Color> _uploadGradient = [Color(0xFFFFEAD9), Color(0xFFFFD3B3)];
const List<Color> _aiGradientStart = [AppColors.raised, AppColors.goldCard];
const List<Color> _uploadGradient = [AppColors.card, AppColors.raised];
/// 创作页:AI 制作与上传发布两张主卡,底部品牌标语。
class CreatePage extends StatelessWidget {
@@ -24,7 +24,7 @@ class CreatePage extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: SafeArea(
bottom: false,
child: ListView(
@@ -131,7 +131,7 @@ class _CreateCard extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(_cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
@@ -296,7 +296,7 @@ class _UploadIllustration extends StatelessWidget {
width: 30,
height: 20,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.55),
color: AppColors.onSurface.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
+2 -2
View File
@@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 剧场页与根导航之间的动作。
enum DiscoverPageAction { home, create, my }
/// 推荐页与根导航之间的动作。
enum DiscoverPageAction { home, recommend, following, mine }
/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。
class DiscoverPageCallbacks {
+20 -42
View File
@@ -17,8 +17,8 @@ import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/catalog_debug_info.dart';
import '../../core/widgets/catalog_endpoint_editor.dart';
import '../../core/widgets/empty_state.dart';
@@ -35,8 +35,8 @@ import 'widgets/discover_header.dart';
import 'widgets/discover_search_results.dart';
import 'widgets/drama_grid.dart';
const Color _filterIconColor = Color(0xFF7B61FF);
const Color _newIconColor = Color(0xFF14B8A6);
const Color _filterIconColor = AppColors.blueSoft;
const Color _newIconColor = AppColors.blueSoft;
const double _toolbarHeight = 36;
const double _toolbarGap = 10;
const double _toolbarRadius = 10;
@@ -149,7 +149,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
key: const Key('theater-page-surface'),
color: AppColors.ink,
child: SafeArea(
bottom: !embedded,
bottom: false,
child: Column(
children: [
DiscoverHeader(
@@ -159,15 +159,15 @@ class _DiscoverPageState extends State<DiscoverPage> {
loading: logic.searchLoading,
),
Expanded(child: _feed(l10n, categories)),
if (!embedded) _bottomNav(l10n),
if (!embedded) _bottomNav(),
],
),
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
@@ -228,13 +228,13 @@ class _DiscoverPageState extends State<DiscoverPage> {
color: AppColors.ink,
border: Border(
bottom: BorderSide(
color: pinned ? AppColors.border : Colors.transparent,
color: pinned ? AppColors.border : AppColors.transparent,
),
),
boxShadow: pinned
? const [
BoxShadow(
color: Color(0x14000000),
color: AppColors.shadowSubtle,
blurRadius: 8,
offset: Offset(0, 2),
),
@@ -419,7 +419,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
_categoryUnderlineHeight,
),
painter: CategoryUnderlinePainter(
color: selected ? AppColors.blue : Colors.transparent,
color: selected ? AppColors.blue : AppColors.transparent,
),
),
],
@@ -611,39 +611,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
unawaited(playDramaFromCatalog(context, drama));
}
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('discover-nav-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
onTap: () => _act(DiscoverPageAction.home),
),
AppBottomNavItem(
tabKey: const Key('discover-nav-current'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.theaterTab,
selected: true,
onTap: () {},
),
AppBottomNavItem(
tabKey: const Key('discover-nav-create'),
icon: Icons.add_box_outlined,
selectedIcon: Icons.add_box_rounded,
label: l10n.createTab,
onTap: () => _act(DiscoverPageAction.create),
),
AppBottomNavItem(
tabKey: const Key('discover-nav-my'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
onTap: () => _act(DiscoverPageAction.my),
),
],
Widget _bottomNav() => AppRootBottomNav(
selected: AppRootTab.recommend,
onSelected: (tab) => _act(_actionOf(tab)),
);
DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) {
AppRootTab.home => DiscoverPageAction.home,
AppRootTab.recommend => DiscoverPageAction.recommend,
AppRootTab.following => DiscoverPageAction.following,
AppRootTab.mine => DiscoverPageAction.mine,
};
}
/// 分类 tab 的弧形选中线。
@@ -97,7 +97,7 @@ class _DiscoverSearchFieldState extends State<DiscoverSearchField> {
color: AppColors.card,
borderRadius: BorderRadius.circular(_searchRadius),
border: Border.all(
color: _focused ? AppColors.blue : Colors.transparent,
color: _focused ? AppColors.blue : AppColors.transparent,
),
),
child: Row(
+4 -4
View File
@@ -23,8 +23,8 @@ const double _tagChipFontSize = 11;
const double _heatFontSize = 9;
const double _heatIconSize = 11;
const int _maxTags = 3;
const Color _heatBackground = Color(0x99000000);
const Color _tagChipBackground = Color(0xFFF4F1EE);
const Color _heatBackground = AppColors.overlayMedium;
const Color _tagChipBackground = AppColors.raised;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 剧场/分类页共用的双列剧目网格。
@@ -231,11 +231,11 @@ class _DiscoverCoverImage extends StatelessWidget {
Widget _placeholder() {
return const ColoredBox(
color: AppColors.card,
color: AppColors.raised,
child: Center(
child: Icon(
Icons.image_not_supported_outlined,
color: AppColors.muted,
color: AppColors.subtle,
size: 32,
),
),
@@ -183,8 +183,8 @@ class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: drama == null
? const [Color(0x00000000), Color(0x8A0B0C10)]
: const [Color(0x00000000), Color(0xE6000000)],
? const [AppColors.transparent, AppColors.bannerScrimMid]
: const [AppColors.transparent, AppColors.bannerScrimEnd],
),
),
),
@@ -232,7 +232,7 @@ class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
child: dramaId == null
? card
: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: InkWell(
+9 -9
View File
@@ -56,7 +56,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('downloads-page'),
backKey: const Key('downloads-back'),
@@ -105,7 +105,7 @@ class _DownloadsBody extends StatelessWidget {
child: Text(
l10n.downloadedItems,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
@@ -236,7 +236,7 @@ class _PolicyPanel extends StatelessWidget {
Text(
l10n.downloadQuota(policy.remaining, policy.maxPerDevice),
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 17,
fontWeight: FontWeight.w800,
),
@@ -261,7 +261,7 @@ class _PolicyPanel extends StatelessWidget {
child: Text(
l10n.downloadWifiOnly,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: AppDimens.body,
),
),
@@ -279,7 +279,7 @@ class _PolicyPanel extends StatelessWidget {
child: Text(
l10n.downloadQuality,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: AppDimens.body,
),
),
@@ -375,7 +375,7 @@ class _LicenseTile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w700,
),
@@ -418,12 +418,12 @@ class _LicenseTile extends StatelessWidget {
),
Text(
l10n.downloadProgress(license.progressPercent),
style: const TextStyle(color: Colors.white70, fontSize: 12),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
if (license.quality.isNotEmpty)
Text(
license.quality,
style: const TextStyle(color: Colors.white70, fontSize: 12),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
),
@@ -433,7 +433,7 @@ class _LicenseTile extends StatelessWidget {
child: LinearProgressIndicator(
minHeight: AppDimens.progressHeight,
value: progress.clamp(0.0, 1.0),
backgroundColor: Colors.white24,
backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft),
),
),
@@ -88,15 +88,15 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('detail-system-overlay'),
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: AppColors.transparent,
systemNavigationBarColor: AppColors.ink,
systemNavigationBarDividerColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
),
child: Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: ListenableBuilder(
listenable: logic,
builder: (context, _) => _page(context),
@@ -330,7 +330,7 @@ class DramaEpisodeGrid extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_chipRadius),
side: BorderSide(
color: selected || locked ? Colors.transparent : AppColors.border,
color: selected || locked ? AppColors.transparent : AppColors.border,
),
),
clipBehavior: Clip.antiAlias,
+37 -25
View File
@@ -44,14 +44,33 @@ const double _loadMoreThreshold = 240;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 独立我的收藏页。
class FavoritesPage extends StatefulWidget {
class FavoritesPage extends StatelessWidget {
const FavoritesPage({super.key});
@override
State<FavoritesPage> createState() => _FavoritesPageState();
Widget build(BuildContext context) {
return Theme(
data: AppTheme.dark(),
child: Scaffold(
key: const Key('favorites-page'),
backgroundColor: AppColors.ink,
body: const SafeArea(child: FavoritesContent()),
),
);
}
}
class _FavoritesPageState extends State<FavoritesPage> {
/// 收藏列表主体;可独立使用,也可直接嵌入根导航。
class FavoritesContent extends StatefulWidget {
const FavoritesContent({super.key, this.showBack = true});
final bool showBack;
@override
State<FavoritesContent> createState() => _FavoritesContentState();
}
class _FavoritesContentState extends State<FavoritesContent> {
bool _requested = false;
@override
@@ -74,28 +93,21 @@ class _FavoritesPageState extends State<FavoritesPage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
child: Scaffold(
key: const Key('favorites-page'),
backgroundColor: AppColors.ink,
body: SafeArea(
child: Column(
children: [
AppSubpageHeader(
title: l10n.myFavorites,
backKey: const Key('favorites-back'),
),
Expanded(
child: signedIn
? _FavoritesBody(
favorites: context.watch<FavoriteController>(),
)
: const AuthGatePanel(),
),
],
return SafeArea(
bottom: false,
child: Column(
children: [
AppSubpageHeader(
title: l10n.myFavorites,
backKey: const Key('favorites-back'),
showBack: widget.showBack,
),
),
Expanded(
child: signedIn
? _FavoritesBody(favorites: context.watch<FavoriteController>())
: const AuthGatePanel(),
),
],
),
);
}
@@ -264,7 +276,7 @@ class _FavoriteCard extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(_cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
+1 -1
View File
@@ -58,7 +58,7 @@ class _FilterPageState extends State<FilterPage> {
);
final results = _filtered(catalog.dramas);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
value: SystemUiOverlayStyle.light,
child: Scaffold(
key: const Key('filter-page'),
backgroundColor: AppColors.ink,
+8 -4
View File
@@ -79,9 +79,11 @@ class HomeLogic extends ChangeNotifier {
switch (action) {
case DiscoverPageAction.home:
select(0);
case DiscoverPageAction.create:
case DiscoverPageAction.recommend:
select(1, search: true);
case DiscoverPageAction.following:
select(2);
case DiscoverPageAction.my:
case DiscoverPageAction.mine:
select(3);
}
}
@@ -102,10 +104,12 @@ class HomeLogic extends ChangeNotifier {
switch (action) {
case MyPageAction.home:
select(0);
case MyPageAction.discover:
case MyPageAction.recommend:
select(1, search: true);
case MyPageAction.create:
case MyPageAction.following:
select(2);
case MyPageAction.mine:
select(3);
case MyPageAction.resume:
select(0, resume: true);
}
+74 -111
View File
@@ -13,24 +13,32 @@ import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/catalog_retry.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/catalog_debug_info.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/catalog_endpoint_editor.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
import '../create/create_view.dart';
import '../favorites/favorites_page.dart';
import '../filter/filter_page.dart';
import '../mine/mine_types.dart';
import '../new_releases/new_releases_page.dart';
import '../player/widgets/home_feed_header.dart';
import '../player/player_view.dart';
import '../ranking/ranking_page.dart';
import '../mine/mine_view.dart';
import '../store/vip_page.dart';
import '../wallet/wallet_page.dart';
import 'home_feed_channel.dart';
import 'home_binding.dart';
import 'home_logic.dart';
import 'widgets/home_feed_grid.dart';
import 'widgets/home_failure_view.dart';
import 'widgets/home_grid_header.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -100,7 +108,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
final catalog = context.watch<CatalogController>();
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: _buildHome(context, catalog, l10n),
);
}
@@ -115,7 +123,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
}
if (catalog.error != null) {
return Scaffold(
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
body: HomeFailureView(
error: catalog.debugError ?? catalog.error,
onRetry: () => unawaited(_retryHome(catalog)),
@@ -125,7 +133,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
}
if (catalog.dramas.isEmpty) {
return Scaffold(
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
body: EmptyState(
key: const Key('home-empty'),
icon: Icons.movie_outlined,
@@ -189,34 +197,6 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
}
}
/// 首页目录加载失败与重试入口。
class HomeFailureView extends StatelessWidget {
const HomeFailureView({
super.key,
this.error,
required this.onRetry,
this.onEditEndpoint,
});
final String? error;
final VoidCallback onRetry;
final VoidCallback? onEditEndpoint;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return EmptyState(
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: onRetry,
footer: kDebugMode
? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint)
: null,
);
}
}
/// 四个根页面共享同一路由并保持各自状态。
class _HomeTabs extends StatefulWidget {
const _HomeTabs({super.key, required this.feedController});
@@ -261,8 +241,7 @@ class _HomeTabsState extends State<_HomeTabs> {
}
Widget _scaffold(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final feed = logic.tab == 0;
final mediaTab = logic.tab == 0 || logic.tab == 1;
final content = PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
@@ -271,15 +250,15 @@ class _HomeTabsState extends State<_HomeTabs> {
},
child: Scaffold(
key: logic.scaffoldKey,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
resizeToAvoidBottomInset: false,
body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context),
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: feed ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
child: feed
value: SystemUiOverlayStyle.light,
child: mediaTab
? content
: DecoratedBox(
key: const Key('home-page-gradient'),
@@ -293,15 +272,31 @@ class _HomeTabsState extends State<_HomeTabs> {
return IndexedStack(
index: logic.tab,
children: [
_home(context),
_discover(context),
const CreatePage(),
_homeGrid(context),
_recommend(context),
const FavoritesContent(key: Key('favorites-page'), showBack: false),
_my(context),
],
);
}
Widget _home(BuildContext context) {
Widget _homeGrid(BuildContext context) {
return HomeFeedGrid(
feedController: widget.feedController,
callbacks: HomeGridCallbacks(
onSearch: () => _openSearch(context),
onOpenVip: () => unawaited(_openVip(context)),
onOpenWallet: () => unawaited(_openWallet(context)),
onOpenNewReleases: () => _pushPage(context, const NewReleasesPage()),
onOpenCategories: () => _pushPage(context, const FilterPage()),
onOpenRanking: () => _pushPage(context, const RankingPage()),
onOpenFilter: () => _pushPage(context, const FilterPage()),
),
onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)),
);
}
Widget _recommend(BuildContext context) {
final currentDramas = widget.feedController.dramas;
final currentChannel = widget.feedController.channel;
if (currentDramas.isNotEmpty) {
@@ -330,7 +325,7 @@ class _HomeTabsState extends State<_HomeTabs> {
PlayerPage(
homeMode: true,
embedded: true,
active: logic.tab == 0,
active: logic.tab == 1,
drama: playerDramas.first,
initialIndex: 0,
feedDramas: playerDramas,
@@ -343,7 +338,7 @@ class _HomeTabsState extends State<_HomeTabs> {
onChannelSelected: _selectFeedChannel,
onDramaChanged: logic.onDramaChanged,
onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => logic.select(1, search: true),
onSearch: () => _openSearch(context),
onDiscover: () => logic.select(1),
onMy: () => logic.select(3),
),
@@ -352,13 +347,13 @@ class _HomeTabsState extends State<_HomeTabs> {
child: IgnorePointer(
key: const Key('home-feed-channel-loading'),
child: ColoredBox(
color: Colors.black26,
color: AppColors.controlSoftScrim,
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: Colors.white70,
color: AppColors.muted,
),
),
),
@@ -374,7 +369,7 @@ class _HomeTabsState extends State<_HomeTabs> {
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
const ColoredBox(color: AppColors.mediaBlack),
SafeArea(
bottom: false,
child: Column(
@@ -384,7 +379,7 @@ class _HomeTabsState extends State<_HomeTabs> {
channel: widget.feedController.channel,
onChannelSelected: _selectFeedChannel,
onRefresh: () => _refreshPlaceholder(context),
onSearch: () => logic.select(1, search: true),
onSearch: () => _openSearch(context),
onMore: () {},
),
Expanded(
@@ -395,7 +390,7 @@ class _HomeTabsState extends State<_HomeTabs> {
child: CircularProgressIndicator(
key: Key('home-feed-channel-loading'),
strokeWidth: 2.4,
color: Colors.white70,
color: AppColors.muted,
),
),
)
@@ -428,18 +423,33 @@ class _HomeTabsState extends State<_HomeTabs> {
unawaited(widget.feedController.selectChannel(channel));
}
Widget _discover(BuildContext context) {
return DiscoverPage(
active: logic.tab == 1,
searchRequest: logic.searchRequest,
callbacks: DiscoverPageCallbacks(
onAction: logic.discoverAction,
onDramaSelected: (drama) => logic.playDrama(context, drama),
onProfile: logic.openProfile,
void _openSearch(BuildContext context) {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)),
);
}
void _pushPage(BuildContext context, Widget page) {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
}
Future<void> _openVip(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
final wallet = context.read<WalletController>();
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
),
);
}
Future<void> _openWallet(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
}
Widget _my(BuildContext context) {
return MyPage(
active: logic.tab == 3,
@@ -450,61 +460,14 @@ class _HomeTabsState extends State<_HomeTabs> {
);
}
Widget _nav(BuildContext context, AppLocalizations l10n) {
return AppBottomNav(
Widget _nav(BuildContext context) {
return AppRootBottomNav(
barKey: const Key('home-tabs-bar'),
dark: logic.tab == 0,
items: [
_navItem(context, l10n, 0),
_navItem(context, l10n, 1),
_navItem(context, l10n, 2),
_navItem(context, l10n, 3),
],
selected: AppRootTab.values[logic.tab],
onSelected: (tab) => _selectTab(context, tab.index),
);
}
AppBottomNavItem _navItem(
BuildContext context,
AppLocalizations l10n,
int index,
) {
final selected = logic.tab == index;
return switch (index) {
0 => AppBottomNavItem(
tabKey: const Key('tab-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
1 => AppBottomNavItem(
tabKey: const Key('tab-theater'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.theaterTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
2 => AppBottomNavItem(
tabKey: const Key('tab-create'),
icon: Icons.add_box_outlined,
selectedIcon: Icons.add_box_rounded,
label: l10n.createTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
_ => AppBottomNavItem(
tabKey: const Key('tab-mine'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
};
}
void _selectTab(BuildContext context, int tab) {
final same = logic.tab == tab;
if (!same) {
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
import '../../../core/data/models/drama.dart';
const int homeNewReleaseDays = 7;
const int homeHotViewThreshold = 100000;
const int _snowflakeEpochMillis = 1288834974657;
const int _snowflakeTimestampShift = 22;
const Map<String, List<String>> _tagAliases = {
'uplifting': ['爽剧', '逆袭', '复仇', 'revenge', 'balas'],
'sweet': ['甜宠', '甜', 'sweet', 'romantis'],
'faceSlap': ['打脸', '虐渣', 'comeuppance', 'comeback'],
'angst': ['虐恋', '虐心', 'angst', 'heartbreak'],
};
DateTime? publishedAtForDrama(Drama drama) {
if (drama.createdAt != null) return drama.createdAt;
final snowflake = BigInt.tryParse(drama.id);
if (snowflake == null) return null;
final timestamp =
(snowflake >> _snowflakeTimestampShift).toInt() + _snowflakeEpochMillis;
final publishedAt = DateTime.fromMillisecondsSinceEpoch(timestamp);
return publishedAt.year >= 2015 ? publishedAt : null;
}
bool isNewDrama(Drama drama, {DateTime? now}) {
final publishedAt = publishedAtForDrama(drama);
if (publishedAt == null) return false;
final age = (now ?? DateTime.now()).difference(publishedAt);
return !age.isNegative && age <= const Duration(days: homeNewReleaseDays);
}
bool isHotDrama(Drama drama) {
return drama.isHot || drama.viewCount >= homeHotViewThreshold;
}
bool matchesHomeTag(Drama drama, String tagId) {
if (tagId == 'hot') return isHotDrama(drama);
return tagMatchesText(tagId, drama.genre) ||
tagMatchesText(tagId, drama.category);
}
bool tagMatchesText(String tagId, String text) {
if (tagId == 'hot') return false;
final normalized = text.toLowerCase();
final aliases = _tagAliases[tagId] ?? const [];
return aliases.any(normalized.contains);
}
+1 -1
View File
@@ -86,7 +86,7 @@ class HomeDramaCard extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../core/widgets/catalog_debug_info.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
/// 首页目录加载失败与重试入口。
class HomeFailureView extends StatelessWidget {
const HomeFailureView({
super.key,
this.error,
required this.onRetry,
this.onEditEndpoint,
});
final String? error;
final VoidCallback onRetry;
final VoidCallback? onEditEndpoint;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return EmptyState(
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: onRetry,
footer: kDebugMode
? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint)
: null,
);
}
}
+225
View File
@@ -0,0 +1,225 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import 'home_drama_classification.dart';
const double _coverAspectRatio = 5 / 6;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
enum HomeDramaBadge { hot, newRelease, anime }
class HomeFeedCard extends StatelessWidget {
const HomeFeedCard({
super.key,
required this.drama,
required this.onTap,
this.now,
});
final Drama drama;
final VoidCallback onTap;
final DateTime? now;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: Key('home-feed-card-${drama.id}'),
decoration: BoxDecoration(
color: AppColors.contentCard,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
border: Border.all(color: AppColors.contentBorder),
),
child: Material(
color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_cover(l10n),
Expanded(
child: Padding(
padding: const EdgeInsets.all(AppDimens.homeCardPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
key: Key('home-feed-title-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.homeCardTitle,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
const SizedBox(height: AppDimens.spaceXs),
_tags(),
],
),
),
),
],
),
),
),
);
}
Widget _cover(AppLocalizations l10n) {
final badge = _badge();
return AspectRatio(
aspectRatio: _coverAspectRatio,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
key: Key('home-feed-cover-${drama.id}'),
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
if (badge != null)
Positioned(
top: AppDimens.spaceXs,
left: AppDimens.spaceXs,
child: _badgeChip(l10n, badge),
),
Positioned(
left: AppDimens.spaceXs,
bottom: AppDimens.spaceXs,
child: _views(),
),
],
),
);
}
HomeDramaBadge? _badge() {
if (drama.dramaType.toUpperCase() == 'ANIME') {
return HomeDramaBadge.anime;
}
if (isNewDrama(drama, now: now)) return HomeDramaBadge.newRelease;
if (isHotDrama(drama)) {
return HomeDramaBadge.hot;
}
return null;
}
Widget _badgeChip(AppLocalizations l10n, HomeDramaBadge badge) {
final (label, color) = switch (badge) {
HomeDramaBadge.hot => (l10n.homeBadgeHot, AppColors.badgeHot),
HomeDramaBadge.newRelease => (l10n.homeBadgeNew, AppColors.badgeNew),
HomeDramaBadge.anime => (l10n.homeBadgeAnime, AppColors.badgeAnime),
};
return Container(
key: Key('home-feed-badge-${badge.name}-${drama.id}'),
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceSm,
vertical: 2,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppDimens.chipRadius),
),
child: Text(
label,
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.homeCardBadge,
fontWeight: FontWeight.w700,
letterSpacing: 0,
),
),
);
}
Widget _views() {
return Container(
key: Key('home-feed-views-${drama.id}'),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: AppColors.playerOverlayPill,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
size: AppDimens.homeCardMeta,
color: AppColors.navSelected,
),
const SizedBox(width: 1),
Text(
formatInteractionCount(drama.viewCount),
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.homeCardMeta,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
),
],
),
);
}
Widget _tags() {
final tags = _cardTags();
if (tags.isEmpty) return const SizedBox.shrink();
return Row(
children: [
for (final (index, tag) in tags.indexed)
Flexible(
child: Padding(
padding: EdgeInsets.only(
right: index == tags.length - 1 ? 0 : AppDimens.spaceXs,
),
child: Container(
key: Key('home-feed-tag-${drama.id}-$index'),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.chipRadius),
),
child: Text(
tag,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.homeTagFont,
letterSpacing: 0,
),
),
),
),
),
],
);
}
List<String> _cardTags() {
final seen = <String>{};
return [drama.genre, drama.category]
.expand((value) => value.split(_tagSplitter))
.map((value) => value.trim())
.where((value) => value.isNotEmpty && seen.add(value))
.take(2)
.toList(growable: false);
}
}
+294
View File
@@ -0,0 +1,294 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/content_category.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/data/services/category_controller.dart';
import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/data/services/wallet_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/paged_list_footer.dart';
import '../../../l10n/app_localizations.dart';
import 'home_drama_classification.dart';
import 'home_feed_card.dart';
import 'home_grid_header.dart';
const double _loadMoreThreshold = 240;
const List<String> _costumeAliases = ['古装', 'costume', 'period'];
const List<String> _communityAliases = ['社群', 'community', 'komunitas'];
class HomeFeedGrid extends StatefulWidget {
const HomeFeedGrid({
super.key,
required this.feedController,
required this.callbacks,
required this.onOpenDrama,
});
final HomeFeedController feedController;
final HomeGridCallbacks callbacks;
final ValueChanged<Drama> onOpenDrama;
@override
State<HomeFeedGrid> createState() => _HomeFeedGridState();
}
class _HomeFeedGridState extends State<HomeFeedGrid> {
String? _selectedCategory;
String? _selectedTag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>().categories;
final auth = context.watch<AuthController>();
final wallet = context.watch<WalletController>();
final shortcuts = _categoryShortcuts(categories, l10n);
final tags = _availableTags(l10n);
final activeCategory =
shortcuts.any((shortcut) => shortcut.categoryName == _selectedCategory)
? _selectedCategory
: null;
final activeTag = tags.any((tag) => tag.id == _selectedTag)
? _selectedTag
: null;
final dramas = _filteredDramas(activeCategory, activeTag);
final walletBadge = auth.isSignedIn && wallet.snapshot != null
? wallet.balance
: null;
return ColoredBox(
key: const Key('home-grid'),
color: AppColors.appBackground,
child: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.maxWidth -
AppDimens.pageHorizontal * 2 -
AppDimens.homeGridGap * 2) /
3;
final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent;
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
key: const Key('home-grid-refresh'),
onRefresh: _refresh,
child: CustomScrollView(
key: const Key('home-grid-scroll'),
physics: AppPullToRefresh.physics,
slivers: [
SliverToBoxAdapter(
child: HomeGridHeader(
callbacks: widget.callbacks,
categories: shortcuts,
tags: tags,
selectedCategory: activeCategory,
selectedTag: activeTag,
onCategorySelected: _selectCategory,
onTagSelected: (tag) =>
setState(() => _selectedTag = tag),
walletBadge: walletBadge,
),
),
if (widget.feedController.loadingFirstPage)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (dramas.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: _emptyState(l10n, activeCategory, activeTag),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
sliver: SliverGrid(
key: const Key('home-grid-cards'),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridGap,
mainAxisExtent: itemExtent,
),
delegate: SliverChildBuilderDelegate(
(context, index) => HomeFeedCard(
drama: dramas[index],
onTap: () => widget.onOpenDrama(dramas[index]),
),
childCount: dramas.length,
),
),
),
if (dramas.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
bottom: AppDimens.spaceXl,
),
child: PagedListFooter(
loading: widget.feedController.loadingMore,
hasMore: widget.feedController.hasMore,
hasItems: dramas.isNotEmpty,
errorMessage:
widget.feedController.lastError == null
? null
: l10n.catalogError,
onRetry: widget.feedController.loadMore,
),
),
),
],
),
),
);
},
),
),
);
}
Future<void> _refresh() async {
await widget.feedController.refreshCurrent();
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(widget.feedController.loadMore());
}
return false;
}
void _selectCategory(String? category) {
setState(() {
_selectedCategory = category;
_selectedTag = null;
});
}
List<HomeGridCategoryShortcut> _categoryShortcuts(
List<ContentCategory> categories,
AppLocalizations l10n,
) {
final shortcuts = <HomeGridCategoryShortcut>[];
final costume = _findCategory(categories, _costumeAliases);
final community = _findCategory(categories, _communityAliases);
if (costume != null) {
shortcuts.add(
HomeGridCategoryShortcut(
label: l10n.homeSectionCostume,
categoryName: costume.name,
),
);
}
if (community != null) {
shortcuts.add(
HomeGridCategoryShortcut(
label: l10n.homeSectionCommunity,
categoryName: community.name,
),
);
}
return shortcuts;
}
ContentCategory? _findCategory(
List<ContentCategory> categories,
List<String> aliases,
) {
for (final category in categories) {
final name = category.name.toLowerCase();
if (aliases.any(name.contains)) return category;
}
return null;
}
List<HomeGridTagOption> _availableTags(AppLocalizations l10n) {
final definitions = [
HomeGridTagOption(id: 'hot', label: l10n.homeTagHot),
HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting),
HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet),
HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap),
HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst),
];
final categoryText = context
.read<CategoryController>()
.categories
.map((category) => category.name)
.toList(growable: false);
return definitions
.where((option) {
if (option.id == 'hot') {
return widget.feedController.dramas.any(isHotDrama);
}
return categoryText.any((text) => tagMatchesText(option.id, text)) ||
widget.feedController.dramas.any(
(drama) => matchesHomeTag(drama, option.id),
);
})
.toList(growable: false);
}
List<Drama> _filteredDramas(String? category, String? tag) {
final normalizedCategory = category?.toLowerCase();
return widget.feedController.dramas
.where((drama) {
final categoryMatches =
normalizedCategory == null ||
drama.category.toLowerCase().contains(normalizedCategory) ||
drama.genre.toLowerCase().contains(normalizedCategory);
final tagMatches = tag == null || matchesHomeTag(drama, tag);
return categoryMatches && tagMatches;
})
.toList(growable: false);
}
Widget _emptyState(AppLocalizations l10n, String? category, String? tag) {
if (widget.feedController.lastError != null) {
return EmptyState(
key: const Key('home-grid-error'),
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: () => unawaited(widget.feedController.refreshCurrent()),
);
}
final filtered = category != null || tag != null;
return EmptyState(
key: const Key('home-grid-empty'),
icon: Icons.movie_filter_outlined,
title: filtered ? l10n.homeGridFilterEmpty : l10n.noStories,
subtitle: filtered ? null : l10n.emptyHint,
actionLabel: filtered ? l10n.homeGridClearFilter : null,
onAction: filtered
? () => setState(() {
_selectedCategory = null;
_selectedTag = null;
})
: null,
);
}
}
@@ -0,0 +1,365 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
class HomeGridCategoryShortcut {
const HomeGridCategoryShortcut({
required this.label,
required this.categoryName,
});
final String label;
final String categoryName;
}
class HomeGridTagOption {
const HomeGridTagOption({required this.id, required this.label});
final String id;
final String label;
}
class HomeGridCallbacks {
const HomeGridCallbacks({
required this.onSearch,
required this.onOpenVip,
required this.onOpenWallet,
required this.onOpenNewReleases,
required this.onOpenCategories,
required this.onOpenRanking,
required this.onOpenFilter,
});
final VoidCallback onSearch;
final VoidCallback onOpenVip;
final VoidCallback onOpenWallet;
final VoidCallback onOpenNewReleases;
final VoidCallback onOpenCategories;
final VoidCallback onOpenRanking;
final VoidCallback onOpenFilter;
}
class HomeGridHeader extends StatelessWidget {
const HomeGridHeader({
super.key,
required this.callbacks,
required this.categories,
required this.tags,
required this.selectedCategory,
required this.selectedTag,
required this.onCategorySelected,
required this.onTagSelected,
this.walletBadge,
});
final HomeGridCallbacks callbacks;
final List<HomeGridCategoryShortcut> categories;
final List<HomeGridTagOption> tags;
final String? selectedCategory;
final String? selectedTag;
final ValueChanged<String?> onCategorySelected;
final ValueChanged<String?> onTagSelected;
final int? walletBadge;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
key: const Key('home-grid-header'),
children: [_searchRow(l10n), _primaryRow(l10n), _tagRow(l10n)],
);
}
Widget _searchRow(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceSm,
AppDimens.pageHorizontal,
AppDimens.spaceSm,
),
child: Row(
children: [
Expanded(
child: InkWell(
key: const Key('home-search-entry'),
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
onTap: callbacks.onSearch,
child: Container(
height: AppDimens.searchHeight,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceMd,
),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
),
child: Row(
children: [
const Icon(
Icons.search_rounded,
size: AppDimens.homeHeaderIcon,
color: AppColors.textSecondary,
),
const SizedBox(width: AppDimens.spaceSm),
Expanded(
child: Text(
l10n.homeSearchHint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.body,
),
),
),
],
),
),
),
),
const SizedBox(width: AppDimens.spaceSm),
_headerAction(
key: const Key('home-vip-entry'),
icon: Icons.workspace_premium_outlined,
tooltip: l10n.vipMembership,
onTap: callbacks.onOpenVip,
),
const SizedBox(width: AppDimens.spaceXs),
_headerAction(
key: const Key('home-wallet-entry'),
icon: Icons.card_giftcard_rounded,
tooltip: l10n.wallet,
onTap: callbacks.onOpenWallet,
badge: walletBadge,
),
],
),
);
}
Widget _headerAction({
required Key key,
required IconData icon,
required String tooltip,
required VoidCallback onTap,
int? badge,
}) {
return SizedBox.square(
dimension: AppDimens.homeIconSlot,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Tooltip(
message: tooltip,
child: Material(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
child: InkWell(
key: key,
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
onTap: onTap,
child: Icon(
icon,
size: AppDimens.homeHeaderIcon,
color: AppColors.navSelected,
),
),
),
),
),
if (badge != null && badge > 0)
Positioned(
key: const Key('home-wallet-badge'),
top: -5,
right: -6,
child: Container(
constraints: const BoxConstraints(minWidth: 20, minHeight: 16),
padding: const EdgeInsets.symmetric(horizontal: 5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.badgeHot,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
formatInteractionCount(badge),
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.homeCardBadge,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
Widget _primaryRow(AppLocalizations l10n) {
return SingleChildScrollView(
key: const Key('home-primary-sections'),
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
child: Row(
children: [
_primaryItem(
key: const Key('home-section-featured'),
label: l10n.homeSectionFeatured,
selected: selectedCategory == null,
onTap: () => onCategorySelected(null),
),
_primaryItem(
key: const Key('home-section-new'),
label: l10n.homeSectionNew,
selected: false,
onTap: callbacks.onOpenNewReleases,
),
_primaryItem(
key: const Key('home-section-categories'),
label: l10n.homeSectionCategories,
selected: false,
onTap: callbacks.onOpenCategories,
),
_primaryItem(
key: const Key('home-section-ranking'),
label: l10n.homeSectionRanking,
selected: false,
onTap: callbacks.onOpenRanking,
),
for (final category in categories)
_primaryItem(
key: Key('home-section-category-${category.categoryName}'),
label: category.label,
selected: selectedCategory == category.categoryName,
onTap: () => onCategorySelected(category.categoryName),
),
],
),
);
}
Widget _primaryItem({
required Key key,
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return InkWell(
key: key,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(right: AppDimens.spaceXl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: selected
? AppColors.navSelected
: AppColors.textSecondary,
fontSize: AppDimens.homeSectionFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: 0,
),
),
const SizedBox(height: AppDimens.spaceXs),
Container(
height: AppDimens.homeSectionIndicator,
width: selected ? 20 : 0,
decoration: BoxDecoration(
color: AppColors.navSelected,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
),
],
),
),
);
}
Widget _tagRow(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceMd,
AppDimens.pageHorizontal,
AppDimens.spaceSm,
),
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
key: const Key('home-secondary-tags'),
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final tag in tags) ...[
_tagChip(tag),
const SizedBox(width: AppDimens.spaceSm),
],
],
),
),
),
const SizedBox(width: AppDimens.spaceXs),
Tooltip(
message: l10n.filter,
child: SizedBox.square(
dimension: AppDimens.tapTarget,
child: Material(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
child: InkWell(
key: const Key('home-grid-filter'),
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
onTap: callbacks.onOpenFilter,
child: const Icon(
Icons.filter_list_rounded,
color: AppColors.navSelected,
size: AppDimens.homeHeaderIcon,
),
),
),
),
),
],
),
);
}
Widget _tagChip(HomeGridTagOption tag) {
final selected = selectedTag == tag.id;
return InkWell(
key: Key('home-tag-${tag.id}'),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
onTap: () => onTagSelected(selected ? null : tag.id),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceMd,
vertical: AppDimens.spaceSm,
),
decoration: BoxDecoration(
color: selected ? AppColors.contentChip : AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
tag.label,
style: TextStyle(
color: selected ? AppColors.navSelected : AppColors.textSecondary,
fontSize: AppDimens.homeSectionFont - 2,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
letterSpacing: 0,
),
),
),
);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 「我的」页与根导航之间的返回动作。
enum MyPageAction { home, discover, create, resume }
enum MyPageAction { home, recommend, following, mine, resume }
/// 「我的」页可进入的功能目的地。
enum MineDestination {
+16 -38
View File
@@ -12,7 +12,7 @@ import '../../core/data/services/wallet_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/plain_page_scaffold.dart';
import '../../l10n/app_localizations.dart';
@@ -81,17 +81,17 @@ class _MyPageState extends State<MyPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final content = SafeArea(
bottom: !embedded,
bottom: false,
child: Column(
children: [
_topBar(l10n),
Expanded(child: _body(context, l10n)),
if (!embedded) _bottomNav(l10n),
if (!embedded) _bottomNav(),
],
),
);
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
@@ -223,7 +223,7 @@ class _MyPageState extends State<MyPage> {
Future<void> _openLanguage() async {
final selected = await showModalBottomSheet<Locale>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
builder: (_) =>
_LanguageSheet(selected: context.read<LocaleController>().locale),
@@ -237,42 +237,20 @@ class _MyPageState extends State<MyPage> {
_openProtected(RechargePage(onPurchased: () => unawaited(wallet.load())));
}
Widget _bottomNav(AppLocalizations l10n) {
return AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('mine-nav-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
onTap: () => _act(MyPageAction.home),
),
AppBottomNavItem(
tabKey: const Key('mine-nav-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.theaterTab,
onTap: () => _act(MyPageAction.discover),
),
AppBottomNavItem(
tabKey: const Key('mine-nav-create'),
icon: Icons.add_box_outlined,
selectedIcon: Icons.add_box_rounded,
label: l10n.createTab,
onTap: () => _act(MyPageAction.create),
),
AppBottomNavItem(
tabKey: const Key('mine-nav-current'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
selected: true,
onTap: () {},
),
],
Widget _bottomNav() {
return AppRootBottomNav(
selected: AppRootTab.mine,
onSelected: (tab) => _act(_actionOf(tab)),
);
}
MyPageAction _actionOf(AppRootTab tab) => switch (tab) {
AppRootTab.home => MyPageAction.home,
AppRootTab.recommend => MyPageAction.recommend,
AppRootTab.following => MyPageAction.following,
AppRootTab.mine => MyPageAction.mine,
};
void _act(MyPageAction action) {
logic.act(
context,
+1 -1
View File
@@ -36,7 +36,7 @@ class MineHeader extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
+3 -3
View File
@@ -71,7 +71,7 @@ class _ShortcutTile extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
@@ -198,7 +198,7 @@ class MineVipCard extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
@@ -287,7 +287,7 @@ class MineMenuList extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias,
child: Column(
+10 -7
View File
@@ -102,7 +102,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
final hero = visible.isEmpty ? null : visible.first;
final more = pager.items.skip(1).take(_moreLimit).toList();
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
value: SystemUiOverlayStyle.light,
child: Scaffold(
key: const Key('new-releases-page'),
backgroundColor: AppColors.ink,
@@ -259,7 +259,10 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0xB3000000)],
colors: [
AppColors.transparent,
AppColors.playerOverlayPill,
],
),
),
),
@@ -282,7 +285,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
Text(
l10n.premiereToday,
style: const TextStyle(
color: Colors.white70,
color: AppColors.muted,
fontSize: 10,
fontWeight: FontWeight.w600,
),
@@ -293,7 +296,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: 20,
fontWeight: FontWeight.w800,
),
@@ -315,7 +318,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
color: AppColors.muted,
fontSize: 11,
),
),
@@ -338,7 +341,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
color: AppColors.onPrimary,
size: 22,
),
),
@@ -490,7 +493,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
child: Text(
l10n.newBadge,
style: TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w800,
),
+1 -1
View File
@@ -46,7 +46,7 @@ class _NoticesPageState extends State<NoticesPage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('notices-page'),
backKey: const Key('notices-back'),
+11 -8
View File
@@ -23,6 +23,7 @@ import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../core/navigation/fade_page_route.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
@@ -937,8 +938,9 @@ class PlayerLogic extends ChangeNotifier {
} catch (_) {
if (context.mounted && session.active == player) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
}
} finally {
if (scrubbingPlayer == player) cancelScrub();
@@ -1017,7 +1019,7 @@ class PlayerLogic extends ChangeNotifier {
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
isScrollControlled: true,
builder: (_) => EpisodePickerSheet(
drama: drama,
@@ -1079,10 +1081,11 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
final action = await Navigator.of(context)
.push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
final action = await Navigator.of(
context,
).push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
if (!context.mounted) return;
if (action == MyPageAction.discover) {
if (action == MyPageAction.recommend) {
await showDiscover(context);
} else {
session.setRouteVisible(true);
@@ -1114,7 +1117,7 @@ class PlayerLogic extends ChangeNotifier {
MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)),
);
if (!context.mounted) return;
if (action == DiscoverPageAction.my) {
if (action == DiscoverPageAction.mine) {
await showMy(context);
} else {
session.setRouteVisible(true);
@@ -1154,7 +1157,7 @@ class PlayerLogic extends ChangeNotifier {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
isDismissible: false,
enableDrag: false,
+12 -12
View File
@@ -393,19 +393,19 @@ class _PlayerPageState extends State<PlayerPage>
return AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('player-system-overlay'),
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.black,
systemNavigationBarDividerColor: Colors.transparent,
statusBarColor: AppColors.transparent,
systemNavigationBarColor: AppColors.mediaBlack,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
),
child: Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: widget.embedded
? body
: Scaffold(
key: const Key('player-scaffold'),
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
resizeToAvoidBottomInset: false,
body: body,
),
@@ -457,7 +457,7 @@ class _PlayerPageState extends State<PlayerPage>
if (widget.homeMode)
const ColoredBox(
key: Key('home-feed-black-background'),
color: Colors.black,
color: AppColors.mediaBlack,
),
PlayerVideoSurface(
controller: videoPort?.controller,
@@ -497,14 +497,14 @@ class _PlayerPageState extends State<PlayerPage>
children: [
const Icon(
Icons.wifi_off_rounded,
color: Colors.white70,
color: AppColors.muted,
size: 36,
),
const SizedBox(height: 12),
Text(
errorText(l10n, logic.ticketError, l10n.playbackError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.muted),
),
const SizedBox(height: 12),
FilledButton(
@@ -524,14 +524,14 @@ class _PlayerPageState extends State<PlayerPage>
children: [
const Icon(
Icons.error_outline_rounded,
color: Colors.white70,
color: AppColors.muted,
size: 36,
),
const SizedBox(height: 12),
Text(
l10n.playbackError,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.muted),
),
],
),
@@ -551,7 +551,7 @@ class _PlayerPageState extends State<PlayerPage>
children: [
const Icon(
Icons.movie_filter_outlined,
color: Colors.white70,
color: AppColors.muted,
size: 36,
),
const SizedBox(height: 12),
@@ -560,7 +560,7 @@ class _PlayerPageState extends State<PlayerPage>
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.muted),
),
],
),
+3 -3
View File
@@ -6,7 +6,7 @@ extension _PlayerViewActions on _PlayerPageState {
Future<void> _openSpeedSheet(BuildContext context) async {
final selected = await showModalBottomSheet<double>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
builder: (_) => PlayerSpeedSheet(selected: _playbackSpeed),
);
@@ -19,7 +19,7 @@ extension _PlayerViewActions on _PlayerPageState {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
isScrollControlled: true,
builder: (_) => PlayerMoreSheet(
@@ -52,7 +52,7 @@ extension _PlayerViewActions on _PlayerPageState {
if (wallet == null) return;
await showModalBottomSheet<bool>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
isScrollControlled: true,
builder: (_) => PlayerUnlockSheet(
@@ -413,13 +413,16 @@ class _CommentPostSuccessOverlay extends StatelessWidget {
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
color: AppColors.mediaBlack.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(8),
),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 14),
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 14,
),
),
),
),
@@ -40,11 +40,11 @@ class EpisodePickerSheet extends StatelessWidget {
final episodes = drama.episodes;
return AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('player-episodes-system-overlay'),
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: AppColors.transparent,
systemNavigationBarColor: AppColors.card,
systemNavigationBarDividerColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
),
child: Container(
@@ -2,6 +2,8 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// Shared floating action used to leave clear-screen mode.
class ExitClearScreenButton extends StatelessWidget {
const ExitClearScreenButton({
@@ -23,8 +25,8 @@ class ExitClearScreenButton extends StatelessWidget {
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0x80000000),
foregroundColor: Colors.white,
backgroundColor: AppColors.controlScrim,
foregroundColor: AppColors.onPrimary,
shape: const CircleBorder(),
),
icon: const Icon(Icons.fullscreen_exit_rounded, size: 18),
+14 -8
View File
@@ -2,6 +2,8 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../home/home_feed_channel.dart';
import '../../../l10n/app_localizations.dart';
@@ -140,8 +142,8 @@ class _TopIconButton extends StatelessWidget {
icon: Icon(
icon,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
color: AppColors.onPrimary,
shadows: const [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
),
),
);
@@ -184,15 +186,17 @@ class _RefreshButton extends StatelessWidget {
child: CircularProgressIndicator(
key: Key('home-feed-refreshing'),
strokeWidth: 2,
color: Colors.white,
color: AppColors.onPrimary,
),
),
)
: Icon(
Icons.refresh_rounded,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
color: AppColors.onPrimary,
shadows: const [
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
],
),
),
),
@@ -232,10 +236,12 @@ class _FeedTabItem extends StatelessWidget {
label,
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : Colors.white70,
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: fontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
shadows: const [
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
],
),
),
const SizedBox(height: 4),
@@ -243,7 +249,7 @@ class _FeedTabItem extends StatelessWidget {
width: underlineWidth,
height: underlineHeight,
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
color: selected ? AppColors.onPrimary : AppColors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
+13 -13
View File
@@ -20,8 +20,8 @@ import 'seek_progress_bar.dart';
part 'home_feed_overlay_parts.dart';
const Color _overlayPill = Color(0x66000000);
const Color _fullBarBackground = Color(0x4D000000);
const Color _overlayPill = AppColors.overlaySoft;
const Color _fullBarBackground = AppColors.overlayBar;
const double _titleFontSize = 17;
const double _metaFontSize = 12;
const double _tagFontSize = 11;
@@ -29,7 +29,7 @@ const double _fullBarHeight = 44;
const double _progressHeight = 2;
const double _progressHitHeight = 24;
const List<Shadow> _boostTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
];
const int _maxTags = 4;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
@@ -236,7 +236,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
key: const Key('home-feed-author-note'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 11),
style: const TextStyle(color: AppColors.muted, fontSize: 11),
),
],
],
@@ -260,14 +260,14 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
color: AppColors.muted,
fontSize: _metaFontSize,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white70,
color: AppColors.muted,
size: 16,
),
],
@@ -288,16 +288,16 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: Colors.white,
color: AppColors.onPrimary,
fontSize: _titleFontSize,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
shadows: [Shadow(blurRadius: 8, color: AppColors.mediaBlack)],
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
color: AppColors.onPrimary,
size: 20,
),
],
@@ -326,7 +326,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
tag,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: _tagFontSize,
),
),
@@ -369,7 +369,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
children: [
const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
color: AppColors.onPrimary,
size: 14,
),
const SizedBox(width: 6),
@@ -381,7 +381,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
@@ -389,7 +389,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
color: AppColors.onPrimary,
size: 18,
),
],
@@ -251,7 +251,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
bottom: bottom,
child: SizedBox(
height: widget.height,
child: ColoredBox(key: _inactiveTrackKey, color: Colors.white24),
child: ColoredBox(
key: _inactiveTrackKey,
color: AppColors.border,
),
),
),
],
@@ -301,7 +304,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
child: Container(
key: const Key('home-feed-progress-track'),
height: trackHeight,
color: Colors.white24,
color: AppColors.border,
),
),
Positioned(
@@ -321,7 +324,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
width: size,
height: size,
decoration: const BoxDecoration(
color: Colors.white,
color: AppColors.onPrimary,
shape: BoxShape.circle,
),
),
@@ -71,7 +71,7 @@ class _RailButton extends StatelessWidget {
width: _iconSize,
height: _iconSize,
fit: BoxFit.contain,
color: item.selected ? AppColors.blue : Colors.white,
color: item.selected ? AppColors.blue : AppColors.onPrimary,
colorBlendMode: BlendMode.srcIn,
),
),
@@ -83,11 +83,11 @@ class _RailButton extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
height: 1,
fontSize: 12,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
shadows: [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
),
),
],
@@ -2,13 +2,15 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// 播放未就绪时沿进度轨道循环移动的加载高光。
class LoadingProgressTrack extends StatefulWidget {
const LoadingProgressTrack({
super.key = const Key('progress-loading'),
required this.height,
this.trackColor = Colors.white24,
this.highlightColor = Colors.white,
this.trackColor = AppColors.inactiveTrack,
this.highlightColor = AppColors.onPrimary,
});
final double height;
@@ -6,9 +6,9 @@ import '../../../core/theme/app_colors.dart';
const double _toggleSize = 68;
const double _iconSize = 36;
const Color _toggleBackground = Colors.black26;
const Color _toggleBackground = AppColors.controlSoftScrim;
const List<Shadow> _iconShadows = [
Shadow(color: Colors.black54, blurRadius: 12),
Shadow(color: AppColors.shadow, blurRadius: 12),
];
/// 首页沉浸流与全屏播放器共用的播放/暂停按钮。
@@ -271,7 +271,7 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
}) {
return Material(
key: key,
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
@@ -347,7 +347,7 @@ class _SegmentButton extends StatelessWidget {
? AppColors.muted
: AppColors.subtle;
return Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(_segmentRadius),
child: InkWell(
onTap: onTap,
@@ -357,12 +357,12 @@ class _SegmentButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: _segmentPadding),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.card : Colors.transparent,
color: selected ? AppColors.card : AppColors.transparent,
borderRadius: BorderRadius.circular(_segmentRadius),
boxShadow: selected
? const [
BoxShadow(
color: Color(0x14000000),
color: AppColors.shadowSubtle,
blurRadius: 4,
offset: Offset(0, 1),
),
+3 -3
View File
@@ -18,7 +18,7 @@ const double _topBarHeight = 58;
const double _boostHintIconSize = 16;
const double _boostHintFontSize = 13;
const List<Shadow> _playerTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
];
/// K29 竖屏播放页浮层。
@@ -270,7 +270,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
thumbnailsUrl: data.episode.thumbnailsUrl,
episodeId: data.episode.id,
posterUrl: data.drama.posterUrl,
inactiveTrackColor: Colors.white24,
inactiveTrackColor: AppColors.inactiveTrack,
onScrubStart: actions.onScrubStart,
onSeek: actions.onSeek,
),
@@ -279,7 +279,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
Widget _detailRow(String series) {
return Material(
color: Colors.transparent,
color: AppColors.transparent,
child: InkWell(
key: const Key('player-open-detail'),
onTap: actions.onDetail,
@@ -22,7 +22,7 @@ class _TopButton extends StatelessWidget {
tooltip: tooltip,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0x80000000),
backgroundColor: AppColors.controlScrim,
foregroundColor: AppColors.onPrimary,
),
onPressed: onPressed,
@@ -41,7 +41,7 @@ class _SpeedEntry extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0x80000000),
color: AppColors.controlScrim,
borderRadius: BorderRadius.circular(999),
child: InkWell(
onTap: onTap,
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart';
const Duration _videoFadeDuration = Duration(milliseconds: 200);
const double _errorHorizontal = 32;
const int _errorMaxLines = 3;
@@ -55,7 +57,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
children: [
const ColoredBox(
key: Key('player-fallback-background'),
color: Colors.black,
color: AppColors.mediaBlack,
),
AnimatedOpacity(
key: const Key('player-video-opacity'),
@@ -81,7 +83,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
maxLines: _errorMaxLines,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70, fontSize: 13),
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
),
),
@@ -164,7 +164,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white, width: 1),
border: Border.all(color: AppColors.onPrimary, width: 1),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
@@ -174,7 +174,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
color: Colors.black87,
color: AppColors.scrubPreviewScrim,
child: Text(
'${_format(widget.position)} / ${_format(widget.duration)}',
key: const Key('scrub-preview-time'),
@@ -182,7 +182,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.2,
@@ -233,7 +233,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
'${_format(previewPosition)} / ${_format(video.duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.2,
+6 -6
View File
@@ -27,10 +27,10 @@ const double _rankColumnWidth = 56;
const double _listCoverWidth = 72;
const double _listCoverHeight = 96;
const double _loadMoreThreshold = 200;
const Color _goldStart = Color(0xFFF3B34C);
const Color _goldEnd = Color(0xFFD9A23A);
const Color _bronzeStart = Color(0xFFCD7F32);
const Color _bronzeEnd = Color(0xFFA9713A);
const Color _goldStart = AppColors.gold;
const Color _goldEnd = AppColors.goldDeep;
const Color _bronzeStart = AppColors.bronze;
const Color _bronzeEnd = AppColors.bronzeDeep;
/// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。
class RankingPage extends StatefulWidget {
@@ -265,7 +265,7 @@ class _PodiumCard extends StatelessWidget {
l10n.topLabel(rank),
maxLines: 1,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: 11,
fontWeight: FontWeight.w800,
),
@@ -335,7 +335,7 @@ class _RankingRow extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
+5 -5
View File
@@ -12,7 +12,7 @@ import '../new_releases/new_releases_page.dart';
const double _ticketRadius = 20;
const double _notchSize = 24;
const Color _ticketDash = Color(0x66FF7A1A);
const Color _ticketDash = AppColors.primaryBorder;
/// 预约页:档期周历 + 电影票式空态 + 预约权益说明。
class ReservePage extends StatefulWidget {
@@ -131,7 +131,7 @@ class _ReservePageState extends State<ReservePage> {
Text(
weekdays[date.weekday - 1],
style: TextStyle(
color: selected ? Colors.white : AppColors.muted,
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 11,
fontWeight: FontWeight.w600,
),
@@ -140,7 +140,7 @@ class _ReservePageState extends State<ReservePage> {
Text(
'${date.day}',
style: TextStyle(
color: selected ? Colors.white : AppColors.onSurface,
color: selected ? AppColors.onPrimary : AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
@@ -207,7 +207,7 @@ class _ReservePageState extends State<ReservePage> {
),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: Colors.white,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -285,7 +285,7 @@ class _ReservePageState extends State<ReservePage> {
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: 12,
fontWeight: FontWeight.w800,
),
+1 -1
View File
@@ -23,7 +23,7 @@ class SettingsPage extends StatelessWidget {
final l10n = AppLocalizations.of(context)!;
final auth = context.watch<AuthController>();
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('settings-page'),
backKey: const Key('settings-back'),
+1 -1
View File
@@ -57,7 +57,7 @@ class _StorePageState extends State<StorePage> {
final l10n = AppLocalizations.of(context)!;
final isVip = widget.type == StoreController.vipType;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('store-page'),
backKey: const Key('store-back'),
+1 -1
View File
@@ -46,7 +46,7 @@ class _WalletPageState extends State<WalletPage> {
final l10n = AppLocalizations.of(context)!;
final wallet = context.watch<WalletController>();
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('wallet-page'),
backKey: const Key('wallet-back'),
@@ -38,7 +38,7 @@ class WatchRecordsPage extends StatelessWidget {
final signedIn = context.watch<AuthController>().isSignedIn;
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: Scaffold(
key: const Key('watch-records-page'),
backgroundColor: AppColors.ink,
@@ -170,7 +170,7 @@ class WatchRecordCard extends StatelessWidget {
key: ValueKey('watch-record-${record.dramaId}'),
decoration: _cardDecoration(),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(_cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
+5 -1
View File
@@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
@@ -131,7 +132,10 @@ void main() {
final card = tester.widget<Container>(find.byKey(const Key('auth-card')));
final cardDecoration = card.decoration! as BoxDecoration;
expect(cardDecoration.color, AppColors.card);
expect(cardDecoration.borderRadius, BorderRadius.circular(20));
expect(
cardDecoration.borderRadius,
BorderRadius.circular(AppDimens.authCardRadius),
);
expect(cardDecoration.boxShadow, contains(AppTheme.cardShadow));
expect(card.padding, const EdgeInsets.all(20));
+6 -6
View File
@@ -7,7 +7,7 @@ import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/core/widgets/subpage_header.dart';
void main() {
testWidgets('should_render_unified_light_header_dimensions', (tester) async {
testWidgets('should_render_unified_dark_header_dimensions', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
@@ -47,7 +47,7 @@ void main() {
)
.decoration
as BoxDecoration;
expect(shadow.boxShadow!.single.color, const Color(0x14000000));
expect(shadow.boxShadow!.single.color, AppColors.shadowSubtle);
expect(shadow.boxShadow!.single.blurRadius, 8);
expect(shadow.boxShadow!.single.offset, const Offset(0, 2));
});
@@ -75,7 +75,7 @@ void main() {
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
backgroundColor: Colors.black,
backgroundColor: AppColors.mediaBlack,
body: AppSubpageHeader(
title: 'Title',
backKey: Key('sample-back'),
@@ -97,8 +97,8 @@ void main() {
matching: find.byIcon(Icons.chevron_left_rounded),
),
);
expect(material.color, const Color(0x59000000));
expect(icon.color, Colors.white);
expect(material.color, AppColors.headerScrim);
expect(icon.color, AppColors.onSurface);
expect(icon.size, 22);
expect(icon.weight, 600);
final shadow =
@@ -108,7 +108,7 @@ void main() {
)
.decoration
as BoxDecoration;
expect(shadow.boxShadow!.single.color, const Color(0x33000000));
expect(shadow.boxShadow!.single.color, AppColors.shadowSoft);
expect(shadow.boxShadow!.single.blurRadius, 8);
});
}
+2 -2
View File
@@ -99,9 +99,9 @@ void main() {
);
expect(forwarded, [DiscoverPageAction.home]);
logic.act(pageContext, DiscoverPageAction.my, embedded: false);
logic.act(pageContext, DiscoverPageAction.mine, embedded: false);
await tester.pumpAndSettle();
await expectLater(popped.future, completion(DiscoverPageAction.my));
await expectLater(popped.future, completion(DiscoverPageAction.mine));
logic.dispose();
});
+1 -1
View File
@@ -151,7 +151,7 @@ Future<void> _pumpFilterPage(WidgetTester tester) async {
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.tap(find.byKey(const Key('home-search-entry')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('theater-toolbar-filter')));
await tester.pumpAndSettle();
+3 -1
View File
@@ -455,7 +455,9 @@ Future<void> _pumpTheater(
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.tap(find.byKey(const Key('home-search-entry')));
await tester.pumpAndSettle();
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
}
+1 -1
View File
@@ -303,7 +303,7 @@ Future<void> _pumpTheater(
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.tap(find.byKey(const Key('home-search-entry')));
await tester.pumpAndSettle();
}
+4 -4
View File
@@ -33,7 +33,7 @@ void main() {
);
expect(
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
AppColors.blue,
AppColors.blueSoft,
);
expect(
tester
@@ -103,7 +103,7 @@ void main() {
expect(find.byKey(const Key('empty-state-action')), findsNothing);
});
testWidgets('should_tint_the_bare_icon_with_the_light_theme', (tester) async {
testWidgets('should_tint_the_bare_icon_with_the_dark_theme', (tester) async {
await _pump(
tester,
theme: AppTheme.light(),
@@ -111,7 +111,7 @@ void main() {
);
expect(
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
AppColors.blue,
AppColors.blueSoft,
);
await _pump(
@@ -121,7 +121,7 @@ void main() {
);
expect(
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
AppColors.blue,
AppColors.blueSoft,
);
});
+69 -36
View File
@@ -7,14 +7,22 @@ import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
void main() {
testWidgets('should_render_four_slots_with_orange_selected_state', (
test('should_use_exact_root_navigation_colors', () {
expect(AppColors.navBackground, const Color(0xFF000000));
expect(AppColors.navSelected, const Color(0xFFFFFFFF));
expect(AppColors.navUnselected, const Color(0xFF8E8E93));
});
testWidgets('should_render_four_slots_with_white_selected_state', (
tester,
) async {
var theaterTaps = 0;
const barKey = Key('nav-bar');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
bottomNavigationBar: AppBottomNav(
barKey: barKey,
items: [
AppBottomNavItem(
tabKey: const Key('tab-home'),
@@ -25,17 +33,17 @@ void main() {
onTap: () {},
),
AppBottomNavItem(
tabKey: const Key('tab-theater'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: 'Theater',
tabKey: const Key('tab-recommend'),
icon: Icons.smart_display_outlined,
selectedIcon: Icons.smart_display_rounded,
label: 'Recommend',
onTap: () => theaterTaps++,
),
AppBottomNavItem(
tabKey: const Key('tab-create'),
icon: Icons.add_box_outlined,
selectedIcon: Icons.add_box_rounded,
label: 'Create',
tabKey: const Key('tab-follow'),
icon: Icons.bookmark_border_rounded,
selectedIcon: Icons.bookmark_rounded,
label: 'Following',
onTap: () {},
),
AppBottomNavItem(
@@ -52,34 +60,57 @@ void main() {
);
expect(find.byKey(const Key('tab-home')), findsOneWidget);
expect(find.byKey(const Key('tab-theater')), findsOneWidget);
expect(find.byKey(const Key('tab-create')), findsOneWidget);
expect(find.byKey(const Key('tab-recommend')), findsOneWidget);
expect(find.byKey(const Key('tab-follow')), findsOneWidget);
expect(find.byKey(const Key('tab-mine')), findsOneWidget);
expect(find.byKey(const Key('nav-center-action')), findsNothing);
expect(find.text('Home'), findsOneWidget);
final selectedLabel = tester.widget<Text>(find.text('Home'));
expect(selectedLabel.style?.color, AppColors.navSelected);
expect(selectedLabel.style?.fontWeight, FontWeight.w600);
expect(selectedLabel.style?.fontSize, AppDimens.navLabel);
expect(selectedLabel.style?.letterSpacing, 0);
final unselectedLabel = tester.widget<Text>(find.text('Recommend'));
expect(unselectedLabel.style?.color, AppColors.navUnselected);
expect(unselectedLabel.style?.fontWeight, FontWeight.w400);
final centers = [
Key('tab-home'),
Key('tab-theater'),
Key('tab-create'),
Key('tab-recommend'),
Key('tab-follow'),
Key('tab-mine'),
].map((key) => tester.getCenter(find.byKey(key)).dx).toList();
expect(centers, orderedEquals(centers.toList()..sort()));
final widths = [
Key('tab-home'),
Key('tab-recommend'),
Key('tab-follow'),
Key('tab-mine'),
].map((key) => tester.getSize(find.byKey(key)).width).toList();
expect(widths.toSet(), hasLength(1));
expect(
tester.getSize(find.byType(AppBottomNav)).height,
AppDimens.navHeight,
);
expect(
tester
.widget<Icon>(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.movie_rounded),
),
)
.color,
AppColors.blue,
final bar = tester.widget<Container>(find.byKey(barKey));
expect(bar.color, AppColors.navBackground);
expect(bar.decoration, isNull);
final selectedIcon = tester.widget<Icon>(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.movie_rounded),
),
);
await tester.tap(find.byKey(const Key('tab-theater')));
expect(selectedIcon.color, AppColors.navSelected);
expect(
tester.getSize(find.byIcon(Icons.movie_rounded)),
const Size(26, 26),
);
expect(
tester.getTopLeft(find.text('Home')).dy -
tester.getBottomLeft(find.byIcon(Icons.movie_rounded)).dy,
AppDimens.navLabelGap,
);
await tester.tap(find.byKey(const Key('tab-recommend')));
expect(theaterTaps, 1);
});
@@ -93,7 +124,6 @@ void main() {
data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)),
child: Scaffold(
bottomNavigationBar: AppBottomNav(
dark: true,
barKey: barKey,
items: [
AppBottomNavItem(
@@ -105,17 +135,17 @@ void main() {
onTap: () {},
),
AppBottomNavItem(
tabKey: const Key('tab-theater'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: 'Theater',
tabKey: const Key('tab-recommend'),
icon: Icons.smart_display_outlined,
selectedIcon: Icons.smart_display_rounded,
label: 'Recommend',
onTap: () {},
),
AppBottomNavItem(
tabKey: const Key('tab-create'),
icon: Icons.add_box_outlined,
selectedIcon: Icons.add_box_rounded,
label: 'Create',
tabKey: const Key('tab-follow'),
icon: Icons.bookmark_border_rounded,
selectedIcon: Icons.bookmark_rounded,
label: 'Following',
onTap: () {},
),
AppBottomNavItem(
@@ -138,12 +168,15 @@ void main() {
);
expect(tester.getSize(find.byKey(barKey)).height, AppDimens.navHeight);
final coloredBox = tester.widget<ColoredBox>(
expect(
find.descendant(
of: find.byType(AppBottomNav),
matching: find.byType(ColoredBox),
matching: find.byWidgetPredicate(
(widget) =>
widget is ColoredBox && widget.color == AppColors.navBackground,
),
),
findsWidgets,
);
expect(coloredBox.color, Colors.black);
});
}
+1 -1
View File
@@ -10,7 +10,7 @@ import 'package:fruitshorts/core/data/services/catalog_retry.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_view.dart';
import 'package:fruitshorts/pages/home/widgets/home_failure_view.dart';
void main() {
tearDown(AppConfig.clearRuntimeBaseUrl);
+128 -62
View File
@@ -7,91 +7,157 @@ import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/widgets/home_drama_grid.dart';
import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart';
void main() {
testWidgets('should_render_compact_cover_badges_without_plus_action', (
testWidgets('should_render_grid_card_cover_badge_views_title_and_tags', (
tester,
) async {
var taps = 0;
await _pumpCard(
tester,
drama: _drama(
id: 'anime-card',
title: 'A very long home grid drama title that must wrap',
dramaType: 'ANIME',
viewCount: 444500,
),
onTap: () => taps++,
);
final card = find.byKey(const Key('home-feed-card-anime-card'));
final container = tester.widget<Container>(card);
final decoration = container.decoration! as BoxDecoration;
expect(decoration.color, AppColors.contentCard);
expect(decoration.boxShadow, isNull);
expect(
decoration.borderRadius,
BorderRadius.circular(AppDimens.homeCardRadius),
);
expect((decoration.border! as Border).top.width, 1);
expect((decoration.border! as Border).top.color, AppColors.contentBorder);
final cover = find.byKey(const Key('home-feed-cover-anime-card'));
final coverSize = tester.getSize(cover);
expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02));
expect(
find.byKey(const Key('home-feed-badge-anime-anime-card')),
findsOneWidget,
);
expect(find.text('Anime'), findsOneWidget);
expect(find.byKey(const Key('home-feed-views-anime-card')), findsOneWidget);
final views = find.byKey(const Key('home-feed-views-anime-card'));
final viewsText = tester.widget<Text>(
find.descendant(of: views, matching: find.byType(Text)),
);
expect(viewsText.data, startsWith('44.'));
expect(
tester
.widget<Text>(find.byKey(const Key('home-feed-title-anime-card')))
.maxLines,
2,
);
expect(find.byKey(const Key('home-feed-tag-anime-card-0')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tag-anime-card-1')), findsOneWidget);
expect(find.text('Romance'), findsOneWidget);
expect(find.text('Urban'), findsOneWidget);
await tester.tap(card);
expect(taps, 1);
});
testWidgets('should_choose_anime_new_and_hot_badges_from_real_data', (
tester,
) async {
final now = DateTime(2026, 10, 22, 12);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
theme: AppTheme.dark(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold(
body: SingleChildScrollView(
child: Builder(
builder: (context) => SizedBox(
width: 180,
child: HomeDramaCard(
l10n: AppLocalizations.of(context)!,
drama: _drama,
onTap: () {},
body: Row(
children: [
for (final drama in [
_drama(id: 'anime', title: 'Anime', dramaType: 'ANIME'),
_drama(
id: 'new',
title: 'New',
createdAt: now.subtract(const Duration(days: 2)),
),
),
),
_drama(id: 'hot', title: 'Hot', isHot: true),
])
SizedBox(
width: 130,
height: 240,
child: HomeFeedCard(drama: drama, onTap: () {}, now: now),
),
],
),
),
),
);
await tester.pumpAndSettle();
final card = find.byKey(const Key('home-drama-card-style-drama'));
final cardContainer = tester.widget<Container>(
find.ancestor(of: card, matching: find.byType(Container)).last,
);
final cardDecoration = cardContainer.decoration! as BoxDecoration;
expect(cardDecoration.color, AppColors.card);
expect(
cardDecoration.borderRadius,
BorderRadius.circular(AppDimens.cardRadius),
);
expect(cardDecoration.boxShadow, const [AppTheme.cardShadow]);
final tag = tester.widget<Container>(
find.byKey(const Key('home-drama-tag-style-drama')),
);
expect((tag.decoration! as BoxDecoration).color, AppColors.badgePink);
expect(find.text('Free'), findsOneWidget);
expect(find.text('All 24'), findsOneWidget);
expect(
find.byKey(const Key('home-drama-locked-style-drama')),
findsNothing,
);
expect(find.byKey(const Key('home-drama-add-style-drama')), findsNothing);
// 播放次数角标落在封面内,并按万位格式化。
final views = find.byKey(const Key('home-drama-views-style-drama'));
expect(
find.descendant(
of: find.byKey(const Key('home-drama-cover-style-drama')),
matching: views,
),
find.byKey(const Key('home-feed-badge-anime-anime')),
findsOneWidget,
);
expect(
find.descendant(of: views, matching: find.text('1.2万')),
find.byKey(const Key('home-feed-badge-newRelease-new')),
findsOneWidget,
);
// 压缩后卡片高度接近「卡宽 × 1.5」,明显矮于旧版 3:4 封面。
final cardSize = tester.getSize(card);
expect(cardSize.height, lessThan(cardSize.width * 1.6));
final coverSize = tester.getSize(
find.byKey(const Key('home-drama-cover-style-drama')),
);
expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02));
expect(find.byKey(const Key('home-feed-badge-hot-hot')), findsOneWidget);
});
}
const _drama = Drama(
id: 'style-drama',
title: 'Warm Orange Story',
synopsis: '暖橙短剧',
posterUrl: '/files/style.jpg',
genre: 'Romance',
totalEpisodes: 24,
freeEpisodes: 6,
viewCount: 12400,
);
Drama _drama({
required String id,
required String title,
String dramaType = 'LIVE',
int viewCount = 0,
bool isHot = false,
DateTime? createdAt,
}) {
return Drama(
id: id,
title: title,
synopsis: 'synopsis',
posterUrl: '/files/$id.jpg',
genre: 'Romance',
category: 'Urban',
viewCount: viewCount,
isHot: isHot,
dramaType: dramaType,
createdAt: createdAt,
);
}
Future<void> _pumpCard(
WidgetTester tester, {
required Drama drama,
required VoidCallback onTap,
}) {
return tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold(
body: Center(
child: SizedBox(
width: 130,
height: 240,
child: HomeFeedCard(
drama: drama,
onTap: onTap,
now: DateTime(2026, 10, 22, 12),
),
),
),
),
),
);
}
+252
View File
@@ -0,0 +1,252 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_category.dart';
import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
const _categories = [
ContentCategory(id: 'costume', name: '古装'),
ContentCategory(id: 'community', name: '社群'),
ContentCategory(id: 'uplifting', name: '爽剧'),
ContentCategory(id: 'sweet', name: '甜宠'),
ContentCategory(id: 'face-slap', name: '打脸虐渣'),
ContentCategory(id: 'angst', name: '虐恋'),
];
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_render_primary_sections_and_secondary_tags_in_order', (
tester,
) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
final sectionKeys = [
const Key('home-section-featured'),
const Key('home-section-new'),
const Key('home-section-categories'),
const Key('home-section-ranking'),
const Key('home-section-category-古装'),
const Key('home-section-category-社群'),
];
final sectionCenters = sectionKeys
.map((key) => tester.getCenter(find.byKey(key)).dx)
.toList();
expect(sectionCenters, orderedEquals(sectionCenters.toList()..sort()));
final tagKeys = [
const Key('home-tag-hot'),
const Key('home-tag-uplifting'),
const Key('home-tag-sweet'),
const Key('home-tag-faceSlap'),
const Key('home-tag-angst'),
];
final tagCenters = tagKeys
.map((key) => tester.getCenter(find.byKey(key)).dx)
.toList();
expect(tagCenters, orderedEquals(tagCenters.toList()..sort()));
expect(find.text('Featured'), findsOneWidget);
expect(find.text('New'), findsOneWidget);
expect(find.text('Categories'), findsOneWidget);
expect(find.text('Charts'), findsOneWidget);
expect(find.text('Costume'), findsOneWidget);
expect(find.text('Community'), findsOneWidget);
});
testWidgets('should_hide_unmatched_content_sections', (tester) async {
final repository = _PagedCatalogRepository();
await _pumpApp(
tester,
repository,
categories: const [ContentCategory(id: 'romance', name: 'Romance')],
);
expect(find.byKey(const Key('home-section-category-古装')), findsNothing);
expect(find.byKey(const Key('home-section-category-社群')), findsNothing);
expect(find.byKey(const Key('home-tag-uplifting')), findsNothing);
});
testWidgets('should_open_existing_pages_from_home_sections', (tester) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
await tester.tap(find.byKey(const Key('home-section-new')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('new-releases-page')), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('home-section-ranking')));
await tester.tap(find.byKey(const Key('home-section-ranking')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('ranking-page')), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('home-section-categories')),
);
await tester.tap(find.byKey(const Key('home-section-categories')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('filter-page')), findsOneWidget);
});
testWidgets('should_load_next_page_without_expanding_page_size', (
tester,
) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
expect(find.byKey(const Key('home-feed-card-page1-0')), findsOneWidget);
await tester.drag(
find.byKey(const Key('home-grid-scroll')),
const Offset(0, -1200),
);
await tester.pumpAndSettle();
expect(repository.cursors, contains('page1'));
expect(repository.requestedSizes.every((size) => size <= 10), isTrue);
await tester.scrollUntilVisible(
find.byKey(const Key('home-feed-card-page2-1')),
260,
scrollable: find
.descendant(
of: find.byKey(const Key('home-grid-refresh')),
matching: find.byType(Scrollable),
)
.first,
);
expect(find.byKey(const Key('home-feed-card-page2-1')), findsOneWidget);
});
testWidgets('should_open_player_when_grid_card_is_tapped', (tester) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
await tester.tap(find.byKey(const Key('home-feed-card-page1-0')));
await tester.pumpAndSettle();
final player = tester.widget<PlayerPage>(find.byType(PlayerPage).last);
expect(player.homeMode, isFalse);
});
}
Future<void> _pumpApp(
WidgetTester tester,
_PagedCatalogRepository repository, {
List<ContentCategory> categories = _categories,
}) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: repository,
categoryRepository: FakeCategoryRepository(
catalog: repository,
categories: categories,
),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(
cards: repository.allDramas,
),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
haptics: FakeHaptics(),
),
);
await tester.pumpAndSettle();
}
class _PagedCatalogRepository implements CatalogRepository {
_PagedCatalogRepository()
: pageOne = List.generate(10, (index) => _drama('page1-$index')),
pageTwo = List.generate(2, (index) => _drama('page2-$index'));
final List<Drama> pageOne;
final List<Drama> pageTwo;
final List<String?> cursors = [];
final List<int> requestedSizes = [];
List<Drama> get allDramas => [...pageOne, ...pageTwo];
@override
Future<HomeFeed> fetchHomeFeed() async => _feed(pageOne);
@override
Future<HomeFeed> fetchDiscoverFeed() async => _feed(pageOne);
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async {
requestedSizes.add(size);
if (types.isNotEmpty) {
return const FeedPage(dramas: []);
}
cursors.add(cursor);
if (cursor == 'page1') {
return FeedPage(dramas: pageTwo, hasMore: false);
}
return FeedPage(dramas: pageOne, nextCursor: 'page1', hasMore: true);
}
@override
Future<Drama> fetchDrama(String dramaId) async {
return allDramas.firstWhere((drama) => drama.id == dramaId);
}
HomeFeed _feed(List<Drama> dramas) {
return HomeFeed(
sections: [FeedSection(code: 'hot', title: 'Hot', dramas: dramas)],
);
}
}
Drama _drama(String id) {
return Drama(
id: id,
title: 'Grid Drama $id',
synopsis: 'synopsis',
posterUrl: '',
genre: 'Romance',
category: 'Urban',
viewCount: 200000,
detailLoaded: true,
episodes: [
Episode(
id: '$id-1',
number: 1,
title: 'Episode 1',
isFree: true,
videoUrl: 'https://example.test/$id.mp4',
),
],
);
}
+4 -4
View File
@@ -159,11 +159,11 @@ void main() {
test('should_map_onto_root_tabs_when_my_page_actions_fire', () {
final logic = HomeLogic(dramas);
logic.myAction(MyPageAction.discover);
logic.myAction(MyPageAction.recommend);
expect(logic.tab, 1);
expect(logic.searchRequest, 1);
logic.myAction(MyPageAction.create);
logic.myAction(MyPageAction.following);
expect(logic.tab, 2);
logic.myAction(MyPageAction.resume);
@@ -179,10 +179,10 @@ void main() {
test('should_map_onto_root_tabs_when_discover_actions_fire', () {
final logic = HomeLogic(dramas);
logic.discoverAction(DiscoverPageAction.my);
logic.discoverAction(DiscoverPageAction.mine);
expect(logic.tab, 3);
logic.discoverAction(DiscoverPageAction.create);
logic.discoverAction(DiscoverPageAction.following);
expect(logic.tab, 2);
logic.discoverAction(DiscoverPageAction.home);
+108 -32
View File
@@ -7,6 +7,8 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
import 'package:fruitshorts/pages/favorites/favorites_page.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
@@ -19,8 +21,8 @@ import '../support/widget_player.dart';
const _keys = [
Key('home-tabs-bar'),
Key('tab-home'),
Key('tab-theater'),
Key('tab-create'),
Key('tab-recommend'),
Key('tab-follow'),
Key('tab-mine'),
];
@@ -47,25 +49,55 @@ void main() {
for (final key in _keys.skip(1)) {
expect(tester.getSize(find.byKey(key)).height, greaterThanOrEqualTo(48));
}
final tabCenters = _keys
.skip(1)
.map((key) => tester.getCenter(find.byKey(key)).dx)
.toList();
expect(tabCenters, orderedEquals(tabCenters.toList()..sort()));
expect(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.text('Home'),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-recommend')),
matching: find.text('Recommend'),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-follow')),
matching: find.text('Following'),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-mine')),
matching: find.text('My'),
),
findsOneWidget,
);
final barFinder = find.byKey(const Key('home-tabs-bar'));
final bar = tester.widget<Container>(barFinder);
final decoration = bar.decoration as BoxDecoration;
final border = decoration.border as Border;
// 首页 feed 用深色导航。
expect(decoration.color, Colors.black);
expect(border.top.color, Colors.white12);
expect(bar.color, AppColors.navBackground);
expect(bar.decoration, isNull);
expect(
tester
.widget<Text>(
find.descendant(
of: find.byKey(const Key('tab-theater')),
matching: find.text('Theater'),
of: find.byKey(const Key('tab-recommend')),
matching: find.text('Recommend'),
),
)
.style
?.color,
Colors.white70,
AppColors.navUnselected,
);
expect(
tester
@@ -85,6 +117,27 @@ void main() {
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-recommend')),
matching: find.byIcon(Icons.smart_display_outlined),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-follow')),
matching: find.byIcon(Icons.bookmark_border_rounded),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const Key('tab-mine')),
matching: find.byIcon(Icons.person_outline_rounded),
),
findsOneWidget,
);
expect(
tester
.widget<Icon>(
@@ -94,23 +147,22 @@ void main() {
),
)
.color,
AppColors.blue,
AppColors.navSelected,
);
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
expect(haptics.selectionClicks, 1);
final lightBar = tester.widget<Container>(barFinder);
final lightDecoration = lightBar.decoration as BoxDecoration;
expect(lightDecoration.color, AppColors.bar.withValues(alpha: 0.95));
expect((lightDecoration.border as Border).top.color, AppColors.border);
await tester.tap(find.byKey(const Key('tab-theater')));
final theaterBar = tester.widget<Container>(barFinder);
expect(theaterBar.color, AppColors.navBackground);
expect(theaterBar.decoration, isNull);
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
expect(haptics.selectionClicks, 1);
expect(
find.descendant(
of: find.byKey(const Key('tab-theater')),
matching: find.byIcon(Icons.explore_rounded),
of: find.byKey(const Key('tab-recommend')),
matching: find.byIcon(Icons.smart_display_rounded),
),
findsOneWidget,
);
@@ -118,13 +170,13 @@ void main() {
tester
.widget<Text>(
find.descendant(
of: find.byKey(const Key('tab-theater')),
matching: find.text('Theater'),
of: find.byKey(const Key('tab-recommend')),
matching: find.text('Recommend'),
),
)
.style
?.color,
AppColors.blue,
AppColors.navSelected,
);
expect(
tester
@@ -147,35 +199,59 @@ void main() {
);
});
testWidgets('should_map_four_tabs_to_home_theater_create_and_me', (
testWidgets('should_map_four_tabs_to_home_recommend_following_and_me', (
tester,
) async {
await _pumpApp(tester);
expect(find.byKey(const Key('tab-home')), findsOneWidget);
expect(find.byKey(const Key('tab-theater')), findsOneWidget);
expect(find.byKey(const Key('tab-create')), findsOneWidget);
expect(find.byKey(const Key('tab-recommend')), findsOneWidget);
expect(find.byKey(const Key('tab-follow')), findsOneWidget);
expect(find.byKey(const Key('tab-mine')), findsOneWidget);
expect(find.byKey(const Key('tab-create')), findsNothing);
expect(find.byKey(const Key('home-grid')), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-create')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('create-page')), findsOneWidget);
final favorites = find.byType(FavoritesContent);
expect(favorites, findsOneWidget);
expect(find.byKey(const Key('favorites-page')), findsOneWidget);
expect(find.byKey(const Key('favorites-back')), findsNothing);
expect(find.byType(AppBottomNav), findsOneWidget);
expect(
find.descendant(of: favorites, matching: find.byType(Scaffold)),
findsNothing,
);
expect(Navigator.canPop(tester.element(favorites)), isFalse);
expect(
find.descendant(
of: find.byKey(const Key('tab-follow')),
matching: find.byIcon(Icons.bookmark_rounded),
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('tab-mine')));
await tester.pumpAndSettle();
expect(find.text('Viewer'), findsOneWidget);
expect(find.byKey(const Key('mine-menu')), findsOneWidget);
expect(find.byKey(const Key('mine-watchlist')), findsOneWidget);
expect(find.byKey(const Key('mine-creator-center')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(favorites, findsOneWidget);
expect(find.byType(AppBottomNav), findsOneWidget);
expect(Navigator.canPop(tester.element(favorites)), isFalse);
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-grid')), findsOneWidget);
});
testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
+61 -6
View File
@@ -9,6 +9,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
@@ -20,6 +21,7 @@ import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:video_player/video_player.dart';
import '../support/fake_catalog.dart';
@@ -30,7 +32,9 @@ import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_render_immersive_feed_on_home', (tester) async {
testWidgets('should_render_three_column_explorer_grid_on_home', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
tester.view.physicalSize = const Size(390, 844);
@@ -48,6 +52,58 @@ void main() {
),
],
);
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(
catalog: catalog,
categories: const [
ContentCategory(id: 'costume', name: '古装'),
ContentCategory(id: 'community', name: '社群'),
],
),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(),
haptics: FakeHaptics(),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-grid')), findsOneWidget);
expect(find.byKey(const Key('home-grid-cards')), findsOneWidget);
final grid = tester.widget<SliverGrid>(
find.byKey(const Key('home-grid-cards')),
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 3);
expect(delegate.crossAxisSpacing, 8);
expect(delegate.mainAxisSpacing, 8);
expect(find.byKey(const Key('home-section-featured')), findsOneWidget);
expect(find.byKey(const Key('home-section-new')), findsOneWidget);
expect(find.byKey(const Key('home-section-categories')), findsOneWidget);
expect(find.byKey(const Key('home-section-ranking')), findsOneWidget);
expect(find.byKey(const Key('home-section-category-古装')), findsOneWidget);
expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget);
expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget);
expect(find.byKey(const Key('home-grid-filter')), findsOneWidget);
expect(find.byType(PageView), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('should_keep_immersive_player_on_recommend_tab', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
@@ -63,14 +119,13 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
expect(find.byKey(const Key('home-grid')), findsNothing);
expect(find.byType(PageView), findsOneWidget);
expect(find.byKey(const Key('home-browse-page')), findsNothing);
expect(tester.takeException(), isNull);
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
});
testWidgets('should_render_new_feed_structure_with_formatted_counts', (
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_view.dart';
import 'package:fruitshorts/pages/home/widgets/home_failure_view.dart';
void main() {
setUp(() {
+1 -1
View File
@@ -123,7 +123,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('startup-splash')), findsNothing);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-grid')), findsOneWidget);
});
}
+53 -107
View File
@@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
@@ -27,7 +28,7 @@ void main() {
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_render_four_tabs_and_open_create_placeholder', (
testWidgets('should_render_four_tabs_and_open_following_page', (
tester,
) async {
await _pumpApp(tester);
@@ -35,59 +36,28 @@ void main() {
for (final key in _tabKeys) {
expect(find.byKey(key), findsOneWidget);
}
expect(find.byKey(const Key('tab-create')), findsNothing);
await tester.tap(find.byKey(const Key('tab-create')));
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('create-page')), findsOneWidget);
expect(find.text('Create'), findsWidgets);
expect(find.text('Stories become short dramas'), findsOneWidget);
expect(find.byKey(const Key('create-ai')), findsOneWidget);
expect(find.byKey(const Key('create-upload')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('create-ai'))).height, 196);
expect(find.byKey(const Key('create-motto-primary')), findsOneWidget);
expect(find.byKey(const Key('create-motto-secondary')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('create-motto-line'))),
const Size(96, 2),
);
await tester.tap(find.byKey(const Key('create-ai')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('create-placeholder-page')), findsOneWidget);
expect(find.text('Coming soon'), findsOneWidget);
expect(find.byKey(const Key('favorites-page')), findsOneWidget);
expect(find.byKey(const Key('favorites-back')), findsNothing);
expect(find.byType(AppBottomNav), findsOneWidget);
});
testWidgets('should_start_home_on_immersive_feed', (tester) async {
testWidgets('should_start_home_on_explorer_grid', (tester) async {
await _pumpApp(tester);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
expect(find.byKey(const Key('home-feed-black-background')), findsOneWidget);
expect(find.byType(PageView), findsOneWidget);
expect(find.byKey(const Key('home-browse-page')), findsNothing);
expect(
tester.widget<Text>(find.byKey(const Key('rail-like-count'))).data,
'41.9万',
);
expect(
tester.widget<Text>(find.byKey(const Key('rail-save-count'))).data,
'1006',
);
expect(
tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data,
'3.2万',
);
expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget);
expect(find.byKey(const Key('home-feed-danmaku')), findsNothing);
expect(find.byKey(const Key('home-feed-fullscreen')), findsNothing);
expect(find.byKey(const Key('home-grid')), findsOneWidget);
expect(find.byKey(const Key('home-grid-cards')), findsOneWidget);
expect(find.byType(PageView), findsNothing);
expect(
tester.widget<Scaffold>(find.byType(Scaffold).first).backgroundColor,
Colors.transparent,
);
expect(find.byKey(const Key('home-page-gradient')), findsNothing);
expect(_hasLightStatusBar(tester), isTrue);
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue);
});
testWidgets('should_keep_home_feed_size_when_keyboard_insets_change', (
@@ -95,7 +65,7 @@ void main() {
) async {
addTearDown(tester.view.resetViewInsets);
await _pumpApp(tester);
final feed = find.byKey(const Key('home-feed-black-background'));
final feed = find.byKey(const Key('home-grid'));
final before = tester.getRect(feed);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
@@ -111,7 +81,7 @@ void main() {
});
testWidgets('should_open_full_player_from_full_series_bar', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle();
@@ -121,7 +91,7 @@ void main() {
});
testWidgets('should_switch_to_anime_channel', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
await tester.pumpAndSettle();
@@ -138,7 +108,7 @@ void main() {
tester,
) async {
final catalog = _PendingAnimeCatalog();
await _pumpApp(tester, catalog: catalog);
await _pumpApp(tester, catalog: catalog, recommend: true);
for (var attempt = 0; attempt < 5 && catalog.animeCalls == 0; attempt++) {
await tester.pump();
}
@@ -163,7 +133,7 @@ void main() {
testWidgets('should_show_empty_state_when_anime_has_no_dramas', (
tester,
) async {
await _pumpApp(tester, catalog: _EmptyAnimeCatalog());
await _pumpApp(tester, catalog: _EmptyAnimeCatalog(), recommend: true);
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
await tester.pumpAndSettle();
@@ -173,7 +143,7 @@ void main() {
});
testWidgets('should_refresh_feed_without_blank_loading', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
await tester.tap(find.byKey(const Key('home-feed-refresh')));
await tester.pump();
@@ -185,7 +155,7 @@ void main() {
});
testWidgets('should_keep_feed_rail_above_bottom_panels', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
final railTop = tester.getCenter(find.byKey(const Key('rail-save'))).dy;
final railBottom = tester
.getRect(find.byKey(const Key('rail-share')))
@@ -206,7 +176,7 @@ void main() {
testWidgets('should_keep_feed_header_and_progress_after_clear', (
tester,
) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
final gesture = await tester.startGesture(const Offset(195, 300));
await tester.pump(const Duration(milliseconds: 700));
await tester.pump();
@@ -235,7 +205,7 @@ void main() {
});
testWidgets('should_boost_feed_on_side_long_press', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
final gesture = await tester.startGesture(const Offset(60, 300));
@@ -257,7 +227,7 @@ void main() {
});
testWidgets('should_hide_exit_clear_button_while_boosting', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
final moreGesture = await tester.startGesture(const Offset(195, 300));
await tester.pump(const Duration(milliseconds: 700));
await tester.pump();
@@ -294,7 +264,7 @@ void main() {
testWidgets('should_resume_playback_when_boosting_while_paused', (
tester,
) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
await tester.tap(find.byKey(const Key('home-feed-video-toggle')));
await tester.pump();
expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget);
@@ -314,7 +284,7 @@ void main() {
});
testWidgets('should_open_more_sheet_on_center_long_press', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
final gesture = await tester.startGesture(const Offset(195, 300));
await tester.pump(const Duration(milliseconds: 700));
@@ -328,21 +298,22 @@ void main() {
expect(find.byKey(const Key('home-feed-pip')), findsNothing);
});
testWidgets('should_restore_light_theme_after_leaving_feed_tab', (
testWidgets('should_keep_light_status_bar_in_grid_and_recommend', (
tester,
) async {
await _pumpApp(tester);
expect(_hasLightStatusBar(tester), isTrue);
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-page-gradient')), findsOneWidget);
expect(_hasLightStatusBar(tester), isFalse);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-page-gradient')), findsNothing);
expect(_hasLightStatusBar(tester), isTrue);
});
testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
final before = tester
.widget<Text>(find.byKey(const Key('home-feed-title')))
.data;
@@ -357,7 +328,7 @@ void main() {
});
testWidgets('should_keep_feed_header_fixed_when_swiping', (tester) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
final before = tester.getRect(find.byKey(const Key('home-feed-search')));
await tester.drag(find.byType(PageView).first, const Offset(0, -600));
@@ -372,7 +343,7 @@ void main() {
testWidgets('should_resume_feed_after_returning_from_full_player', (
tester,
) async {
await _pumpApp(tester);
await _pumpApp(tester, recommend: true);
await tester.tap(find.byKey(const Key('home-feed-video-toggle')));
await tester.pump();
expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget);
@@ -391,23 +362,21 @@ void main() {
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
});
testWidgets('should_hide_feed_when_switching_to_theater', (tester) async {
testWidgets('should_switch_from_grid_to_recommend_player', (tester) async {
await _pumpApp(tester);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-grid')), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-title')), findsNothing);
expect(find.byKey(const Key('discover-search')), findsOneWidget);
expect(find.byKey(const Key('home-grid')), findsNothing);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
});
testWidgets('should_open_theater_search_when_feed_search_is_tapped', (
tester,
) async {
testWidgets('should_open_discover_search_from_home_search', (tester) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('home-feed-search')));
await tester.tap(find.byKey(const Key('home-search-entry')));
await tester.pumpAndSettle();
final field = tester.widget<TextField>(
@@ -416,41 +385,12 @@ void main() {
expect(field.focusNode?.hasFocus, isTrue);
});
testWidgets('should_not_render_theater_language_filters', (tester) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('theater-language-chips')), findsNothing);
expect(find.byKey(const Key('theater-category-chips')), findsOneWidget);
expect(find.byKey(const Key('discover-search')), findsOneWidget);
expect(find.byKey(const Key('discover-refresh')), findsOneWidget);
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsOneWidget,
);
});
testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', (
testWidgets('should_open_player_when_home_grid_card_is_tapped', (
tester,
) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.pumpAndSettle();
final card = find.byKey(const Key('home-drama-card-the-signal-demo'));
await tester.scrollUntilVisible(
card,
220,
scrollable: find
.descendant(
of: find.byKey(const Key('discover-refresh')),
matching: find.byType(Scrollable),
)
.first,
);
final card = find.byKey(const Key('home-feed-card-the-signal-demo'));
await tester.ensureVisible(card);
await tester.pumpAndSettle();
await tester.tap(card);
await tester.pumpAndSettle();
@@ -458,24 +398,25 @@ void main() {
expect(player.homeMode, isFalse);
});
testWidgets('should_fit_home_feed_and_theater_on_320_width', (tester) async {
testWidgets('should_fit_home_grid_and_recommend_on_320_width', (
tester,
) async {
await _pumpApp(tester, size: const Size(320, 568));
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-grid-cards')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('theater-category-chips')), findsOneWidget);
expect(find.byKey(const Key('theater-language-chips')), findsNothing);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
const _tabKeys = [
Key('tab-home'),
Key('tab-theater'),
Key('tab-create'),
Key('tab-recommend'),
Key('tab-follow'),
Key('tab-mine'),
];
@@ -489,6 +430,7 @@ Future<void> _pumpApp(
WidgetTester tester, {
Size size = const Size(390, 844),
FakeCatalogRepository? catalog,
bool recommend = false,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
@@ -509,6 +451,10 @@ Future<void> _pumpApp(
),
);
await tester.pumpAndSettle();
if (recommend) {
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
}
}
class _PendingAnimeCatalog extends FakeCatalogRepository {
+2 -2
View File
@@ -91,11 +91,11 @@ void main() {
logic.act(
pageContext,
MyPageAction.discover,
MyPageAction.recommend,
embedded: true,
onAction: forwarded.add,
);
expect(forwarded, [MyPageAction.discover]);
expect(forwarded, [MyPageAction.recommend]);
logic.act(pageContext, MyPageAction.resume, embedded: false);
await tester.pumpAndSettle();
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more