merge: 重排我的页并新增迷你播放器
This commit is contained in:
commit
5cead7ce00
22 files changed
+899
-531
No files matched your search
@@ -1000,3 +1000,42 @@ $ flutter test
|
||||
解读:三 tab、双列网格、后端历史元数据直出、编辑删除确认、最后一条自动
|
||||
退出编辑、未登录与空态均有测试覆盖;`following_page.dart` 低于 500 行。
|
||||
`pubspec.lock` 未提交,未操作真机。
|
||||
|
||||
## 2026-10-22 「我的」页布局重排与迷你播放器
|
||||
|
||||
- 分支:`feature/mine-layout-redesign`
|
||||
- 基线:`develop` / `origin/develop` = `5aa8170`
|
||||
- 实现提交:`cbc6d80 feat(mine): 重排我的页并新增迷你播放器`
|
||||
|
||||
### 页面与入口
|
||||
|
||||
- 用户区改为 56 圆形头像、20/w700 昵称与 13 灰色 UID;UID 支持复制,
|
||||
未登录显示 Visitor 与登录按钮,已登录显示编辑按钮。
|
||||
- VIP 改为金色渐变通栏卡,品牌文案走 l10n,右侧开通按钮复用现有 VIP 页。
|
||||
- 删除收藏/历史/下载三宫格;单列菜单固定为钱包、离线缓存、播放历史、
|
||||
观看偏好、联系客服、语言、设置,行高 56、图标 22、仅行间显示分割线。
|
||||
- 收藏由底部追剧 tab 承载;历史、下载、钱包、偏好、客服、语言、设置均
|
||||
复用现有页面。我的作品、创作者中心与通知保留在登录后的编辑操作面板,
|
||||
设置页中的通知入口继续可用。
|
||||
- 我的页 token 收敛到 `AppColors/AppDimens`:背景纯黑、卡片 `#16161A`、
|
||||
16 圆角、极淡描边、金色渐变仅用于 VIP。
|
||||
- 左下角迷你播放器为 96x132,直接使用观看历史首条数据,展示封面、剧名和
|
||||
集号;无记录或关闭后不占位,点击按记录集号进入全屏播放器。
|
||||
- 删除三宫格对应的 `localViewer/watchlist` 废弃 l10n key 与旧组件代码。
|
||||
|
||||
### 门禁命令与原始输出
|
||||
|
||||
```text
|
||||
$ flutter analyze
|
||||
Analyzing reelshort-mine-redesign...
|
||||
No issues found! (ran in 2.1s)
|
||||
```
|
||||
|
||||
```text
|
||||
$ flutter test
|
||||
01:01 +647: All tests passed!
|
||||
```
|
||||
|
||||
解读:用户区、UID、登录/编辑、VIP、7 项菜单顺序与跳转、旧入口可达性、
|
||||
迷你播放器显隐/关闭/开播及无数据不渲染均有测试覆盖。`mine_view.dart`
|
||||
与新增子组件均低于 500 行;`pubspec.lock` 未提交,未操作真机。
|
||||
@@ -39,6 +39,9 @@ abstract final class AppColors {
|
||||
/// 金币与奖励。
|
||||
static const gold = Color(0xFFF3B34C);
|
||||
static const goldDeep = Color(0xFFD9A23A);
|
||||
static const vipGoldStart = Color(0xFFF5D9A0);
|
||||
static const vipGoldEnd = Color(0xFFE8B65A);
|
||||
static const vipBadgeSurface = Color(0x1F3D2408);
|
||||
static const bronze = Color(0xFFCD7F32);
|
||||
static const bronzeDeep = Color(0xFFA9713A);
|
||||
|
||||
@@ -69,6 +72,10 @@ abstract final class AppColors {
|
||||
static const contentChip = Color(0xFF2C2C2E);
|
||||
static const contentBorder = Color(0x1AFFFFFF);
|
||||
|
||||
/// 「我的」页卡片与描边。
|
||||
static const mineCard = Color(0xFF16161A);
|
||||
static const mineOutline = Color(0xFF4A4A4A);
|
||||
|
||||
/// 次级文字与列表 chevron。
|
||||
static const textPrimary = Color(0xFFFFFFFF);
|
||||
static const textSecondary = Color(0xFF8E8E93);
|
||||
|
||||
@@ -90,7 +90,16 @@ abstract final class AppDimens {
|
||||
|
||||
/// 「我的」页中性视觉。
|
||||
static const double mineCardRadius = 16;
|
||||
static const double mineRowHeight = 52;
|
||||
static const double mineRowHeight = 56;
|
||||
static const double minePageTitle = 21;
|
||||
static const double mineSectionTitle = 15;
|
||||
static const double mineAvatar = 56;
|
||||
static const double mineUserName = 20;
|
||||
static const double mineUid = 13;
|
||||
static const double mineMenuIcon = 22;
|
||||
static const double mineMiniWidth = 96;
|
||||
static const double mineMiniHeight = 132;
|
||||
static const double mineMiniRadius = 8;
|
||||
static const double mineMiniTitle = 10;
|
||||
static const double mineMiniClose = 22;
|
||||
}
|
||||
+8
-2
@@ -115,7 +115,6 @@
|
||||
"emptyHint": "New content will appear here.",
|
||||
"demoCatalogNotice": "Three demo dramas share the same local video.",
|
||||
"myTab": "My",
|
||||
"localViewer": "Local Viewer",
|
||||
"localDemoProfile": "Local demo profile",
|
||||
"continueWatching": "Continue Watching",
|
||||
"episodeRemaining": "Episode {episode} · {minutes} min left",
|
||||
@@ -153,7 +152,6 @@
|
||||
},
|
||||
"resume": "Resume",
|
||||
"history": "History",
|
||||
"watchlist": "Favorites",
|
||||
"likedTab": "Liked",
|
||||
"all": "All",
|
||||
"inProgress": "In Progress",
|
||||
@@ -733,6 +731,14 @@
|
||||
"modernFilter": "Modern",
|
||||
"edit": "Edit",
|
||||
"done": "Done",
|
||||
"visitor": "Visitor",
|
||||
"copyUid": "Copy UID",
|
||||
"uidCopied": "UID copied",
|
||||
"offlineDownloads": "Offline downloads",
|
||||
"watchPreferences": "Watch preferences",
|
||||
"contactSupport": "Contact support",
|
||||
"vipBrandName": "Netshort VIP",
|
||||
"mineVipSubtitle": "Enjoy exclusive benefits",
|
||||
"favoriteEpisodeProgress": "Episode {episode} / {total} total",
|
||||
"@favoriteEpisodeProgress": {
|
||||
"placeholders": {
|
||||
|
||||
+8
-2
@@ -94,7 +94,6 @@
|
||||
"emptyHint": "Konten baru akan muncul di sini.",
|
||||
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
|
||||
"myTab": "Saya",
|
||||
"localViewer": "Penonton Lokal",
|
||||
"localDemoProfile": "Profil demo lokal",
|
||||
"continueWatching": "Lanjut Tonton",
|
||||
"episodeRemaining": "Episode {episode} · {minutes} menit tersisa",
|
||||
@@ -132,7 +131,6 @@
|
||||
},
|
||||
"resume": "Lanjutkan",
|
||||
"history": "Riwayat",
|
||||
"watchlist": "Daftar Tonton",
|
||||
"likedTab": "Disukai",
|
||||
"all": "Semua",
|
||||
"inProgress": "Sedang Berjalan",
|
||||
@@ -690,6 +688,14 @@
|
||||
"modernFilter": "Modern",
|
||||
"edit": "Edit",
|
||||
"done": "Selesai",
|
||||
"visitor": "Pengunjung",
|
||||
"copyUid": "Salin UID",
|
||||
"uidCopied": "UID disalin",
|
||||
"offlineDownloads": "Unduhan offline",
|
||||
"watchPreferences": "Preferensi tontonan",
|
||||
"contactSupport": "Hubungi dukungan",
|
||||
"vipBrandName": "Netshort VIP",
|
||||
"mineVipSubtitle": "Nikmati manfaat eksklusif",
|
||||
"favoriteEpisodeProgress": "Episode {episode} / total {total}",
|
||||
"@favoriteEpisodeProgress": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -568,12 +568,6 @@ abstract class AppLocalizations {
|
||||
/// **'My'**
|
||||
String get myTab;
|
||||
|
||||
/// No description provided for @localViewer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Local Viewer'**
|
||||
String get localViewer;
|
||||
|
||||
/// No description provided for @localDemoProfile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -628,12 +622,6 @@ abstract class AppLocalizations {
|
||||
/// **'History'**
|
||||
String get history;
|
||||
|
||||
/// No description provided for @watchlist.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favorites'**
|
||||
String get watchlist;
|
||||
|
||||
/// No description provided for @likedTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3208,6 +3196,54 @@ abstract class AppLocalizations {
|
||||
/// **'Done'**
|
||||
String get done;
|
||||
|
||||
/// No description provided for @visitor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Visitor'**
|
||||
String get visitor;
|
||||
|
||||
/// No description provided for @copyUid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copy UID'**
|
||||
String get copyUid;
|
||||
|
||||
/// No description provided for @uidCopied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'UID copied'**
|
||||
String get uidCopied;
|
||||
|
||||
/// No description provided for @offlineDownloads.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline downloads'**
|
||||
String get offlineDownloads;
|
||||
|
||||
/// No description provided for @watchPreferences.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch preferences'**
|
||||
String get watchPreferences;
|
||||
|
||||
/// No description provided for @contactSupport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Contact support'**
|
||||
String get contactSupport;
|
||||
|
||||
/// No description provided for @vipBrandName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Netshort VIP'**
|
||||
String get vipBrandName;
|
||||
|
||||
/// No description provided for @mineVipSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enjoy exclusive benefits'**
|
||||
String get mineVipSubtitle;
|
||||
|
||||
/// No description provided for @favoriteEpisodeProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -261,9 +261,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get myTab => 'My';
|
||||
|
||||
@override
|
||||
String get localViewer => 'Local Viewer';
|
||||
|
||||
@override
|
||||
String get localDemoProfile => 'Local demo profile';
|
||||
|
||||
@@ -301,9 +298,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get history => 'History';
|
||||
|
||||
@override
|
||||
String get watchlist => 'Favorites';
|
||||
|
||||
@override
|
||||
String get likedTab => 'Liked';
|
||||
|
||||
@@ -1671,6 +1665,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get done => 'Done';
|
||||
|
||||
@override
|
||||
String get visitor => 'Visitor';
|
||||
|
||||
@override
|
||||
String get copyUid => 'Copy UID';
|
||||
|
||||
@override
|
||||
String get uidCopied => 'UID copied';
|
||||
|
||||
@override
|
||||
String get offlineDownloads => 'Offline downloads';
|
||||
|
||||
@override
|
||||
String get watchPreferences => 'Watch preferences';
|
||||
|
||||
@override
|
||||
String get contactSupport => 'Contact support';
|
||||
|
||||
@override
|
||||
String get vipBrandName => 'Netshort VIP';
|
||||
|
||||
@override
|
||||
String get mineVipSubtitle => 'Enjoy exclusive benefits';
|
||||
|
||||
@override
|
||||
String favoriteEpisodeProgress(int episode, int total) {
|
||||
return 'Episode $episode / $total total';
|
||||
|
||||
@@ -261,9 +261,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get myTab => 'Saya';
|
||||
|
||||
@override
|
||||
String get localViewer => 'Penonton Lokal';
|
||||
|
||||
@override
|
||||
String get localDemoProfile => 'Profil demo lokal';
|
||||
|
||||
@@ -301,9 +298,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get history => 'Riwayat';
|
||||
|
||||
@override
|
||||
String get watchlist => 'Daftar Tonton';
|
||||
|
||||
@override
|
||||
String get likedTab => 'Disukai';
|
||||
|
||||
@@ -1673,6 +1667,30 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get done => 'Selesai';
|
||||
|
||||
@override
|
||||
String get visitor => 'Pengunjung';
|
||||
|
||||
@override
|
||||
String get copyUid => 'Salin UID';
|
||||
|
||||
@override
|
||||
String get uidCopied => 'UID disalin';
|
||||
|
||||
@override
|
||||
String get offlineDownloads => 'Unduhan offline';
|
||||
|
||||
@override
|
||||
String get watchPreferences => 'Preferensi tontonan';
|
||||
|
||||
@override
|
||||
String get contactSupport => 'Hubungi dukungan';
|
||||
|
||||
@override
|
||||
String get vipBrandName => 'Netshort VIP';
|
||||
|
||||
@override
|
||||
String get mineVipSubtitle => 'Nikmati manfaat eksklusif';
|
||||
|
||||
@override
|
||||
String favoriteEpisodeProgress(int episode, int total) {
|
||||
return 'Episode $episode / total $total';
|
||||
|
||||
@@ -256,9 +256,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get myTab => '我的';
|
||||
|
||||
@override
|
||||
String get localViewer => '本地观众';
|
||||
|
||||
@override
|
||||
String get localDemoProfile => '本地演示档案';
|
||||
|
||||
@@ -296,9 +293,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get history => '历史';
|
||||
|
||||
@override
|
||||
String get watchlist => '收藏';
|
||||
|
||||
@override
|
||||
String get likedTab => '喜欢';
|
||||
|
||||
@@ -1647,6 +1641,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get done => '完成';
|
||||
|
||||
@override
|
||||
String get visitor => '访客';
|
||||
|
||||
@override
|
||||
String get copyUid => '复制 UID';
|
||||
|
||||
@override
|
||||
String get uidCopied => 'UID 已复制';
|
||||
|
||||
@override
|
||||
String get offlineDownloads => '离线缓存';
|
||||
|
||||
@override
|
||||
String get watchPreferences => '观看偏好';
|
||||
|
||||
@override
|
||||
String get contactSupport => '联系客服';
|
||||
|
||||
@override
|
||||
String get vipBrandName => 'Netshort VIP';
|
||||
|
||||
@override
|
||||
String get mineVipSubtitle => '享受专属权益';
|
||||
|
||||
@override
|
||||
String favoriteEpisodeProgress(int episode, int total) {
|
||||
return '第 $episode 集 / 共 $total 集';
|
||||
|
||||
+8
-2
@@ -94,7 +94,6 @@
|
||||
"emptyHint": "有新内容时会显示在这里。",
|
||||
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
|
||||
"myTab": "我的",
|
||||
"localViewer": "本地观众",
|
||||
"localDemoProfile": "本地演示档案",
|
||||
"continueWatching": "继续观看",
|
||||
"episodeRemaining": "第 {episode} 集 · 还剩 {minutes} 分钟",
|
||||
@@ -132,7 +131,6 @@
|
||||
},
|
||||
"resume": "继续播放",
|
||||
"history": "历史",
|
||||
"watchlist": "收藏",
|
||||
"likedTab": "喜欢",
|
||||
"all": "全部",
|
||||
"inProgress": "未看完",
|
||||
@@ -652,6 +650,14 @@
|
||||
"modernFilter": "现代",
|
||||
"edit": "编辑",
|
||||
"done": "完成",
|
||||
"visitor": "访客",
|
||||
"copyUid": "复制 UID",
|
||||
"uidCopied": "UID 已复制",
|
||||
"offlineDownloads": "离线缓存",
|
||||
"watchPreferences": "观看偏好",
|
||||
"contactSupport": "联系客服",
|
||||
"vipBrandName": "Netshort VIP",
|
||||
"mineVipSubtitle": "享受专属权益",
|
||||
"favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集",
|
||||
"@favoriteEpisodeProgress": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -324,17 +324,15 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
|
||||
Future<void> _openWallet(BuildContext context) async {
|
||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
||||
await Navigator.of(context)
|
||||
.push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
|
||||
}
|
||||
|
||||
Widget _my(BuildContext context) {
|
||||
return MyPage(
|
||||
active: logic.tab == 3,
|
||||
callbacks: MyPageCallbacks(
|
||||
onAction: logic.myAction,
|
||||
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
||||
),
|
||||
callbacks: MyPageCallbacks(onAction: logic.myAction),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
|
||||
/// 「我的」页与根导航之间的返回动作。
|
||||
enum MyPageAction { home, recommend, following, mine, resume }
|
||||
|
||||
/// 「我的」页可进入的功能目的地。
|
||||
enum MineDestination {
|
||||
watchlist,
|
||||
history,
|
||||
downloads,
|
||||
wallet,
|
||||
vip,
|
||||
preferences,
|
||||
works,
|
||||
creatorCenter,
|
||||
notices,
|
||||
@@ -23,10 +21,9 @@ enum MineDestination {
|
||||
|
||||
/// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。
|
||||
class MyPageCallbacks {
|
||||
const MyPageCallbacks({this.onAction, this.onDramaSelected});
|
||||
const MyPageCallbacks({this.onAction});
|
||||
|
||||
final ValueChanged<MyPageAction>? onAction;
|
||||
final ValueChanged<Drama>? onDramaSelected;
|
||||
|
||||
bool get embedded => onAction != null;
|
||||
}
|
||||
+130
-43
@@ -5,8 +5,11 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/locale_controller.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
@@ -17,10 +20,11 @@ import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/plain_page_scaffold.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../downloads/downloads_page.dart';
|
||||
import '../favorites/favorites_page.dart';
|
||||
import '../account/account_profile_page.dart';
|
||||
import '../filter/filter_page.dart';
|
||||
import '../notices/notices_page.dart';
|
||||
import '../player/open_player.dart';
|
||||
import '../settings/settings_page.dart';
|
||||
import '../store/recharge_page.dart';
|
||||
import '../store/vip_page.dart';
|
||||
import '../wallet/wallet_page.dart';
|
||||
import '../watch_records/watch_records_page.dart';
|
||||
@@ -29,6 +33,7 @@ import 'mine_logic.dart';
|
||||
import 'mine_types.dart';
|
||||
import 'widgets/mine_header.dart';
|
||||
import 'widgets/mine_menu.dart';
|
||||
import 'widgets/mine_mini_player.dart';
|
||||
|
||||
/// K28-B 我的页。
|
||||
class MyPage extends StatefulWidget {
|
||||
@@ -47,6 +52,7 @@ class MyPage extends StatefulWidget {
|
||||
|
||||
class _MyPageState extends State<MyPage> {
|
||||
late final MineLogic logic;
|
||||
bool _miniDismissed = false;
|
||||
|
||||
bool get embedded => widget.callbacks.embedded;
|
||||
|
||||
@@ -126,53 +132,51 @@ class _MyPageState extends State<MyPage> {
|
||||
}
|
||||
|
||||
Widget _body(BuildContext context, AppLocalizations l10n) {
|
||||
final auth = context.watch<AuthController>();
|
||||
final wallet = context.watch<WalletController>();
|
||||
final locale = context.watch<LocaleController>();
|
||||
return ListView(
|
||||
key: const Key('mine-page-scroll'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.pageHorizontal,
|
||||
0,
|
||||
AppDimens.pageHorizontal,
|
||||
AppDimens.spaceXl,
|
||||
),
|
||||
final recent = _miniRecord;
|
||||
return Stack(
|
||||
children: [
|
||||
MineHeader(onTap: () => _openDestination(MineDestination.settings)),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
MineShortcutGrid(onOpen: _openDestination),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
MineWalletCard(
|
||||
balance: _walletValue(l10n, auth, wallet),
|
||||
onOpen: () => _openDestination(MineDestination.wallet),
|
||||
onRecharge: _openRecharge,
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
MineVipCard(onOpen: () => _openDestination(MineDestination.vip)),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
MineMenuList(
|
||||
onOpen: _openDestination,
|
||||
onLanguage: _openLanguage,
|
||||
languageLabel: _languageLabel(l10n, locale.locale),
|
||||
Positioned.fill(
|
||||
child: ListView(
|
||||
key: const Key('mine-page-scroll'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppDimens.pageHorizontal,
|
||||
0,
|
||||
AppDimens.pageHorizontal,
|
||||
recent == null ? AppDimens.spaceXl : 156,
|
||||
),
|
||||
children: [
|
||||
MineHeader(onLogin: _openLogin, onEdit: _openEditActions),
|
||||
MineVipBanner(
|
||||
onOpen: () => _openDestination(MineDestination.vip),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
MineMenuList(
|
||||
onOpen: _openDestination,
|
||||
onLanguage: _openLanguage,
|
||||
languageLabel: _languageLabel(l10n, locale.locale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (recent != null)
|
||||
Positioned(
|
||||
left: AppDimens.pageHorizontal,
|
||||
bottom: AppDimens.spaceMd,
|
||||
child: MineMiniPlayer(
|
||||
title: _miniTitle(l10n, recent),
|
||||
coverUrl: recent.dramaCover?.trim() ?? '',
|
||||
episodeLabel: l10n.episodeLabel(recent.episodeNumber ?? 0),
|
||||
onTap: () => unawaited(_openRecent(recent)),
|
||||
onClose: () => setState(() => _miniDismissed = true),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _walletValue(
|
||||
AppLocalizations l10n,
|
||||
AuthController auth,
|
||||
WalletController wallet,
|
||||
) {
|
||||
if (!auth.isSignedIn) return l10n.logInHint;
|
||||
if (wallet.snapshot == null) return l10n.coins;
|
||||
return l10n.walletBalanceShort(wallet.balance);
|
||||
}
|
||||
|
||||
void _openDestination(MineDestination destination) {
|
||||
switch (destination) {
|
||||
case MineDestination.watchlist:
|
||||
_push(const FavoritesPage());
|
||||
case MineDestination.history:
|
||||
_push(const WatchRecordsPage());
|
||||
case MineDestination.downloads:
|
||||
@@ -181,6 +185,8 @@ class _MyPageState extends State<MyPage> {
|
||||
_openProtected(const WalletPage());
|
||||
case MineDestination.vip:
|
||||
_openVip();
|
||||
case MineDestination.preferences:
|
||||
_push(const FilterPage());
|
||||
case MineDestination.works:
|
||||
_pushPlaceholder(MinePlaceholderKind.works);
|
||||
case MineDestination.creatorCenter:
|
||||
@@ -194,6 +200,64 @@ class _MyPageState extends State<MyPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _openLogin() {
|
||||
unawaited(openAuthPage(context));
|
||||
}
|
||||
|
||||
Future<void> _openEditActions() async {
|
||||
final selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.transparent,
|
||||
showDragHandle: false,
|
||||
builder: (sheetContext) => Material(
|
||||
color: AppColors.mineCard,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppDimens.mineCardRadius),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: const Key('mine-edit-profile'),
|
||||
title: Text(AppLocalizations.of(sheetContext)!.accountProfile),
|
||||
onTap: () => Navigator.pop(sheetContext, 'profile'),
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('mine-edit-works'),
|
||||
title: Text(AppLocalizations.of(sheetContext)!.myWorks),
|
||||
onTap: () => Navigator.pop(sheetContext, 'works'),
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('mine-edit-creator'),
|
||||
title: Text(AppLocalizations.of(sheetContext)!.creatorCenter),
|
||||
onTap: () => Navigator.pop(sheetContext, 'creator'),
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('mine-edit-notices'),
|
||||
title: Text(AppLocalizations.of(sheetContext)!.notices),
|
||||
onTap: () => Navigator.pop(sheetContext, 'notices'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted || selected == null) return;
|
||||
switch (selected) {
|
||||
case 'profile':
|
||||
_push(const AccountProfilePage());
|
||||
case 'works':
|
||||
_pushPlaceholder(MinePlaceholderKind.works);
|
||||
case 'creator':
|
||||
_pushPlaceholder(MinePlaceholderKind.creator);
|
||||
case 'notices':
|
||||
_push(const NoticesPage());
|
||||
}
|
||||
}
|
||||
|
||||
void _push(Widget page) {
|
||||
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
@@ -236,9 +300,32 @@ class _MyPageState extends State<MyPage> {
|
||||
await context.read<LocaleController>().select(selected);
|
||||
}
|
||||
|
||||
void _openRecharge() {
|
||||
final wallet = context.read<WalletController>();
|
||||
_openProtected(RechargePage(onPurchased: () => unawaited(wallet.load())));
|
||||
WatchProgress? get _miniRecord {
|
||||
if (_miniDismissed || logic.recent.isEmpty) return null;
|
||||
final recent = logic.recent.first;
|
||||
return recent.episodeId.isEmpty ? null : recent;
|
||||
}
|
||||
|
||||
String _miniTitle(AppLocalizations l10n, WatchProgress recent) {
|
||||
final title = recent.dramaTitle?.trim() ?? '';
|
||||
return title.isEmpty ? l10n.episodeLabel(recent.episodeNumber ?? 0) : title;
|
||||
}
|
||||
|
||||
Future<void> _openRecent(WatchProgress recent) async {
|
||||
final catalog = context.read<CatalogController>();
|
||||
final seed = Drama(
|
||||
id: recent.dramaId,
|
||||
title: recent.dramaTitle?.trim() ?? '',
|
||||
synopsis: '',
|
||||
posterUrl: recent.dramaCover?.trim() ?? '',
|
||||
);
|
||||
try {
|
||||
final detail = await catalog.requireDetail(seed);
|
||||
if (!mounted) return;
|
||||
await playDramaFromCatalog(context, detail, episodeId: recent.episodeId);
|
||||
} catch (_) {
|
||||
// 最近记录失效时不投射错误页,等待下一次记录刷新。
|
||||
}
|
||||
}
|
||||
|
||||
Widget _bottomNav() {
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/services/auth_controller.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/centered_toast.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 「我的」页顶部身份卡。
|
||||
class MineHeader extends StatelessWidget {
|
||||
const MineHeader({super.key, required this.onTap});
|
||||
const double _buttonHeight = 34;
|
||||
const double _buttonRadius = 20;
|
||||
const double _copyIconSize = 15;
|
||||
|
||||
final VoidCallback onTap;
|
||||
/// 「我的」页用户区。
|
||||
class MineHeader extends StatelessWidget {
|
||||
const MineHeader({super.key, required this.onLogin, required this.onEdit});
|
||||
|
||||
final VoidCallback onLogin;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,41 +30,80 @@ class MineHeader extends StatelessWidget {
|
||||
final auth = context.watch<AuthController>();
|
||||
final user = auth.user;
|
||||
final signedIn = auth.isSignedIn;
|
||||
final name = signedIn && (user?.nickname.isNotEmpty ?? false)
|
||||
? user!.nickname
|
||||
: l10n.localViewer;
|
||||
final subtitle = signedIn && (user?.uid.isNotEmpty ?? false)
|
||||
? 'ID: ${user!.uid}'
|
||||
: l10n.logInHint;
|
||||
return Container(
|
||||
final nickname = signedIn && (user?.nickname.trim().isNotEmpty ?? false)
|
||||
? user!.nickname.trim()
|
||||
: l10n.visitor;
|
||||
final uid = signedIn ? user?.uid.trim() ?? '' : '';
|
||||
return Padding(
|
||||
key: const Key('mine-user-row'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.contentCard,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
border: Border.all(color: AppColors.contentBorder),
|
||||
),
|
||||
child: Material(
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimens.cardPadding),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceMd),
|
||||
child: Row(
|
||||
children: [
|
||||
_avatar(user?.avatarUrl ?? ''),
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_avatar(user?.avatarUrl ?? ''),
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
Expanded(child: _labels(name, subtitle)),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20,
|
||||
Text(
|
||||
nickname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.mineUserName,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${l10n.accountUid}: ${uid.isEmpty ? '--' : uid}',
|
||||
key: const Key('mine-uid'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.mineUid,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceXs),
|
||||
IconButton(
|
||||
key: const Key('mine-uid-copy'),
|
||||
tooltip: l10n.copyUid,
|
||||
onPressed: uid.isEmpty
|
||||
? null
|
||||
: () => unawaited(
|
||||
_copyUid(context, uid, l10n.uidCopied),
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: AppDimens.tapTarget,
|
||||
height: AppDimens.tapTarget,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(
|
||||
Icons.copy_rounded,
|
||||
color: AppColors.textSecondary,
|
||||
size: _copyIconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
_accountButton(
|
||||
key: signedIn ? const Key('mine-edit') : const Key('mine-login'),
|
||||
label: signedIn ? l10n.edit : l10n.logIn,
|
||||
onTap: signedIn ? onEdit : onLogin,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -63,8 +111,8 @@ class MineHeader extends StatelessWidget {
|
||||
Widget _avatar(String path) {
|
||||
return Container(
|
||||
key: const Key('mine-avatar'),
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: AppDimens.mineAvatar,
|
||||
height: AppDimens.mineAvatar,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.contentSurface,
|
||||
shape: BoxShape.circle,
|
||||
@@ -74,38 +122,50 @@ class MineHeader extends StatelessWidget {
|
||||
? const Icon(
|
||||
Icons.person_rounded,
|
||||
color: AppColors.textPrimary,
|
||||
size: 30,
|
||||
size: 28,
|
||||
)
|
||||
: RemoteImage(path, fit: BoxFit.cover),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _labels(String name, String subtitle) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
Widget _accountButton({
|
||||
required Key key,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return OutlinedButton(
|
||||
key: key,
|
||||
onPressed: onTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, _buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
side: const BorderSide(color: AppColors.mineOutline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_buttonRadius),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: AppDimens.body,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.chevron_right_rounded, size: 17),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _copyUid(
|
||||
BuildContext context,
|
||||
String uid,
|
||||
String message,
|
||||
) async {
|
||||
await Clipboard.setData(ClipboardData(text: uid));
|
||||
if (context.mounted) showCenteredToast(context, message);
|
||||
}
|
||||
}
|
||||
@@ -7,180 +7,13 @@ import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../mine_types.dart';
|
||||
|
||||
/// 我的页三宫格快捷入口。
|
||||
class MineShortcutGrid extends StatelessWidget {
|
||||
const MineShortcutGrid({super.key, required this.onOpen});
|
||||
const double _vipRadius = 12;
|
||||
const double _vipBadgeSize = 36;
|
||||
const double _vipButtonHeight = 34;
|
||||
|
||||
final ValueChanged<MineDestination> onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ShortcutTile(
|
||||
key: const Key('mine-watchlist'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
label: l10n.watchlist,
|
||||
onTap: () => onOpen(MineDestination.watchlist),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.homeGridGap),
|
||||
Expanded(
|
||||
child: _ShortcutTile(
|
||||
key: const Key('mine-history'),
|
||||
icon: Icons.history_rounded,
|
||||
label: l10n.watchHistory,
|
||||
onTap: () => onOpen(MineDestination.history),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.homeGridGap),
|
||||
Expanded(
|
||||
child: _ShortcutTile(
|
||||
key: const Key('mine-downloads'),
|
||||
icon: Icons.download_outlined,
|
||||
label: l10n.downloads,
|
||||
onTap: () => onOpen(MineDestination.downloads),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShortcutTile extends StatelessWidget {
|
||||
const _ShortcutTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.contentCard,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
border: Border.all(color: AppColors.contentBorder),
|
||||
),
|
||||
child: Material(
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 88,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: AppColors.textPrimary, size: 24),
|
||||
const SizedBox(height: AppDimens.spaceSm),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.caption,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 金币余额卡。
|
||||
class MineWalletCard extends StatelessWidget {
|
||||
const MineWalletCard({
|
||||
super.key,
|
||||
required this.balance,
|
||||
required this.onOpen,
|
||||
required this.onRecharge,
|
||||
});
|
||||
|
||||
final String balance;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onRecharge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
key: const Key('mine-wallet'),
|
||||
padding: const EdgeInsets.all(AppDimens.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.contentCard,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
border: Border.all(color: AppColors.contentBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.toll_outlined,
|
||||
color: AppColors.textSecondary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.coinBalance,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
balance,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.pageTitle,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: const Key('mine-wallet-recharge'),
|
||||
onPressed: onRecharge,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
minimumSize: const Size(64, AppDimens.buttonCompact),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimens.buttonRadius),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.topUpWallet),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 会员权益卡。
|
||||
class MineVipCard extends StatelessWidget {
|
||||
const MineVipCard({super.key, required this.onOpen});
|
||||
/// 金色会员横幅。
|
||||
class MineVipBanner extends StatelessWidget {
|
||||
const MineVipBanner({super.key, required this.onOpen});
|
||||
|
||||
final VoidCallback onOpen;
|
||||
|
||||
@@ -190,30 +23,34 @@ class MineVipCard extends StatelessWidget {
|
||||
return Container(
|
||||
key: const Key('mine-vip'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.contentCard,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
border: Border.all(color: AppColors.contentBorder),
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(_vipRadius),
|
||||
),
|
||||
child: Material(
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
borderRadius: BorderRadius.circular(_vipRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimens.cardPadding),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.cardPadding,
|
||||
vertical: AppDimens.spaceMd,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: AppDimens.buttonLarge,
|
||||
height: AppDimens.buttonLarge,
|
||||
width: _vipBadgeSize,
|
||||
height: _vipBadgeSize,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.contentSurface,
|
||||
color: AppColors.vipBadgeSurface,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.workspace_premium_rounded,
|
||||
color: AppColors.textPrimary,
|
||||
color: AppColors.onGold,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
@@ -223,33 +60,44 @@ class MineVipCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.vipMembership,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.mineSectionTitle,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
l10n.vipUnlockMessage,
|
||||
l10n.vipBrandName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
color: AppColors.onGold,
|
||||
fontSize: AppDimens.sectionTitle,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.mineVipSubtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onGold,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.joinVip,
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: AppDimens.body,
|
||||
fontWeight: FontWeight.w700,
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
TextButton(
|
||||
key: const Key('mine-vip-open'),
|
||||
onPressed: onOpen,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, _vipButtonHeight),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.spaceMd,
|
||||
),
|
||||
backgroundColor: AppColors.onGold,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_vipButtonHeight),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.joinVip),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -260,7 +108,7 @@ class MineVipCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的页功能菜单。
|
||||
/// 「我的」页单列功能菜单。
|
||||
class MineMenuList extends StatelessWidget {
|
||||
const MineMenuList({
|
||||
super.key,
|
||||
@@ -279,7 +127,7 @@ class MineMenuList extends StatelessWidget {
|
||||
return Container(
|
||||
key: const Key('mine-menu'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.contentCard,
|
||||
color: AppColors.mineCard,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
border: Border.all(color: AppColors.contentBorder),
|
||||
),
|
||||
@@ -290,57 +138,63 @@ class MineMenuList extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
_row(
|
||||
key: const Key('mine-works'),
|
||||
icon: Icons.video_collection_outlined,
|
||||
title: l10n.myWorks,
|
||||
destination: MineDestination.works,
|
||||
key: const Key('mine-wallet'),
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
title: l10n.wallet,
|
||||
destination: MineDestination.wallet,
|
||||
),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-creator-center'),
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
title: l10n.creatorCenter,
|
||||
destination: MineDestination.creatorCenter,
|
||||
key: const Key('mine-downloads'),
|
||||
icon: Icons.download_outlined,
|
||||
title: l10n.offlineDownloads,
|
||||
destination: MineDestination.downloads,
|
||||
),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-notices'),
|
||||
icon: Icons.notifications_none_rounded,
|
||||
title: l10n.notices,
|
||||
destination: MineDestination.notices,
|
||||
key: const Key('mine-history'),
|
||||
icon: Icons.history_rounded,
|
||||
title: l10n.watchHistory,
|
||||
destination: MineDestination.history,
|
||||
),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-watch-preferences'),
|
||||
icon: Icons.tune_rounded,
|
||||
title: l10n.watchPreferences,
|
||||
destination: MineDestination.preferences,
|
||||
),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-help'),
|
||||
icon: Icons.support_agent_rounded,
|
||||
title: l10n.contactSupport,
|
||||
destination: MineDestination.help,
|
||||
),
|
||||
_divider(),
|
||||
_languageRow(l10n),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-settings'),
|
||||
icon: Icons.settings_outlined,
|
||||
title: l10n.settings,
|
||||
destination: MineDestination.settings,
|
||||
),
|
||||
_divider(),
|
||||
_languageRow(context),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-help'),
|
||||
icon: Icons.help_outline_rounded,
|
||||
title: l10n.helpFeedback,
|
||||
destination: MineDestination.help,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _languageRow(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
Widget _languageRow(AppLocalizations l10n) {
|
||||
return ListTile(
|
||||
key: const Key('mine-language'),
|
||||
minTileHeight: AppDimens.mineRowHeight,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg),
|
||||
leading: const Icon(
|
||||
Icons.language_rounded,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20,
|
||||
color: AppColors.textPrimary,
|
||||
size: AppDimens.mineMenuIcon,
|
||||
),
|
||||
title: Text(
|
||||
l10n.language,
|
||||
@@ -360,7 +214,7 @@ class MineMenuList extends StatelessWidget {
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: AppDimens.spaceXs),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -381,8 +235,12 @@ class MineMenuList extends StatelessWidget {
|
||||
return ListTile(
|
||||
key: key,
|
||||
minTileHeight: AppDimens.mineRowHeight,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
leading: Icon(icon, color: AppColors.textSecondary, size: 20),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg),
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: AppColors.textPrimary,
|
||||
size: AppDimens.mineMenuIcon,
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
@@ -403,8 +261,8 @@ class MineMenuList extends StatelessWidget {
|
||||
Widget _divider() {
|
||||
return const Divider(
|
||||
height: 1,
|
||||
indent: 14,
|
||||
endIndent: 14,
|
||||
indent: AppDimens.spaceLg,
|
||||
endIndent: AppDimens.spaceLg,
|
||||
color: AppColors.contentSurface,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
|
||||
const double _infoHeight = 42;
|
||||
|
||||
/// 「我的」页左下角最近观看迷你播放器。
|
||||
class MineMiniPlayer extends StatelessWidget {
|
||||
const MineMiniPlayer({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.coverUrl,
|
||||
required this.episodeLabel,
|
||||
required this.onTap,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String coverUrl;
|
||||
final String episodeLabel;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
key: const Key('mine-mini-player'),
|
||||
width: AppDimens.mineMiniWidth,
|
||||
height: AppDimens.mineMiniHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.mineCard,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineMiniRadius),
|
||||
border: Border.all(color: AppColors.contentBorder),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineMiniRadius),
|
||||
child: Material(
|
||||
color: AppColors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RemoteImage(
|
||||
coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
fallbackColor: AppColors.contentSurface,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: _infoHeight,
|
||||
child: ColoredBox(
|
||||
color: AppColors.mineCard,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.mineMiniTitle,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
episodeLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.mineMiniTitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 3,
|
||||
top: 3,
|
||||
child: IconButton(
|
||||
key: const Key('mine-mini-close'),
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).closeButtonTooltip,
|
||||
onPressed: onClose,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size.square(AppDimens.mineMiniClose),
|
||||
maximumSize: const Size.square(AppDimens.mineMiniClose),
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: AppColors.controlScrim,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
),
|
||||
iconSize: 14,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ void main() {
|
||||
|
||||
expect(l10n.homeTab, 'Beranda');
|
||||
expect(l10n.watchNow, 'Tonton gratis');
|
||||
expect(l10n.watchlist, 'Daftar Tonton');
|
||||
expect(l10n.coins, 'Koin');
|
||||
expect(l10n.signIn, 'Masuk');
|
||||
});
|
||||
|
||||
@@ -238,8 +238,8 @@ void main() {
|
||||
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-wallet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-edit')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
|
||||
@@ -228,12 +228,10 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('mine-watchlist')));
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(Key('favorite-card-${drama.id}')));
|
||||
await tester.tap(find.byKey(Key('following-card-${drama.id}')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PlayerPage), findsOneWidget);
|
||||
|
||||
@@ -3,160 +3,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.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/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';
|
||||
import 'package:fruitshorts/pages/mine/mine_types.dart';
|
||||
import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
import 'package:fruitshorts/pages/store/vip_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';
|
||||
|
||||
import '../support/fake_catalog.dart';
|
||||
import '../support/fake_category.dart';
|
||||
import '../support/fake_haptics.dart';
|
||||
import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/fake_notice_repository.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_minimal_profile_shortcuts_wallet_vip_and_menu', (
|
||||
testWidgets('should_render_visitor_user_vip_and_seven_menu_rows', (
|
||||
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,
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
commentRepository: FakeCommentRepository(),
|
||||
interactionRepository: FakeInteractionRepository(),
|
||||
noticeRepository: FakeNoticeRepository(),
|
||||
haptics: FakeHaptics(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
await _pumpApp(tester, signedIn: false);
|
||||
await _openMine(tester);
|
||||
|
||||
expect(find.byKey(const Key('mine-user-row')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-continue-watching')), findsNothing);
|
||||
for (final key in const [
|
||||
expect(find.byKey(const Key('mine-avatar')), findsOneWidget);
|
||||
expect(find.text('Visitor'), findsOneWidget);
|
||||
expect(find.text('UID: --'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-uid-copy')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-login')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-edit')), findsNothing);
|
||||
expect(find.byKey(const Key('mine-vip')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-vip-open')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-menu')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
|
||||
for (final oldShortcut in const [
|
||||
Key('mine-watchlist'),
|
||||
Key('mine-history'),
|
||||
Key('mine-history-shortcut'),
|
||||
Key('mine-downloads-shortcut'),
|
||||
]) {
|
||||
expect(find.byKey(oldShortcut), findsNothing);
|
||||
}
|
||||
|
||||
final menuRows = tester.widgetList<ListTile>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('mine-menu')),
|
||||
matching: find.byType(ListTile),
|
||||
),
|
||||
);
|
||||
expect(menuRows.map((row) => row.key), const [
|
||||
Key('mine-wallet'),
|
||||
Key('mine-downloads'),
|
||||
Key('mine-wallet'),
|
||||
Key('mine-wallet-recharge'),
|
||||
Key('mine-vip'),
|
||||
Key('mine-menu'),
|
||||
Key('mine-works'),
|
||||
Key('mine-creator-center'),
|
||||
Key('mine-notices'),
|
||||
Key('mine-settings'),
|
||||
Key('mine-language'),
|
||||
Key('mine-history'),
|
||||
Key('mine-watch-preferences'),
|
||||
Key('mine-help'),
|
||||
]) {
|
||||
expect(find.byKey(key), findsOneWidget);
|
||||
}
|
||||
expect(find.byKey(const Key('mine-language')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
|
||||
expect(find.byKey(const Key('mine-library-tab-1')), findsNothing);
|
||||
expect(find.byKey(const Key('mine-library-tab-2')), findsNothing);
|
||||
final surface = tester.widget<ColoredBox>(
|
||||
find.byKey(const Key('mine-page-surface')),
|
||||
);
|
||||
expect(surface.color, AppColors.appBackground);
|
||||
final title = tester.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('mine-page-surface')),
|
||||
matching: find.text('My'),
|
||||
),
|
||||
);
|
||||
expect(title.style?.color, AppColors.textPrimary);
|
||||
expect(title.style?.fontSize, AppDimens.minePageTitle);
|
||||
expect(title.style?.fontWeight, FontWeight.w700);
|
||||
|
||||
for (final key in const [
|
||||
Key('mine-user-row'),
|
||||
Key('mine-wallet'),
|
||||
Key('mine-vip'),
|
||||
Key('mine-menu'),
|
||||
]) {
|
||||
final container = tester.widget<Container>(find.byKey(key));
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
expect(decoration.color, AppColors.contentCard);
|
||||
expect(
|
||||
decoration.borderRadius,
|
||||
BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
);
|
||||
expect(decoration.gradient, isNull);
|
||||
expect(decoration.boxShadow, isNull);
|
||||
expect((decoration.border! as Border).top.color, AppColors.contentBorder);
|
||||
}
|
||||
|
||||
final shortcutIcon = tester.widget<Icon>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('mine-watchlist')),
|
||||
matching: find.byType(Icon),
|
||||
),
|
||||
);
|
||||
expect(shortcutIcon.size, 24);
|
||||
expect(shortcutIcon.color, AppColors.textPrimary);
|
||||
final shortcutKeys = [
|
||||
const Key('mine-watchlist'),
|
||||
const Key('mine-history'),
|
||||
const Key('mine-downloads'),
|
||||
];
|
||||
final shortcutWidths = shortcutKeys
|
||||
.map((key) => tester.getSize(find.byKey(key)).width)
|
||||
.toList();
|
||||
expect(shortcutWidths.toSet(), hasLength(1));
|
||||
final shortcutCard = tester.widget<Container>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('mine-watchlist')),
|
||||
matching: find.byType(Container),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final shortcutDecoration = shortcutCard.decoration! as BoxDecoration;
|
||||
expect(shortcutDecoration.color, AppColors.contentCard);
|
||||
Key('mine-language'),
|
||||
Key('mine-settings'),
|
||||
]);
|
||||
expect(menuRows, hasLength(7));
|
||||
expect(
|
||||
shortcutDecoration.borderRadius,
|
||||
BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
);
|
||||
expect(shortcutDecoration.boxShadow, isNull);
|
||||
final menuRows = find.descendant(
|
||||
of: find.byKey(const Key('mine-menu')),
|
||||
matching: find.byType(ListTile),
|
||||
);
|
||||
expect(menuRows, findsNWidgets(6));
|
||||
for (final row in tester.widgetList<ListTile>(menuRows)) {
|
||||
expect(row.minTileHeight, AppDimens.mineRowHeight);
|
||||
}
|
||||
final settingsRow = tester.widget<ListTile>(
|
||||
find.byKey(const Key('mine-settings')),
|
||||
);
|
||||
final settingsTitle = settingsRow.title! as Text;
|
||||
expect(settingsTitle.style?.color, AppColors.textPrimary);
|
||||
expect(settingsTitle.style?.fontSize, AppDimens.listTitle);
|
||||
expect((settingsRow.trailing! as Icon).color, AppColors.textSecondary);
|
||||
final recharge = tester.widget<TextButton>(
|
||||
find.byKey(const Key('mine-wallet-recharge')),
|
||||
);
|
||||
expect(recharge.style?.backgroundColor?.resolve(const {}), AppColors.blue);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Join VIP')).style?.color,
|
||||
AppColors.blue,
|
||||
menuRows.every((row) => row.minTileHeight == AppDimens.mineRowHeight),
|
||||
isTrue,
|
||||
);
|
||||
final dividers = tester.widgetList<Divider>(
|
||||
find.descendant(
|
||||
@@ -164,15 +77,186 @@ void main() {
|
||||
matching: find.byType(Divider),
|
||||
),
|
||||
);
|
||||
expect(dividers, hasLength(5));
|
||||
expect(dividers, hasLength(6));
|
||||
expect(
|
||||
dividers.every((divider) => divider.color == AppColors.contentSurface),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final menu = tester.widget<Container>(find.byKey(const Key('mine-menu')));
|
||||
final menuDecoration = menu.decoration! as BoxDecoration;
|
||||
expect(menuDecoration.color, AppColors.mineCard);
|
||||
expect(
|
||||
menuDecoration.borderRadius,
|
||||
BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
);
|
||||
expect(menuDecoration.boxShadow, isNull);
|
||||
final vip = tester.widget<Container>(find.byKey(const Key('mine-vip')));
|
||||
final vipDecoration = vip.decoration! as BoxDecoration;
|
||||
expect((vipDecoration.gradient! as LinearGradient).colors, const [
|
||||
AppColors.vipGoldStart,
|
||||
AppColors.vipGoldEnd,
|
||||
]);
|
||||
expect(vipDecoration.boxShadow, isNull);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-watchlist')));
|
||||
await tester.tap(find.byKey(const Key('mine-login')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('favorites-page')), findsOneWidget);
|
||||
expect(find.byType(AuthPage), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_render_signed_in_user_and_dismiss_mini_player', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(
|
||||
tester,
|
||||
signedIn: true,
|
||||
history: InMemoryWatchHistoryRepository(seed: [_recent]),
|
||||
);
|
||||
await _openMine(tester);
|
||||
|
||||
expect(find.text('Viewer'), findsOneWidget);
|
||||
expect(find.text('UID: U-100'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-edit')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-login')), findsNothing);
|
||||
expect(find.byKey(const Key('mine-mini-player')), findsOneWidget);
|
||||
expect(find.text('Recent drama'), findsOneWidget);
|
||||
expect(find.text('Episode 3'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('mine-mini-player'))),
|
||||
const Size(AppDimens.mineMiniWidth, AppDimens.mineMiniHeight),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-mini-close')));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-edit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('mine-edit-profile')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-edit-works')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-edit-creator')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-edit-notices')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('mine-edit-works')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_open_vip_and_recent_player_from_mine_entries', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(
|
||||
tester,
|
||||
signedIn: true,
|
||||
history: InMemoryWatchHistoryRepository(seed: [_recent]),
|
||||
);
|
||||
await _openMine(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-vip-open')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(VipPage), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-mini-player')));
|
||||
await tester.pumpAndSettle();
|
||||
final player = tester.widget<PlayerPage>(find.byType(PlayerPage).last);
|
||||
expect(player.embedded, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('should_expose_seven_menu_destinations_in_order', (tester) async {
|
||||
tester.view.physicalSize = const Size(390, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final opened = <MineDestination>[];
|
||||
var languageOpened = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.dark(),
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimens.pageHorizontal),
|
||||
child: MineMenuList(
|
||||
onOpen: opened.add,
|
||||
onLanguage: () => languageOpened++,
|
||||
languageLabel: 'English',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const rows = [
|
||||
(Key('mine-wallet'), MineDestination.wallet),
|
||||
(Key('mine-downloads'), MineDestination.downloads),
|
||||
(Key('mine-history'), MineDestination.history),
|
||||
(Key('mine-watch-preferences'), MineDestination.preferences),
|
||||
(Key('mine-help'), MineDestination.help),
|
||||
];
|
||||
for (final row in rows) {
|
||||
await tester.tap(find.byKey(row.$1));
|
||||
await tester.pump();
|
||||
expect(opened.last, row.$2);
|
||||
}
|
||||
await tester.tap(find.byKey(const Key('mine-language')));
|
||||
await tester.pump();
|
||||
expect(languageOpened, 1);
|
||||
await tester.tap(find.byKey(const Key('mine-settings')));
|
||||
await tester.pump();
|
||||
expect(opened.last, MineDestination.settings);
|
||||
});
|
||||
}
|
||||
|
||||
const _recent = WatchProgress(
|
||||
dramaId: 'city-lights-demo',
|
||||
episodeId: 'city-lights-demo-3',
|
||||
dramaTitle: 'Recent drama',
|
||||
dramaCover: '/files/recent.jpg',
|
||||
episodeNumber: 3,
|
||||
totalEpisodes: 8,
|
||||
position: Duration(seconds: 30),
|
||||
duration: Duration(minutes: 2),
|
||||
);
|
||||
|
||||
Future<void> _openMine(WidgetTester tester) async {
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _pumpApp(
|
||||
WidgetTester tester, {
|
||||
required bool signedIn,
|
||||
WatchHistoryRepository? history,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(390, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
SharedPreferencesAsyncPlatform.instance = signedIn
|
||||
? InMemorySharedPreferencesAsync.withData({
|
||||
'auth.accessToken': 'access',
|
||||
'auth.refreshToken': 'refresh',
|
||||
'auth.user': '{"uid":"U-100","nickname":"Viewer","avatarUrl":""}',
|
||||
})
|
||||
: InMemorySharedPreferencesAsync.empty();
|
||||
final catalog = FakeCatalogRepository();
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(
|
||||
playerFactory: createWidgetPlayer,
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
commentRepository: FakeCommentRepository(),
|
||||
interactionRepository: FakeInteractionRepository(),
|
||||
noticeRepository: FakeNoticeRepository(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import '../support/fake_notice_repository.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_open_notices_from_mine_menu_without_login_gate', (
|
||||
testWidgets('should_open_notices_from_settings_without_login_gate', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
@@ -46,9 +46,11 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.byKey(const Key('mine-notices')));
|
||||
await tester.ensureVisible(find.byKey(const Key('mine-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('mine-notices')));
|
||||
await tester.tap(find.byKey(const Key('mine-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('settings-notices')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('notices-page')), findsOneWidget);
|
||||
|
||||
@@ -135,7 +135,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Vicky sheng'), findsOneWidget);
|
||||
expect(find.text('Coins'), findsOneWidget);
|
||||
expect(find.text('Netshort VIP'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-language')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
|
||||
|
||||
@@ -297,9 +297,7 @@ void main() {
|
||||
|
||||
await tester.tap(find.byKey(const Key('player-back')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('mine-watchlist')));
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||
|
||||
Reference in new issue
Block a user