feat(app): 接入首页轮播 banner

This commit is contained in:
Zhou Weidong committed 2026-10-19 23:53:03 +08:00
1 parent 061aac6577
commit 7b927c794e
7 files changed
+469 -37

No files matched your search

+17
View File
@@ -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<DiscoverPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
final List<FeedBanner> banners;
final ValueChanged<FeedBanner>? onBannerSelected;
@override
State<DiscoverPage> createState() => _DiscoverPageState();
@@ -147,6 +153,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
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(
@@ -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<FeedBanner> banners;
final ValueChanged<FeedBanner> onOpen;
final bool active;
@override
State<FeedBannerCarousel> createState() => _FeedBannerCarouselState();
}
class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
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,
),
);
}
+16
View File
@@ -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<void> openDramaById(BuildContext context, String dramaId) async {
try {
final drama = await context.read<CatalogRepository>().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<void> playDrama(BuildContext context, Drama drama) async {
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
var playable = drama;
+15 -1
View File
@@ -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<Drama> dramas;
final List<FeedSection> sections;
final List<FeedBanner> 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(
+74
View File
@@ -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<void> _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();
}
+88
View File
@@ -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<PageView>(find.byType(PageView)).controller!.page!;
Future<void> _pump(
WidgetTester tester,
List<FeedBanner> banners, {
ValueChanged<FeedBanner>? 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();
}
+25 -36
View File
@@ -4,9 +4,11 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
class FakeCatalogRepository implements CatalogRepository {
FakeCatalogRepository({List<Drama>? dramas}) : dramas = dramas ?? demoDramas;
FakeCatalogRepository({List<Drama>? dramas, this.banners = const []})
: dramas = dramas ?? demoDramas;
final List<Drama> dramas;
final List<FeedBanner> banners;
@override
Future<HomeFeed> 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<Drama> 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',
),
],
);