diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index 28800d8..b48cc83 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -27,6 +27,7 @@ class FeedBannerCarousel extends StatefulWidget { required this.onOpen, this.active = true, this.dramaFor, + this.flush = false, }); final List banners; @@ -34,6 +35,9 @@ class FeedBannerCarousel extends StatefulWidget { final bool active; final Drama? Function(FeedBanner banner)? dramaFor; + /// 首页 hero 铺满屏幕并去掉圆角;发现页保持默认 false。 + final bool flush; + @override State createState() => _FeedBannerCarouselState(); } @@ -161,10 +165,14 @@ class _FeedBannerCarouselState extends State { final banner = widget.banners[index]; final dramaId = banner.dramaId; final drama = widget.dramaFor?.call(banner); + final radius = widget.flush + ? 0.0 + : (drama == null + ? AppDimens.authCardRadius + : AppDimens.heroBannerRadius); + final borderRadius = BorderRadius.circular(radius); final card = ClipRRect( - borderRadius: BorderRadius.circular( - drama == null ? AppDimens.authCardRadius : AppDimens.heroBannerRadius, - ), + borderRadius: borderRadius, child: Stack( fit: StackFit.expand, children: [ @@ -225,11 +233,7 @@ class _FeedBannerCarouselState extends State { ? card : Material( color: Colors.transparent, - borderRadius: BorderRadius.circular( - drama == null - ? AppDimens.authCardRadius - : AppDimens.heroBannerRadius, - ), + borderRadius: borderRadius, clipBehavior: Clip.antiAlias, child: InkWell( key: Key('feed-banner-$index'), diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index b8a04ea..ebf63d0 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -20,6 +20,17 @@ import 'home_browse_logic.dart'; import 'widgets/home_browse_header.dart'; import 'widgets/home_drama_grid.dart'; +const double _channelRowHeight = 44; +const double _channelGap = 20; +const double _channelUnderlineWidth = 18; +const double _channelUnderlineHeight = 2; +const double _channelUnderlineGap = 4; +const double _channelOverlayTop = 8; +const double _channelFontSize = 15; +const List _channelTextShadows = [ + Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), +]; + /// 浏览型首页:频道、Hero、继续观看与剧目目录。 class HomeBrowseView extends StatefulWidget { const HomeBrowseView({ @@ -116,8 +127,10 @@ class _HomeBrowseViewState extends State { ? _fallbackBanners(source) : catalog.banners; return [ - _channels(l10n), - if (banners.isNotEmpty) _hero(banners, source), + if (banners.isNotEmpty) + _heroWithChannels(l10n, banners, source) + else + _channels(l10n), if (continueItem != null) ...[ _continueHeader(l10n), ContinueWatchingCard( @@ -182,7 +195,30 @@ class _HomeBrowseViewState extends State { ), ); - Widget _channels(AppLocalizations l10n) { + /// 频道行叠加在 hero 轮播之上;无 banner 时作为普通行显示。 + Widget _heroWithChannels( + AppLocalizations l10n, + List banners, + List dramas, + ) { + return SizedBox( + key: const Key('home-hero-section'), + height: AppDimens.heroBannerHeight, + child: Stack( + children: [ + Positioned.fill(child: _hero(banners, dramas)), + Positioned( + top: _channelOverlayTop, + left: 0, + right: 0, + child: _channels(l10n, overImage: true), + ), + ], + ), + ); + } + + Widget _channels(AppLocalizations l10n, {bool overImage = false}) { final entries = [ (HomeChannel.hot, l10n.hot), (HomeChannel.free, l10n.free), @@ -190,60 +226,90 @@ class _HomeBrowseViewState extends State { (HomeChannel.ranking, l10n.rankingTab), ]; return SizedBox( - height: AppDimens.buttonMedium, + key: const Key('home-channels'), + height: _channelRowHeight, child: ListView.separated( - key: const Key('home-channels'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: AppDimens.pageHorizontal, ), itemCount: entries.length, - separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm), + separatorBuilder: (_, _) => const SizedBox(width: _channelGap), itemBuilder: (context, index) { final entry = entries[index]; final selected = logic.channel == entry.$1; - return ChoiceChip( - key: Key('home-channel-${entry.$1.name}'), - label: Text(entry.$2), + return _channelItem( + entry.$1, + entry.$2, selected: selected, - showCheckmark: false, - onSelected: (_) => logic.selectChannel(entry.$1), - backgroundColor: AppColors.card, - selectedColor: AppColors.blue, - side: BorderSide( - color: selected ? AppColors.blue : AppColors.border, - ), - labelStyle: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.onSurface, - fontSize: AppDimens.chipFont, - fontWeight: FontWeight.w700, - ), - padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), - shape: const StadiumBorder(), + overImage: overImage, ); }, ), ); } + Widget _channelItem( + HomeChannel channel, + String label, { + required bool selected, + required bool overImage, + }) { + final color = overImage + ? (selected ? Colors.white : Colors.white70) + : (selected ? AppColors.onSurface : AppColors.muted); + final underlineColor = selected + ? (overImage ? Colors.white : AppColors.blue) + : Colors.transparent; + return Semantics( + button: true, + selected: selected, + child: GestureDetector( + key: Key('home-channel-${channel.name}'), + behavior: HitTestBehavior.opaque, + onTap: () => logic.selectChannel(channel), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + style: TextStyle( + color: color, + fontSize: _channelFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + shadows: overImage ? _channelTextShadows : null, + ), + ), + const SizedBox(height: _channelUnderlineGap), + Container( + key: Key('home-channel-underline-${channel.name}'), + width: _channelUnderlineWidth, + height: _channelUnderlineHeight, + decoration: BoxDecoration( + color: underlineColor, + borderRadius: BorderRadius.circular( + _channelUnderlineHeight / 2, + ), + ), + ), + ], + ), + ), + ); + } + Widget _hero(List banners, List dramas) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceMd, - AppDimens.pageHorizontal, - 0, - ), - child: FeedBannerCarousel( - key: const Key('home-hero-banner'), - banners: banners, - active: widget.active, - dramaFor: (banner) => _dramaForBanner(banner, dramas), - onOpen: (banner) { - final drama = _dramaForBanner(banner, dramas); - if (drama != null) unawaited(widget.onOpenDrama(drama)); - }, - ), + return FeedBannerCarousel( + key: const Key('home-hero-banner'), + banners: banners, + active: widget.active, + flush: true, + dramaFor: (banner) => _dramaForBanner(banner, dramas), + onOpen: (banner) { + final drama = _dramaForBanner(banner, dramas); + if (drama != null) unawaited(widget.onOpenDrama(drama)); + }, ); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 9a17708..932692d 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -8,7 +8,6 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -49,27 +48,55 @@ void main() { expect(find.text('Coming soon'), findsOneWidget); }); - testWidgets('should_switch_home_channel_when_chip_is_tapped', (tester) async { + testWidgets('should_switch_home_channel_when_underline_tab_is_tapped', ( + tester, + ) async { await _pumpApp(tester); - expect(_chip(tester, 'home-channel-hot').selected, isTrue); - expect(_chip(tester, 'home-channel-free').selected, isFalse); - expect(_chip(tester, 'home-channel-hot').selectedColor, AppColors.blue); - expect(_chip(tester, 'home-channel-free').backgroundColor, AppColors.card); - expect( - _chip(tester, 'home-channel-free').labelStyle?.color, - AppColors.onSurface, - ); + // 悬浮在轮播图上:选中白色下划线,未选中透明占位。 + expect(_underlineColor(tester, 'hot'), Colors.white); + expect(_underlineColor(tester, 'free'), Colors.transparent); + expect(_labelStyle(tester, 'hot').fontWeight, FontWeight.w800); + expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w600); + expect(_labelStyle(tester, 'hot').color, Colors.white); + expect(_labelStyle(tester, 'free').color, Colors.white70); await tester.tap(find.byKey(const Key('home-channel-free'))); await tester.pumpAndSettle(); - expect(_chip(tester, 'home-channel-hot').selected, isFalse); - expect(_chip(tester, 'home-channel-free').selected, isTrue); - expect( - _chip(tester, 'home-channel-free').labelStyle?.color, - AppColors.onPrimary, + expect(_underlineColor(tester, 'hot'), Colors.transparent); + expect(_underlineColor(tester, 'free'), Colors.white); + expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w800); + }); + + testWidgets('should_render_flush_hero_with_channels_overlaid', ( + tester, + ) async { + await _pumpApp(tester); + + final hero = tester.getRect(find.byKey(const Key('home-hero-banner'))); + expect(hero.width, 390); + expect(hero.left, 0); + final card = tester.widget( + find + .descendant( + of: find.byKey(const Key('home-hero-banner')), + matching: find.byType(ClipRRect), + ) + .first, ); + expect(card.borderRadius, BorderRadius.zero); + + // 频道行在 hero Stack 内、位于轮播顶部 8 的位置上。 + expect( + find.ancestor( + of: find.byKey(const Key('home-channels')), + matching: find.byKey(const Key('home-hero-section')), + ), + findsOneWidget, + ); + final channels = tester.getRect(find.byKey(const Key('home-channels'))); + expect(channels.top, closeTo(hero.top + 8, 1)); }); testWidgets('should_open_theater_search_when_home_search_is_tapped', ( @@ -209,8 +236,22 @@ const _tabKeys = [ Key('tab-mine'), ]; -ChoiceChip _chip(WidgetTester tester, String key) => - tester.widget(find.byKey(Key(key))); +Color? _underlineColor(WidgetTester tester, String name) { + final underline = tester.widget( + find.byKey(Key('home-channel-underline-$name')), + ); + return (underline.decoration! as BoxDecoration).color; +} + +TextStyle _labelStyle(WidgetTester tester, String name) { + final label = tester.widget( + find.descendant( + of: find.byKey(Key('home-channel-$name')), + matching: find.byType(Text), + ), + ); + return label.style!; +} Future _pumpApp( WidgetTester tester, {