Files

233 lines
7.2 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import '../../../shared/widgets/smart_image.dart';
import '../widgets/detail_nav_bar.dart';
import 'android_detail_banner.dart';
import 'android_detail_colors.dart';
import 'android_detail_geometry.dart';
class AndroidDetailLayout extends StatelessWidget {
const AndroidDetailLayout({
super.key,
required this.scrollController,
required this.scrollProgress,
this.backdropUrl,
this.fallbackUrls = const [],
this.embyBaseUrl = '',
this.imageHeaders,
this.backgroundColor,
this.navigationColor,
required this.title,
required this.onBack,
required this.heroInfo,
this.heroActions,
this.overview,
this.belowOverview,
this.sections = const [],
});
final ScrollController scrollController;
final ValueNotifier<double> scrollProgress;
final String? backdropUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
final Color? backgroundColor;
final Color? navigationColor;
final String title;
final VoidCallback onBack;
final Widget heroInfo;
final Widget? heroActions;
final Widget? overview;
final Widget? belowOverview;
final List<Widget> sections;
@override
Widget build(BuildContext context) {
final topInset = MediaQuery.paddingOf(context).top;
final bannerHeight = computeAndroidDetailBannerHeight(context);
final backdrop =
backdropUrl ?? (fallbackUrls.isNotEmpty ? fallbackUrls.first : null);
final effectiveFallbackUrls = backdropUrl != null
? fallbackUrls
: fallbackUrls.skip(1).toList(growable: false);
final targetBackgroundColor =
backgroundColor ?? AndroidDetailColors.background;
final animationDuration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 350);
return TweenAnimationBuilder<Color?>(
tween: ColorTween(
begin: AndroidDetailColors.background,
end: targetBackgroundColor,
),
duration: animationDuration,
curve: Curves.easeOutCubic,
builder: (context, animatedBackgroundColor, child) {
final effectiveBackgroundColor =
animatedBackgroundColor ?? targetBackgroundColor;
return Scaffold(
backgroundColor: effectiveBackgroundColor,
body: Stack(
children: [
Positioned.fill(
child: _AndroidDetailBlurredBackdrop(
imageUrl: backdrop,
fallbackUrls: effectiveFallbackUrls,
embyBaseUrl: embyBaseUrl,
imageHeaders: imageHeaders,
),
),
CustomScrollView(
controller: scrollController,
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
if (backdrop != null)
SliverAppBar(
automaticallyImplyLeading: false,
primary: false,
pinned: false,
stretch: true,
toolbarHeight: 0,
collapsedHeight: 0,
expandedHeight: bannerHeight,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
stretchModes: const [StretchMode.zoomBackground],
background: AndroidDetailBanner(
scrollProgress: scrollProgress,
backdropUrl: backdrop,
fallbackUrls: effectiveFallbackUrls,
embyBaseUrl: embyBaseUrl,
imageHeaders: imageHeaders,
),
),
)
else
SliverToBoxAdapter(child: SizedBox(height: topInset + 72)),
SliverToBoxAdapter(child: heroInfo),
if (heroActions != null)
SliverToBoxAdapter(child: heroActions!),
if (overview != null) SliverToBoxAdapter(child: overview!),
if (belowOverview != null)
SliverToBoxAdapter(child: belowOverview!),
SliverList.list(
children: [
...sections,
SizedBox(
height: 32 + MediaQuery.paddingOf(context).bottom,
),
],
),
],
),
DetailNavBar(
scrollProgress: scrollProgress,
title: title,
onBack: onBack,
chromeColor: navigationColor ?? AndroidDetailColors.navBarBg,
),
],
),
);
},
);
}
}
class _AndroidDetailBlurredBackdrop extends StatelessWidget {
const _AndroidDetailBlurredBackdrop({
required this.imageUrl,
required this.fallbackUrls,
required this.embyBaseUrl,
required this.imageHeaders,
});
final String? imageUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
static const double _blurSigma = 32;
static const LinearGradient _scrim = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.4, 1.0],
colors: [Color(0x59000000), Color(0x8C000000), Color(0xD9000000)],
);
@override
Widget build(BuildContext context) {
final url = imageUrl;
final Widget child;
if (url == null) {
child = const SizedBox.expand(key: ValueKey<String>('__no_backdrop__'));
} else {
child = RepaintBoundary(
key: ValueKey<String>(url),
child: Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _blurSigma,
sigmaY: _blurSigma,
tileMode: TileMode.clamp,
),
child: SmartImage(
url: url,
fallbackUrls: fallbackUrls,
fit: BoxFit.cover,
borderRadius: 0,
memCacheWidth: 400,
fallbackIcon: null,
placeholder: const SizedBox.expand(),
resolveHttpHeaders: (candidateUrl) =>
embyBaseUrl.isNotEmpty &&
candidateUrl.startsWith(embyBaseUrl)
? imageHeaders
: null,
),
),
const DecoratedBox(decoration: BoxDecoration(gradient: _scrim)),
],
),
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) => Stack(
fit: StackFit.expand,
children: [...previousChildren, if (currentChild != null) currentChild],
),
child: child,
);
}
}