diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 94fd0bd..dfa073d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -119,6 +119,10 @@ "allLanguages": "All languages", "bahasaMelayu": "Malay", "createSubtitle": "Stories become short dramas", + "createAiTitle": "AI production", + "createAiSubtitle": "Start from a story or an idea", + "createUploadTitle": "Upload and publish", + "createUploadSubtitle": "Publish a finished short drama", "weekdayMon": "Mon", "weekdayTue": "Tue", "weekdayWed": "Wed", @@ -316,6 +320,10 @@ "newest": "New", "favorite": "Favorite", "myAccount": "My account", + "myWorks": "My works", + "creatorCenter": "Creator center", + "helpFeedback": "Help & feedback", + "vipUnlockMessage": "Unlock more with membership", "coins": "Coins", "topUpWallet": "Top Up Wallet", "vipMembership": "VIP Membership", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d8c5f60..26d598b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -119,6 +119,10 @@ "allLanguages": "Semua bahasa", "bahasaMelayu": "Bahasa Melayu", "createSubtitle": "Cerita menjadi drama pendek", + "createAiTitle": "Produksi AI", + "createAiSubtitle": "Mulai dari cerita atau ide", + "createUploadTitle": "Unggah dan terbitkan", + "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", "weekdayMon": "Sen", "weekdayTue": "Sel", "weekdayWed": "Rab", @@ -316,6 +320,10 @@ "newest": "Terbaru", "favorite": "Favorit", "myAccount": "Akun saya", + "myWorks": "Karya saya", + "creatorCenter": "Pusat kreator", + "helpFeedback": "Bantuan & masukan", + "vipUnlockMessage": "Buka lebih banyak dengan keanggotaan", "coins": "Koin", "topUpWallet": "Isi Ulang Dompet", "vipMembership": "Keanggotaan VIP", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 04fc45b..2e4be26 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -580,6 +580,30 @@ abstract class AppLocalizations { /// **'Stories become short dramas'** String get createSubtitle; + /// No description provided for @createAiTitle. + /// + /// In en, this message translates to: + /// **'AI production'** + String get createAiTitle; + + /// No description provided for @createAiSubtitle. + /// + /// In en, this message translates to: + /// **'Start from a story or an idea'** + String get createAiSubtitle; + + /// No description provided for @createUploadTitle. + /// + /// In en, this message translates to: + /// **'Upload and publish'** + String get createUploadTitle; + + /// No description provided for @createUploadSubtitle. + /// + /// In en, this message translates to: + /// **'Publish a finished short drama'** + String get createUploadSubtitle; + /// No description provided for @weekdayMon. /// /// In en, this message translates to: @@ -1360,6 +1384,30 @@ abstract class AppLocalizations { /// **'My account'** String get myAccount; + /// No description provided for @myWorks. + /// + /// In en, this message translates to: + /// **'My works'** + String get myWorks; + + /// No description provided for @creatorCenter. + /// + /// In en, this message translates to: + /// **'Creator center'** + String get creatorCenter; + + /// No description provided for @helpFeedback. + /// + /// In en, this message translates to: + /// **'Help & feedback'** + String get helpFeedback; + + /// No description provided for @vipUnlockMessage. + /// + /// In en, this message translates to: + /// **'Unlock more with membership'** + String get vipUnlockMessage; + /// No description provided for @coins. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ee969f7..5265724 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -267,6 +267,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createSubtitle => 'Stories become short dramas'; + @override + String get createAiTitle => 'AI production'; + + @override + String get createAiSubtitle => 'Start from a story or an idea'; + + @override + String get createUploadTitle => 'Upload and publish'; + + @override + String get createUploadSubtitle => 'Publish a finished short drama'; + @override String get weekdayMon => 'Mon'; @@ -681,6 +693,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get myAccount => 'My account'; + @override + String get myWorks => 'My works'; + + @override + String get creatorCenter => 'Creator center'; + + @override + String get helpFeedback => 'Help & feedback'; + + @override + String get vipUnlockMessage => 'Unlock more with membership'; + @override String get coins => 'Coins'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 0280e21..e56a798 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -267,6 +267,19 @@ class AppLocalizationsId extends AppLocalizations { @override String get createSubtitle => 'Cerita menjadi drama pendek'; + @override + String get createAiTitle => 'Produksi AI'; + + @override + String get createAiSubtitle => 'Mulai dari cerita atau ide'; + + @override + String get createUploadTitle => 'Unggah dan terbitkan'; + + @override + String get createUploadSubtitle => + 'Terbitkan drama pendek yang sudah selesai'; + @override String get weekdayMon => 'Sen'; @@ -683,6 +696,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get myAccount => 'Akun saya'; + @override + String get myWorks => 'Karya saya'; + + @override + String get creatorCenter => 'Pusat kreator'; + + @override + String get helpFeedback => 'Bantuan & masukan'; + + @override + String get vipUnlockMessage => 'Buka lebih banyak dengan keanggotaan'; + @override String get coins => 'Koin'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 489c4ab..f7ee1e3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -262,6 +262,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get createSubtitle => '让故事成为漫剧'; + @override + String get createAiTitle => 'AI 制作'; + + @override + String get createAiSubtitle => '从故事或想法开始'; + + @override + String get createUploadTitle => '上传发布'; + + @override + String get createUploadSubtitle => '发布已完成的漫剧'; + @override String get weekdayMon => '周一'; @@ -670,6 +682,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get myAccount => '我的账户'; + @override + String get myWorks => '我的作品'; + + @override + String get creatorCenter => '创作者中心'; + + @override + String get helpFeedback => '帮助与反馈'; + + @override + String get vipUnlockMessage => '开通会员,解锁更多精彩内容'; + @override String get coins => '金币'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8d474f2..687245a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -119,6 +119,10 @@ "allLanguages": "全部语言", "bahasaMelayu": "马来语", "createSubtitle": "让故事成为漫剧", + "createAiTitle": "AI 制作", + "createAiSubtitle": "从故事或想法开始", + "createUploadTitle": "上传发布", + "createUploadSubtitle": "发布已完成的漫剧", "weekdayMon": "周一", "weekdayTue": "周二", "weekdayWed": "周三", @@ -278,6 +282,10 @@ "newest": "最新", "favorite": "收藏", "myAccount": "我的账户", + "myWorks": "我的作品", + "creatorCenter": "创作者中心", + "helpFeedback": "帮助与反馈", + "vipUnlockMessage": "开通会员,解锁更多精彩内容", "coins": "金币", "topUpWallet": "充值", "vipMembership": "会员", diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index 313beb0..2004eaf 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -7,7 +7,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; -/// K28-A1 创作页占位骨架。 +/// K28-B 创作页:AI 制作与上传发布入口。 class CreatePage extends StatelessWidget { const CreatePage({super.key}); @@ -18,7 +18,7 @@ class CreatePage extends StatelessWidget { data: AppTheme.light(), child: SafeArea( bottom: false, - child: Padding( + child: ListView( key: const Key('create-page'), padding: const EdgeInsets.fromLTRB( AppDimens.pageHorizontal, @@ -26,30 +26,165 @@ class CreatePage extends StatelessWidget { AppDimens.pageHorizontal, AppDimens.spaceXl, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.createTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: AppDimens.pageTitle, - fontWeight: FontWeight.w800, - ), + children: [ + Text( + l10n.createTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.heroTitle, + fontWeight: FontWeight.w800, ), - const SizedBox(height: AppDimens.spaceSm), - Text( - l10n.createSubtitle, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.body, - ), + ), + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.createSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, ), - ], + ), + const SizedBox(height: AppDimens.spaceXl), + _CreateEntry( + key: const Key('create-ai'), + icon: Icons.auto_awesome_rounded, + title: l10n.createAiTitle, + subtitle: l10n.createAiSubtitle, + onTap: () => _openPlaceholder(context, l10n.createAiTitle), + ), + const SizedBox(height: AppDimens.spaceMd), + _CreateEntry( + key: const Key('create-upload'), + icon: Icons.upload_file_rounded, + title: l10n.createUploadTitle, + subtitle: l10n.createUploadSubtitle, + onTap: () => _openPlaceholder(context, l10n.createUploadTitle), + ), + ], + ), + ), + ); + } + + void _openPlaceholder(BuildContext context, String title) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => _CreatePlaceholderPage(title: title)), + ); + } +} + +class _CreateEntry extends StatelessWidget { + const _CreateEntry({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppDimens.cardPadding), + child: Row( + children: [ + Container( + width: AppDimens.buttonLarge, + height: AppDimens.buttonLarge, + decoration: BoxDecoration( + color: AppColors.badgePink, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + ), + child: Icon(icon, color: AppColors.secondary, size: 22), + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + subtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 20, + ), + ], + ), ), ), ), ); } } + +class _CreatePlaceholderPage extends StatelessWidget { + const _CreatePlaceholderPage({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Scaffold( + key: const Key('create-placeholder-page'), + backgroundColor: AppColors.ink, + appBar: AppBar(title: Text(title)), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.construction_rounded, + color: AppColors.blue, + size: 42, + ), + const SizedBox(height: AppDimens.spaceMd), + Text( + l10n.comingSoon, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index 51783b3..a823e8b 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -7,6 +7,20 @@ import '../../core/data/models/drama.dart'; /// 「我的」页与根导航之间的返回动作。 enum MyPageAction { home, discover, create, resume } +/// 「我的」页可进入的功能目的地。 +enum MineDestination { + watchlist, + history, + downloads, + wallet, + vip, + works, + creatorCenter, + notices, + settings, + help, +} + /// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 class MyPageCallbacks { const MyPageCallbacks({this.onAction, this.onDramaSelected}); diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index f1203b4..b3af0f2 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -5,33 +5,30 @@ 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/download_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../l10n/app_localizations.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; -import '../player/player_view.dart'; +import '../notices/notices_page.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'; import 'mine_binding.dart'; import 'mine_logic.dart'; import 'mine_types.dart'; -import 'widgets/continue_watching_card.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; -/// 极简「我的」页:续看 + 追剧 / 历史 / 下载 / 钱包 / 会员 / 设置。 +/// K28-B 我的页。 class MyPage extends StatefulWidget { const MyPage({ super.key, @@ -101,36 +98,22 @@ class _MyPageState extends State { Widget _topBar(AppLocalizations l10n) { return SizedBox( - height: 46, + height: AppDimens.contentHeaderHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, - ), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.myTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, ), - const SizedBox(width: 8), - Text( - l10n.myTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], + ), ), ), ); @@ -139,26 +122,27 @@ class _MyPageState extends State { Widget _body(BuildContext context, AppLocalizations l10n) { final auth = context.watch(); final wallet = context.watch(); - final record = _continueRecord; - final drama = _dramaFor(record); return ListView( - padding: const EdgeInsets.only(bottom: 18), + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + 0, + AppDimens.pageHorizontal, + AppDimens.spaceXl, + ), children: [ - const MineHeader(), - if (drama != null && record != null) - ContinueWatchingCard( - drama: drama, - record: record, - onResume: () => _resume(drama), - ), - const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: MineMenu( - walletValue: _walletValue(l10n, auth, wallet), - onOpen: _openDestination, - ), + 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), ], ); } @@ -173,39 +157,6 @@ class _MyPageState extends State { return l10n.walletBalanceShort(wallet.balance); } - WatchProgress? get _continueRecord { - for (final record in logic.recent) { - if (!record.finished && record.hasPosition) return record; - } - return null; - } - - Drama? _dramaFor(WatchProgress? record) { - if (record == null) return null; - for (final drama in context.read().dramas) { - if (drama.id == record.dramaId) return drama; - } - return null; - } - - void _resume(Drama drama) { - final callback = widget.callbacks.onDramaSelected; - if (callback != null) { - callback(drama); - return; - } - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlayerPage( - drama: drama, - initialIndex: 0, - wallet: context.read(), - downloads: context.read(), - ), - ), - ); - } - void _openDestination(MineDestination destination) { switch (destination) { case MineDestination.watchlist: @@ -218,8 +169,16 @@ class _MyPageState extends State { _openProtected(const WalletPage()); case MineDestination.vip: _openVip(); + case MineDestination.works: + _pushPlaceholder(MinePlaceholderKind.works); + case MineDestination.creatorCenter: + _pushPlaceholder(MinePlaceholderKind.creator); + case MineDestination.notices: + _push(const NoticesPage()); case MineDestination.settings: _push(const SettingsPage()); + case MineDestination.help: + _pushPlaceholder(MinePlaceholderKind.help); } } @@ -227,6 +186,10 @@ class _MyPageState extends State { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } + void _pushPlaceholder(MinePlaceholderKind kind) { + _push(_MinePlaceholderPage(kind: kind)); + } + Future _openProtected(Widget page) async { if (!context.read().isSignedIn) { await ensureSignedIn(context); @@ -240,6 +203,11 @@ class _MyPageState extends State { _openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); } + void _openRecharge() { + final wallet = context.read(); + _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); + } + Widget _bottomNav(AppLocalizations l10n) { return AppBottomNav( items: [ @@ -266,8 +234,8 @@ class _MyPageState extends State { ), AppBottomNavItem( tabKey: const Key('mine-nav-current'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, selected: true, onTap: () {}, @@ -285,3 +253,35 @@ class _MyPageState extends State { ); } } + +enum MinePlaceholderKind { works, creator, help } + +class _MinePlaceholderPage extends StatelessWidget { + const _MinePlaceholderPage({required this.kind}); + + final MinePlaceholderKind kind; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final title = switch (kind) { + MinePlaceholderKind.works => l10n.myWorks, + MinePlaceholderKind.creator => l10n.creatorCenter, + MinePlaceholderKind.help => l10n.helpFeedback, + }; + return Scaffold( + key: const Key('mine-placeholder-page'), + backgroundColor: AppColors.ink, + appBar: AppBar(title: Text(title)), + body: Center( + child: Text( + l10n.comingSoon, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + ), + ), + ), + ); + } +} diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index a51cc36..cce6ebc 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -5,11 +5,16 @@ 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/theme/app_theme.dart'; +import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -/// 「我的」页顶部身份区,只保留头像、昵称和账号提示。 +/// 「我的」页顶部身份卡。 class MineHeader extends StatelessWidget { - const MineHeader({super.key}); + const MineHeader({super.key, required this.onTap}); + + final VoidCallback onTap; @override Widget build(BuildContext context) { @@ -23,37 +28,56 @@ class MineHeader extends StatelessWidget { final subtitle = signedIn && (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.logInHint; - return Padding( + return Container( key: const Key('mine-user-row'), - padding: const EdgeInsets.fromLTRB(14, 14, 14, 6), - child: Row( - children: [ - _avatar(), - const SizedBox(width: 10), - Expanded(child: _labels(name, subtitle)), - ], + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppDimens.cardPadding), + child: Row( + children: [ + _avatar(user?.avatarUrl ?? ''), + const SizedBox(width: AppDimens.spaceMd), + Expanded(child: _labels(name, subtitle)), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 20, + ), + ], + ), + ), + ), ), ); } - Widget _avatar() { + Widget _avatar(String path) { return Container( key: const Key('mine-avatar'), - width: 48, - height: 48, + width: 64, + height: 64, decoration: const BoxDecoration( + color: AppColors.badgePink, shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.secondary, AppColors.blue], - ), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onPrimary, - size: 22, ), + clipBehavior: Clip.antiAlias, + child: path.isEmpty + ? const Icon( + Icons.person_rounded, + color: AppColors.badgePinkText, + size: 30, + ) + : RemoteImage(path, fit: BoxFit.cover), ); } @@ -66,17 +90,21 @@ class MineHeader extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( + fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 15, + fontSize: 18, fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 2), + const SizedBox(height: AppDimens.spaceXs), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.muted, fontSize: 10), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), ), ], ); diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 125e8ad..3cda8dd 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -3,16 +3,270 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; +import '../mine_types.dart'; -/// 「我的」页六个核心入口。 -enum MineDestination { watchlist, history, downloads, wallet, vip, settings } +/// 我的页三宫格快捷入口。 +class MineShortcutGrid extends StatelessWidget { + const MineShortcutGrid({super.key, required this.onOpen}); -/// 极简功能清单,避免在主页堆叠内容列表。 -class MineMenu extends StatelessWidget { - const MineMenu({super.key, required this.walletValue, required this.onOpen}); + final ValueChanged 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_rounded, + label: l10n.watchlist, + onTap: () => onOpen(MineDestination.watchlist), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _ShortcutTile( + key: const Key('mine-history'), + icon: Icons.history_rounded, + label: l10n.watchHistory, + onTap: () => onOpen(MineDestination.history), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _ShortcutTile( + key: const Key('mine-downloads'), + icon: Icons.download_rounded, + 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.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 88, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: AppColors.blue, size: 24), + const SizedBox(height: AppDimens.spaceSm), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.caption, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 金币余额卡。 +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( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.goldCard, AppColors.card], + ), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Row( + children: [ + const Icon( + Icons.monetization_on_rounded, + color: AppColors.gold, + size: 30, + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: InkWell( + onTap: onOpen, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.coinBalance, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + const SizedBox(height: 3), + Text( + balance, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ), + 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: const StadiumBorder(), + ), + child: Text(l10n.topUpWallet), + ), + ], + ), + ); + } +} + +/// 会员权益卡。 +class MineVipCard extends StatelessWidget { + const MineVipCard({super.key, required this.onOpen}); + + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-vip'), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.all(AppDimens.cardPadding), + child: Row( + children: [ + Container( + width: AppDimens.buttonLarge, + height: AppDimens.buttonLarge, + decoration: const BoxDecoration( + color: AppColors.badgePink, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.workspace_premium_rounded, + color: AppColors.badgePinkText, + size: 22, + ), + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.vipMembership, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + l10n.vipUnlockMessage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + Text( + l10n.joinVip, + style: const TextStyle( + color: AppColors.blue, + fontSize: AppDimens.caption, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 我的页功能菜单。 +class MineMenuList extends StatelessWidget { + const MineMenuList({super.key, required this.onOpen}); - final String walletValue; final ValueChanged onOpen; @override @@ -20,94 +274,85 @@ class MineMenu extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Container( key: const Key('mine-menu'), - padding: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + boxShadow: const [AppTheme.cardShadow], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _row( + key: const Key('mine-works'), + icon: Icons.video_collection_outlined, + title: l10n.myWorks, + destination: MineDestination.works, + ), + _divider(), + _row( + key: const Key('mine-creator-center'), + icon: Icons.auto_awesome_rounded, + title: l10n.creatorCenter, + destination: MineDestination.creatorCenter, + ), + _divider(), + _row( + key: const Key('mine-notices'), + icon: Icons.notifications_none_rounded, + title: l10n.notices, + destination: MineDestination.notices, + ), + _divider(), + _row( + key: const Key('mine-settings'), + icon: Icons.settings_outlined, + title: l10n.settings, + destination: MineDestination.settings, + ), + _divider(), + _row( + key: const Key('mine-help'), + icon: Icons.help_outline_rounded, + title: l10n.helpFeedback, + destination: MineDestination.help, + ), + ], + ), ), - child: Column(children: [..._libraryTiles(l10n), ..._accountTiles(l10n)]), ); } - List _libraryTiles(AppLocalizations l10n) { - return [ - _tile( - key: const Key('mine-watchlist'), - icon: Icons.bookmark_border_rounded, - title: l10n.watchlist, - onTap: () => onOpen(MineDestination.watchlist), - ), - _tile( - key: const Key('mine-history'), - icon: Icons.history_rounded, - title: l10n.watchHistory, - onTap: () => onOpen(MineDestination.history), - ), - _tile( - key: const Key('mine-downloads'), - icon: Icons.download_outlined, - title: l10n.downloads, - onTap: () => onOpen(MineDestination.downloads), - ), - ]; - } - - List _accountTiles(AppLocalizations l10n) { - return [ - _tile( - key: const Key('mine-wallet'), - icon: Icons.monetization_on_outlined, - title: l10n.wallet, - subtitle: walletValue, - onTap: () => onOpen(MineDestination.wallet), - ), - _tile( - key: const Key('mine-vip'), - icon: Icons.workspace_premium_outlined, - title: l10n.vipMembership, - onTap: () => onOpen(MineDestination.vip), - ), - _tile( - key: const Key('mine-settings'), - icon: Icons.settings_outlined, - title: l10n.settings, - onTap: () => onOpen(MineDestination.settings), - ), - ]; - } - - Widget _tile({ + Widget _row({ required Key key, required IconData icon, required String title, - required VoidCallback onTap, - String? subtitle, + required MineDestination destination, }) { - return Material( - color: Colors.transparent, - child: ListTile( - key: key, - dense: true, - minTileHeight: 48, - contentPadding: const EdgeInsets.symmetric(horizontal: 14), - leading: Icon(icon, color: AppColors.blueSoft, size: 19), - title: Text( - title, - style: const TextStyle(color: AppColors.onSurface, fontSize: 12), + return ListTile( + key: key, + minTileHeight: 48, + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + leading: Icon(icon, color: AppColors.blue, size: 20), + title: Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.body, ), - subtitle: subtitle == null - ? null - : Text( - subtitle, - style: const TextStyle(color: AppColors.muted, fontSize: 9), - ), - trailing: const Icon( - Icons.chevron_right_rounded, - color: AppColors.subtle, - size: 18, - ), - onTap: onTap, ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 18, + ), + onTap: () => onOpen(destination), ); } + + Widget _divider() { + return const Divider(height: 1, indent: 14, endIndent: 14); + } } diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index 305292c..11d17b6 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -26,6 +26,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('mine-settings'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); @@ -49,6 +51,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('mine-settings'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 0ca92e9..ede4e89 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -40,6 +40,13 @@ void main() { expect(find.byKey(const Key('create-page')), findsOneWidget); expect(find.text('Create'), findsWidgets); expect(find.text('Stories become short dramas'), findsOneWidget); + expect(find.byKey(const Key('create-ai')), findsOneWidget); + expect(find.byKey(const Key('create-upload')), findsOneWidget); + + await tester.tap(find.byKey(const Key('create-ai'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('create-placeholder-page')), findsOneWidget); + expect(find.text('Coming soon'), findsOneWidget); }); testWidgets('should_switch_home_channel_when_chip_is_tapped', (tester) async { diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index ddafc78..4e7047d 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -3,7 +3,6 @@ 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'; @@ -18,7 +17,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_only_continue_and_six_core_entries', ( + testWidgets('should_render_minimal_profile_shortcuts_wallet_vip_and_menu', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -34,16 +33,7 @@ void main() { playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: InMemoryWatchHistoryRepository( - seed: const [ - WatchProgress( - dramaId: 'city-lights-demo', - episodeId: 'city-lights-demo-1', - position: Duration(minutes: 2), - duration: Duration(minutes: 5), - ), - ], - ), + watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), @@ -57,15 +47,20 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-user-row')), findsOneWidget); - expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); - expect(find.byKey(const Key('mine-continue-play')), findsOneWidget); + expect(find.byKey(const Key('mine-continue-watching')), findsNothing); for (final key in const [ Key('mine-watchlist'), Key('mine-history'), 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-help'), ]) { expect(find.byKey(key), findsOneWidget); } diff --git a/test/mine/settings_page_test.dart b/test/mine/settings_page_test.dart index def8456..7280809 100644 --- a/test/mine/settings_page_test.dart +++ b/test/mine/settings_page_test.dart @@ -46,6 +46,8 @@ 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-settings'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index b413c55..9fdbe56 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -46,11 +46,9 @@ 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-settings'))); + await tester.ensureVisible(find.byKey(const Key('mine-notices'))); await tester.pumpAndSettle(); - - expect(find.byKey(const Key('settings-notices')), findsOneWidget); - await tester.tap(find.byKey(const Key('settings-notices'))); + await tester.tap(find.byKey(const Key('mine-notices'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('notices-page')), findsOneWidget);