Files

602 lines
17 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../constants/breakpoints.dart';
import '../theme/app_theme.dart';
import 'banner_carousel_mixin.dart';
import 'circle_nav_icon_button.dart';
import 'frosted_panel.dart';
import 'smart_image.dart';
class BannerSlideData {
final String? imageUrl;
final String? fallbackImageUrl;
final Map<String, String>? imageHeaders;
final String? logoUrl;
final String title;
final String? overview;
final String? rating;
final List<String> primaryMetaLabels;
final List<String> secondaryMetaLabels;
const BannerSlideData({
required this.imageUrl,
required this.title,
this.fallbackImageUrl,
this.imageHeaders,
this.logoUrl,
this.overview,
this.rating,
this.primaryMetaLabels = const [],
this.secondaryMetaLabels = const [],
});
}
class BannerCarouselScaffold extends StatelessWidget {
final BannerCarouselMixin controller;
final List<BannerSlideData> slides;
final ValueChanged<int> onSlideTap;
final bool reserveLogoArea;
const BannerCarouselScaffold({
super.key,
required this.controller,
required this.slides,
required this.onSlideTap,
this.reserveLogoArea = false,
});
@override
Widget build(BuildContext context) {
if (slides.isEmpty) return const SizedBox.shrink();
final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
if (compact) return _buildCompact(context);
return _buildDesktop(context);
}
Widget _contentSwitcher({required bool compact}) {
return IgnorePointer(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) => Stack(
fit: StackFit.expand,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
),
child: _BannerSlideContent(
key: ValueKey(controller.currentIndex),
data: slides[controller.currentIndex],
compact: compact,
reserveLogoArea: reserveLogoArea,
),
),
);
}
Widget _pageIndicator() {
return FrostedPanel(
radius: 999,
enableBlur: false,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
color: Colors.black.withValues(alpha: 0.24),
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < slides.length; index++)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => controller.goToPage(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 240),
curve: Curves.easeOut,
width: controller.currentIndex == index ? 22 : 7,
height: 7,
margin: EdgeInsets.only(
right: index == slides.length - 1 ? 0 : 6,
),
decoration: BoxDecoration(
color: controller.currentIndex == index
? Colors.white
: const Color(0x66FFFFFF),
borderRadius: BorderRadius.circular(999),
),
),
),
],
),
);
}
Widget _pageView({required bool compact}) {
return PageView.builder(
controller: controller.pageController,
physics: compact ? null : const NeverScrollableScrollPhysics(),
itemCount: slides.length,
onPageChanged: controller.onPageChanged,
itemBuilder: (context, index) {
return _BannerSlide(data: slides[index], compact: compact);
},
);
}
Widget _buildCompact(BuildContext context) {
final screenHeight = MediaQuery.sizeOf(context).height;
final height = screenHeight * 0.55;
final screenWidth = MediaQuery.sizeOf(context).width;
controller.lastBannerWidth = screenWidth.isFinite && screenWidth > 0
? screenWidth
: null;
return SizedBox(
height: height,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSlideTap(controller.currentIndex),
child: Stack(
fit: StackFit.expand,
children: [
_pageView(compact: true),
_contentSwitcher(compact: true),
Positioned(
left: 0,
right: 0,
bottom: 20,
child: Center(child: _pageIndicator()),
),
],
),
),
);
}
Widget _buildDesktop(BuildContext context) {
final tokens = context.appTokens;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
controller.lastBannerWidth = width.isFinite && width > 0
? width
: null;
final height = (width * 7 / 16).clamp(260.0, 500.0);
return SizedBox(
height: height,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens.panelRadius + 6),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => controller.setHovering(true),
onExit: (_) => controller.setHovering(false),
child: Stack(
fit: StackFit.expand,
children: [
_BlurredBackdropLayer(
data: slides[controller.currentIndex],
animateSwitch: slides.length > 1,
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSlideTap(controller.currentIndex),
child: _pageView(compact: false),
),
_contentSwitcher(compact: false),
Positioned(right: 22, bottom: 20, child: _pageIndicator()),
if (controller.hovering && slides.length > 1) ...[
Positioned(
left: 14,
top: 0,
bottom: 0,
child: Center(
child: CircleNavIconButton(
icon: Icons.chevron_left_rounded,
onPressed: controller.prevPage,
iconSize: 28,
),
),
),
Positioned(
right: 14,
top: 0,
bottom: 0,
child: Center(
child: CircleNavIconButton(
icon: Icons.chevron_right_rounded,
onPressed: controller.nextPage,
iconSize: 28,
),
),
),
],
],
),
),
),
);
},
),
);
}
}
class _BlurredBackdropLayer extends StatelessWidget {
final BannerSlideData data;
final bool animateSwitch;
const _BlurredBackdropLayer({
required this.data,
required this.animateSwitch,
});
@override
Widget build(BuildContext context) {
final image = _BlurredBackdropImage(
key: ValueKey<String>(data.imageUrl ?? '__none__'),
data: data,
);
if (!animateSwitch) return image;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
fit: StackFit.expand,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: image,
);
}
}
class _BlurredBackdropImage extends StatelessWidget {
final BannerSlideData data;
const _BlurredBackdropImage({super.key, required this.data});
@override
Widget build(BuildContext context) {
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 36,
sigmaY: 36,
tileMode: TileMode.clamp,
),
child: SmartImage(
url: data.imageUrl,
fallbackUrls: [
if (data.fallbackImageUrl != null) data.fallbackImageUrl!,
],
httpHeaders: data.imageHeaders,
borderRadius: 0,
fit: BoxFit.cover,
memCacheWidth: 800,
fallbackIcon: null,
placeholder: const ColoredBox(color: Colors.black),
),
),
IgnorePointer(
child: ColoredBox(color: Colors.black.withValues(alpha: 0.30)),
),
],
),
);
}
}
class _BannerSlide extends StatelessWidget {
final BannerSlideData data;
final bool compact;
const _BannerSlide({required this.data, this.compact = false});
@override
Widget build(BuildContext context) {
if (compact) {
return Stack(
fit: StackFit.expand,
children: [
SmartImage(
url: data.imageUrl,
fallbackUrls: [
if (data.fallbackImageUrl != null) data.fallbackImageUrl!,
],
borderRadius: 0,
fit: BoxFit.cover,
httpHeaders: data.imageHeaders,
fallbackIcon: Icons.movie_outlined,
placeholder: const SizedBox.expand(),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.12, 0.4, 0.75, 1.0],
colors: [
Colors.black.withValues(alpha: 0.35),
Colors.black.withValues(alpha: 0.12),
Colors.black.withValues(alpha: 0.0),
Colors.black.withValues(alpha: 0.35),
Colors.black.withValues(alpha: 0.70),
],
),
),
),
],
);
}
return Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 36,
sigmaY: 36,
tileMode: TileMode.mirror,
),
child: SmartImage(
url: data.imageUrl,
fallbackUrls: [
if (data.fallbackImageUrl != null) data.fallbackImageUrl!,
],
borderRadius: 0,
fit: BoxFit.cover,
httpHeaders: data.imageHeaders,
fallbackIcon: null,
placeholder: const SizedBox.expand(),
),
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.32),
),
),
SmartImage(
url: data.imageUrl,
fallbackUrls: [
if (data.fallbackImageUrl != null) data.fallbackImageUrl!,
],
borderRadius: 0,
fit: BoxFit.contain,
httpHeaders: data.imageHeaders,
fallbackIcon: Icons.movie_outlined,
placeholder: const SizedBox.expand(),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.35, 0.6, 1.0],
colors: [
Colors.black.withValues(alpha: 0.0),
Colors.black.withValues(alpha: 0.03),
Colors.black.withValues(alpha: 0.25),
Colors.black.withValues(alpha: 0.55),
],
),
),
),
],
);
}
}
class _BannerSlideContent extends StatelessWidget {
final BannerSlideData data;
final bool compact;
final bool reserveLogoArea;
const _BannerSlideContent({
super.key,
required this.data,
required this.compact,
required this.reserveLogoArea,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final ratingColor = tokens.ratingColor;
final hasMetaInfo =
data.rating != null ||
data.primaryMetaLabels.isNotEmpty ||
data.secondaryMetaLabels.isNotEmpty;
final titleFontSize = compact ? 24.0 : 32.0;
final titleText = Text(
data.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontSize: titleFontSize,
height: 1.0,
),
);
final padding = compact
? const EdgeInsets.only(left: 24, bottom: 64, right: 24)
: const EdgeInsets.only(left: 32, bottom: 32);
return Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: padding,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (reserveLogoArea) _buildLogoArea(titleText) else titleText,
if (hasMetaInfo) ...[
const SizedBox(height: 10),
_MetaRow(
rating: data.rating,
ratingColor: ratingColor,
primaryLabels: data.primaryMetaLabels,
secondaryLabels: data.secondaryMetaLabels,
),
],
if ((data.overview ?? '').trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(
data.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.78),
height: 1.5,
),
),
],
],
),
),
),
);
}
Widget _buildLogoArea(Widget titleText) {
final logoHeight = compact ? 56.0 : 72.0;
final logoMaxWidth = compact ? 240.0 : 320.0;
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: logoMaxWidth),
child: SizedBox(
width: double.infinity,
height: logoHeight,
child: data.logoUrl != null
? CachedNetworkImage(
imageUrl: data.logoUrl!,
httpHeaders: data.imageHeaders,
fit: BoxFit.contain,
alignment: Alignment.bottomLeft,
memCacheWidth: 960,
fadeInDuration: const Duration(milliseconds: 300),
placeholderFadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 300),
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) =>
Align(alignment: Alignment.bottomLeft, child: titleText),
)
: Align(alignment: Alignment.bottomLeft, child: titleText),
),
);
}
}
class _MetaRow extends StatelessWidget {
final String? rating;
final Color ratingColor;
final List<String> primaryLabels;
final List<String> secondaryLabels;
const _MetaRow({
required this.rating,
required this.ratingColor,
required this.primaryLabels,
required this.secondaryLabels,
});
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodySmall;
final children = <Widget>[];
void addSep() {
children.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'·',
style: style?.copyWith(color: Colors.white.withValues(alpha: 0.38)),
),
),
);
}
if (rating != null) {
children.add(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star_rounded, size: 13, color: ratingColor),
const SizedBox(width: 3),
Text(
rating!,
style: style?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
for (final label in primaryLabels) {
if (children.isNotEmpty) addSep();
children.add(
Text(
label,
style: style?.copyWith(color: Colors.white.withValues(alpha: 0.80)),
),
);
}
for (final label in secondaryLabels) {
if (children.isNotEmpty) addSep();
children.add(
Text(
label,
style: style?.copyWith(color: Colors.white.withValues(alpha: 0.62)),
),
);
}
return Row(mainAxisSize: MainAxisSize.min, children: children);
}
}