feat(mine): 重排我的页并新增迷你播放器

This commit is contained in:
Zhou Weidong committed 2026-10-22 17:44:48 +08:00
1 parent 5aa8170951
commit cbc6d80cd4
21 files changed
+860 -531

No files matched your search

+7
View File
@@ -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);
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+48 -12
View File
@@ -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:
+24 -6
View File
@@ -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';
+24 -6
View File
@@ -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';
+24 -6
View File
@@ -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
View File
@@ -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": {
+4 -6
View File
@@ -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 -5
View File
@@ -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
View File
@@ -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() {
+120 -60
View File
@@ -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);
}
}
+90 -232
View File
@@ -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),
),
),
],
),
),
),
),
),
);
}
}