From 7b927c794e45e098b03a1ef7613d5bfbefff8c22 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 23:53:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=8E=A5=E5=85=A5=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E8=BD=AE=E6=92=AD=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 17 ++ .../widgets/feed_banner_carousel.dart | 234 ++++++++++++++++++ lib/pages/home/home_logic.dart | 16 ++ lib/pages/home/home_view.dart | 16 +- test/discover/discover_banner_test.dart | 74 ++++++ test/home/feed_banner_carousel_test.dart | 88 +++++++ test/support/fake_catalog.dart | 61 ++--- 7 files changed, 469 insertions(+), 37 deletions(-) create mode 100644 lib/pages/discover/widgets/feed_banner_carousel.dart create mode 100644 test/discover/discover_banner_test.dart create mode 100644 test/home/feed_banner_carousel_test.dart diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 656adcb..059cd19 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/category_controller.dart'; @@ -18,6 +19,7 @@ import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; +import 'widgets/feed_banner_carousel.dart'; part 'widgets/home_drama_grid.dart'; @@ -38,6 +40,8 @@ class DiscoverPage extends StatefulWidget { this.onAction, this.dramas, this.onDramaSelected, + this.banners = const [], + this.onBannerSelected, }); final Drama drama; @@ -54,6 +58,8 @@ class DiscoverPage extends StatefulWidget { final ValueChanged? onAction; final List? dramas; final ValueChanged? onDramaSelected; + final List banners; + final ValueChanged? onBannerSelected; @override State createState() => _DiscoverPageState(); @@ -147,6 +153,17 @@ class _DiscoverPageState extends State { controller: logic.scroll, physics: const AlwaysScrollableScrollPhysics(), slivers: [ + if (widget.banners.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: FeedBannerCarousel( + banners: widget.banners, + active: widget.active, + onOpen: (banner) => widget.onBannerSelected?.call(banner), + ), + ), + ), SliverToBoxAdapter(child: _search()), SliverToBoxAdapter( child: Padding( diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart new file mode 100644 index 0000000..9d9c944 --- /dev/null +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -0,0 +1,234 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/home_feed.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _carouselHeight = 154; +const double _indicatorHeight = 24; +const double _dotSize = 6; +const double _activeDotWidth = 18; +const double _viewportFraction = 0.94; +const Duration _autoAdvanceInterval = Duration(milliseconds: 4500); +const Duration _pageTransition = Duration(milliseconds: 520); + +/// Auto-advancing, touch-pausable carousel for real `/app/home/feed` banners. +class FeedBannerCarousel extends StatefulWidget { + const FeedBannerCarousel({ + super.key, + required this.banners, + required this.onOpen, + this.active = true, + }); + + final List banners; + final ValueChanged onOpen; + final bool active; + + @override + State createState() => _FeedBannerCarouselState(); +} + +class _FeedBannerCarouselState extends State { + final PageController _controller = PageController( + viewportFraction: _viewportFraction, + ); + Timer? _timer; + int _index = 0; + bool _pressed = false; + bool _tickerEnabled = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final enabled = TickerMode.valuesOf(context).enabled; + if (enabled != _tickerEnabled) { + _tickerEnabled = enabled; + _syncTimer(); + } + } + + @override + void didUpdateWidget(covariant FeedBannerCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.banners.length != widget.banners.length || + oldWidget.active != widget.active) { + if (_index >= widget.banners.length) _index = 0; + _syncTimer(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _syncTimer() { + _timer?.cancel(); + _timer = null; + final canAdvance = + mounted && + widget.active && + _tickerEnabled && + !_pressed && + widget.banners.length > 1; + if (!canAdvance) return; + _timer = Timer.periodic(_autoAdvanceInterval, (_) => _advance()); + } + + void _advance() { + if (!_controller.hasClients || widget.banners.length < 2) return; + final next = (_index + 1) % widget.banners.length; + _controller.animateToPage( + next, + duration: _pageTransition, + curve: Curves.easeOutCubic, + ); + } + + void _pause() { + _pressed = true; + _timer?.cancel(); + } + + void _resume() { + _pressed = false; + _syncTimer(); + } + + @override + Widget build(BuildContext context) { + if (widget.banners.isEmpty) return const SizedBox.shrink(); + return Listener( + key: const Key('feed-banner-carousel'), + onPointerDown: (_) => _pause(), + onPointerUp: (_) => _resume(), + onPointerCancel: (_) => _resume(), + child: Column( + children: [ + SizedBox( + height: _carouselHeight, + child: PageView.builder( + controller: _controller, + itemCount: widget.banners.length, + onPageChanged: (value) => setState(() => _index = value), + itemBuilder: (context, index) => _bannerCard(index), + ), + ), + SizedBox(height: _indicatorHeight, child: _indicator(context)), + ], + ), + ); + } + + Widget _bannerCard(int index) { + final banner = widget.banners[index]; + final dramaId = banner.dramaId; + final card = ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage(banner.imageUrl, showFallbackIcon: true), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0x8A0B0C10)], + ), + ), + ), + if (dramaId != null) + const Positioned(right: 14, bottom: 14, child: _PlayMark()), + ], + ), + ); + final page = Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: dramaId == null + ? card + : Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppDimens.authCardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: Key('feed-banner-$index'), + onTap: () => widget.onOpen(banner), + child: card, + ), + ), + ); + return Semantics(container: true, button: dramaId != null, child: page); + } + + Widget _indicator(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return ExcludeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var index = 0; index < widget.banners.length; index++) + Semantics( + label: l10n.carouselSemantics(index + 1, widget.banners.length), + selected: index == _index, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + key: Key('feed-banner-dot-$index'), + width: index == _index ? _activeDotWidth : _dotSize, + height: _dotSize, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: index == _index + ? AppColors.onPrimary + : AppColors.muted, + borderRadius: BorderRadius.circular(_dotSize), + ), + ), + ), + ], + ), + ); + } +} + +class _PlayMark extends StatelessWidget { + const _PlayMark(); + + @override + Widget build(BuildContext context) => Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.blue.withValues(alpha: 0.35), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 22, + ), + ); +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index f2e5fec..b3b5781 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -7,6 +7,8 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/wallet_controller.dart'; +import '../../core/data/repositories/catalog_repository.dart'; +import '../../l10n/app_localizations.dart'; import '../discover/discover_logic.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_logic.dart'; @@ -85,6 +87,20 @@ class HomeLogic extends ChangeNotifier { ); } + /// Resolves a banner link that is not in the loaded feed, then opens detail. + Future openDramaById(BuildContext context, String dramaId) async { + try { + final drama = await context.read().fetchDrama(dramaId); + if (context.mounted) openDrama(context, drama); + } catch (_) { + if (!context.mounted) return; + final message = AppLocalizations.of(context)!.detailLoadFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } + Future playDrama(BuildContext context, Drama drama) async { // 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。 var playable = drama; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 92c96b5..e522637 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -76,6 +76,7 @@ class HomePage extends StatelessWidget { key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, sections: catalog.sections, + banners: catalog.banners, ); } } @@ -100,10 +101,16 @@ class HomeFailureView extends StatelessWidget { /// The three primary destinations share a single route and keep their state. class _HomeTabs extends StatefulWidget { - const _HomeTabs({super.key, required this.dramas, required this.sections}); + const _HomeTabs({ + super.key, + required this.dramas, + required this.sections, + required this.banners, + }); final List dramas; final List sections; + final List banners; @override State<_HomeTabs> createState() => _HomeTabsState(); @@ -182,6 +189,13 @@ class _HomeTabsState extends State<_HomeTabs> { onSignIn: () => openAuthPage(context), onCoins: logic.openProfile, onRewards: logic.openProfile, + banners: widget.banners, + onBannerSelected: (banner) { + final dramaId = banner.dramaId; + if (dramaId != null) { + unawaited(logic.openDramaById(context, dramaId)); + } + }, onAction: (action) => logic.discoverAction(context, action), ), MyPage( diff --git a/test/discover/discover_banner_test.dart b/test/discover/discover_banner_test.dart new file mode 100644 index 0000000..991e7c7 --- /dev/null +++ b/test/discover/discover_banner_test.dart @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/home_feed.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/pages/drama_detail/drama_detail_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'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({}); + }); + + testWidgets('should_open_linked_drama_when_discover_banner_is_tapped', ( + tester, + ) async { + final catalog = FakeCatalogRepository( + banners: const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'the-signal-demo', + ), + ], + ); + await _pumpApp(tester, catalog); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget); + await tester.tap(find.byKey(const Key('feed-banner-0'))); + await tester.pumpAndSettle(); + + expect(find.byType(DramaDetailPage), findsOneWidget); + expect(find.text('The Signal'), findsWidgets); + }); +} + +Future _pumpApp( + WidgetTester tester, + FakeCatalogRepository catalog, +) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/home/feed_banner_carousel_test.dart b/test/home/feed_banner_carousel_test.dart new file mode 100644 index 0000000..7fac22b --- /dev/null +++ b/test/home/feed_banner_carousel_test.dart @@ -0,0 +1,88 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/discover/widgets/feed_banner_carousel.dart'; + +void main() { + testWidgets('should_render_nothing_when_banner_list_is_empty', ( + tester, + ) async { + await _pump(tester, const []); + + expect(find.byKey(const Key('feed-banner-carousel')), findsNothing); + }); + + testWidgets('should_open_linked_drama_when_banner_is_tapped', (tester) async { + FeedBanner? opened; + await _pump(tester, const [ + FeedBanner( + id: 'banner-1', + imageUrl: '/files/banner.jpg', + linkType: 'DRAMA', + linkValue: 'drama-1', + ), + ], onOpen: (banner) => opened = banner); + + expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget); + expect(find.byKey(const Key('feed-banner-0')), findsOneWidget); + await tester.tap(find.byKey(const Key('feed-banner-0'))); + await tester.pump(); + + expect(opened?.dramaId, 'drama-1'); + }); + + testWidgets('should_advance_to_next_banner_when_interval_elapses', ( + tester, + ) async { + await _pump(tester, _banners); + + await tester.pump(const Duration(milliseconds: 4600)); + await tester.pumpAndSettle(); + + expect(_page(tester), 1); + }); + + testWidgets('should_pause_auto_advance_when_pointer_is_held', (tester) async { + await _pump(tester, _banners); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('feed-banner-carousel'))), + ); + + await tester.pump(const Duration(seconds: 5)); + expect(_page(tester), 0); + + await gesture.up(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(_page(tester), 1); + }); +} + +const _banners = [ + FeedBanner(id: 'banner-1', imageUrl: '/files/one.jpg'), + FeedBanner(id: 'banner-2', imageUrl: '/files/two.jpg'), +]; + +double _page(WidgetTester tester) => + tester.widget(find.byType(PageView)).controller!.page!; + +Future _pump( + WidgetTester tester, + List banners, { + ValueChanged? onOpen, +}) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: FeedBannerCarousel(banners: banners, onOpen: onOpen ?? (_) {}), + ), + ), + ); + await tester.pump(); +} diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index fc6d54a..7c41eed 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -4,9 +4,11 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; /// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。 class FakeCatalogRepository implements CatalogRepository { - FakeCatalogRepository({List? dramas}) : dramas = dramas ?? demoDramas; + FakeCatalogRepository({List? dramas, this.banners = const []}) + : dramas = dramas ?? demoDramas; final List dramas; + final List banners; @override Future fetchHomeFeed() async => _feed(); @@ -20,51 +22,38 @@ class FakeCatalogRepository implements CatalogRepository { } HomeFeed _feed() => HomeFeed( + banners: banners, sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)], ); } final List demoDramas = [ - _drama( - 'the-signal-demo', - 'The Signal', - 'Suspense · Mystery', - format: 1, - ), + _drama('the-signal-demo', 'The Signal', 'Suspense · Mystery', format: 1), _drama( 'city-lights-demo', 'City Lights & Velvet Nights', 'Urban Romance', format: 8, ), - _drama( - 'vengeance-demo', - 'Vengeance Rising', - 'Action · Thriller', - format: 15, - ), + _drama('vengeance-demo', 'Vengeance Rising', 'Action · Thriller', format: 15), ]; -Drama _drama( - String id, - String title, - String genre, { - required int format, -}) => Drama( - id: id, - title: title, - synopsis: '$title synopsis', - posterUrl: '/files/$id.png', - genre: genre, - detailLoaded: true, - episodes: [ - for (var index = 0; index < 7; index++) - Episode( - id: '$id-${index + 1}', - number: index + 1, - title: 'Episode ${index + 1}', - isFree: true, - videoUrl: 'https://example.test/$format-$index.mp4', - ), - ], -); +Drama _drama(String id, String title, String genre, {required int format}) => + Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '/files/$id.png', + genre: genre, + detailLoaded: true, + episodes: [ + for (var index = 0; index < 7; index++) + Episode( + id: '$id-${index + 1}', + number: index + 1, + title: 'Episode ${index + 1}', + isFree: true, + videoUrl: 'https://example.test/$format-$index.mp4', + ), + ], + );