From 5346be40a9b27dfd3172858bd5e10201901e3b14 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Wed, 28 Oct 2026 16:36:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E6=89=93=E7=A3=A8=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E8=BF=BD=E5=89=A7=E6=88=91=E7=9A=84=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E9=97=B4=E8=B7=9D=E4=B8=8E=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 26 +- lib/core/theme/app_dimens.dart | 78 ++- lib/core/theme/app_theme.dart | 22 +- lib/core/widgets/plain_page_scaffold.dart | 15 +- lib/core/widgets/remote_image.dart | 10 +- lib/core/widgets/subpage_header.dart | 4 +- lib/pages/account/account_entry_card.dart | 5 +- lib/pages/discover/search_page.dart | 2 +- .../widgets/drama_detail_bottom_bar.dart | 2 +- .../widgets/drama_detail_header.dart | 14 +- lib/pages/following/following_edit_bar.dart | 90 +-- .../following/following_favorites_grid.dart | 99 +++- lib/pages/following/following_grid_card.dart | 53 +- .../following/following_history_card.dart | 2 +- lib/pages/following/following_page.dart | 258 +++++---- lib/pages/following/following_paged_grid.dart | 6 + .../widgets/continue_watching_banner.dart | 207 +++++++ lib/pages/home/home_view.dart | 11 +- lib/pages/mine/mine_view.dart | 230 +++----- lib/pages/mine/widgets/mine_header.dart | 51 +- lib/pages/mine/widgets/mine_menu.dart | 522 ++++++++++-------- .../mine/widgets/mine_more_services.dart | 93 ---- lib/pages/new_releases/new_releases_page.dart | 14 +- lib/pages/recommend/recommend_page.dart | 44 +- .../widgets/all_dramas_filter_grid.dart | 6 +- .../widgets/playlist_detail_page.dart | 23 +- .../widgets/recommend_channel_nav.dart | 13 +- .../widgets/recommend_sections_view.dart | 10 +- .../recommend/widgets/recommend_top_nav.dart | 13 +- .../recommend/widgets/search_gift_glyphs.dart | 78 +++ lib/pages/settings/legal_links_card.dart | 21 +- lib/pages/settings/settings_page.dart | 195 ++++++- .../widgets/watch_record_card.dart | 2 +- 33 files changed, 1408 insertions(+), 811 deletions(-) create mode 100644 lib/pages/following/widgets/continue_watching_banner.dart delete mode 100644 lib/pages/mine/widgets/mine_more_services.dart create mode 100644 lib/pages/recommend/widgets/search_gift_glyphs.dart diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 1bfe192..49d2071 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -27,13 +27,13 @@ abstract final class AppColors { static const overlaySecondary = Color(0xFFC7C7CC); static const overlayLabel = Color(0xFFE6E6EB); - /// 主操作、选中态、播放进度(品牌橙)。 - static const blue = Color(0xFFFF6B2C); + /// 主操作、选中态、播放进度。旧品牌橙已全量映射为品牌红(2026-10 改版)。 + static const blue = Color(0xFFF04E4E); static const primary = blue; static const actionRed = Color(0xFFFF3B30); /// 主操作按下态。 - static const bluePressed = Color(0xFFE55A1F); + static const bluePressed = Color(0xFFD64040); /// 链接与次要高亮:统一改用中性浅灰,避免旧青色残留。 static const blueSoft = Color(0xFFB0B0B0); @@ -47,6 +47,10 @@ abstract final class AppColors { static const homeGiftPink = Color(0xFFFF4D8D); static const vipGoldStart = Color(0xFFF5D9A0); static const vipGoldEnd = Color(0xFFE8B65A); + + /// 「我的」页 VIP 卡的浅香槟金(见设计稿,接近纯色)。 + static const vipCardStart = Color(0xFFF6E8C6); + static const vipCardEnd = Color(0xFFEDD9A8); static const vipBadgeSurface = Color(0x1F3D2408); static const bronze = Color(0xFFCD7F32); static const bronzeDeep = Color(0xFFA9713A); @@ -75,6 +79,15 @@ abstract final class AppColors { /// 首页/追剧/我的统一的纯黑内容背景。 static const appBackground = Color(0xFF000000); + /// 追剧页顶部的暗红光晕,向下淡入纯黑(见设计稿)。 + static const followingGlow = Color(0xFF4C161A); + + /// 光晕的过渡色:让暖色维持更久再压黑。 + static const followingGlowMid = Color(0xFF1E0B0C); + + /// 继续观看横幅剧名的暖白(见设计稿,非纯白)。 + static const bannerTitleWarm = Color(0xFFF5EFE4); + /// 中性深灰卡片与控件表面。 static const contentCard = Color(0xFF16161A); static const contentSurface = Color(0xFF1C1C1E); @@ -96,7 +109,7 @@ abstract final class AppColors { static const mineGradientTop = Color(0xFF3A1B0E); static const mineGradientMiddle = Color(0xFF1A0C07); static const mineGradientBottom = Color(0xFF000000); - static const mineGradientGlow = Color(0x33FF6B2C); + static const mineGradientGlow = Color(0x33F04E4E); static const mineGridSurface = Color(0x99101010); static const minePanelSurface = Color(0xFF101010); static const mineMoreDivider = Color(0xFF1C1C1E); @@ -111,7 +124,7 @@ abstract final class AppColors { static const mineIconLanguage = Color(0xFF22D3EE); static const mineIconSettings = Color(0xFFA1A1AA); static const mineIconNotices = Color(0xFFFF8A4C); - static const recommendPlaylistGlow = Color(0x26FF6B2C); + static const recommendPlaylistGlow = Color(0x26F04E4E); /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); @@ -136,6 +149,7 @@ abstract final class AppColors { /// 底部导航专用:纯黑背景、选中品牌红与未选中白。 static const navBackground = Color(0xFF000000); static const brandRed = Color(0xFFF04E4E); + static const brandRedPressed = Color(0xFFD64040); static const navSelected = brandRed; /// 卡片阴影(深色几乎不用阴影)。 @@ -214,7 +228,7 @@ abstract final class AppColors { static const bannerScrimEnd = Color(0xE6000000); /// 票券虚线等主色描边。 - static const primaryBorder = Color(0x66FF6B2C); + static const primaryBorder = Color(0x66F04E4E); static const outlineDark = border; static const outlineVariantDark = border; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 9f42692..8feec71 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -146,10 +146,12 @@ abstract final class AppDimens { static const double homeActionGap = 6; /// 推荐分区。 + static const double recommendSectionGap = 12; static const double recommendSectionTitle = 17; static const double recommendSectionSubtitle = 11; static const double recommendSectionMore = 12; - static const double recommendRowHeight = 252; + // 封面 122*(4/3)≈163 + 间距6 + 两行标题39 + 间距6 + 标签13 ≈ 227。 + static const double recommendRowHeight = 228; static const double recommendCardWidth = 122; /// 首页热播榜。 @@ -169,25 +171,26 @@ abstract final class AppDimens { /// 全部短剧筛选与追剧双列。 static const double filterChipFont = 16; - static const double filterChipHeight = 32; - static const double filterChipRadius = 16; + static const double filterChipHeight = 28; + static const double filterChipRadius = 14; static const double filterChipHorizontalPadding = 12; static const double filterRowGap = 6; static const double filterItemGap = 10; static const double allDramasHorizontal = 12; static const double allDramasGridGap = 6; - static const double allDramasGridRowGap = 12; + static const double allDramasGridRowGap = 6; static const double allDramasFilterToGridGap = 12; static const double followingHeaderHeight = 52; static const double followingTabFont = 18; static const double followingTabGap = 24; static const double followingEditIconSize = 20; static const double followingEditHitSize = 40; - static const double followingAddIconSize = 28; + static const double followingAddIconSize = 20; static const double followingAddIconStroke = 1.5; static const double followingGridGap = 6; static const double followingGridRowGap = 12; - static const double followingCardTextExtent = 70; + // 单行标题 16*1.3 + 间距 6+4 + 副标题约 16 ≈ 47。 + static const double followingCardTextExtent = 48; static const double followingSubtitleFont = 12; static const double followingSubtitleGap = 4; static const double followingProgressHeight = 2; @@ -201,6 +204,47 @@ abstract final class AppDimens { static const double followingHistoryProgressGap = 8; static const double followingHistoryLabelGap = 4; static const double followingHistoryActionHeight = 28; + + /// 追剧页新版头部与收藏布局。 + static const double followingTitleFont = 26; + static const double followingTitleRowHeight = 42; + static const double followingTitleTopGap = 8; + static const double followingSearchIcon = 26; + static const double followingManageFont = 16; + static const double followingHeaderActionGap = 8; + static const double followingSegmentFont = 16; + static const double followingSegmentGap = 28; + static const double followingSegmentRowHeight = 42; + static const double followingSegmentIndicatorWidth = 24; + static const double followingSegmentIndicatorHeight = 3; + static const double followingSegmentIndicatorRadius = 2; + static const double followingSegmentIndicatorTopGap = 6; + static const double followingBannerHeight = 176; + static const double followingBannerRadius = 16; + static const double followingBannerEdgeGap = 16; + static const double followingBannerInnerGap = 16; + static const double followingBannerTitleFont = 22; + static const double followingBannerSubtitleFont = 13; + static const double followingBannerMetaGap = 8; + static const double followingBannerButtonHeight = 40; + static const double followingBannerButtonRadius = 20; + static const double followingBannerButtonFont = 15; + static const double followingBannerButtonIcon = 18; + static const double followingBannerButtonGap = 6; + static const double followingBannerButtonHorizontalPadding = 20; + static const double followingBannerButtonTopGap = 14; + static const double followingBannerProgressHeight = 3; + static const double followingSectionTitleFont = 16; + static const double followingSectionCountFont = 13; + static const double followingSectionTopGap = 20; + static const double followingSectionBottomGap = 12; + static const double followingCardTitleFontNew = 16; + static const double followingCardSubtitleFontNew = 13; + static const double followingBadgeFont = 11; + static const double followingBadgeRadius = 6; + static const double followingBadgeInset = 8; + static const double followingBadgePaddingH = 8; + static const double followingBadgePaddingV = 3; static const Duration followingTabAnimationDuration = Duration( milliseconds: 180, ); @@ -211,8 +255,8 @@ abstract final class AppDimens { /// 「我的」页中性视觉。 static const double mineCardRadius = 16; static const double mineSectionTitle = 15; - static const double mineAvatar = 56; - static const double mineUserName = 20; + static const double mineAvatar = 64; + static const double mineUserName = 24; static const double mineUserNameLineHeight = 1.15; static const double mineUserNameUidGap = 2; static const double mineUid = 12; @@ -227,7 +271,23 @@ abstract final class AppDimens { static const double mineGridDividerWidth = 1; static const double mineSectionTitleFont = 16; static const double mineSectionLinkFont = 12; - static const double mineSectionGap = 20; + static const double mineSectionGap = 12; + + /// 「我的」页新版:快捷图标行与服务列表。 + static const double mineQuickActionIcon = 28; + static const double mineQuickActionLabelFont = 14; + static const double mineQuickActionGap = 8; + static const double mineServiceRowHeight = 56; + static const double mineServiceIcon = 24; + static const double mineServiceTitleFont = 16; + static const double mineServiceValueFont = 14; + static const double mineVipBenefitFont = 14; + static const double mineVipBenefitIcon = 20; + static const double mineVipBenefitBox = 30; + static const double mineVipBenefitBoxRadius = 7; + static const double mineVipBenefitGlyph = 17; + static const double mineVipBenefitRowGap = 4; + static const double mineVipButtonHeight = 36; static const double mineContinueCardWidth = 96; static const double mineContinueRowHeight = 172; static const double mineContinueTitleFont = 13; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 0f1789a..f0d351f 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -40,7 +40,7 @@ abstract final class AppTheme { /// 全局深色主题。 static ThemeData dark() { const scheme = ColorScheme.dark( - primary: AppColors.blue, + primary: AppColors.brandRed, onPrimary: AppColors.onPrimary, secondary: AppColors.secondary, onSecondary: AppColors.ink, @@ -54,7 +54,7 @@ abstract final class AppTheme { outline: AppColors.border, outlineVariant: AppColors.border, surfaceTint: AppColors.transparent, - inversePrimary: AppColors.bluePressed, + inversePrimary: AppColors.brandRedPressed, error: AppColors.error, onError: AppColors.onPrimary, ); @@ -66,16 +66,16 @@ abstract final class AppTheme { canvasColor: AppColors.ink, fontFamily: 'PlusJakartaSans', progressIndicatorTheme: const ProgressIndicatorThemeData( - color: AppColors.blue, + color: AppColors.brandRed, ), sliderTheme: const SliderThemeData( - activeTrackColor: AppColors.blue, + activeTrackColor: AppColors.brandRed, inactiveTrackColor: AppColors.border, thumbColor: AppColors.onPrimary, ), tabBarTheme: const TabBarThemeData( - labelColor: AppColors.blue, - indicatorColor: AppColors.blue, + labelColor: AppColors.brandRed, + indicatorColor: AppColors.brandRed, unselectedLabelColor: AppColors.muted, ), textButtonTheme: TextButtonThemeData( @@ -138,12 +138,12 @@ abstract final class AppTheme { ), focusedBorder: OutlineInputBorder( borderRadius: _inputRadius, - borderSide: const BorderSide(color: AppColors.blue), + borderSide: const BorderSide(color: AppColors.brandRed), ), ), chipTheme: ChipThemeData( backgroundColor: AppColors.raised, - selectedColor: AppColors.blue.withValues(alpha: 0.22), + selectedColor: AppColors.brandRed.withValues(alpha: 0.22), labelStyle: const TextStyle(color: AppColors.onSurface), secondaryLabelStyle: const TextStyle(color: AppColors.muted), side: const BorderSide(color: AppColors.border), @@ -216,7 +216,7 @@ abstract final class AppTheme { ), trackColor: WidgetStateProperty.resolveWith( (states) => - states.contains(WidgetState.selected) ? AppColors.blue : null, + states.contains(WidgetState.selected) ? AppColors.brandRed : null, ), ), snackBarTheme: const SnackBarThemeData( @@ -273,8 +273,8 @@ abstract final class AppTheme { if (states.contains(WidgetState.pressed) || states.contains(WidgetState.hovered) || states.contains(WidgetState.focused)) { - return AppColors.bluePressed; + return AppColors.brandRedPressed; } - return AppColors.blue; + return AppColors.brandRed; } } diff --git a/lib/core/widgets/plain_page_scaffold.dart b/lib/core/widgets/plain_page_scaffold.dart index 29c83e4..4918eb2 100644 --- a/lib/core/widgets/plain_page_scaffold.dart +++ b/lib/core/widgets/plain_page_scaffold.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; -import '../theme/app_theme.dart'; import 'subpage_header.dart'; /// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。 @@ -29,7 +28,19 @@ class PlainPageScaffold extends StatelessWidget { key: pageKey, backgroundColor: AppColors.ink, body: DecoratedBox( - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + // 与首页各 tab 同族:纯黑底 + 顶部暗红光晕。 + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 4462959..e6594df 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -74,9 +74,13 @@ class RemoteImage extends StatelessWidget { : null); final cacheWidth = decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio); - final cacheHeight = - decodeHeight ?? - (inferredHeight == null + // cover/contain 本身会保持宽高比,此时若同时强制 cacheWidth 和 + // cacheHeight,解码会把竖版图强行拉成横版像素。只在显式指定了 + // decodeHeight、或 fit 不保持宽高比时才限定解码高度。 + final keepsAspectRatio = + fit == BoxFit.cover || fit == BoxFit.contain; + final cacheHeight = decodeHeight ?? + (keepsAspectRatio || inferredHeight == null ? null : resolveImageDecodePixels(inferredHeight, pixelRatio)); return Image.network( diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart index acdfb12..fedb316 100644 --- a/lib/core/widgets/subpage_header.dart +++ b/lib/core/widgets/subpage_header.dart @@ -8,7 +8,7 @@ const double _height = 52; const double _heightWithSubtitle = 64; const double _horizontal = 14; const double _backSize = 40; -const double _backGap = 12; +const double _backGap = 6; const double _titleFontSize = 18; const double _subtitleFontSize = 12; const double _actionGap = 8; @@ -163,7 +163,7 @@ class AppSubpageHeader extends StatelessWidget { style: TextStyle( color: foreground, fontSize: _titleFontSize, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w400, ), ), if (subtitle != null) ...[ diff --git a/lib/pages/account/account_entry_card.dart b/lib/pages/account/account_entry_card.dart index 8b21ec3..16204a7 100644 --- a/lib/pages/account/account_entry_card.dart +++ b/lib/pages/account/account_entry_card.dart @@ -45,8 +45,7 @@ class AccountEntryCard extends StatelessWidget { ]; return Material( key: const Key('mine-account-card'), - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + color: AppColors.contentSurface, clipBehavior: Clip.antiAlias, child: Column( children: [ @@ -55,7 +54,7 @@ class AccountEntryCard extends StatelessWidget { const Divider( height: AppDimens.mineGridDividerWidth, thickness: AppDimens.mineGridDividerWidth, - color: AppColors.outlineVariantDark, + color: AppColors.contentBorder, ), _AccountListTile(row: rows[index]), ], diff --git a/lib/pages/discover/search_page.dart b/lib/pages/discover/search_page.dart index c0b149f..5b406d4 100644 --- a/lib/pages/discover/search_page.dart +++ b/lib/pages/discover/search_page.dart @@ -139,7 +139,7 @@ class _SearchPageState extends State { color: AppColors.textSecondary, size: 18, ), - const SizedBox(width: AppDimens.spaceSm), + const SizedBox(width: 2), Expanded( child: TextField( key: const Key('discover-search'), diff --git a/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart index e2852e2..423fd12 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart @@ -95,7 +95,7 @@ class DramaResumeBar extends StatelessWidget { value: fraction, minHeight: 3, backgroundColor: AppColors.border, - valueColor: const AlwaysStoppedAnimation(AppColors.blue), + valueColor: const AlwaysStoppedAnimation(AppColors.brandRed), ), ), if (label != null) ...[ diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart index 5c27728..9b08c09 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_header.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../core/widgets/subpage_header.dart'; import '../../../l10n/app_localizations.dart'; @@ -54,7 +53,18 @@ class DramaDetailHeader extends StatelessWidget { Widget build(BuildContext context) { return Container( key: const Key('detail-hero'), - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( bottom: false, child: Padding( diff --git a/lib/pages/following/following_edit_bar.dart b/lib/pages/following/following_edit_bar.dart index 54c0643..c440769 100644 --- a/lib/pages/following/following_edit_bar.dart +++ b/lib/pages/following/following_edit_bar.dart @@ -25,87 +25,6 @@ const double _barHorizontal = 16; const double _barFontSize = 13; const double _barButtonHeight = 30; const double _removeDialogButtonHeight = 32; -const double _removeDialogRadius = 6; -const double _removeDialogBottomInset = 8; -const double _cancelFollowingDialogPadding = 20; - -/// 播放历史移除确认。只把移除按钮压矮。 -Future confirmHistoryRemove(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - return await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - actionsPadding: const EdgeInsets.fromLTRB( - 24, - 0, - 16, - _removeDialogBottomInset, - ), - content: Text(l10n.confirmRemove), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - FilledButton( - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(0, _removeDialogButtonHeight), - padding: const EdgeInsets.symmetric(horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_removeDialogRadius), - ), - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(l10n.remove), - ), - ], - ), - ) ?? - false; -} - -/// 确认后返回是否取消已选追剧。 -Future confirmCancelFollowing(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - return await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - contentPadding: const EdgeInsets.all(_cancelFollowingDialogPadding), - actionsPadding: const EdgeInsets.fromLTRB( - _cancelFollowingDialogPadding, - 0, - _cancelFollowingDialogPadding, - _cancelFollowingDialogPadding, - ), - content: Text(l10n.confirmCancelFollowing), - actions: [ - TextButton( - style: TextButton.styleFrom( - visualDensity: VisualDensity.standard, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(0, _removeDialogButtonHeight), - padding: const EdgeInsets.symmetric(horizontal: 12), - ), - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - FilledButton( - style: FilledButton.styleFrom( - visualDensity: VisualDensity.standard, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(0, _removeDialogButtonHeight), - padding: const EdgeInsets.symmetric(horizontal: 12), - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(l10n.cancelFollowing), - ), - ], - ), - ) ?? - false; -} /// 只在追剧编辑中显示底栏。 class FollowingEditSlot extends StatelessWidget { @@ -195,6 +114,7 @@ class FollowingEditBar extends StatelessWidget { TextButton( key: const Key('following-select-all'), style: TextButton.styleFrom( + foregroundColor: AppColors.brandRed, visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap, minimumSize: const Size(0, _barButtonHeight), @@ -214,16 +134,14 @@ class FollowingEditBar extends StatelessWidget { ), ), const Spacer(), - FilledButton( + TextButton( key: const Key('following-cancel-follow'), - style: FilledButton.styleFrom( + style: TextButton.styleFrom( + foregroundColor: AppColors.brandRed, visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap, minimumSize: const Size(0, _removeDialogButtonHeight), padding: const EdgeInsets.symmetric(horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_removeDialogRadius), - ), textStyle: const TextStyle( fontSize: _barFontSize, fontWeight: FontWeight.w600, diff --git a/lib/pages/following/following_favorites_grid.dart b/lib/pages/following/following_favorites_grid.dart index 5839090..19885d8 100644 --- a/lib/pages/following/following_favorites_grid.dart +++ b/lib/pages/following/following_favorites_grid.dart @@ -5,12 +5,18 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../../core/data/models/watch_record.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/watch_records_controller.dart'; import '../../core/formatting/error_text.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; import 'following_paged_grid.dart'; +import 'widgets/continue_watching_banner.dart'; /// 追剧编辑中的选中状态。 class FollowingFavoriteSelection { @@ -30,15 +36,19 @@ class FollowingFavoritesGrid extends StatelessWidget { const FollowingFavoritesGrid({ super.key, required this.controller, + required this.history, required this.selection, required this.scrollController, required this.onGoHome, + required this.onContinueRecord, }); final FavoriteController controller; + final WatchRecordsController history; final FollowingFavoriteSelection selection; final ScrollController scrollController; final VoidCallback onGoHome; + final ValueChanged onContinueRecord; @override Widget build(BuildContext context) { @@ -49,7 +59,7 @@ class FollowingFavoritesGrid extends StatelessWidget { valueListenable: selection.selected, builder: (context, picked, _) { return ListenableBuilder( - listenable: controller, + listenable: Listenable.merge([controller, history]), builder: (context, _) => _grid(context, isEditing, picked), ); }, @@ -60,14 +70,15 @@ class FollowingFavoritesGrid extends StatelessWidget { Widget _grid(BuildContext context, bool isEditing, Set picked) { final l10n = AppLocalizations.of(context)!; - final extra = isEditing ? 0 : 1; + final hasFavorites = controller.cards.isNotEmpty; + final extra = !isEditing && hasFavorites ? 1 : 0; return FollowingPagedGrid( key: const Key('following-favorites-grid'), scrollController: scrollController, loading: controller.loading, loadingMore: controller.loadingMore, hasMore: controller.hasMore, - hasItems: controller.cards.isNotEmpty, + hasItems: hasFavorites, showInitialLoading: false, errorMessage: controller.error == null ? null @@ -78,23 +89,96 @@ class FollowingFavoritesGrid extends StatelessWidget { onRefresh: controller.load, onLoadMore: controller.loadMore, itemCount: controller.cards.length + extra, - empty: const SizedBox.shrink(), + topSlivers: _topSlivers(context), + empty: EmptyState( + key: const Key('following-favorites-empty'), + icon: Icons.bookmark_border_rounded, + title: l10n.noFavoritesYet, + ), itemBuilder: (context, index) => _item(context, index, isEditing, picked), ); } + List _topSlivers(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final continueRecord = _continueRecord(); + return [ + if (continueRecord != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + 4, + AppDimens.feedPageHorizontal, + 0, + ), + child: ContinueWatchingBanner( + record: continueRecord, + languageCode: Localizations.localeOf(context).languageCode, + onTap: () => onContinueRecord(continueRecord), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + AppDimens.followingSectionTopGap, + AppDimens.feedPageHorizontal, + AppDimens.followingSectionBottomGap, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + l10n.favoriteDramas, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingSectionTitleFont, + fontWeight: FontWeight.w400, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + l10n.totalDramasCount(controller.total), + style: const TextStyle( + color: AppColors.textTabInactive, + fontSize: AppDimens.followingSectionCountFont, + ), + ), + ), + ], + ), + ), + ), + ]; + } + + /// 最近一条观看记录(records 已按最近观看排序,与观看历史页首条一致)。 + WatchRecord? _continueRecord() { + return history.records.isEmpty ? null : history.records.first; + } + Widget _item( BuildContext context, int index, bool isEditing, Set picked, ) { + final l10n = AppLocalizations.of(context)!; if (index == controller.cards.length) { return FollowingAddCard(onTap: onGoHome); } - final l10n = AppLocalizations.of(context)!; final drama = controller.cards[index]; + final record = history.recordForDrama(drama.id); final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number; + final watchedEpisode = record?.episodeNumber ?? 0; + final total = drama.episodeCount; + final subtitle = watchedEpisode > 0 && total > 0 + ? l10n.watchedEpisodeTotal(watchedEpisode, total) + : l10n.favoriteEpisodeProgress(episode, total); return FollowingGridCard( dramaId: drama.id, title: drama.localizedTitle(Localizations.localeOf(context).languageCode), @@ -102,7 +186,10 @@ class FollowingFavoritesGrid extends StatelessWidget { viewCount: drama.viewCount, editing: isEditing, selected: isEditing ? picked.contains(drama.id) : null, - subtitle: l10n.favoriteEpisodeProgress(episode, drama.episodeCount), + subtitle: subtitle, + // TODO(backend): Drama 增加 hasUpdate / latestEpisodeNumber / isFinished + // 字段后,在此传 updateBadge(l10n.hasUpdateBadge)、红色更新副标题 + // (l10n.updatedToEpisode)或灰色完结副标题(l10n.finishedAllEpisodes)。 onTap: () => _openOrToggle(context, drama.id, isEditing), ); } diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index 870831a..bdf7d85 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -25,6 +25,8 @@ class FollowingGridCard extends StatelessWidget { this.editing = false, this.selected, this.onRemove, + this.updateBadge, + this.subtitleColor, }); final String dramaId; @@ -38,6 +40,13 @@ class FollowingGridCard extends StatelessWidget { final bool? selected; final VoidCallback? onRemove; + /// 左上角「有更新」角标文案;null 不显示。 + /// 待后端在 Drama 增加更新状态字段后由收藏网格传入。 + final String? updateBadge; + + /// 副标题颜色;更新中用强调红,完结用默认灰。 + final Color? subtitleColor; + @override Widget build(BuildContext context) { return InkWell( @@ -109,6 +118,32 @@ class FollowingGridCard extends StatelessWidget { count: viewCount, ), ), + if (updateBadge != null && selected == null) + Positioned( + left: AppDimens.followingBadgeInset, + top: AppDimens.followingBadgeInset, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.followingBadgePaddingH, + vertical: AppDimens.followingBadgePaddingV, + ), + decoration: BoxDecoration( + color: AppColors.brandRed, + borderRadius: BorderRadius.circular( + AppDimens.followingBadgeRadius, + ), + ), + child: Text( + updateBadge!, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: AppDimens.followingBadgeFont, + fontWeight: FontWeight.w400, + height: 1, + ), + ), + ), + ), if (progress != null) Positioned( left: 0, @@ -120,7 +155,7 @@ class FollowingGridCard extends StatelessWidget { minHeight: AppDimens.followingProgressHeight, backgroundColor: AppColors.inactiveTrack, valueColor: const AlwaysStoppedAnimation( - AppColors.primary, + AppColors.brandRed, ), ), ), @@ -130,13 +165,13 @@ class FollowingGridCard extends StatelessWidget { const SizedBox(height: AppDimens.cardCoverTitleGap), Text( title, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, style: const TextStyle( color: AppColors.textPrimary, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, + fontSize: AppDimens.followingCardTitleFontNew, + fontWeight: FontWeight.w400, height: AppDimens.cardTitleLineHeight, ), ), @@ -146,9 +181,9 @@ class FollowingGridCard extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.followingSubtitleFont, + style: TextStyle( + color: subtitleColor ?? AppColors.textTertiary, + fontSize: AppDimens.followingCardSubtitleFontNew, ), ), ], @@ -169,9 +204,9 @@ class _SelectMark extends StatelessWidget { height: _selectMarkSize, decoration: BoxDecoration( shape: BoxShape.circle, - color: selected ? AppColors.blue : AppColors.transparent, + color: selected ? AppColors.brandRed : AppColors.transparent, border: Border.all( - color: selected ? AppColors.blue : AppColors.textPrimary, + color: selected ? AppColors.brandRed : AppColors.textPrimary, width: _selectBorderWidth, ), ), diff --git a/lib/pages/following/following_history_card.dart b/lib/pages/following/following_history_card.dart index e2a2af3..d47bbdf 100644 --- a/lib/pages/following/following_history_card.dart +++ b/lib/pages/following/following_history_card.dart @@ -158,7 +158,7 @@ class FollowingHistoryCard extends StatelessWidget { minHeight: AppDimens.followingHistoryProgressHeight, backgroundColor: AppColors.inactiveTrack, valueColor: const AlwaysStoppedAnimation( - AppColors.primary, + AppColors.brandRed, ), ), ), diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 26d7993..b467784 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -17,28 +17,31 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; +import '../discover/search_page.dart'; import '../player/open_player.dart'; +import '../recommend/widgets/search_gift_glyphs.dart'; import 'following_edit_bar.dart'; import 'following_favorites_grid.dart'; import 'following_history_card.dart'; import 'following_keep_alive_tab.dart'; import 'following_paged_grid.dart'; -const double _doneFontSize = 14; -const double _doneTextGap = 12; - class FollowingPage extends StatefulWidget { const FollowingPage({ super.key, required this.onGoHome, required this.favoriteController, this.historyRequest = 0, + this.visible = true, }); final VoidCallback onGoHome; final FavoriteController favoriteController; final int historyRequest; + /// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证横幅是最新进度。 + final bool visible; + @override State createState() => _FollowingPageState(); } @@ -102,6 +105,9 @@ class _FollowingPageState extends State { @override void didUpdateWidget(covariant FollowingPage oldWidget) { super.didUpdateWidget(oldWidget); + if (!oldWidget.visible && widget.visible) { + unawaited(_history.load()); + } if (oldWidget.historyRequest == widget.historyRequest) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _selectTab(FollowingTab.history); @@ -111,9 +117,21 @@ class _FollowingPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return ColoredBox( + return DecoratedBox( key: const Key('following-page'), - color: AppColors.appBackground, + // 纯黑底 + 顶部暗红光晕,与设计稿一致。 + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( bottom: false, child: Column( @@ -128,6 +146,7 @@ class _FollowingPageState extends State { FollowingKeepAliveTab( child: FollowingFavoritesGrid( controller: widget.favoriteController, + history: _history, selection: FollowingFavoriteSelection( editing: _editing, selected: _selected, @@ -135,6 +154,9 @@ class _FollowingPageState extends State { ), scrollController: _favoriteScroll, onGoHome: widget.onGoHome, + onContinueRecord: (record) => unawaited( + _openHistory(context, _history, record), + ), ), ), FollowingKeepAliveTab( @@ -165,119 +187,165 @@ class _FollowingPageState extends State { } Widget _header(AppLocalizations l10n) { - return SizedBox( + return Padding( key: const Key('following-header'), - height: AppDimens.followingHeaderHeight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - child: ValueListenableBuilder( - valueListenable: _tab, - builder: (context, selectedTab, _) { - return ValueListenableBuilder( - valueListenable: _editing, - builder: (context, editing, _) { + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + AppDimens.followingTitleTopGap, + AppDimens.feedPageHorizontal, + 0, + ), + child: Column( + children: [ + SizedBox( + height: AppDimens.followingTitleRowHeight, + child: Row( + children: [ + Expanded( + child: Text( + l10n.followingTab, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingTitleFont, + fontWeight: FontWeight.w400, + height: 1.0, + ), + ), + ), + _searchButton(l10n), + const SizedBox(width: AppDimens.followingHeaderActionGap), + _manageButton(l10n), + ], + ), + ), + SizedBox( + height: AppDimens.followingSegmentRowHeight, + child: ValueListenableBuilder( + valueListenable: _tab, + builder: (context, selectedTab, _) { return Row( children: [ - Expanded( - child: Row( - children: [ - Flexible( - fit: FlexFit.loose, - child: _tabButton( - FollowingTab.favorites, - l10n.followingTab, - 'favorites', - selectedTab == FollowingTab.favorites, - ), + ListenableBuilder( + listenable: widget.favoriteController, + builder: (context, _) { + return _segment( + FollowingTab.favorites, + Text( + '${l10n.myFavorites} ${widget.favoriteController.total}', + maxLines: 1, ), - const SizedBox(width: AppDimens.followingTabGap), - Flexible( - fit: FlexFit.loose, - child: _tabButton( - FollowingTab.history, - l10n.watchHistory, - 'history', - selectedTab == FollowingTab.history, - ), - ), - ], - ), + 'favorites', + selectedTab == FollowingTab.favorites, + ); + }, + ), + const SizedBox(width: AppDimens.followingSegmentGap), + _segment( + FollowingTab.history, + Text(l10n.watchHistory, maxLines: 1), + 'history', + selectedTab == FollowingTab.history, ), - if (editing) - InkWell( - key: const Key('following-edit'), - onTap: _toggleEditing, - child: Padding( - padding: const EdgeInsets.only(left: _doneTextGap), - child: Text( - l10n.done, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _doneFontSize, - fontWeight: FontWeight.w500, - ), - ), - ), - ) - else - Semantics( - button: true, - label: l10n.edit, - child: SizedBox( - width: AppDimens.followingEditHitSize, - height: AppDimens.followingEditHitSize, - child: InkWell( - key: const Key('following-edit'), - onTap: _toggleEditing, - child: const Align( - alignment: Alignment.centerRight, - child: Icon( - Icons.edit_outlined, - size: AppDimens.followingEditIconSize, - color: AppColors.textTabInactive, - ), - ), - ), - ), - ), ], ); }, - ); - }, + ), + ), + ], + ), + ); + } + + Widget _searchButton(AppLocalizations l10n) { + return Semantics( + button: true, + label: l10n.search, + child: InkWell( + key: const Key('following-search'), + borderRadius: BorderRadius.circular(8), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SearchPage()), + ); + }, + child: Padding( + padding: EdgeInsets.all(4), + child: SearchGlyph( + size: AppDimens.followingSearchIcon, + color: AppColors.textPrimary, + ), ), ), ); } - Widget _tabButton( + Widget _manageButton(AppLocalizations l10n) { + return ValueListenableBuilder( + valueListenable: _editing, + builder: (context, editing, _) { + return InkWell( + key: const Key('following-edit'), + borderRadius: BorderRadius.circular(8), + onTap: _toggleEditing, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8), + child: Text( + editing ? l10n.done : l10n.manage, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingManageFont, + fontWeight: FontWeight.w400, + height: 1.0, + ), + ), + ), + ); + }, + ); + } + + Widget _segment( FollowingTab tab, - String label, + Widget label, String keyName, bool selected, ) { return InkWell( key: Key('following-tab-$keyName'), onTap: () => _selectTab(tab), - child: SizedBox( - height: AppDimens.followingHeaderHeight, - child: Align( - alignment: Alignment.centerLeft, - widthFactor: 1, - child: AnimatedDefaultTextStyle( + borderRadius: BorderRadius.circular(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedDefaultTextStyle( duration: AppDimens.followingTabAnimationDuration, style: TextStyle( color: selected ? AppColors.textPrimary : AppColors.textTabInactive, - fontSize: AppDimens.followingTabFont, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + fontSize: AppDimens.followingSegmentFont, + fontWeight: FontWeight.w400, ), - child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + child: label, ), - ), + const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap), + AnimatedContainer( + duration: AppDimens.followingTabAnimationDuration, + curve: Curves.easeOut, + width: selected + ? AppDimens.followingSegmentIndicatorWidth + : 0, + height: AppDimens.followingSegmentIndicatorHeight, + decoration: BoxDecoration( + color: AppColors.brandRed, + borderRadius: BorderRadius.circular( + AppDimens.followingSegmentIndicatorRadius, + ), + ), + ), + ], ), ); } @@ -329,10 +397,7 @@ class _FollowingPageState extends State { Future _cancelSelected() async { final ids = _selected.value.toList(); - if (ids.isEmpty || !mounted || !await confirmCancelFollowing(context)) { - return; - } - if (!mounted) return; + if (ids.isEmpty || !mounted) return; final removed = await widget.favoriteController.removeMany(ids); if (!removed || !mounted) return; _selected.value = {}; @@ -424,8 +489,7 @@ class _HistoryList extends StatelessWidget { onTap: openRecord, onContinue: openRecord, onRemove: () async { - if (await confirmHistoryRemove(context) && - await controller.remove(record.dramaId) && + if (await controller.remove(record.dramaId) && controller.records.isEmpty) { onListEmptied(); } diff --git a/lib/pages/following/following_paged_grid.dart b/lib/pages/following/following_paged_grid.dart index 5ea8167..95ca40f 100644 --- a/lib/pages/following/following_paged_grid.dart +++ b/lib/pages/following/following_paged_grid.dart @@ -28,6 +28,7 @@ class FollowingPagedGrid extends StatelessWidget { this.singleColumn = false, this.errorMessage, this.loadMoreError, + this.topSlivers = const [], }); static const double _loadMoreThreshold = 240; @@ -44,6 +45,10 @@ class FollowingPagedGrid extends StatelessWidget { final String? loadMoreError; final bool showInitialLoading; final bool singleColumn; + + /// 列表顶部的非分页内容(继续观看横幅、区块标题等)。 + /// 必须是 sliver;空列表时也会保留,因此可携带空态上方的固定区块。 + final List topSlivers; final Future Function() onRefresh; final Future Function() onLoadMore; @@ -81,6 +86,7 @@ class FollowingPagedGrid extends StatelessWidget { controller: scrollController, physics: AppPullToRefresh.physics, slivers: [ + ...topSlivers, if (itemCount == 0) SliverFillRemaining( hasScrollBody: false, diff --git a/lib/pages/following/widgets/continue_watching_banner.dart b/lib/pages/following/widgets/continue_watching_banner.dart new file mode 100644 index 0000000..22461e2 --- /dev/null +++ b/lib/pages/following/widgets/continue_watching_banner.dart @@ -0,0 +1,207 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/watch_record.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 ContinueWatchingBanner extends StatelessWidget { + const ContinueWatchingBanner({ + super.key, + required this.record, + required this.languageCode, + required this.onTap, + }); + + final WatchRecord record; + final String languageCode; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final episode = record.episodeNumber; + final total = + record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0; + final title = record.localizedDisplayTitle(languageCode); + final showMeta = episode > 0 && total > 0; + + return SizedBox( + height: AppDimens.followingBannerHeight, + child: ClipRRect( + borderRadius: + BorderRadius.circular(AppDimens.followingBannerRadius), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + record.displayCover, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color(0xCC000000), + Color(0x99000000), + Color(0x00000000), + ], + stops: [0, 0.45, 1], + ), + ), + ), + Material( + type: MaterialType.transparency, + child: InkWell( + key: const Key('following-continue-banner'), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all( + AppDimens.followingBannerInnerGap, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title.isNotEmpty) + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.bannerTitleWarm, + fontSize: + AppDimens.followingBannerTitleFont, + fontWeight: FontWeight.w400, + ), + ), + if (showMeta) ...[ + const SizedBox( + height: AppDimens.followingBannerMetaGap, + ), + Text( + l10n.continueWatchingSubtitle(episode, total), + maxLines: 1, + style: const TextStyle( + color: Color(0xB3F5EFE4), + fontSize: + AppDimens.followingBannerSubtitleFont, + ), + ), + ], + const SizedBox( + height: AppDimens.followingBannerButtonTopGap, + ), + _ContinueButton(label: l10n.continueWatching), + ], + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: _ProgressBar(fraction: record.progress.fraction), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ContinueButton extends StatelessWidget { + const _ContinueButton({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: AppColors.brandRed, + borderRadius: BorderRadius.circular( + AppDimens.followingBannerButtonRadius, + ), + ), + child: SizedBox( + height: AppDimens.followingBannerButtonHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: + AppDimens.followingBannerButtonHorizontalPadding, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + size: AppDimens.followingBannerButtonIcon, + color: AppColors.onPrimary, + ), + const SizedBox(width: AppDimens.followingBannerButtonGap), + Text( + label, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: AppDimens.followingBannerButtonFont, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ProgressBar extends StatelessWidget { + const _ProgressBar({required this.fraction}); + + final double fraction; + + @override + Widget build(BuildContext context) { + // SizedBox 给紧约束让 DecoratedBox 轨道撑满整宽; + // 不能用无子 ColoredBox 直接放 Stack(会缩成 0×0 隐形)。 + return ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.followingBannerProgressHeight, + ), + child: SizedBox( + height: AppDimens.followingBannerProgressHeight, + width: double.infinity, + child: DecoratedBox( + decoration: const BoxDecoration( + color: Color(0x66FFFFFF), + ), + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: fraction.clamp(0.0, 1.0).toDouble(), + // 无 child 的 ColoredBox 在松约束下会缩成 0×0,高度必须显式占满。 + heightFactor: 1.0, + child: const ColoredBox(color: AppColors.brandRed), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 66e2ce3..b6dc824 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -143,7 +143,7 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _scaffold(BuildContext context) { - final mediaTab = logic.tab == 0 || logic.tab == 1; + // 各 tab 均自绘背景(媒体 tab 纯黑,追剧/我的带顶部红光晕),无需外层渐变。 final content = PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -160,13 +160,7 @@ class _HomeTabsState extends State<_HomeTabs> { ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, - child: mediaTab - ? content - : DecoratedBox( - key: const Key('home-page-gradient'), - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), - child: content, - ), + child: content, ); } @@ -178,6 +172,7 @@ class _HomeTabsState extends State<_HomeTabs> { onGoHome: () => logic.select(0), favoriteController: context.read(), historyRequest: logic.followingHistoryRequest, + visible: logic.tab == 2, ), _my(context), ]; diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 18ab58d..2e616b4 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -6,39 +6,33 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/favorite_controller.dart'; import '../../core/data/services/locale_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_glow_panel.dart'; import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../downloads/downloads_page.dart'; import '../account/account_profile_page.dart'; +import '../favorites/favorites_page.dart'; import '../filter/filter_page.dart'; import '../notices/notices_page.dart'; -import '../player/open_player.dart'; 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 { @@ -136,41 +130,27 @@ class _MyPageState extends State { Widget _page(BuildContext context) { final content = DecoratedBox( key: const Key('mine-page-surface'), + // 与追剧页同款:纯黑底 + 顶部暗红光晕。 decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - AppColors.mineGradientTop, - AppColors.mineGradientMiddle, - AppColors.mineGradientBottom, + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, ], - stops: [0, 0.4, 0.75], + stops: [0, 0.14, 0.3], ), ), - child: Stack( - fit: StackFit.expand, - children: [ - const DecoratedBox( - key: Key('mine-page-glow'), - decoration: BoxDecoration( - gradient: RadialGradient( - center: Alignment(0, -0.95), - radius: 0.9, - colors: [AppColors.mineGradientGlow, AppColors.transparent], - ), - ), - ), - SafeArea( - bottom: false, - child: Column( - children: [ - Expanded(child: _body(context)), - if (!embedded) _bottomNav(), - ], - ), - ), - ], + child: SafeArea( + bottom: false, + child: Column( + children: [ + Expanded(child: _body(context)), + if (!embedded) _bottomNav(), + ], + ), ), ); return AnnotatedRegion( @@ -185,22 +165,7 @@ 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 = - favorites.error == null && !(favorites.loading && favorites.total == 0) - ? favorites.total - : null; - final watchRecordCount = - logic.recentError == null && - !(logic.recentLoading && logic.watchRecordTotal == null) - ? logic.watchRecordTotal - : null; - final coinBalance = auth.isSignedIn && wallet.snapshot != null - ? wallet.balance - : null; + final l10n = AppLocalizations.of(context)!; return ListView( key: const Key('mine-page-scroll'), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), @@ -214,6 +179,7 @@ class _MyPageState extends State { onEdit: () => _push(const AccountProfilePage()), ), ), + const SizedBox(height: AppDimens.spaceMd), Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.pageHorizontal, @@ -222,23 +188,16 @@ class _MyPageState extends State { onOpen: () => _openDestination(MineDestination.vip), ), ), - if (continueWatching.isNotEmpty) ...[ - const SizedBox(height: AppDimens.mineSectionGap), - MineContinueWatching( - records: continueWatching, - onOpenRecord: _openContinueRecord, - onOpenAll: _openHistoryTab, - ), - ], const SizedBox(height: AppDimens.mineSectionGap), Padding( padding: const EdgeInsets.symmetric( - horizontal: AppDimens.mineGridHorizontal, + horizontal: AppDimens.pageHorizontal, ), - child: MineStatsPanel( - followingCount: followingCount, - watchRecordCount: watchRecordCount, - coinBalance: coinBalance, + child: MineQuickActions( + onWallet: () => _openDestination(MineDestination.wallet), + onFavorites: () => _push(const FavoritesPage()), + onDownloads: () => _openDestination(MineDestination.downloads), + onHistory: () => _openDestination(MineDestination.history), ), ), const SizedBox(height: AppDimens.mineSectionGap), @@ -246,25 +205,40 @@ class _MyPageState extends State { padding: const EdgeInsets.symmetric( horizontal: AppDimens.mineGridHorizontal, ), - child: MineMenuList( - onOpen: _openDestination, - onLanguage: _openLanguage, - ), - ), - const SizedBox(height: AppDimens.mineSectionGap), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.mineGridHorizontal, - ), - child: MineMoreServices( - onCommonQuestions: _pushPlaceholder, - onAbout: () => _push(const MineAboutPage()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.moreServices, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineSectionTitle, + ), + ), + const SizedBox(height: AppDimens.spaceSm), + MineServiceList( + onOpen: _openDestination, + onLanguage: _openLanguage, + languageName: _languageName(context), + ), + ], ), ), ], ); } + /// 语言设置行右侧展示的当前语言名。 + String _languageName(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final code = context.read().locale?.languageCode; + return switch (code) { + 'en' => l10n.english, + 'id' => l10n.bahasaIndonesia, + _ => l10n.simplifiedChinese, + }; + } + void _openDestination(MineDestination destination) { switch (destination) { case MineDestination.history: @@ -290,45 +264,6 @@ class _MyPageState extends State { unawaited(openAuthPage(context)); } - void _openHistoryTab() { - final callback = widget.callbacks.onOpenHistoryTab; - if (callback != null) { - callback(); - return; - } - unawaited(_openHistoryPage()); - } - - /// 继续观看卡片:补齐剧集后直接进全屏播放页,播放器自己会定位到记录的秒数。 - Future _openContinueRecord(WatchProgress record) async { - final catalog = context.read(); - // 续看记录只有剧的概要信息,先拼一个占位对象让目录去取完整详情 - final summary = Drama( - id: record.dramaId, - title: record.dramaTitle ?? '', - synopsis: '', - posterUrl: record.dramaCover ?? '', - ); - Drama drama; - try { - drama = await catalog.requireDetail(summary); - } catch (_) { - if (!mounted) return; - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed))); - return; - } - if (!mounted) return; - await playDramaFromCatalog( - context, - drama, - episodeId: record.episodeId.isEmpty ? null : record.episodeId, - ); - if (mounted) unawaited(logic.refreshRecent()); - } - Future _openHistoryPage() async { await _push(const WatchRecordsPage()); if (mounted) unawaited(logic.refreshRecent()); @@ -341,7 +276,25 @@ class _MyPageState extends State { } void _pushPlaceholder() { - unawaited(_push(const _MinePlaceholderPage())); + final l10n = AppLocalizations.of(context)!; + unawaited( + _push( + PlainPageScaffold( + pageKey: const Key('mine-placeholder-page'), + backKey: const Key('mine-placeholder-back'), + title: l10n.contactSupport, + child: Center( + child: Text( + l10n.comingSoon, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.body, + ), + ), + ), + ), + ), + ); } Future _openProtected(Widget page) async { @@ -410,14 +363,16 @@ class _LanguageSheet extends StatelessWidget { (const Locale('en'), const Key('mine-language-en'), l10n.english), (const Locale('id'), const Key('mine-language-id'), l10n.bahasaIndonesia), ]; - return Material( - key: const Key('mine-language-sheet'), - color: AppColors.contentCard, + return AppGlowPanel( borderRadius: const BorderRadius.vertical( top: Radius.circular(AppDimens.mineCardRadius), ), - clipBehavior: Clip.antiAlias, - child: SafeArea( + border: false, + child: Material( + key: const Key('mine-language-sheet'), + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), @@ -440,7 +395,13 @@ class _LanguageSheet extends StatelessWidget { IconButton( tooltip: l10n.close, onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close_rounded), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + icon: const Icon(Icons.close_rounded, size: 20), ), ], ), @@ -461,29 +422,6 @@ class _LanguageSheet extends StatelessWidget { ), ), ), - ); - } -} - -class _MinePlaceholderPage extends StatelessWidget { - const _MinePlaceholderPage(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final title = l10n.helpFeedback; - return PlainPageScaffold( - pageKey: const Key('mine-placeholder-page'), - backKey: const Key('mine-placeholder-back'), - title: title, - child: Center( - child: Text( - l10n.comingSoon, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.body, - ), - ), ), ); } diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index 94bb6f3..890ead8 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -13,8 +14,7 @@ import '../../../core/widgets/centered_toast.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -const double _buttonHeight = 34; -const double _buttonRadius = 6; +const double _buttonHeight = 40; /// 「我的」页用户区。 class MineHeader extends StatelessWidget { @@ -35,7 +35,7 @@ class MineHeader extends StatelessWidget { final uid = signedIn ? user?.uid.trim() ?? '' : ''; return Padding( key: const Key('mine-user-row'), - padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceMd), + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -55,7 +55,7 @@ class MineHeader extends StatelessWidget { fontFamily: 'Outfit', color: AppColors.textPrimary, fontSize: AppDimens.mineUserName, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, height: AppDimens.mineUserNameLineHeight, ), ), @@ -121,18 +121,29 @@ class MineHeader extends StatelessWidget { key: const Key('mine-avatar'), width: AppDimens.mineAvatar, height: AppDimens.mineAvatar, - decoration: const BoxDecoration( - color: AppColors.contentSurface, + decoration: BoxDecoration( shape: BoxShape.circle, + border: Border.all( + color: AppColors.textTertiary, + width: 1.2, + ), + ), + child: ClipOval( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + color: AppColors.textPrimary.withValues(alpha: 0.06), + alignment: Alignment.center, + child: path.isEmpty + ? const Icon( + Icons.person_outline_rounded, + color: AppColors.textPrimary, + size: 30, + ) + : RemoteImage(path, fit: BoxFit.cover), + ), + ), ), - clipBehavior: Clip.antiAlias, - child: path.isEmpty - ? const Icon( - Icons.person_rounded, - color: AppColors.textPrimary, - size: 28, - ) - : RemoteImage(path, fit: BoxFit.cover), ); } @@ -141,20 +152,20 @@ class MineHeader extends StatelessWidget { required String label, required VoidCallback onTap, }) { - return OutlinedButton( + return TextButton( key: key, onPressed: onTap, - style: OutlinedButton.styleFrom( + style: TextButton.styleFrom( minimumSize: const Size(0, _buttonHeight), padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), - foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.mineOutline), + backgroundColor: AppColors.brandRed, + foregroundColor: AppColors.onPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_buttonRadius), + borderRadius: BorderRadius.circular(_buttonHeight / 2), ), textStyle: const TextStyle( fontSize: AppDimens.body, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, ), ), child: Row( diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 220b073..e2da934 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -7,11 +7,9 @@ import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import '../mine_types.dart'; -const double _vipRadius = 12; -const double _vipBadgeSize = 36; -const double _vipButtonHeight = 34; +const double _vipRadius = 14; -/// 金色会员横幅。 +/// 「我的」页金色 VIP 卡:品牌行 + 立即开通 + 2×2 权益宫格。 class MineVipBanner extends StatelessWidget { const MineVipBanner({super.key, required this.onOpen}); @@ -24,7 +22,9 @@ class MineVipBanner extends StatelessWidget { key: const Key('mine-vip'), decoration: BoxDecoration( gradient: const LinearGradient( - colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.vipCardStart, AppColors.vipCardEnd], ), borderRadius: BorderRadius.circular(_vipRadius), ), @@ -35,69 +35,89 @@ class MineVipBanner extends StatelessWidget { child: InkWell( onTap: onOpen, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.cardPadding, - vertical: AppDimens.spaceMd, + padding: const EdgeInsets.fromLTRB( + AppDimens.cardPadding, + AppDimens.spaceSm + 2, + AppDimens.cardPadding, + AppDimens.spaceSm + 2, ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: _vipBadgeSize, - height: _vipBadgeSize, - decoration: const BoxDecoration( - color: AppColors.vipBadgeSurface, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.workspace_premium_rounded, - color: AppColors.onGold, - size: 22, - ), - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( + Row( + children: [ + const Icon( + Icons.diamond_outlined, + color: AppColors.onGold, + size: 24, + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Text( l10n.vipBrandName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onGold, - fontSize: AppDimens.sectionTitle, - fontWeight: FontWeight.w700, + fontSize: 18, + fontWeight: FontWeight.w400, ), ), - const SizedBox(height: 2), - Text( - l10n.mineVipSubtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onGold, - fontSize: AppDimens.caption, - ), - ), - ], + ), + const SizedBox(width: AppDimens.spaceSm), + _openButton(l10n), + ], + ), + Transform.translate( + offset: const Offset(0, -7), + child: Text( + l10n.mineVipSubtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onGold.withValues(alpha: 0.7), + fontSize: AppDimens.caption, + ), ), ), - 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, + const SizedBox(height: AppDimens.mineVipBenefitRowGap), + Container( + height: 1, + color: AppColors.onGold.withValues(alpha: 0.15), + ), + const SizedBox(height: AppDimens.mineVipBenefitRowGap), + Table( + defaultColumnWidth: const FlexColumnWidth(), + children: [ + TableRow( + children: [ + _benefit( + key: const Key('mine-vip-benefit-0'), + icon: Icons.play_circle_outline_rounded, + label: l10n.vipBenefitFree, + ), + _benefit( + key: const Key('mine-vip-benefit-1'), + icon: Icons.ads_click_rounded, + label: l10n.vipBenefitNoAds, + ), + ], ), - backgroundColor: AppColors.onGold, - foregroundColor: AppColors.textPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_vipButtonHeight), + TableRow( + children: [ + _benefit( + key: const Key('mine-vip-benefit-2'), + icon: Icons.hd_outlined, + label: l10n.vipBenefitHd, + ), + _benefit( + key: const Key('mine-vip-benefit-3'), + icon: Icons.download_for_offline_outlined, + label: l10n.vipBenefitFastCache, + ), + ], ), - ), - child: Text(l10n.joinVip), + ], ), ], ), @@ -106,184 +126,256 @@ class MineVipBanner extends StatelessWidget { ), ); } + + Widget _openButton(AppLocalizations l10n) { + return TextButton( + key: const Key('mine-vip-open'), + onPressed: onOpen, + style: TextButton.styleFrom( + minimumSize: const Size(0, AppDimens.mineVipButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + backgroundColor: AppColors.onGold, + foregroundColor: AppColors.vipGoldStart, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.mineVipButtonHeight / 2, + ), + ), + textStyle: const TextStyle( + fontSize: AppDimens.body, + fontWeight: FontWeight.w400, + ), + ), + child: Text(l10n.vipOpenNow), + ); + } + + Widget _benefit({required Key key, required IconData icon, required String label}) { + return Padding( + key: key, + padding: const EdgeInsets.symmetric(vertical: AppDimens.mineVipBenefitRowGap), + child: Row( + children: [ + Container( + width: AppDimens.mineVipBenefitBox, + height: AppDimens.mineVipBenefitBox, + decoration: BoxDecoration( + border: Border.all( + color: AppColors.onGold.withValues(alpha: 0.6), + width: 1.2, + ), + borderRadius: BorderRadius.circular( + AppDimens.mineVipBenefitBoxRadius, + ), + ), + child: Icon( + icon, + color: AppColors.onGold, + size: AppDimens.mineVipBenefitGlyph, + ), + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onGold, + fontSize: AppDimens.mineVipBenefitFont, + ), + ), + ), + ], + ), + ); + } } -/// 「我的」页 4 列功能入口。 -class MineMenuList extends StatelessWidget { - const MineMenuList({ +/// 快捷图标行:钱包 / 我的收藏 / 离线缓存 / 播放历史(设计稿的「奖励」暂不做)。 +class MineQuickActions extends StatelessWidget { + const MineQuickActions({ super.key, - required this.onOpen, - required this.onLanguage, + required this.onWallet, + required this.onFavorites, + required this.onDownloads, + required this.onHistory, }); - final ValueChanged onOpen; - final VoidCallback onLanguage; + final VoidCallback onWallet; + final VoidCallback onFavorites; + final VoidCallback onDownloads; + final VoidCallback onHistory; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Container( - key: const Key('mine-menu'), - decoration: BoxDecoration( - color: AppColors.mineGridSurface, - borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), - ), - child: Material( - color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), - clipBehavior: Clip.antiAlias, - child: Table( - key: const Key('mine-menu-grid'), - defaultColumnWidth: const FlexColumnWidth(), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - border: const TableBorder( - horizontalInside: BorderSide( - color: AppColors.mineGridDivider, - width: AppDimens.mineGridDividerWidth, - ), - verticalInside: BorderSide( - color: AppColors.mineGridDivider, - width: AppDimens.mineGridDividerWidth, - ), + return Row( + children: [ + _action( + key: const Key('mine-wallet'), + icon: Icons.wallet_outlined, + label: l10n.wallet, + onTap: onWallet, + ), + _action( + key: const Key('mine-favorites'), + icon: Icons.star_border_rounded, + label: l10n.myFavorites, + onTap: onFavorites, + ), + _action( + key: const Key('mine-downloads'), + icon: Icons.download_outlined, + label: l10n.offlineDownloads, + onTap: onDownloads, + ), + _action( + key: const Key('mine-history'), + icon: Icons.history_rounded, + label: l10n.watchHistory, + onTap: onHistory, + ), + ], + ); + } + + Widget _action({ + required Key key, + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return Expanded( + child: InkWell( + key: key, + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.textPrimary, size: AppDimens.mineQuickActionIcon), + const SizedBox(height: AppDimens.mineQuickActionGap), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineQuickActionLabelFont, + ), + ), + ], ), + ), + ), + ); + } +} + +/// 「更多服务」列表行:白图标 + 标题 + 可选值 + chevron,行间细分隔线。 +class MineServiceList extends StatelessWidget { + const MineServiceList({ + super.key, + required this.onOpen, + required this.onLanguage, + required this.languageName, + }); + + final ValueChanged onOpen; + final VoidCallback onLanguage; + final String languageName; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final rows = [ + _row( + key: const Key('mine-help'), + icon: Icons.headset_outlined, + label: l10n.contactSupport, + onTap: () => onOpen(MineDestination.help), + ), + _divider(), + _row( + key: const Key('mine-language'), + icon: Icons.language_rounded, + label: l10n.language, + value: languageName, + onTap: onLanguage, + ), + _divider(), + _row( + key: const Key('mine-settings'), + icon: Icons.settings_outlined, + label: l10n.settings, + onTap: () => onOpen(MineDestination.settings), + ), + _divider(), + _row( + key: const Key('mine-notices'), + icon: Icons.notifications_none_rounded, + label: l10n.notices, + onTap: () => onOpen(MineDestination.notices), + ), + ]; + return Column(key: const Key('mine-menu'), children: rows); + } + + Widget _divider() => const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.contentBorder, + ); + + Widget _row({ + required Key key, + required IconData icon, + required String label, + String? value, + required VoidCallback onTap, + }) { + return SizedBox( + height: AppDimens.mineServiceRowHeight, + child: InkWell( + key: key, + onTap: onTap, + child: Row( children: [ - TableRow( - children: [ - _cell( - index: 0, - actionKey: const Key('mine-wallet'), - icon: Icons.account_balance_wallet_outlined, - iconColor: AppColors.mineIconWallet, - label: l10n.wallet, - onTap: () => onOpen(MineDestination.wallet), + Icon(icon, color: AppColors.textPrimary, size: AppDimens.mineServiceIcon), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineServiceTitleFont, ), - _cell( - index: 1, - actionKey: const Key('mine-downloads'), - icon: Icons.download_outlined, - iconColor: AppColors.mineIconDownloads, - label: l10n.offlineDownloads, - onTap: () => onOpen(MineDestination.downloads), - ), - _cell( - index: 2, - actionKey: const Key('mine-history'), - icon: Icons.history_rounded, - iconColor: AppColors.mineIconHistory, - label: l10n.watchHistory, - onTap: () => onOpen(MineDestination.history), - ), - _cell( - index: 3, - actionKey: const Key('mine-watch-preferences'), - icon: Icons.tune_rounded, - iconColor: AppColors.mineIconPreferences, - label: l10n.watchPreferences, - onTap: () => onOpen(MineDestination.preferences), - ), - ], + ), ), - TableRow( - children: [ - _cell( - index: 4, - actionKey: const Key('mine-help'), - icon: Icons.support_agent_rounded, - iconColor: AppColors.mineIconSupport, - label: l10n.contactSupport, - onTap: () => onOpen(MineDestination.help), + if (value != null && value.isNotEmpty) ...[ + const SizedBox(width: AppDimens.spaceSm), + Text( + value, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineServiceValueFont, ), - _cell( - index: 5, - actionKey: const Key('mine-language'), - icon: Icons.language_rounded, - iconColor: AppColors.mineIconLanguage, - label: l10n.language, - onTap: onLanguage, - ), - _cell( - index: 6, - actionKey: const Key('mine-settings'), - icon: Icons.settings_outlined, - iconColor: AppColors.mineIconSettings, - label: l10n.settings, - onTap: () => onOpen(MineDestination.settings), - ), - _cell( - index: 7, - actionKey: const Key('mine-notices'), - icon: Icons.notifications_none_rounded, - iconColor: AppColors.mineIconNotices, - label: l10n.notices, - onTap: () => onOpen(MineDestination.notices), - ), - ], + ), + ], + const SizedBox(width: AppDimens.spaceXs), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: 22, ), ], ), ), ); } - - Widget _cell({ - required int index, - Key? actionKey, - IconData? icon, - Color? iconColor, - String? label, - VoidCallback? onTap, - }) { - return SizedBox( - key: Key('mine-menu-cell-$index'), - height: AppDimens.mineGridCellHeight, - child: actionKey == null - ? const SizedBox.shrink() - : InkWell( - key: actionKey, - onTap: onTap, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: AppDimens.mineGridIconSurface, - height: AppDimens.mineGridIconSurface, - decoration: const BoxDecoration( - color: AppColors.mineGridIconSurface, - shape: BoxShape.circle, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: iconColor?.withValues(alpha: 0.08), - shape: BoxShape.circle, - ), - child: Icon( - icon, - color: iconColor, - size: AppDimens.mineGridIcon, - ), - ), - ), - const SizedBox(height: AppDimens.spaceXs + 2), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - label ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.mineGridLabel, - fontSize: AppDimens.mineGridLabel, - fontWeight: FontWeight.w500, - height: 1.2, - ), - ), - ), - ], - ), - ), - ), - ); - } } diff --git a/lib/pages/mine/widgets/mine_more_services.dart b/lib/pages/mine/widgets/mine_more_services.dart deleted file mode 100644 index 2cd0130..0000000 --- a/lib/pages/mine/widgets/mine_more_services.dart +++ /dev/null @@ -1,93 +0,0 @@ -// 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/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 37aa204..7c0e565 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -10,7 +10,6 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/remote_image.dart'; @@ -108,7 +107,18 @@ class _NewReleasesPageState extends State { key: const Key('new-releases-page'), backgroundColor: AppColors.ink, body: DecoratedBox( - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 93c98ec..8d7c144 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -358,40 +358,22 @@ class _RecommendPageState extends State begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - AppColors.mineGradientTop, - AppColors.mineGradientMiddle, - AppColors.mineGradientBottom, + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, ], - stops: [0, 0.4, 0.75], + stops: [0, 0.14, 0.3], ), ), - child: Stack( - fit: StackFit.expand, - children: [ - const DecoratedBox( - key: Key('recommend-playlist-glow'), - decoration: BoxDecoration( - gradient: RadialGradient( - center: Alignment(0, -0.95), - radius: 0.9, - colors: [ - AppColors.recommendPlaylistGlow, - AppColors.transparent, - ], - ), - ), - ), - _withTopNavSpace( - RecommendSectionsView( - feedController: widget.feedController, - categories: categories, - onOpenDrama: _openDrama, - onMore: _handleMore, - playlists: _watchPlaylists(context), - onOpenPlaylist: _openPlaylist, - ), - ), - ], + child: _withTopNavSpace( + RecommendSectionsView( + feedController: widget.feedController, + categories: categories, + onOpenDrama: _openDrama, + onMore: _handleMore, + playlists: _watchPlaylists(context), + onOpenPlaylist: _openPlaylist, + ), ), ); } diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 7f43e8b..d3d8d1e 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -20,6 +20,8 @@ import '../../home/widgets/home_feed_card.dart'; const double _loadMoreThreshold = 240; const int _gridColumns = 3; +// 标题两行39 + 上间距6 + 标签间距6 + 标签13 = 64,比 homeCardBodyExtent 更紧凑。 +const double _cardBodyExtent = 64; /// 推荐页「全部短剧」筛选和三列列表。 class AllDramasFilterGrid extends StatefulWidget { @@ -94,7 +96,7 @@ class _AllDramasFilterGridState extends State { final itemWidth = (width - AppDimens.allDramasHorizontal * 2 - AppDimens.allDramasGridGap * 2) / _gridColumns; - return itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent; + return itemWidth / AppDimens.cardCoverAspectRatio + _cardBodyExtent; } Widget _dramaList(AppLocalizations l10n, double itemExtent) { @@ -309,7 +311,7 @@ class _AllDramasFilterGridState extends State { ? AppColors.filterChipTextSelected : AppColors.filterChipTextInactive, fontSize: AppDimens.filterChipFont, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + fontWeight: FontWeight.w400, height: 1, ), ), diff --git a/lib/pages/recommend/widgets/playlist_detail_page.dart b/lib/pages/recommend/widgets/playlist_detail_page.dart index d3e04b4..3ac7c37 100644 --- a/lib/pages/recommend/widgets/playlist_detail_page.dart +++ b/lib/pages/recommend/widgets/playlist_detail_page.dart @@ -6,7 +6,6 @@ import '../../../core/data/models/drama.dart'; import '../../../core/data/models/playlist_detail.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/paged_list_footer.dart'; import '../../../core/widgets/remote_image.dart'; @@ -19,6 +18,8 @@ const int _collageCoverCount = 5; const double _loadMoreThreshold = 200; const double _pageHorizontal = 14; const double _gridGap = 10; +// 标题两行39 + 上间距6 + 标签间距6 + 标签13 = 64,比 homeCardBodyExtent 更紧凑。 +const double _cardBodyExtent = 64; const double _sprocketBand = 22; const Color _filmBody = Color(0xFF161311); const Color _filmHole = Color(0xFFE6D3BE); @@ -111,13 +112,23 @@ class _PlaylistDetailPageState extends State { Widget build(BuildContext context) { final width = MediaQuery.sizeOf(context).width; final itemWidth = (width - _pageHorizontal * 2 - _gridGap) / 2; - final itemExtent = - itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent; + final itemExtent = itemWidth / AppDimens.cardCoverAspectRatio + _cardBodyExtent; return Scaffold( key: const Key('playlist-detail-page'), backgroundColor: AppColors.ink, body: DecoratedBox( - decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.followingGlow, + AppColors.followingGlowMid, + AppColors.appBackground, + ], + stops: [0, 0.14, 0.3], + ), + ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -176,7 +187,7 @@ class _PlaylistDetailPageState extends State { gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: _gridGap, - mainAxisSpacing: 16, + mainAxisSpacing: 10, mainAxisExtent: itemExtent, ), delegate: SliverChildBuilderDelegate( @@ -228,7 +239,7 @@ class _PlaylistDetailPageState extends State { style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.recommendSectionTitle, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, ), ), const SizedBox(height: 2), diff --git a/lib/pages/recommend/widgets/recommend_channel_nav.dart b/lib/pages/recommend/widgets/recommend_channel_nav.dart index 74bbdcc..3d69c7a 100644 --- a/lib/pages/recommend/widgets/recommend_channel_nav.dart +++ b/lib/pages/recommend/widgets/recommend_channel_nav.dart @@ -5,13 +5,14 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/content_category.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; +import 'search_gift_glyphs.dart'; const double _navHeight = 56; const double _tabGap = 20; const double _tabFont = 17; const double _searchHitSize = 40; const double _searchIconSize = 26; -const double _giftIconSize = 30; +const double _giftIconSize = 26; const double _underlineWidth = 26; const double _underlineHeight = 3; @@ -146,7 +147,7 @@ class _RecommendChannelNavState extends State { ), iconSize: _searchIconSize, color: AppColors.textPrimary, - icon: const Icon(Icons.search_rounded), + icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary), ), ), if (widget.onGift != null) @@ -162,12 +163,7 @@ class _RecommendChannelNavState extends State { height: _searchHitSize, ), iconSize: _giftIconSize, - icon: Image.asset( - 'assets/icon/gift.png', - width: _giftIconSize, - height: _giftIconSize, - fit: BoxFit.contain, - ), + icon: GiftGlyph(size: _giftIconSize), ), ), const SizedBox(width: 8), @@ -200,6 +196,7 @@ class _RecommendChannelNavState extends State { fontSize: _tabFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, + height: 1.0, shadows: const [ Shadow(color: AppColors.mediaBlack, blurRadius: 6), ], diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 69418a9..e64527d 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -92,7 +92,7 @@ class RecommendSectionsView extends StatelessWidget { dramas: hits.take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.hits), ), - const SizedBox(height: AppDimens.spaceXl), + const SizedBox(height: AppDimens.recommendSectionGap), _section( context, l10n, @@ -102,7 +102,7 @@ class RecommendSectionsView extends StatelessWidget { dramas: newest.take(_sectionPreviewCount).toList(), onMore: () => onMore(RecommendSectionKind.newReleases), ), - const SizedBox(height: AppDimens.spaceXl), + const SizedBox(height: AppDimens.recommendSectionGap), _section( context, l10n, @@ -150,7 +150,7 @@ class RecommendSectionsView extends StatelessWidget { final sections = []; for (final playlist in remote.items) { if (sections.isNotEmpty) { - sections.add(const SizedBox(height: AppDimens.spaceXl)); + sections.add(const SizedBox(height: AppDimens.recommendSectionGap)); } sections.add( _section( @@ -238,7 +238,7 @@ class RecommendSectionsView extends StatelessWidget { style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.recommendSectionTitle, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, ), ), const SizedBox(height: 2), @@ -277,7 +277,7 @@ class RecommendSectionsView extends StatelessWidget { ], ), ), - const SizedBox(height: AppDimens.spaceMd), + const SizedBox(height: AppDimens.spaceXs), SizedBox( height: AppDimens.recommendRowHeight, child: ListView.separated( diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart index 1a85118..96a7af4 100644 --- a/lib/pages/recommend/widgets/recommend_top_nav.dart +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -5,13 +5,14 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../recommend_types.dart'; +import 'search_gift_glyphs.dart'; const double _navHeight = 56; const double _tabGap = 20; const double _tabFont = 17; const double _searchHitSize = 40; const double _searchIconSize = 26; -const double _giftIconSize = 30; +const double _giftIconSize = 26; const double _underlineWidth = 26; const double _underlineHeight = 3; @@ -87,7 +88,7 @@ class RecommendTopNav extends StatelessWidget { ), iconSize: _searchIconSize, color: AppColors.textPrimary, - icon: const Icon(Icons.search_rounded), + icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary), ), ), if (onGift != null) @@ -103,12 +104,7 @@ class RecommendTopNav extends StatelessWidget { height: _searchHitSize, ), iconSize: _giftIconSize, - icon: Image.asset( - 'assets/icon/gift.png', - width: _giftIconSize, - height: _giftIconSize, - fit: BoxFit.contain, - ), + icon: GiftGlyph(size: _giftIconSize), ), ), const SizedBox(width: 8), @@ -142,6 +138,7 @@ class RecommendTopNav extends StatelessWidget { fontSize: _tabFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, + height: 1.0, shadows: shadows, ), ), diff --git a/lib/pages/recommend/widgets/search_gift_glyphs.dart b/lib/pages/recommend/widgets/search_gift_glyphs.dart new file mode 100644 index 0000000..721953a --- /dev/null +++ b/lib/pages/recommend/widgets/search_gift_glyphs.dart @@ -0,0 +1,78 @@ +// Author: fruit.com.cn + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// 首页右上角搜索/礼物图标的自定义样式: +/// 搜索为粗线条放大镜(Material 图标无法调线条粗细),礼物为 emoji 红金礼盒。 + +/// 粗线条白色放大镜,圆环在左上、手柄朝右下。 +class SearchGlyph extends StatelessWidget { + const SearchGlyph({super.key, required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size.square(size), + painter: _SearchPainter(color: color), + ); + } +} + +class _SearchPainter extends CustomPainter { + const _SearchPainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final stroke = size.width * 0.09; + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round; + + final circleDiameter = size.width * 0.7; + final direction = math.pi / 4; + // 图形只占画布左上角,先平移让它视觉居中,否则会比文字基线偏高。 + final drawnExtent = + circleDiameter / 2 + + (circleDiameter / 2 + size.width * 0.12) * math.sin(direction) + + stroke / 2; + final centering = (size.width - drawnExtent) / 2; + canvas.translate(centering, centering); + + final center = Offset(circleDiameter / 2, circleDiameter / 2); + canvas.drawCircle(center, circleDiameter / 2 - stroke / 2, paint); + + final start = center + Offset.fromDirection(direction, circleDiameter / 2 - stroke * 0.2); + final end = center + Offset.fromDirection(direction, circleDiameter / 2 + size.width * 0.12); + canvas.drawLine(start, end, paint); + } + + @override + bool shouldRepaint(covariant _SearchPainter oldDelegate) => + oldDelegate.color != color; +} + +/// emoji 礼盒(红盒金丝带,随系统 emoji 风格渲染)。 +class GiftGlyph extends StatelessWidget { + const GiftGlyph({super.key, required this.size}); + + final double size; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: size, + child: FittedBox( + child: Text('🎁', style: TextStyle(fontSize: size, height: 1)), + ), + ); + } +} diff --git a/lib/pages/settings/legal_links_card.dart b/lib/pages/settings/legal_links_card.dart index 020f592..8fd1405 100644 --- a/lib/pages/settings/legal_links_card.dart +++ b/lib/pages/settings/legal_links_card.dart @@ -24,16 +24,17 @@ class LegalLinksCard extends StatelessWidget { key: const Key('settings-terms'), title: l10n.legalTerms, path: legalTermsPath, + icon: Icons.article_outlined, ), _LegalRow( key: const Key('settings-privacy'), title: l10n.legalPrivacy, path: legalPrivacyPath, + icon: Icons.shield_outlined, ), ]; return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + color: AppColors.contentSurface, clipBehavior: Clip.antiAlias, child: Column( children: [ @@ -42,7 +43,7 @@ class LegalLinksCard extends StatelessWidget { const Divider( height: AppDimens.mineGridDividerWidth, thickness: AppDimens.mineGridDividerWidth, - color: AppColors.outlineVariantDark, + color: AppColors.contentBorder, ), _tile(context, rows[index]), ], @@ -61,6 +62,12 @@ class LegalLinksCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), child: Row( children: [ + Icon( + row.icon, + color: AppColors.textSecondary, + size: 20, + ), + const SizedBox(width: 10), Expanded( child: Text( row.title, @@ -96,9 +103,15 @@ class LegalLinksCard extends StatelessWidget { } class _LegalRow { - const _LegalRow({required this.key, required this.title, required this.path}); + const _LegalRow({ + required this.key, + required this.title, + required this.path, + required this.icon, + }); final Key key; final String title; final String path; + final IconData icon; } diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index 02c798d..9c9fc59 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -1,17 +1,17 @@ // Author: fruit.com.cn -import 'dart:async'; - import 'package:flutter/material.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/theme/app_theme.dart'; -import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/app_glow_panel.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../account/account_entry_card.dart'; +import '../mine/about_page.dart'; import 'legal_links_card.dart'; /// 设置页:账号入口按列表展示,不提供通知和手动语言切换。 @@ -31,35 +31,184 @@ class SettingsPage extends StatelessWidget { child: ListView( padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), children: [ + Container( + key: const Key('settings-group'), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + if (auth.isSignedIn) ...[ + const AccountEntryCard(), + const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.contentBorder, + ), + ], + const LegalLinksCard(), + const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.contentBorder, + ), + _supportSection(context, l10n), + ], + ), + ), if (auth.isSignedIn) ...[ - const AccountEntryCard(), - const SizedBox(height: AppDimens.spaceXl), + const SizedBox(height: 12), + _signOutButton(context, l10n), ], - const LegalLinksCard(), - const SizedBox(height: AppDimens.spaceXl), - _authAction(context, l10n, auth), ], ), ), ); } - Widget _authAction( - BuildContext context, - AppLocalizations l10n, - AuthController auth, - ) { - if (auth.isSignedIn) { - return OutlinedButton( + /// 退出登录:透明背景独立按钮,确认后清除本地会话并吊销刷新令牌。 + Widget _signOutButton(BuildContext context, AppLocalizations l10n) { + return SizedBox( + width: double.infinity, + height: 48, + child: TextButton( key: const Key('settings-sign-out'), - onPressed: () => unawaited(auth.signOut()), - child: Text(l10n.signOut), - ); - } - return FilledButton( - key: const Key('settings-sign-in'), - onPressed: () => unawaited(openAuthPage(context)), - child: Text(l10n.signIn), + style: TextButton.styleFrom( + backgroundColor: AppColors.transparent, + foregroundColor: AppColors.error, + ), + onPressed: () => _confirmSignOut(context, l10n), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.logout_rounded, size: 18), + const SizedBox(width: 6), + Text( + l10n.signOut, + style: const TextStyle( + fontSize: AppDimens.body, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ); + } + + Future _confirmSignOut(BuildContext context, AppLocalizations l10n) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AppGlowDialog( + title: l10n.signOut, + content: l10n.signOutConfirm, + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: AppColors.error), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.signOut), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + await context.read().signOut(); + } + + /// 常见问题 / 关于我们。 + Widget _supportSection(BuildContext context, AppLocalizations l10n) { + return Container( + key: const Key('settings-support'), + decoration: const BoxDecoration(color: AppColors.contentSurface), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _row( + key: const Key('settings-common-questions'), + label: l10n.helpFeedback, + icon: Icons.help_outline, + onTap: () => _pushPlaceholder(context, l10n), + ), + const Divider( + height: 1, + thickness: 1, + color: AppColors.contentBorder, + ), + _row( + key: const Key('settings-about'), + label: l10n.aboutUs, + icon: Icons.info_outline, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MineAboutPage()), + ), + ), + ], + ), + ); + } + + Widget _row({ + required Key key, + required String label, + required IconData icon, + required VoidCallback onTap, + }) { + return SizedBox( + height: 56, + child: InkWell( + key: key, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(icon, color: AppColors.textSecondary, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.body, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: 20, + ), + ], + ), + ), + ), + ); + } + + void _pushPlaceholder(BuildContext context, AppLocalizations l10n) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlainPageScaffold( + pageKey: const Key('settings-placeholder-page'), + backKey: const Key('settings-placeholder-back'), + title: l10n.helpFeedback, + child: Center( + child: Text( + l10n.comingSoon, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.body, + ), + ), + ), + ), + ), ); } } diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index b889936..7300b39 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -115,7 +115,7 @@ class WatchRecordCard extends StatelessWidget { value: record.progress.fraction, backgroundColor: AppColors.border, valueColor: const AlwaysStoppedAnimation( - AppColors.blue, + AppColors.brandRed, ), ), ),