feat(flutter): 增加轮播自动播放与站内通知

This commit is contained in:
Zhou Weidong committed 2026-10-17 16:50:25 +08:00
1 parent b7b1c50427
commit b870956665
24 files changed
+1398 -26

No files matched your search

+111 -7
View File
@@ -1,7 +1,12 @@
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({
@@ -19,9 +24,10 @@ class CoverItem {
/// 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});
const CoverCarousel({super.key, required this.items, this.active = true});
final List<CoverItem> items;
final bool active;
@override
State<CoverCarousel> createState() => _CoverCarouselState();
@@ -36,6 +42,8 @@ class _CoverCarouselState extends State<CoverCarousel>
int? _pointer;
double _drag = 0;
double _dragPage = 0;
Timer? _autoTimer;
bool _tickerEnabled = true;
@override
void initState() {
@@ -45,34 +53,93 @@ class _CoverCarouselState extends State<CoverCarousel>
@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 SizedBox(
width: double.infinity,
height: height,
child: _stage(width),
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),
],
),
),
);
},
);
@@ -82,6 +149,8 @@ class _CoverCarouselState extends State<CoverCarousel>
return Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) {
_autoTimer?.cancel();
_autoTimer = null;
_snap.stop();
_pointer = event.pointer;
_drag = 0;
@@ -98,11 +167,13 @@ class _CoverCarouselState extends State<CoverCarousel>
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(
@@ -113,6 +184,35 @@ class _CoverCarouselState extends State<CoverCarousel>
);
}
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];
@@ -154,7 +254,11 @@ class _CoverCarouselState extends State<CoverCarousel>
return Stack(
fit: StackFit.expand,
children: [
RemoteImage(item.image),
RemoteImage(
item.image,
showFallbackIcon: true,
fallbackIconKey: const Key('carousel-image-fallback'),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -168,7 +272,7 @@ class _CoverCarouselState extends State<CoverCarousel>
Positioned(
left: 20,
right: 20,
bottom: 22,
bottom: 30,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [