feat(app): 接入首页轮播 banner
This commit is contained in:
1 parent
061aac6577
commit
7b927c794e
7 files changed
+469
-37
No files matched your search
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user