From 9ef8eb15b055550dc1dea7163785620ce449ae84 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Fri, 30 Oct 2026 17:14:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=E8=BD=AE=E6=92=AD=E5=8F=AA?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=A0=87=E9=A2=98=E7=AE=80=E4=BB=8B=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E5=81=9A=E9=A1=B6=E9=83=A8=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recommend/widgets/home_hero_carousel.dart | 171 +++--------------- .../recommend/widgets/recommend_top_nav.dart | 113 ++++++------ test/home/home_preview_layout_test.dart | 4 +- test/recommend/home_hero_carousel_test.dart | 38 +++- .../recommend_refresh_scheduling_test.dart | 11 +- .../recommend_top_nav_accessibility_test.dart | 48 +++++ 6 files changed, 180 insertions(+), 205 deletions(-) create mode 100644 test/recommend/recommend_top_nav_accessibility_test.dart diff --git a/lib/pages/recommend/widgets/home_hero_carousel.dart b/lib/pages/recommend/widgets/home_hero_carousel.dart index 538226b..37e5d87 100644 --- a/lib/pages/recommend/widgets/home_hero_carousel.dart +++ b/lib/pages/recommend/widgets/home_hero_carousel.dart @@ -3,16 +3,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/models/home_feed.dart'; -import '../../../core/data/services/favorite_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; -import '../../../l10n/app_localizations.dart'; -/// 推荐页顶部静态头图轮播:封面 + 居中剧名/简介 + 立即观看/收藏 + 轮播点。 +/// 推荐页顶部静态头图轮播:封面 + 底部剧名/简介 + 轮播点。 class HomeHeroCarousel extends StatefulWidget { const HomeHeroCarousel({ super.key, @@ -27,7 +24,7 @@ class HomeHeroCarousel extends StatefulWidget { /// 按 banner 关联到的剧目;为空时只展示头图。 final Drama? Function(FeedBanner banner) dramaFor; - /// 点击「立即观看」或直接点按头图。 + /// 点击头图进入播放。 final ValueChanged onWatch; final bool active; @@ -44,7 +41,12 @@ class _HomeHeroCarouselState extends State { static const Duration _pageDuration = Duration(milliseconds: 480); static const double _dotSize = 4; static const double _dotGap = 9; - static const double _ctaHeight = 38; + + /// 轮播点距底边的距离。 + static const double _dotBottom = 14; + + /// 文案区距底边:让出轮播点区域(底距 + 点直径)再留一段呼吸空间。 + static const double _copyBottomInset = _dotBottom + _dotSize + 18; /// 无限轮播:PageView 用虚拟大页码,取模映射到真实 banner,首尾天然相接。 static const int _basePage = 1000; @@ -164,7 +166,12 @@ class _HomeHeroCarouselState extends State { itemBuilder: (context, page) => _bannerPage(context, page % widget.banners.length), ), - Positioned(left: 0, right: 0, bottom: 14, child: _dots(context)), + Positioned( + left: 0, + right: 0, + bottom: _dotBottom, + child: _dots(context), + ), ], ), ), @@ -199,16 +206,13 @@ class _HomeHeroCarouselState extends State { ), ), if (drama != null) - Align( - alignment: const Alignment(0, 0.6), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: _HeroCopy( - key: Key('home-hero-copy-$index'), - drama: drama, - ctaHeight: _ctaHeight, - onWatch: () => widget.onWatch(drama), - ), + Positioned( + left: 28, + right: 28, + bottom: _copyBottomInset, + child: _HeroCopy( + key: Key('home-hero-copy-$index'), + drama: drama, ), ), if (drama != null) @@ -251,30 +255,17 @@ class _HomeHeroCarouselState extends State { } } -/// Hero 中部居中文案与按钮组。 +/// Hero 底部居中文案:剧名 + 简介。 class _HeroCopy extends StatelessWidget { - const _HeroCopy({ - super.key, - required this.drama, - required this.ctaHeight, - required this.onWatch, - }); + const _HeroCopy({super.key, required this.drama}); final Drama drama; - final double ctaHeight; - final VoidCallback onWatch; static const Color _titleCream = Color(0xFFE9D3B8); @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; final lang = Localizations.localeOf(context).languageCode; - final types = drama.localizedTypeLabels(lang); - final meta = [ - if (types.isNotEmpty) types.first, - l10n.totalEpisodesShort(drama.episodeCount), - ].join(' · '); return Column( key: const Key('home-hero-copy'), @@ -287,24 +278,13 @@ class _HeroCopy extends StatelessWidget { textAlign: TextAlign.center, style: const TextStyle( color: _titleCream, - fontSize: 28, - fontWeight: FontWeight.w600, + fontSize: 22, + fontWeight: FontWeight.w400, height: 1.15, shadows: [Shadow(color: Color(0x80000000), blurRadius: 12)], ), ), const SizedBox(height: 8), - Text( - meta, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: AppColors.textPrimary.withValues(alpha: 0.92), - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 10), Text( drama.localizedSynopsis(lang), maxLines: 1, @@ -317,108 +297,7 @@ class _HeroCopy extends StatelessWidget { shadows: const [Shadow(color: Color(0x80000000), blurRadius: 8)], ), ), - const SizedBox(height: 20), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _WatchButton( - height: ctaHeight, - label: l10n.watchNowAction, - onTap: onWatch, - ), - const SizedBox(width: 12), - _FavoriteCircle(size: ctaHeight, drama: drama), - ], - ), ], ); } } - -class _WatchButton extends StatelessWidget { - const _WatchButton({ - required this.height, - required this.label, - required this.onTap, - }); - - final double height; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: AppColors.brandRed, - borderRadius: BorderRadius.circular(height / 2), - child: InkWell( - key: const Key('home-hero-watch'), - borderRadius: BorderRadius.circular(height / 2), - onTap: onTap, - child: SizedBox( - height: height, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.play_arrow_rounded, - size: 20, - color: AppColors.textPrimary, - ), - const SizedBox(width: 2), - Text( - label, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _FavoriteCircle extends StatelessWidget { - const _FavoriteCircle({required this.size, required this.drama}); - - final double size; - final Drama drama; - - @override - Widget build(BuildContext context) { - final favorites = context.watch(); - final saved = favorites.isSaved(drama.id); - return Material( - color: saved ? AppColors.brandRed : AppColors.transparent, - shape: CircleBorder( - side: BorderSide( - color: saved - ? AppColors.brandRed - : AppColors.textPrimary.withValues(alpha: 0.85), - width: 1.5, - ), - ), - child: InkWell( - key: const Key('home-hero-favorite'), - customBorder: const CircleBorder(), - onTap: () => unawaited(favorites.toggle(drama.id, card: drama)), - child: SizedBox( - width: size, - height: size, - child: Icon( - saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, - size: 20, - color: AppColors.textPrimary, - ), - ), - ), - ); - } -} diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart index 96a7af4..3fd9b47 100644 --- a/lib/pages/recommend/widgets/recommend_top_nav.dart +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import '../recommend_types.dart'; import 'search_gift_glyphs.dart'; @@ -10,7 +11,7 @@ import 'search_gift_glyphs.dart'; const double _navHeight = 56; const double _tabGap = 20; const double _tabFont = 17; -const double _searchHitSize = 40; +const double _maxNavigationTextScale = 1.3; const double _searchIconSize = 26; const double _giftIconSize = 26; const double _underlineWidth = 26; @@ -47,68 +48,74 @@ class RecommendTopNav extends StatelessWidget { final shadows = onVideo ? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)] : null; - return SizedBox( - key: const Key('recommend-top-nav'), - height: height, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only(left: 20, right: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var index = 0; index < items.length; index++) ...[ - if (index > 0) const SizedBox(width: _tabGap), - _tab( - key: Key('recommend-tab-${items[index].$3}'), - code: items[index].$3, - label: items[index].$2, - selected: selected == items[index].$1, - shadows: shadows, - onTap: () => onSelected(items[index].$1), - ), + return MediaQuery.withClampedTextScaling( + maxScaleFactor: _maxNavigationTextScale, + child: SizedBox( + key: const Key('recommend-top-nav'), + height: height, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 20, right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < items.length; index++) ...[ + if (index > 0) const SizedBox(width: _tabGap), + _tab( + key: Key('recommend-tab-${items[index].$3}'), + code: items[index].$3, + label: items[index].$2, + selected: selected == items[index].$1, + shadows: shadows, + onTap: () => onSelected(items[index].$1), + ), + ], ], - ], + ), ), ), - ), - SizedBox.square( - dimension: _searchHitSize, - child: IconButton( - key: const Key('recommend-search'), - tooltip: l10n.search, - onPressed: onSearch, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _searchHitSize, - height: _searchHitSize, - ), - iconSize: _searchIconSize, - color: AppColors.textPrimary, - icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary), - ), - ), - if (onGift != null) SizedBox.square( - dimension: _searchHitSize, + dimension: AppDimens.tapTarget, child: IconButton( - key: const Key('recommend-gift'), - tooltip: l10n.wallet, - onPressed: onGift, + key: const Key('recommend-search'), + tooltip: l10n.search, + onPressed: onSearch, padding: EdgeInsets.zero, constraints: const BoxConstraints.tightFor( - width: _searchHitSize, - height: _searchHitSize, + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + ), + iconSize: _searchIconSize, + color: AppColors.textPrimary, + icon: SearchGlyph( + size: _searchIconSize, + color: AppColors.textPrimary, ), - iconSize: _giftIconSize, - icon: GiftGlyph(size: _giftIconSize), ), ), - const SizedBox(width: 8), - ], + if (onGift != null) + SizedBox.square( + dimension: AppDimens.tapTarget, + child: IconButton( + key: const Key('recommend-gift'), + tooltip: l10n.wallet, + onPressed: onGift, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + ), + iconSize: _giftIconSize, + icon: GiftGlyph(size: _giftIconSize), + ), + ), + const SizedBox(width: 8), + ], + ), ), ); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index b79ee9d..796d6ce 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -90,11 +90,11 @@ void main() { expect(searchButton.iconSize, 26); expect( tester.getSize(find.byKey(const Key('recommend-search'))), - const Size.square(40), + const Size.square(AppDimens.tapTarget), ); expect( tester.getSize(find.byKey(const Key('recommend-gift'))), - const Size.square(40), + const Size.square(AppDimens.tapTarget), ); expect( tester.getCenter(find.byKey(const Key('recommend-search'))).dy, diff --git a/test/recommend/home_hero_carousel_test.dart b/test/recommend/home_hero_carousel_test.dart index f5b3672..5f4010b 100644 --- a/test/recommend/home_hero_carousel_test.dart +++ b/test/recommend/home_hero_carousel_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; @@ -12,7 +13,14 @@ const List _banners = [ FeedBanner(id: '2', imageUrl: 'https://cdn.test/b.png'), ]; -Widget _harness(List banners) { +const Drama _drama = Drama( + id: 'd1', + title: 'City Lights', + synopsis: 'A quiet city romance.', + posterUrl: 'https://cdn.test/d.png', +); + +Widget _harness(List banners, {Drama? drama}) { return MaterialApp( locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, @@ -20,7 +28,7 @@ Widget _harness(List banners) { home: Scaffold( body: HomeHeroCarousel( banners: banners, - dramaFor: (_) => null, + dramaFor: (_) => drama, onWatch: (_) {}, ), ), @@ -69,6 +77,32 @@ void main() { expect(_dotColor(tester, 0), AppColors.brandRed); }); + testWidgets('should_keep_only_title_and_synopsis_above_dots', (tester) async { + await tester.pumpWidget(_harness(_banners, drama: _drama)); + await tester.pumpAndSettle(); + + final copy = find.byKey(const Key('home-hero-copy-0')); + expect(copy, findsOneWidget); + expect( + find.descendant(of: copy, matching: find.text('City Lights')), + findsOneWidget, + ); + expect( + find.descendant( + of: copy, + matching: find.text('A quiet city romance.'), + ), + findsOneWidget, + ); + expect(find.byKey(const Key('home-hero-watch')), findsNothing); + expect(find.byKey(const Key('home-hero-favorite')), findsNothing); + + // 文案块整体位于轮播点上方,给进度点留出间距。 + final copyRect = tester.getRect(copy); + final dotRect = tester.getRect(find.byKey(const Key('home-hero-dot-0'))); + expect(copyRect.bottom, lessThanOrEqualTo(dotRect.top)); + }); + testWidgets('should_not_loop_when_only_one_banner', (tester) async { await tester.pumpWidget( _harness(const [FeedBanner(id: '1', imageUrl: 'https://cdn.test/a.png')]), diff --git a/test/recommend/recommend_refresh_scheduling_test.dart b/test/recommend/recommend_refresh_scheduling_test.dart index cb9dbfc..d1b724d 100644 --- a/test/recommend/recommend_refresh_scheduling_test.dart +++ b/test/recommend/recommend_refresh_scheduling_test.dart @@ -60,7 +60,7 @@ void main() { expect(fixture.controller.refreshCalls, 2); }); - testWidgets('should_auto_retry_three_times_then_show_network_error', ( + testWidgets('should_allow_manual_retry_after_auto_retries_are_exhausted', ( tester, ) async { final fixture = await _pumpFailingRecommend(tester); @@ -89,7 +89,14 @@ void main() { expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget); expect(find.text(l10n.networkError), findsOneWidget); - expect(find.text(l10n.retry), findsNothing); + expect(find.text(l10n.retry), findsOneWidget); + + await tester.tap(find.byKey(const Key('recommend-feed-retry'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + expect(fixture.controller.refreshCalls, 5); + expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget); }); testWidgets('should_cancel_pending_auto_retry_when_disposed', (tester) async { diff --git a/test/recommend/recommend_top_nav_accessibility_test.dart b/test/recommend/recommend_top_nav_accessibility_test.dart new file mode 100644 index 0000000..c94b87f --- /dev/null +++ b/test/recommend/recommend_top_nav_accessibility_test.dart @@ -0,0 +1,48 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/recommend/recommend_types.dart'; +import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart'; + +void main() { + testWidgets('should_keep_large_text_and_actions_inside_narrow_top_nav', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('id'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: Scaffold( + body: RecommendTopNav( + selected: RecommendInnerTab.feed, + onSelected: (_) {}, + onSearch: () {}, + onGift: () {}, + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.byKey(const Key('recommend-search'))), + const Size.square(AppDimens.tapTarget), + ); + expect( + tester.getSize(find.byKey(const Key('recommend-gift'))), + const Size.square(AppDimens.tapTarget), + ); + }); +}