311 lines
8.6 KiB
Dart
311 lines
8.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/widgets/remote_image.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
|
|
const Duration _autoPlayInterval = Duration(milliseconds: 4500);
|
|
|
|
class CoverItem {
|
|
const CoverItem({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.image,
|
|
required this.onOpen,
|
|
});
|
|
|
|
final String title;
|
|
final String subtitle;
|
|
final String image;
|
|
final VoidCallback onOpen;
|
|
}
|
|
|
|
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
|
|
class CoverCarousel extends StatefulWidget {
|
|
const CoverCarousel({super.key, required this.items, this.active = true});
|
|
|
|
final List<CoverItem> items;
|
|
final bool active;
|
|
|
|
@override
|
|
State<CoverCarousel> createState() => _CoverCarouselState();
|
|
}
|
|
|
|
class _CoverCarouselState extends State<CoverCarousel>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _snap;
|
|
double _page = 0;
|
|
double _from = 0;
|
|
double _to = 0;
|
|
int? _pointer;
|
|
double _drag = 0;
|
|
double _dragPage = 0;
|
|
Timer? _autoTimer;
|
|
bool _tickerEnabled = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_snap = AnimationController(vsync: this)..addListener(_onSnap);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_autoTimer?.cancel();
|
|
_snap.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final enabled = TickerMode.valuesOf(context).enabled;
|
|
if (enabled != _tickerEnabled) {
|
|
_tickerEnabled = enabled;
|
|
_syncAutoTimer();
|
|
} else if (_autoTimer == null) {
|
|
_syncAutoTimer();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant CoverCarousel oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (_page > _last) _page = _last;
|
|
if (oldWidget.items.length != widget.items.length ||
|
|
oldWidget.active != widget.active) {
|
|
_syncAutoTimer();
|
|
}
|
|
}
|
|
|
|
double get _last => math.max(0, widget.items.length - 1).toDouble();
|
|
|
|
void _onSnap() {
|
|
if (!mounted) return;
|
|
final t = Curves.easeOutCubic.transform(_snap.value);
|
|
setState(() => _page = _from + (_to - _from) * t);
|
|
}
|
|
|
|
void _turnTo(double target) {
|
|
if (!mounted || widget.items.isEmpty) return;
|
|
_from = _page;
|
|
_to = target.clamp(0, _last);
|
|
_snap.duration = const Duration(milliseconds: 420);
|
|
_snap.forward(from: 0);
|
|
}
|
|
|
|
void _syncAutoTimer() {
|
|
_autoTimer?.cancel();
|
|
_autoTimer = null;
|
|
if (!mounted ||
|
|
!widget.active ||
|
|
!_tickerEnabled ||
|
|
widget.items.length <= 1) {
|
|
return;
|
|
}
|
|
_autoTimer = Timer.periodic(_autoPlayInterval, (_) {
|
|
if (!mounted || _pointer != null || !widget.active || !_tickerEnabled) {
|
|
return;
|
|
}
|
|
final current = _page.round().clamp(0, _last.toInt());
|
|
if (current >= _last.toInt()) {
|
|
setState(() => _page = 0);
|
|
} else {
|
|
_turnTo((current + 1).toDouble());
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.items.isEmpty) return const SizedBox.shrink();
|
|
final current = _page.round().clamp(0, widget.items.length - 1);
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
final height = math.min(width * 0.52, 210).toDouble();
|
|
return Semantics(
|
|
container: true,
|
|
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
height: height,
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(child: _stage(width)),
|
|
_indicator(l10n, current),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _stage(double width) {
|
|
return Listener(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPointerDown: (event) {
|
|
_autoTimer?.cancel();
|
|
_autoTimer = null;
|
|
_snap.stop();
|
|
_pointer = event.pointer;
|
|
_drag = 0;
|
|
_dragPage = _page;
|
|
},
|
|
onPointerMove: (event) {
|
|
if (event.pointer != _pointer) return;
|
|
_drag += event.delta.dx;
|
|
setState(() {
|
|
_page = (_dragPage - _drag / width).clamp(0.0, _last);
|
|
});
|
|
},
|
|
onPointerUp: (event) {
|
|
if (event.pointer != _pointer) return;
|
|
_pointer = null;
|
|
_turnTo(_page.roundToDouble());
|
|
_syncAutoTimer();
|
|
},
|
|
onPointerCancel: (event) {
|
|
if (event.pointer != _pointer) return;
|
|
_pointer = null;
|
|
_turnTo(_page.roundToDouble());
|
|
_syncAutoTimer();
|
|
},
|
|
child: ClipRect(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [for (final index in _visible) _face(index, width)],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _indicator(AppLocalizations l10n, int current) {
|
|
return Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 8,
|
|
child: Semantics(
|
|
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
for (var index = 0; index < widget.items.length; index++)
|
|
Container(
|
|
key: Key('carousel-dot-$index'),
|
|
width: 6,
|
|
height: 6,
|
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: index == current
|
|
? AppColors.onPrimary
|
|
: AppColors.muted.withValues(alpha: 0.4),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<int> get _visible {
|
|
final first = _page.floor();
|
|
final indexes = <int>[first, first + 1];
|
|
indexes.retainWhere((index) => index >= 0 && index < widget.items.length);
|
|
indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs()));
|
|
return indexes;
|
|
}
|
|
|
|
Widget _face(int index, double width) {
|
|
final delta = index - _page;
|
|
if (delta <= -1 || delta >= 1) return const SizedBox.shrink();
|
|
final item = widget.items[index];
|
|
final angle = delta * math.pi / 2;
|
|
// Push the face out to the cube surface, spin the whole cube on its
|
|
// vertical center, then bring that center back to the banner.
|
|
final face = Matrix4.identity()
|
|
..translateByDouble(0, 0, -width / 2, 1)
|
|
..rotateY(angle)
|
|
..translateByDouble(0, 0, width / 2, 1);
|
|
final transform = Matrix4.identity()
|
|
..setEntry(3, 2, 0.0018)
|
|
..multiply(face);
|
|
return Positioned.fill(
|
|
child: Transform(
|
|
alignment: Alignment.center,
|
|
filterQuality: FilterQuality.medium,
|
|
transform: transform,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
if ((_page - index).abs() < 0.08) item.onOpen();
|
|
},
|
|
child: _poster(item),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _poster(CoverItem item) {
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
RemoteImage(
|
|
item.image,
|
|
showFallbackIcon: true,
|
|
fallbackIconKey: const Key('carousel-image-fallback'),
|
|
),
|
|
const DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)],
|
|
stops: [0, 0.5, 1],
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 20,
|
|
right: 20,
|
|
bottom: 30,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.title,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.15,
|
|
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
item.subtitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Color(0xF2FFFFFF),
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
height: 1.2,
|
|
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|