diff --git a/lib/core/widgets/plain_page_scaffold.dart b/lib/core/widgets/plain_page_scaffold.dart index 640baf0..8e5c07f 100644 --- a/lib/core/widgets/plain_page_scaffold.dart +++ b/lib/core/widgets/plain_page_scaffold.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; +import 'subpage_header.dart'; /// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。 class PlainPageScaffold extends StatelessWidget { @@ -30,47 +31,11 @@ class PlainPageScaffold extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(6, 4, 14, 0), - child: Row( - children: [ - IconButton( - key: backKey, - tooltip: MaterialLocalizations.of( - context, - ).backButtonTooltip, - onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon( - Icons.chevron_left_rounded, - color: AppColors.onSurface, - size: 26, - ), - ), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 20, - fontWeight: FontWeight.w800, - ), - ), - ), - ], - ), + AppSubpageHeader( + title: title, + subtitle: subtitle, + backKey: backKey, ), - if (subtitle != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - subtitle!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ), Expanded(child: child), ], ), diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart new file mode 100644 index 0000000..e98130d --- /dev/null +++ b/lib/core/widgets/subpage_header.dart @@ -0,0 +1,130 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +const double _height = 52; +const double _heightWithSubtitle = 64; +const double _horizontal = 14; +const double _backSize = 36; +const double _backGap = 12; +const double _titleFontSize = 18; +const double _subtitleFontSize = 12; +const double _actionGap = 8; +const double _iconSize = 20; + +/// 二级页面统一返回按钮,浅色页使用 card 圆底,深色页使用透明底。 +class AppSubpageBackButton extends StatelessWidget { + const AppSubpageBackButton({ + super.key, + required this.backKey, + this.dark = false, + this.onBack, + }); + + final Key backKey; + final bool dark; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: backKey, + width: _backSize, + height: _backSize, + child: Tooltip( + message: MaterialLocalizations.of(context).backButtonTooltip, + child: Material( + color: dark ? Colors.transparent : AppColors.card, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onBack ?? () => Navigator.of(context).maybePop(), + child: Icon( + Icons.chevron_left_rounded, + color: dark ? Colors.white : AppColors.onSurface, + size: _iconSize, + ), + ), + ), + ), + ); + } +} + +/// 二级页面统一页头:返回按钮、左对齐标题、可选副标题与右侧操作。 +class AppSubpageHeader extends StatelessWidget { + const AppSubpageHeader({ + super.key, + required this.title, + required this.backKey, + this.subtitle, + this.actions, + this.onBack, + this.dark = false, + }); + + final String title; + final Key backKey; + final String? subtitle; + final Widget? actions; + final VoidCallback? onBack; + final bool dark; + + @override + Widget build(BuildContext context) { + final foreground = dark ? Colors.white : AppColors.onSurface; + final secondary = dark ? Colors.white70 : AppColors.muted; + return SizedBox( + key: const Key('subpage-header'), + height: subtitle == null ? _height : _heightWithSubtitle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontal), + child: Row( + children: [ + AppSubpageBackButton(backKey: backKey, dark: dark, onBack: onBack), + const SizedBox(width: _backGap), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + key: const Key('subpage-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: _titleFontSize, + fontWeight: FontWeight.w800, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + key: const Key('subpage-subtitle'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: secondary, + fontSize: _subtitleFontSize, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + if (actions != null) ...[ + const SizedBox(width: _actionGap), + actions!, + ], + ], + ), + ), + ); + } +} diff --git a/lib/pages/account/account_deactivate_page.dart b/lib/pages/account/account_deactivate_page.dart index 4b04d21..f5a123b 100644 --- a/lib/pages/account/account_deactivate_page.dart +++ b/lib/pages/account/account_deactivate_page.dart @@ -12,6 +12,7 @@ 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/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import 'account_bindings.dart'; import 'account_deactivate_logic.dart'; @@ -51,23 +52,16 @@ class _AccountDeactivatePageState extends State { final canShow = signedIn || _logic.scheduledAt != null; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('account-deactivate-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.accountDeactivate), - ), - body: SafeArea( - top: false, - child: canShow - ? ListenableBuilder( - listenable: _logic, - builder: (context, _) => _body(l10n), - ) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('account-deactivate-page'), + backKey: const Key('account-deactivate-back'), + title: l10n.accountDeactivate, + child: canShow + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/account/account_devices_page.dart b/lib/pages/account/account_devices_page.dart index 804a192..99f3101 100644 --- a/lib/pages/account/account_devices_page.dart +++ b/lib/pages/account/account_devices_page.dart @@ -15,6 +15,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import 'account_bindings.dart'; import 'account_devices_logic.dart'; @@ -51,23 +52,16 @@ class _AccountDevicesPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('account-devices-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.accountDevices), - ), - body: SafeArea( - top: false, - child: signedIn - ? ListenableBuilder( - listenable: _logic, - builder: (context, _) => _body(l10n), - ) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('account-devices-page'), + backKey: const Key('account-devices-back'), + title: l10n.accountDevices, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/account/account_export_page.dart b/lib/pages/account/account_export_page.dart index 1bc0a9d..f451b27 100644 --- a/lib/pages/account/account_export_page.dart +++ b/lib/pages/account/account_export_page.dart @@ -15,6 +15,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import 'account_bindings.dart'; import 'account_export_logic.dart'; @@ -52,23 +53,16 @@ class _AccountExportPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('account-export-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.accountDataExport), - ), - body: SafeArea( - top: false, - child: signedIn - ? ListenableBuilder( - listenable: _logic, - builder: (context, _) => _body(l10n), - ) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('account-export-page'), + backKey: const Key('account-export-back'), + title: l10n.accountDataExport, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart index 6b9ef9e..1b79c5e 100644 --- a/lib/pages/account/account_profile_page.dart +++ b/lib/pages/account/account_profile_page.dart @@ -15,6 +15,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'account_bindings.dart'; @@ -72,23 +73,16 @@ class _AccountProfilePageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('account-profile-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.accountProfile), - ), - body: SafeArea( - top: false, - child: signedIn - ? ListenableBuilder( - listenable: _logic, - builder: (context, _) => _body(l10n), - ) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('account-profile-page'), + backKey: const Key('account-profile-back'), + title: l10n.accountProfile, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index d5cf3a4..bf6e729 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; const double _cardHeight = 196; @@ -343,11 +344,11 @@ class _CreatePlaceholderPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Scaffold( - key: const Key('create-placeholder-page'), - backgroundColor: AppColors.ink, - appBar: AppBar(title: Text(title)), - body: Center( + return PlainPageScaffold( + pageKey: const Key('create-placeholder-page'), + backKey: const Key('create-placeholder-back'), + title: title, + child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/pages/discover/category_dramas_page.dart b/lib/pages/discover/category_dramas_page.dart index 0687f49..9b35069 100644 --- a/lib/pages/discover/category_dramas_page.dart +++ b/lib/pages/discover/category_dramas_page.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; -import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'widgets/drama_grid.dart'; @@ -28,32 +28,25 @@ class CategoryDramasPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('category-dramas-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onSurface, - title: Text(title), - ), - body: SafeArea( - top: false, - child: dramas.isEmpty - ? EmptyState( - key: const Key('category-dramas-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.noCategoryDramas, - subtitle: l10n.emptyHint, - ) - : SingleChildScrollView( - padding: const EdgeInsets.only(top: 12, bottom: 28), - child: DramaGrid( - l10n: l10n, - dramas: dramas, - onOpen: (drama) => _open(context, drama), - ), + child: PlainPageScaffold( + pageKey: const Key('category-dramas-page'), + backKey: const Key('category-dramas-back'), + title: title, + child: dramas.isEmpty + ? EmptyState( + key: const Key('category-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.noCategoryDramas, + subtitle: l10n.emptyHint, + ) + : SingleChildScrollView( + padding: const EdgeInsets.only(top: 8, bottom: 28), + child: DramaGrid( + l10n: l10n, + dramas: dramas, + onOpen: (drama) => _open(context, drama), ), - ), + ), ), ); } diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index 46e87f4..84e839a 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -18,6 +18,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../player/player_view.dart'; import 'downloads_binding.dart'; @@ -55,20 +56,13 @@ class _DownloadsPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('downloads-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(l10n.downloads), - ), - body: SafeArea( - top: false, - child: signedIn - ? _DownloadsBody(downloads: context.watch()) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('downloads-page'), + backKey: const Key('downloads-back'), + title: l10n.downloads, + child: signedIn + ? _DownloadsBody(downloads: context.watch()) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 208ab48..f9a9a06 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -20,6 +20,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; @@ -145,24 +146,14 @@ class _DramaDetailPageState extends State { ); } - Widget _loading(AppLocalizations l10n) => Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.surfaceTop, - foregroundColor: AppColors.onSurface, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: const Center(child: CircularProgressIndicator()), + Widget _loading(AppLocalizations l10n) => _statePage( + localizedDramaTitle(l10n, widget.drama), + const Center(child: CircularProgressIndicator()), ); - Widget _failed(AppLocalizations l10n) => Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.surfaceTop, - foregroundColor: AppColors.onSurface, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: Center( + Widget _failed(AppLocalizations l10n) => _statePage( + localizedDramaTitle(l10n, widget.drama), + Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( @@ -181,14 +172,9 @@ class _DramaDetailPageState extends State { ), ); - Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.surfaceTop, - foregroundColor: AppColors.onSurface, - title: Text(localizedDramaTitle(l10n, drama)), - ), - body: EmptyState( + Widget _empty(AppLocalizations l10n, Drama drama) => _statePage( + localizedDramaTitle(l10n, drama), + EmptyState( key: const Key('drama-detail-empty'), icon: Icons.video_library_outlined, title: l10n.noEpisodesYet, @@ -198,6 +184,18 @@ class _DramaDetailPageState extends State { ), ); + Widget _statePage(String title, Widget child) => Scaffold( + backgroundColor: AppColors.ink, + body: SafeArea( + child: Column( + children: [ + AppSubpageHeader(title: title, backKey: const Key('detail-back')), + Expanded(child: child), + ], + ), + ), + ); + Widget? _resume(AppLocalizations l10n) { final fraction = _continueFraction; final progressError = logic.progressError; diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart index e414d8a..833893b 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_header.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -6,6 +6,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; +import '../../../core/widgets/subpage_header.dart'; import '../../../l10n/app_localizations.dart'; const double _horizontal = 14; @@ -80,11 +81,10 @@ class DramaDetailHeader extends StatelessWidget { Widget _actions(BuildContext context) { return Row( children: [ - _CircleAction( - actionKey: const Key('detail-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Icons.chevron_left_rounded, - onPressed: actions.onBack, + AppSubpageBackButton( + backKey: const Key('detail-back'), + dark: true, + onBack: actions.onBack, ), const Spacer(), _CircleAction( diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 2e5fa8a..0294fa0 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -15,6 +15,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/library_drama_tile.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; import 'favorites_binding.dart'; @@ -52,19 +53,13 @@ class _FavoritesPageState extends State { final signedIn = context.watch().isSignedIn; return Theme( data: AppTheme.light(), - child: Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(l10n.myFavorites), - ), - body: SafeArea( - top: false, - child: signedIn - ? _FavoritesBody(favorites: context.watch()) - : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('favorites-page'), + backKey: const Key('favorites-back'), + title: l10n.myFavorites, + child: signedIn + ? _FavoritesBody(favorites: context.watch()) + : const AuthGatePanel(), ), ); } diff --git a/lib/pages/filter/filter_page.dart b/lib/pages/filter/filter_page.dart index 3828c02..2def7eb 100644 --- a/lib/pages/filter/filter_page.dart +++ b/lib/pages/filter/filter_page.dart @@ -9,6 +9,7 @@ import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'widgets/filter_result_card.dart'; @@ -64,7 +65,10 @@ class _FilterPageState extends State { body: SafeArea( child: Column( children: [ - _topBar(l10n), + AppSubpageHeader( + title: l10n.filter, + backKey: const Key('filter-back'), + ), Expanded( child: CustomScrollView( slivers: [ @@ -123,44 +127,6 @@ class _FilterPageState extends State { ); } - Widget _topBar(AppLocalizations l10n) { - return SizedBox( - height: 52, - child: Row( - children: [ - SizedBox( - key: const Key('filter-back'), - width: 48, - height: 48, - child: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.of(context).pop(), - padding: EdgeInsets.zero, - icon: const Icon( - Icons.chevron_left_rounded, - color: AppColors.onSurface, - size: 26, - ), - ), - ), - Expanded( - child: Center( - child: Text( - l10n.filter, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 18, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - const SizedBox(width: 48), - ], - ), - ); - } - Widget _groups( AppLocalizations l10n, List categories, diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 7b2c44d..3b777a5 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -14,6 +14,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; @@ -364,11 +365,11 @@ class _MinePlaceholderPage extends StatelessWidget { MinePlaceholderKind.creator => l10n.creatorCenter, MinePlaceholderKind.help => l10n.helpFeedback, }; - return Scaffold( - key: const Key('mine-placeholder-page'), - backgroundColor: AppColors.ink, - appBar: AppBar(title: Text(title)), - body: Center( + 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( diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index b540b8a..a02d874 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -11,16 +11,18 @@ import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/remote_image.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; const int _moreLimit = 12; const int _filmHoles = 8; -const Color _premiereTop = Color(0xFF1F1B16); -const Color _premiereBottom = Color(0xFF2E2119); -const Color _filmHole = Color(0x33FFFFFF); +const double _cardPadding = 8; +const double _cardWidth = 124; +const double _rowHeight = 204; +const double _cardRadius = 12; -/// 新剧页:暗色首映厅,今日首映大卡 + 横向更多新剧。 +/// 新剧页:暖色首映厅,今日首映大卡 + 横向更多新剧。 class NewReleasesPage extends StatelessWidget { const NewReleasesPage({super.key}); @@ -32,29 +34,35 @@ class NewReleasesPage extends StatelessWidget { final hero = dramas.isEmpty ? null : dramas.first; final more = dramas.skip(1).take(_moreLimit).toList(); return AnnotatedRegion( - value: SystemUiOverlayStyle.light, + value: SystemUiOverlayStyle.dark, child: Scaffold( key: const Key('new-releases-page'), + backgroundColor: AppColors.ink, body: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [_premiereTop, _premiereBottom], + colors: [AppColors.surfaceTop, AppColors.ink], ), ), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _header(context, l10n, dramas.length), + AppSubpageHeader( + title: l10n.newReleasesTitle, + subtitle: l10n.newReleasesSubtitle, + backKey: const Key('new-releases-back'), + actions: _countChip(l10n, dramas.length), + ), Expanded( child: hero == null ? Center( child: Text( l10n.noStories, key: const Key('new-releases-empty'), - style: const TextStyle(color: Colors.white70), + style: const TextStyle(color: AppColors.muted), ), ) : ListView( @@ -73,7 +81,7 @@ class NewReleasesPage extends StatelessWidget { _moreHeader(l10n), const SizedBox(height: 10), SizedBox( - height: 196, + height: _rowHeight, child: ListView.separated( key: const Key('new-releases-row'), scrollDirection: Axis.horizontal, @@ -103,63 +111,21 @@ class NewReleasesPage extends StatelessWidget { ); } - Widget _header(BuildContext context, AppLocalizations l10n, int count) { - return Padding( - padding: const EdgeInsets.fromLTRB(6, 4, 16, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - key: const Key('new-releases-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon( - Icons.chevron_left_rounded, - color: Colors.white, - size: 26, - ), - ), - Expanded( - child: Text( - l10n.newReleasesTitle, - style: const TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w900, - ), - ), - ), - Container( - key: const Key('new-releases-count'), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - l10n.newReleasesCount(count), - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - l10n.newReleasesSubtitle, - style: const TextStyle(color: Colors.white70, fontSize: 12), - ), - ), - ], + Widget _countChip(AppLocalizations l10n, int count) { + return Container( + key: const Key('new-releases-count'), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.badgeBeige, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + l10n.newReleasesCount(count), + style: const TextStyle( + color: AppColors.blue, + fontSize: 11, + fontWeight: FontWeight.w700, + ), ), ); } @@ -286,7 +252,7 @@ class NewReleasesPage extends StatelessWidget { child: Text( l10n.rankingNew, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 16, fontWeight: FontWeight.w800, ), @@ -294,7 +260,7 @@ class NewReleasesPage extends StatelessWidget { ), Text( l10n.seeAll, - style: const TextStyle(color: Colors.white54, fontSize: 12), + style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), ], ), @@ -310,13 +276,25 @@ class NewReleasesPage extends StatelessWidget { return GestureDetector( key: Key('new-release-card-${drama.id}'), onTap: () => unawaited(playDramaFromCatalog(context, drama)), - child: SizedBox( - width: 108, + child: Container( + width: _cardWidth, + padding: const EdgeInsets.all(_cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + boxShadow: const [ + BoxShadow( + color: AppColors.cardShadow, + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), child: SizedBox( width: 108, height: 144, @@ -348,7 +326,7 @@ class NewReleasesPage extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 13, fontWeight: FontWeight.w700, ), @@ -356,7 +334,7 @@ class NewReleasesPage extends StatelessWidget { const SizedBox(height: 2), Text( _releaseDate(index), - style: const TextStyle(color: Colors.white54, fontSize: 12), + style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), ], ), @@ -407,7 +385,13 @@ class _FilmStrip extends StatelessWidget { return Container( key: const Key('new-releases-film'), height: 22, - color: const Color(0x1A000000), + decoration: const BoxDecoration( + color: AppColors.raised, + border: Border( + top: BorderSide(color: AppColors.border), + bottom: BorderSide(color: AppColors.border), + ), + ), child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [_holes(), _holes()], @@ -424,7 +408,7 @@ class _FilmStrip extends StatelessWidget { width: 6, height: 8, decoration: BoxDecoration( - color: _filmHole, + color: AppColors.border, borderRadius: BorderRadius.circular(2), ), ), diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index a8e2f27..605c1a4 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -14,6 +14,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; /// Anonymous system notice list with pagination and detail dialog. @@ -45,20 +46,13 @@ class _NoticesPageState extends State { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('notices-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(l10n.notices), - ), - body: SafeArea( - top: false, - child: ListenableBuilder( - listenable: _controller, - builder: (context, _) => _body(l10n), - ), + child: PlainPageScaffold( + pageKey: const Key('notices-page'), + backKey: const Key('notices-back'), + title: l10n.notices, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => _body(l10n), ), ), ); diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index 1d8189b..26f5bd3 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../new_releases/new_releases_page.dart'; @@ -43,7 +44,11 @@ class _ReservePageState extends State { child: ListView( padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), children: [ - _header(context, l10n), + AppSubpageHeader( + title: l10n.reserveTitle, + subtitle: l10n.reserveSubtitle, + backKey: const Key('reserve-back'), + ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _weekStrip(l10n), @@ -62,48 +67,6 @@ class _ReservePageState extends State { ); } - Widget _header(BuildContext context, AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(6, 0, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - key: const Key('reserve-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon( - Icons.chevron_left_rounded, - color: AppColors.onSurface, - size: 26, - ), - ), - Expanded( - child: Text( - l10n.reserveTitle, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 24, - fontWeight: FontWeight.w800, - ), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - l10n.reserveSubtitle, - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ), - ], - ), - ); - } - Widget _weekStrip(AppLocalizations l10n) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index 8d06c0a..bd8cc4e 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -9,6 +9,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../account/account_entry_card.dart'; import '../notices/notices_page.dart'; @@ -23,28 +24,21 @@ class SettingsPage extends StatelessWidget { final auth = context.watch(); return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('settings-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onSurface, - title: Text(l10n.settings), - ), - body: SafeArea( - top: false, - child: ListView( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 28), - children: [ - if (auth.isSignedIn) ...[ - const AccountEntryCard(), - const SizedBox(height: 12), - ], - _noticesTile(context, l10n), - const SizedBox(height: 24), - _authAction(context, l10n, auth), + child: PlainPageScaffold( + pageKey: const Key('settings-page'), + backKey: const Key('settings-back'), + title: l10n.settings, + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), + children: [ + if (auth.isSignedIn) ...[ + const AccountEntryCard(), + const SizedBox(height: 12), ], - ), + _noticesTile(context, l10n), + const SizedBox(height: 24), + _authAction(context, l10n, auth), + ], ), ), ); diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index 9fe52d6..beed81a 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -13,6 +13,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; /// 商品货架:金币包与会员档位共用一套真实接口渲染。 @@ -57,20 +58,13 @@ class _StorePageState extends State { final isVip = widget.type == StoreController.vipType; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('store-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onSurface, - title: Text(isVip ? l10n.vipTitle : l10n.rechargeTitle), - ), - body: SafeArea( - top: false, - child: ListenableBuilder( - listenable: controller, - builder: (context, _) => _body(l10n, isVip), - ), + child: PlainPageScaffold( + pageKey: const Key('store-page'), + backKey: const Key('store-back'), + title: isVip ? l10n.vipTitle : l10n.rechargeTitle, + child: ListenableBuilder( + listenable: controller, + builder: (context, _) => _body(l10n, isVip), ), ), ); diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index 7748314..6f80f6e 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -13,6 +13,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../store/recharge_page.dart'; import '../store/vip_page.dart'; import '../../l10n/app_localizations.dart'; @@ -45,15 +46,11 @@ class _WalletPageState extends State { final wallet = context.watch(); return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('wallet-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onSurface, - title: Text(l10n.wallet), - ), - body: SafeArea(top: false, child: _body(l10n, wallet)), + child: PlainPageScaffold( + pageKey: const Key('wallet-page'), + backKey: const Key('wallet-back'), + title: l10n.wallet, + child: _body(l10n, wallet), ), ); } diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 48b46b5..fb3e5b9 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -19,6 +19,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../drama_detail/drama_detail_view.dart'; @@ -34,18 +35,11 @@ class WatchRecordsPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), - child: Scaffold( - key: const Key('watch-records-page'), - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(l10n.watchHistory), - ), - body: SafeArea( - top: false, - child: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(), - ), + child: PlainPageScaffold( + pageKey: const Key('watch-records-page'), + backKey: const Key('watch-records-back'), + title: l10n.watchHistory, + child: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(), ), ); } diff --git a/test/core/subpage_header_test.dart b/test/core/subpage_header_test.dart new file mode 100644 index 0000000..7548468 --- /dev/null +++ b/test/core/subpage_header_test.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/core/widgets/subpage_header.dart'; + +void main() { + testWidgets('should_render_unified_light_header_dimensions', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AppSubpageHeader( + title: 'Title', + backKey: const Key('sample-back'), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(const Key('subpage-header'))).height, 52); + expect( + tester.getSize(find.byKey(const Key('sample-back'))), + const Size(36, 36), + ); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); + expect(title.style?.color, AppColors.onSurface); + final back = tester.getRect(find.byKey(const Key('sample-back'))); + final titleRect = tester.getRect(find.byKey(const Key('subpage-title'))); + expect(titleRect.left - back.right, closeTo(12, 0.5)); + }); + + testWidgets('should_use_64_height_when_subtitle_exists', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AppSubpageHeader( + title: 'Title', + subtitle: 'Subtitle', + backKey: const Key('sample-back'), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(const Key('subpage-header'))).height, 64); + expect(find.byKey(const Key('subpage-subtitle')), findsOneWidget); + }); + + testWidgets('should_use_transparent_dark_back_button', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + backgroundColor: Colors.black, + body: AppSubpageHeader( + title: 'Title', + backKey: Key('sample-back'), + dark: true, + ), + ), + ), + ); + + final material = tester.widget( + find.descendant( + of: find.byKey(const Key('sample-back')), + matching: find.byType(Material), + ), + ); + final icon = tester.widget( + find.descendant( + of: find.byKey(const Key('sample-back')), + matching: find.byIcon(Icons.chevron_left_rounded), + ), + ); + expect(material.color, Colors.transparent); + expect(icon.color, Colors.white); + expect(icon.size, 20); + }); +} diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart index cbfea3b..97fd6ed 100644 --- a/test/discover/filter_page_test.dart +++ b/test/discover/filter_page_test.dart @@ -29,6 +29,7 @@ void main() { await _pumpFilterPage(tester); expect(find.byKey(const Key('filter-page')), findsOneWidget); + _expectUnifiedHeader(tester, const Key('filter-back')); expect( find.byKey(const Key('filter-group-label-category')), findsOneWidget, @@ -184,6 +185,13 @@ final List _filterDramas = [ ), ]; +void _expectUnifiedHeader(WidgetTester tester, Key backKey) { + expect(tester.getSize(find.byKey(backKey)), const Size(36, 36)); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); +} + Drama _drama({ required String id, required String title, diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 8294393..29442e9 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -29,6 +29,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('ranking-page')), findsOneWidget); + _expectUnifiedHeader(tester, const Key('ranking-back')); expect(find.text('TOP 1'), findsOneWidget); expect( tester.widget(find.byKey(const Key('ranking-title-1'))).data, @@ -46,6 +47,15 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('new-releases-page')), findsOneWidget); + _expectUnifiedHeader(tester, const Key('new-releases-back')); + final newPage = tester.widget( + find.byKey(const Key('new-releases-page')), + ); + expect(newPage.backgroundColor, AppColors.ink); + final badge = tester.widget( + find.byKey(const Key('new-releases-hero-badge')), + ); + expect((badge.decoration! as BoxDecoration).color, AppColors.blue); expect(find.byKey(const Key('new-releases-hero')), findsOneWidget); expect(find.text('Vengeance Rising'), findsWidgets); expect( @@ -68,6 +78,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('reserve-page')), findsOneWidget); + _expectUnifiedHeader(tester, const Key('reserve-back')); expect(find.byKey(const Key('reserve-week-strip')), findsOneWidget); for (var index = 0; index < 7; index++) { expect(find.byKey(Key('reserve-day-$index')), findsOneWidget); @@ -207,3 +218,10 @@ List _rankDramas(int count) => [ ], ), ]; + +void _expectUnifiedHeader(WidgetTester tester, Key backKey) { + expect(tester.getSize(find.byKey(backKey)), const Size(36, 36)); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); +} diff --git a/test/mine/settings_page_test.dart b/test/mine/settings_page_test.dart index 7280809..d748fde 100644 --- a/test/mine/settings_page_test.dart +++ b/test/mine/settings_page_test.dart @@ -52,6 +52,13 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('settings-page')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('settings-back'))), + const Size(36, 36), + ); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); expect(find.byKey(const Key('settings-notices')), findsOneWidget); expect(find.byKey(const Key('server-endpoint-setting')), findsNothing); expect(find.text('Server address'), findsNothing); diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 9fdbe56..140352a 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -52,6 +52,13 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('notices-page')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('notices-back'))), + const Size(36, 36), + ); + final title = tester.widget(find.byKey(const Key('subpage-title'))); + expect(title.style?.fontSize, 18); + expect(title.style?.fontWeight, FontWeight.w800); expect(find.byKey(const Key('auth-gate-panel')), findsNothing); }); }