From 9953c955e33e8ec281246ec3db8cd9bc782ed45f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:44:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85=E6=88=91=E7=9A=84?= =?UTF-8?q?=E9=A1=B5=E5=86=85=E5=AE=B9=E5=8C=BA=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_colors.dart | 2 + lib/core/theme/app_dimens.dart | 12 ++ lib/l10n/app_en.arb | 14 +- lib/l10n/app_id.arb | 14 +- lib/l10n/app_localizations.dart | 42 +++++ lib/l10n/app_localizations_en.dart | 24 +++ lib/l10n/app_localizations_id.dart | 24 +++ lib/l10n/app_localizations_zh.dart | 23 +++ lib/l10n/app_zh.arb | 14 +- lib/pages/following/following_page.dart | 11 ++ lib/pages/home/home_logic.dart | 7 + lib/pages/home/home_view.dart | 11 +- lib/pages/mine/about_page.dart | 70 ++++++++ lib/pages/mine/mine_logic.dart | 4 +- lib/pages/mine/mine_types.dart | 3 +- lib/pages/mine/mine_view.dart | 58 +++++- .../mine/widgets/mine_continue_watching.dart | 169 ++++++++++++++++++ .../mine/widgets/mine_more_services.dart | 93 ++++++++++ lib/pages/mine/widgets/mine_stats_panel.dart | 91 ++++++++++ test/home/home_logic_test.dart | 4 + test/mine/mine_logic_test.dart | 1 + test/mine/mine_preview_layout_test.dart | 112 ++++++++++++ 23 files changed, 800 insertions(+), 9 deletions(-) create mode 100644 lib/pages/mine/about_page.dart create mode 100644 lib/pages/mine/widgets/mine_continue_watching.dart create mode 100644 lib/pages/mine/widgets/mine_more_services.dart create mode 100644 lib/pages/mine/widgets/mine_stats_panel.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d6f438c..dd2061b 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1262,3 +1262,9 @@ $ flutter test - 网格使用 14 边距、半透明深灰底、圆角 12 和仅内部 1px 分隔线;`flutter analyze`:No issues found。 - 渐变增强为 `#3A1B0E`/`#1A0C07`/黑色并叠加顶部品牌橙径向光晕,同时移除页面级「我的」标题栏。 - 7 个宫格图标分别使用金币金、天蓝、薄荷绿、粉红、紫色、青色和冷灰;`flutter analyze`:No issues found。 + +## 2026-10-22 我的页补充内容 + +- VIP 与宫格之间新增继续观看横向列表、追剧/观看记录/金币统计面板,继续观看最多 10 条。 +- 宫格后新增常见问题与关于我们入口;关于页版本暂用与 pubspec 同步的常量占位。 +- 继续观看全部入口可切到追剧页播放历史 tab;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 84ec3fe..ae58709 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -89,6 +89,8 @@ abstract final class AppColors { static const mineGradientBottom = Color(0xFF000000); static const mineGradientGlow = Color(0x33FF6B2C); static const mineGridSurface = Color(0x99101010); + static const minePanelSurface = Color(0xFF101010); + static const mineMoreDivider = Color(0xFF1C1C1E); static const mineGridDivider = Color(0xFF2A2A2A); static const mineGridIconSurface = Color(0xFF1C1C1E); static const mineGridLabel = Color(0xFFE6E6EB); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 3e7e345..557c3e9 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -190,4 +190,16 @@ abstract final class AppDimens { static const double mineGridIcon = 22; static const double mineGridLabel = 12; static const double mineGridDividerWidth = 1; + static const double mineSectionTitleFont = 16; + static const double mineSectionLinkFont = 12; + static const double mineSectionGap = 20; + static const double mineContinueCardWidth = 96; + static const double mineContinueRowHeight = 168; + static const double mineContinueTitleFont = 13; + static const double mineContinueMetaFont = 11; + static const double mineStatsValueFont = 18; + static const double mineStatsLabelFont = 11; + static const double minePanelPadding = 12; + static const double mineMoreRowHeight = 52; + static const double mineMoreFont = 15; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f5b44c3..08e4c07 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -749,5 +749,17 @@ "watchedToEpisode": "Watched to Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Watch all", - "confirmRemove": "Remove this item?" + "confirmRemove": "Remove this item?", + "followingCountLabel": "Following", + "watchRecordsCountLabel": "Watch records", + "dataUnavailable": "—", + "commonQuestions": "Common questions", + "aboutUs": "About us", + "aboutDescription": "A focused, immersive place to watch short dramas.", + "versionLabel": "Version {version}", + "@versionLabel": { + "placeholders": { + "version": { "type": "String" } + } + } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 025c1e8..8659caa 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -706,5 +706,17 @@ "watchedToEpisode": "Ditonton hingga Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Tonton semua", - "confirmRemove": "Hapus item ini?" + "confirmRemove": "Hapus item ini?", + "followingCountLabel": "Mengikuti", + "watchRecordsCountLabel": "Riwayat tontonan", + "dataUnavailable": "—", + "commonQuestions": "Pertanyaan umum", + "aboutUs": "Tentang kami", + "aboutDescription": "Tempat fokus dan imersif untuk menonton drama pendek.", + "versionLabel": "Versi {version}", + "@versionLabel": { + "placeholders": { + "version": { "type": "String" } + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2b3ae8a..aa8b922 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3267,6 +3267,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Remove this item?'** String get confirmRemove; + + /// No description provided for @followingCountLabel. + /// + /// In en, this message translates to: + /// **'Following'** + String get followingCountLabel; + + /// No description provided for @watchRecordsCountLabel. + /// + /// In en, this message translates to: + /// **'Watch records'** + String get watchRecordsCountLabel; + + /// No description provided for @dataUnavailable. + /// + /// In en, this message translates to: + /// **'—'** + String get dataUnavailable; + + /// No description provided for @commonQuestions. + /// + /// In en, this message translates to: + /// **'Common questions'** + String get commonQuestions; + + /// No description provided for @aboutUs. + /// + /// In en, this message translates to: + /// **'About us'** + String get aboutUs; + + /// No description provided for @aboutDescription. + /// + /// In en, this message translates to: + /// **'A focused, immersive place to watch short dramas.'** + String get aboutDescription; + + /// No description provided for @versionLabel. + /// + /// In en, this message translates to: + /// **'Version {version}'** + String versionLabel(String version); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ef3ebad..0c0fdb3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1704,4 +1704,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get confirmRemove => 'Remove this item?'; + + @override + String get followingCountLabel => 'Following'; + + @override + String get watchRecordsCountLabel => 'Watch records'; + + @override + String get dataUnavailable => '—'; + + @override + String get commonQuestions => 'Common questions'; + + @override + String get aboutUs => 'About us'; + + @override + String get aboutDescription => + 'A focused, immersive place to watch short dramas.'; + + @override + String versionLabel(String version) { + return 'Version $version'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f76ec32..a190f6a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1706,4 +1706,28 @@ class AppLocalizationsId extends AppLocalizations { @override String get confirmRemove => 'Hapus item ini?'; + + @override + String get followingCountLabel => 'Mengikuti'; + + @override + String get watchRecordsCountLabel => 'Riwayat tontonan'; + + @override + String get dataUnavailable => '—'; + + @override + String get commonQuestions => 'Pertanyaan umum'; + + @override + String get aboutUs => 'Tentang kami'; + + @override + String get aboutDescription => + 'Tempat fokus dan imersif untuk menonton drama pendek.'; + + @override + String versionLabel(String version) { + return 'Versi $version'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1c49d79..a5e27d1 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1680,4 +1680,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get confirmRemove => '确定移除这条内容吗?'; + + @override + String get followingCountLabel => '追剧数'; + + @override + String get watchRecordsCountLabel => '观看记录'; + + @override + String get dataUnavailable => '—'; + + @override + String get commonQuestions => '常见问题'; + + @override + String get aboutUs => '关于我们'; + + @override + String get aboutDescription => '专注于短剧观看与沉浸式追剧体验。'; + + @override + String versionLabel(String version) { + return '版本 $version'; + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bae9656..bb0b6fd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -668,5 +668,17 @@ "watchedToEpisode": "已看至 第 {episode} 集", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "观看全集", - "confirmRemove": "确定移除这条内容吗?" + "confirmRemove": "确定移除这条内容吗?", + "followingCountLabel": "追剧数", + "watchRecordsCountLabel": "观看记录", + "dataUnavailable": "—", + "commonQuestions": "常见问题", + "aboutUs": "关于我们", + "aboutDescription": "专注于短剧观看与沉浸式追剧体验。", + "versionLabel": "版本 {version}", + "@versionLabel": { + "placeholders": { + "version": { "type": "String" } + } + } } diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 4780f36..4a8e05d 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -31,10 +31,12 @@ class FollowingPage extends StatefulWidget { super.key, required this.onGoHome, required this.favoriteController, + this.historyRequest = 0, }); final VoidCallback onGoHome; final FavoriteController favoriteController; + final int historyRequest; @override State createState() => _FollowingPageState(); @@ -74,6 +76,15 @@ class _FollowingPageState extends State { super.dispose(); } + @override + void didUpdateWidget(covariant FollowingPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.historyRequest == widget.historyRequest) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _selectTab(FollowingTab.history); + }); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index dfd1f00..d82c60c 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -20,6 +20,7 @@ class HomeLogic extends ChangeNotifier { int searchRequest = 0; int resumeRequest = 0; int backRequest = 0; + int followingHistoryRequest = 0; bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; @@ -100,6 +101,12 @@ class HomeLogic extends ChangeNotifier { ).push(MaterialPageRoute(builder: (_) => const WatchRecordsPage())); } + void openFollowingHistory() { + tab = 2; + followingHistoryRequest += 1; + notify(); + } + void myAction(MyPageAction action) { switch (action) { case MyPageAction.home: diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 46db787..cb1676c 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -263,6 +263,7 @@ class _HomeTabsState extends State<_HomeTabs> { FollowingPage( onGoHome: () => logic.select(0), favoriteController: context.read(), + historyRequest: logic.followingHistoryRequest, ), _my(context), ], @@ -312,14 +313,18 @@ class _HomeTabsState extends State<_HomeTabs> { Future _openWallet(BuildContext context) async { if (!await ensureSignedIn(context) || !context.mounted) return; - await Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const WalletPage())); + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WalletPage())); } Widget _my(BuildContext context) { return MyPage( active: logic.tab == 3, - callbacks: MyPageCallbacks(onAction: logic.myAction), + callbacks: MyPageCallbacks( + onAction: logic.myAction, + onOpenHistoryTab: logic.openFollowingHistory, + ), ); } diff --git a/lib/pages/mine/about_page.dart b/lib/pages/mine/about_page.dart new file mode 100644 index 0000000..2da3cbe --- /dev/null +++ b/lib/pages/mine/about_page.dart @@ -0,0 +1,70 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; +import '../../l10n/app_localizations.dart'; + +// package_info is not configured in this client; keep the fallback in sync +// with the version in pubspec.yaml. +const String _fallbackAppVersion = '1.0.0'; + +class MineAboutPage extends StatelessWidget { + const MineAboutPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: PlainPageScaffold( + pageKey: const Key('mine-about-page'), + backKey: const Key('mine-about-back'), + title: l10n.aboutUs, + child: ListView( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceXl, + AppDimens.pageHorizontal, + AppDimens.spaceXl, + ), + children: [ + Text( + l10n.appName, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.textPrimary, + fontSize: AppDimens.detailHeadline, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.versionLabel(_fallbackAppVersion), + key: const Key('mine-about-version'), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.caption, + ), + ), + const SizedBox(height: AppDimens.spaceXl), + Text( + l10n.aboutDescription, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textTabInactive, + fontSize: AppDimens.body, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index 6be582e..cf7f0e6 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -14,6 +14,7 @@ class MineLogic extends ChangeNotifier { final WatchHistoryRepository history; List recent = const []; + int? watchRecordTotal; bool recentLoading = false; String? recentError; bool _disposed = false; @@ -28,8 +29,9 @@ class MineLogic extends ChangeNotifier { recentError = null; notify(); try { - final page = await history.recent(pageIndex: 1, pageSize: 20); + final page = await history.recent(pageIndex: 1, pageSize: 10); recent = page.list; + watchRecordTotal = page.total; } catch (_) { recentError = 'progressError'; } finally { diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index 0304e98..3d69297 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -21,9 +21,10 @@ enum MineDestination { /// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 class MyPageCallbacks { - const MyPageCallbacks({this.onAction}); + const MyPageCallbacks({this.onAction, this.onOpenHistoryTab}); final ValueChanged? onAction; + final VoidCallback? onOpenHistoryTab; bool get embedded => onAction != null; } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index bb866e7..9105a09 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; @@ -25,11 +26,15 @@ import '../settings/settings_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; +import 'about_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; import 'mine_types.dart'; +import 'widgets/mine_continue_watching.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; +import 'widgets/mine_more_services.dart'; +import 'widgets/mine_stats_panel.dart'; /// K28-B 我的页。 class MyPage extends StatefulWidget { @@ -131,6 +136,20 @@ class _MyPageState extends State { } Widget _body(BuildContext context) { + final auth = context.watch(); + final favorites = context.watch(); + final wallet = context.watch(); + final continueWatching = logic.recent.take(10).toList(growable: false); + final followingCount = + auth.isSignedIn && + favorites.error == null && + !(favorites.loading && favorites.total == 0) + ? favorites.total + : null; + final watchRecordCount = auth.isSignedIn ? logic.watchRecordTotal : null; + final coinBalance = auth.isSignedIn && wallet.snapshot != null + ? wallet.balance + : null; return ListView( key: const Key('mine-page-scroll'), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), @@ -149,7 +168,25 @@ class _MyPageState extends State { onOpen: () => _openDestination(MineDestination.vip), ), ), - const SizedBox(height: AppDimens.spaceMd), + if (continueWatching.isNotEmpty) ...[ + const SizedBox(height: AppDimens.mineSectionGap), + MineContinueWatching( + records: continueWatching, + onOpenAll: _openHistoryTab, + ), + ], + const SizedBox(height: AppDimens.mineSectionGap), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + child: MineStatsPanel( + followingCount: followingCount, + watchRecordCount: watchRecordCount, + coinBalance: coinBalance, + ), + ), + const SizedBox(height: AppDimens.mineSectionGap), Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.mineGridHorizontal, @@ -159,6 +196,16 @@ class _MyPageState extends State { onLanguage: _openLanguage, ), ), + const SizedBox(height: AppDimens.mineSectionGap), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + child: MineMoreServices( + onCommonQuestions: () => _pushPlaceholder(MinePlaceholderKind.help), + onAbout: () => _push(const MineAboutPage()), + ), + ), ], ); } @@ -192,6 +239,15 @@ class _MyPageState extends State { unawaited(openAuthPage(context)); } + void _openHistoryTab() { + final callback = widget.callbacks.onOpenHistoryTab; + if (callback != null) { + callback(); + return; + } + _push(const WatchRecordsPage()); + } + Future _openEditActions() async { final selected = await showModalBottomSheet( context: context, diff --git a/lib/pages/mine/widgets/mine_continue_watching.dart b/lib/pages/mine/widgets/mine_continue_watching.dart new file mode 100644 index 0000000..1930979 --- /dev/null +++ b/lib/pages/mine/widgets/mine_continue_watching.dart @@ -0,0 +1,169 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/watch_progress.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +class MineContinueWatching extends StatelessWidget { + const MineContinueWatching({ + super.key, + required this.records, + required this.onOpenAll, + }); + + final List records; + final VoidCallback onOpenAll; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final visible = records.take(10).toList(growable: false); + if (visible.isEmpty) return const SizedBox.shrink(); + return Column( + key: const Key('mine-continue-watching'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + child: Row( + children: [ + Expanded( + child: Text( + l10n.continueWatching, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineSectionTitleFont, + fontWeight: FontWeight.w600, + ), + ), + ), + InkWell( + key: const Key('mine-continue-all'), + onTap: onOpenAll, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.all, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineSectionLinkFont, + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textTertiary, + size: 16, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppDimens.spaceSm), + SizedBox( + height: AppDimens.mineContinueRowHeight, + child: ListView.separated( + key: const Key('mine-continue-list'), + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + itemCount: visible.length, + separatorBuilder: (_, _) => + const SizedBox(width: AppDimens.spaceMd), + itemBuilder: (_, index) { + final record = visible[index]; + return _card(l10n, record); + }, + ), + ), + ], + ); + } + + Widget _card(AppLocalizations l10n, WatchProgress record) { + final title = record.dramaTitle?.trim() ?? ''; + final episode = record.episodeNumber; + final hasProgress = record.duration > Duration.zero; + return SizedBox( + width: AppDimens.mineContinueCardWidth, + child: InkWell( + key: Key('mine-continue-${record.dramaId}'), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + onTap: onOpenAll, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.homeCardRadius, + ), + child: RemoteImage( + record.dramaCover ?? '', + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + ), + ), + if (hasProgress) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + value: record.fraction, + minHeight: AppDimens.followingProgressHeight, + backgroundColor: AppColors.inactiveTrack, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppDimens.cardCoverTitleGap), + Text( + title.isEmpty ? l10n.dataUnavailable : title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineContinueTitleFont, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + episode == null || episode <= 0 + ? l10n.dataUnavailable + : l10n.episodeLabel(episode), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineContinueMetaFont, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/mine/widgets/mine_more_services.dart b/lib/pages/mine/widgets/mine_more_services.dart new file mode 100644 index 0000000..2cd0130 --- /dev/null +++ b/lib/pages/mine/widgets/mine_more_services.dart @@ -0,0 +1,93 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +class MineMoreServices extends StatelessWidget { + const MineMoreServices({ + super.key, + required this.onCommonQuestions, + required this.onAbout, + }); + + final VoidCallback onCommonQuestions; + final VoidCallback onAbout; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-more-services'), + decoration: BoxDecoration( + color: AppColors.minePanelSurface, + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _row( + key: const Key('mine-common-questions'), + label: l10n.commonQuestions, + onTap: onCommonQuestions, + ), + const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.mineMoreDivider, + ), + _row( + key: const Key('mine-about'), + label: l10n.aboutUs, + onTap: onAbout, + ), + ], + ), + ); + } + + Widget _row({ + required Key key, + required String label, + required VoidCallback onTap, + }) { + return Material( + color: AppColors.transparent, + child: InkWell( + key: key, + onTap: onTap, + child: SizedBox( + height: AppDimens.mineMoreRowHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.minePanelPadding, + ), + child: Row( + children: [ + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineMoreFont, + fontWeight: FontWeight.w400, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: 18, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/mine/widgets/mine_stats_panel.dart b/lib/pages/mine/widgets/mine_stats_panel.dart new file mode 100644 index 0000000..7c2b344 --- /dev/null +++ b/lib/pages/mine/widgets/mine_stats_panel.dart @@ -0,0 +1,91 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +class MineStatsPanel extends StatelessWidget { + const MineStatsPanel({ + super.key, + required this.followingCount, + required this.watchRecordCount, + required this.coinBalance, + }); + + final int? followingCount; + final int? watchRecordCount; + final int? coinBalance; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-stats'), + padding: const EdgeInsets.all(AppDimens.minePanelPadding), + decoration: BoxDecoration( + color: AppColors.minePanelSurface, + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), + ), + child: Row( + children: [ + _stat( + key: const Key('mine-stat-following'), + value: followingCount, + label: l10n.followingCountLabel, + l10n: l10n, + ), + _stat( + key: const Key('mine-stat-watch-records'), + value: watchRecordCount, + label: l10n.watchRecordsCountLabel, + l10n: l10n, + ), + _stat( + key: const Key('mine-stat-coins'), + value: coinBalance, + label: l10n.coinBalance, + l10n: l10n, + ), + ], + ), + ); + } + + Widget _stat({ + required Key key, + required int? value, + required String label, + required AppLocalizations l10n, + }) { + return Expanded( + child: Column( + key: key, + children: [ + Text( + value?.toString() ?? l10n.dataUnavailable, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineStatsValueFont, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineStatsLabelFont, + ), + ), + ], + ), + ); + } +} diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 478bdad..0b67f63 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -166,6 +166,10 @@ void main() { logic.myAction(MyPageAction.following); expect(logic.tab, 2); + logic.openFollowingHistory(); + expect(logic.tab, 2); + expect(logic.followingHistoryRequest, 1); + logic.myAction(MyPageAction.resume); expect(logic.tab, 0); expect(logic.resumeRequest, 1); diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart index b6105d8..536fc1a 100644 --- a/test/mine/mine_logic_test.dart +++ b/test/mine/mine_logic_test.dart @@ -27,6 +27,7 @@ void main() { await pumpEventQueue(); expect(logic.recent.single.dramaId, 'signal'); + expect(logic.watchRecordTotal, 1); expect(logic.recentLoading, isFalse); expect(logic.recentError, isNull); logic.dispose(); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 2d03568..c09efb5 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; @@ -12,6 +14,7 @@ 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/about_page.dart'; import 'package:fruitshorts/pages/mine/mine_types.dart'; import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; import 'package:fruitshorts/pages/store/vip_page.dart'; @@ -40,7 +43,11 @@ void main() { 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-continue-watching')), findsNothing); + expect(find.byKey(const Key('mine-stats')), findsOneWidget); + expect(find.text('—'), findsNWidgets(3)); expect(find.byKey(const Key('mine-menu')), findsOneWidget); + expect(find.byKey(const Key('mine-more-services')), findsOneWidget); expect(find.byKey(const Key('mine-mini-player')), findsNothing); final surface = tester.widget( find.byKey(const Key('mine-page-surface')), @@ -192,6 +199,14 @@ void main() { AppColors.vipGoldEnd, ]); expect(vipDecoration.boxShadow, isNull); + final order = [ + tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-stats'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-menu'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-more-services'))).dy, + ]; + expect(order, [...order]..sort()); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('mine-login'))); @@ -215,6 +230,33 @@ void main() { expect(find.byKey(const Key('mine-login')), findsNothing); expect(find.byKey(const Key('mine-mini-player')), findsNothing); expect(find.byType(FloatingActionButton), findsNothing); + expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('mine-continue-all')), findsOneWidget); + expect(find.text('Recent drama'), findsOneWidget); + expect(find.text('Episode 3'), findsWidgets); + + String statValue(Key key) { + return tester + .widget( + find + .descendant(of: find.byKey(key), matching: find.byType(Text)) + .first, + ) + .data!; + } + + expect(statValue(const Key('mine-stat-following')), '3'); + expect(statValue(const Key('mine-stat-watch-records')), '1'); + expect(statValue(const Key('mine-stat-coins')), '320'); + final signedInOrder = [ + tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-continue-watching'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-stats'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-menu'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-more-services'))).dy, + ]; + expect(signedInOrder, [...signedInOrder]..sort()); await tester.tap(find.byKey(const Key('mine-edit'))); await tester.pumpAndSettle(); @@ -228,6 +270,48 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('should_open_more_services_and_about_page', (tester) async { + await _pumpApp(tester, signedIn: false); + await _openMine(tester); + + await tester.tap(find.byKey(const Key('mine-common-questions'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('mine-about'))); + await tester.pumpAndSettle(); + expect(find.byType(MineAboutPage), findsOneWidget); + expect(find.byKey(const Key('mine-about-page')), findsOneWidget); + expect(find.text('Version 1.0.0'), findsOneWidget); + expect( + find.text( + AppLocalizations.of( + tester.element(find.byType(MineAboutPage)), + )!.aboutDescription, + ), + findsOneWidget, + ); + }); + + testWidgets('should_open_history_tab_from_continue_watching_all', ( + tester, + ) async { + await _pumpApp( + tester, + signedIn: true, + history: InMemoryWatchHistoryRepository(seed: [_recent]), + ); + await _openMine(tester); + + await tester.tap(find.byKey(const Key('mine-continue-all'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('following-page')), findsOneWidget); + expect(find.byKey(const Key('following-history-grid')), findsOneWidget); + }); + testWidgets('should_open_vip_from_mine_without_floating_player', ( tester, ) async { @@ -313,6 +397,7 @@ Future _pumpApp( WidgetTester tester, { required bool signedIn, WatchHistoryRepository? history, + CommerceRepository? commerce, }) async { tester.view.physicalSize = const Size(390, 1000); tester.view.devicePixelRatio = 1; @@ -337,7 +422,34 @@ Future _pumpApp( commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), + commerceRepository: commerce ?? const _MineWalletCommerceRepository(), ), ); await tester.pumpAndSettle(); } + +class _MineWalletCommerceRepository implements CommerceRepository { + const _MineWalletCommerceRepository(); + + @override + Future fetchWallet() async { + return const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 320, + totalRechargedCoin: 500, + totalConsumedCoin: 180, + totalRefundedCoin: 0, + ), + ); + } + + @override + Future unlock(String episodeId) async { + return const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 0, + coinBalance: 320, + ); + } +}