Initial commit
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user