- 全局字体统一(Outfit/DM Sans), 头部/按钮/Toast规范化 - 故事详情页: Genie Suck吸入动画(标题+卡片一起缩小模糊消失) - 书架页: bookPop弹出+粒子效果(三段式动画完整链路) - 音乐页面: 心情卡片emoji换Material图标+彩色圆块横排布局 - 音乐页面: 进度条胶囊宽度对齐, 播放按钮位置修复, 间距均匀化 - 音乐播放: 接入just_audio, 支持播放暂停进度拖拽自动切歌 - 新增: iOS风格毛玻璃Toast, 渐变背景组件, 通知页面 - 阶段总结文档更新 Co-authored-by: Cursor <cursoragent@cursor.com>
327 lines
10 KiB
Dart
327 lines
10 KiB
Dart
import 'dart:math' as math;
|
||
import 'package:flutter/material.dart';
|
||
|
||
/// 动态渐变背景 — 精确复刻 HTML styles.css 中的 Animated Gradient Background
|
||
/// 5 层渐变 + 5 组不同节奏的有机流动动画
|
||
class AnimatedGradientBackground extends StatefulWidget {
|
||
const AnimatedGradientBackground({super.key});
|
||
|
||
@override
|
||
State<AnimatedGradientBackground> createState() =>
|
||
_AnimatedGradientBackgroundState();
|
||
}
|
||
|
||
class _AnimatedGradientBackgroundState extends State<AnimatedGradientBackground>
|
||
with TickerProviderStateMixin {
|
||
// 5 个不同速度的动画控制器,对应 HTML 的 organicFlow1~5
|
||
late final List<AnimationController> _controllers;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controllers = [
|
||
AnimationController(vsync: this, duration: const Duration(seconds: 20)),
|
||
AnimationController(vsync: this, duration: const Duration(seconds: 25)),
|
||
AnimationController(vsync: this, duration: const Duration(seconds: 30)),
|
||
AnimationController(vsync: this, duration: const Duration(seconds: 35)),
|
||
AnimationController(vsync: this, duration: const Duration(seconds: 28)),
|
||
];
|
||
for (final c in _controllers) {
|
||
c.repeat();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
for (final c in _controllers) {
|
||
c.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Positioned.fill(
|
||
child: Container(
|
||
color: const Color(0xFFFEFEFE),
|
||
child: Stack(
|
||
children: [
|
||
// Layer 1: Soft pink from bottom-left (organicFlow1, 20s)
|
||
_buildLayer(
|
||
controller: _controllers[0],
|
||
gradients: const [
|
||
_GradientDef(
|
||
center: Alignment(-0.6, 0.6),
|
||
radiusX: 1.2,
|
||
radiusY: 0.8,
|
||
color: Color(0x99FFC8DC), // rgba(255, 200, 220, 0.6)
|
||
stops: [0.0, 0.5],
|
||
),
|
||
_GradientDef(
|
||
center: Alignment(-0.4, 0.2),
|
||
radiusX: 0.8,
|
||
radiusY: 0.6,
|
||
color: Color(0x66FFB4C8), // rgba(255, 180, 200, 0.4)
|
||
stops: [0.0, 0.4],
|
||
),
|
||
],
|
||
keyframes: const [
|
||
_Keyframe(0.00, 0, 0, 0, 1.0),
|
||
_Keyframe(0.25, 0.05, -0.08, 2, 1.02),
|
||
_Keyframe(0.50, -0.03, 0.05, -1, 0.98),
|
||
_Keyframe(0.75, 0.08, 0.03, 1, 1.01),
|
||
_Keyframe(1.00, 0, 0, 0, 1.0),
|
||
],
|
||
),
|
||
|
||
// Layer 2: Mint/Cyan from top-right (organicFlow2, 25s)
|
||
_buildLayer(
|
||
controller: _controllers[1],
|
||
gradients: const [
|
||
_GradientDef(
|
||
center: Alignment(0.6, -0.6),
|
||
radiusX: 1.0,
|
||
radiusY: 0.7,
|
||
color: Color(0x80B4F0F0), // rgba(180, 240, 240, 0.5)
|
||
stops: [0.0, 0.45],
|
||
),
|
||
_GradientDef(
|
||
center: Alignment(0.4, -0.2),
|
||
radiusX: 0.7,
|
||
radiusY: 0.9,
|
||
color: Color(0x66C8F5F5), // rgba(200, 245, 245, 0.4)
|
||
stops: [0.0, 0.4],
|
||
),
|
||
],
|
||
keyframes: const [
|
||
_Keyframe(0.00, 0, 0, 0, 1.0),
|
||
_Keyframe(0.33, -0.08, 0.06, -2, 1.03),
|
||
_Keyframe(0.66, 0.06, -0.05, 2, 0.97),
|
||
_Keyframe(1.00, 0, 0, 0, 1.0),
|
||
],
|
||
),
|
||
|
||
// Layer 3: Lavender/Purple diagonal (organicFlow3, 30s)
|
||
_buildLayer(
|
||
controller: _controllers[2],
|
||
gradients: const [
|
||
_GradientDef(
|
||
center: Alignment(0.2, 0.0),
|
||
radiusX: 0.9,
|
||
radiusY: 0.8,
|
||
color: Color(0x73E6D2FA), // rgba(230, 210, 250, 0.45)
|
||
stops: [0.0, 0.45],
|
||
),
|
||
_GradientDef(
|
||
center: Alignment(-0.2, 0.4),
|
||
radiusX: 0.6,
|
||
radiusY: 0.5,
|
||
color: Color(0x59F0DCFF), // rgba(240, 220, 255, 0.35)
|
||
stops: [0.0, 0.35],
|
||
),
|
||
],
|
||
keyframes: const [
|
||
_Keyframe(0.0, 0, 0, 0, 1.0),
|
||
_Keyframe(0.2, 0.04, 0.07, 1, 1.02),
|
||
_Keyframe(0.4, -0.06, -0.04, -2, 0.98),
|
||
_Keyframe(0.6, 0.07, -0.06, 2, 1.01),
|
||
_Keyframe(0.8, -0.04, 0.05, -1, 0.99),
|
||
_Keyframe(1.0, 0, 0, 0, 1.0),
|
||
],
|
||
),
|
||
|
||
// Layer 4 (::before): Warm pink top-left + cool cyan bottom-right (organicFlow4, 35s)
|
||
_buildLayer(
|
||
controller: _controllers[3],
|
||
sizeMultiplier: 1.5,
|
||
gradients: const [
|
||
_GradientDef(
|
||
center: Alignment(-0.7, -0.4),
|
||
radiusX: 0.6,
|
||
radiusY: 0.4,
|
||
color: Color(0x80FFE6F0), // rgba(255, 230, 240, 0.5)
|
||
stops: [0.0, 0.4],
|
||
),
|
||
_GradientDef(
|
||
center: Alignment(0.7, 0.2),
|
||
radiusX: 0.5,
|
||
radiusY: 0.7,
|
||
color: Color(0x66DCFAFA), // rgba(220, 250, 250, 0.4)
|
||
stops: [0.0, 0.35],
|
||
),
|
||
],
|
||
keyframes: const [
|
||
_Keyframe(0.0, 0, 0, 0, 1.0),
|
||
_Keyframe(0.25, -0.05, 0.07, -1, 1.01),
|
||
_Keyframe(0.50, 0.06, -0.04, 2, 0.99),
|
||
_Keyframe(0.75, -0.03, 0.05, -1, 1.02),
|
||
_Keyframe(1.0, 0, 0, 0, 1.0),
|
||
],
|
||
),
|
||
|
||
// Layer 5 (::after): Pink bottom + Cyan top-right (organicFlow5, 28s)
|
||
_buildLayer(
|
||
controller: _controllers[4],
|
||
sizeMultiplier: 1.2,
|
||
gradients: const [
|
||
_GradientDef(
|
||
center: Alignment(0.0, 0.8),
|
||
radiusX: 0.7,
|
||
radiusY: 0.5,
|
||
color: Color(0x59FFD2E6), // rgba(255, 210, 230, 0.35)
|
||
stops: [0.0, 0.4],
|
||
),
|
||
_GradientDef(
|
||
center: Alignment(0.8, -0.8),
|
||
radiusX: 0.4,
|
||
radiusY: 0.6,
|
||
color: Color(0x4DC8F0FF), // rgba(200, 240, 255, 0.3)
|
||
stops: [0.0, 0.35],
|
||
),
|
||
],
|
||
keyframes: const [
|
||
_Keyframe(0.0, 0, 0, 0, 1.0),
|
||
_Keyframe(0.33, 0.04, -0.06, 1, 1.02),
|
||
_Keyframe(0.66, -0.06, 0.04, -2, 0.98),
|
||
_Keyframe(1.0, 0, 0, 0, 1.0),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLayer({
|
||
required AnimationController controller,
|
||
required List<_GradientDef> gradients,
|
||
required List<_Keyframe> keyframes,
|
||
double sizeMultiplier = 2.0,
|
||
}) {
|
||
return AnimatedBuilder(
|
||
animation: controller,
|
||
builder: (context, child) {
|
||
final t = controller.value;
|
||
final frame = _interpolateKeyframes(keyframes, t);
|
||
|
||
return Transform(
|
||
alignment: Alignment.center,
|
||
transform: Matrix4.identity()
|
||
..translate(
|
||
frame.tx * MediaQuery.of(context).size.width,
|
||
frame.ty * MediaQuery.of(context).size.height,
|
||
)
|
||
..rotateZ(frame.rotate * math.pi / 180)
|
||
..scale(frame.scale),
|
||
child: child,
|
||
);
|
||
},
|
||
child: SizedBox.expand(
|
||
child: CustomPaint(
|
||
painter: _GradientLayerPainter(gradients, sizeMultiplier),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
_Keyframe _interpolateKeyframes(List<_Keyframe> keyframes, double t) {
|
||
// 找到 t 所在的两个 keyframe 之间
|
||
int i = 0;
|
||
for (i = 0; i < keyframes.length - 1; i++) {
|
||
if (t <= keyframes[i + 1].time) break;
|
||
}
|
||
if (i >= keyframes.length - 1) i = keyframes.length - 2;
|
||
|
||
final a = keyframes[i];
|
||
final b = keyframes[i + 1];
|
||
final segT = (b.time - a.time) == 0
|
||
? 0.0
|
||
: (t - a.time) / (b.time - a.time);
|
||
// ease-in-out 缓动
|
||
final eased = _easeInOut(segT);
|
||
|
||
return _Keyframe(
|
||
t,
|
||
a.tx + (b.tx - a.tx) * eased,
|
||
a.ty + (b.ty - a.ty) * eased,
|
||
a.rotate + (b.rotate - a.rotate) * eased,
|
||
a.scale + (b.scale - a.scale) * eased,
|
||
);
|
||
}
|
||
|
||
double _easeInOut(double t) {
|
||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||
}
|
||
}
|
||
|
||
class _Keyframe {
|
||
final double time; // 0~1
|
||
final double tx; // translateX(比例)
|
||
final double ty; // translateY(比例)
|
||
final double rotate; // degrees
|
||
final double scale;
|
||
|
||
const _Keyframe(this.time, this.tx, this.ty, this.rotate, this.scale);
|
||
}
|
||
|
||
class _GradientDef {
|
||
final Alignment center;
|
||
final double radiusX;
|
||
final double radiusY;
|
||
final Color color;
|
||
final List<double> stops;
|
||
|
||
const _GradientDef({
|
||
required this.center,
|
||
required this.radiusX,
|
||
required this.radiusY,
|
||
required this.color,
|
||
required this.stops,
|
||
});
|
||
}
|
||
|
||
class _GradientLayerPainter extends CustomPainter {
|
||
final List<_GradientDef> gradients;
|
||
final double sizeMultiplier;
|
||
|
||
_GradientLayerPainter(this.gradients, this.sizeMultiplier);
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final w = size.width * sizeMultiplier;
|
||
final h = size.height * sizeMultiplier;
|
||
final offsetX = (size.width - w) / 2;
|
||
final offsetY = (size.height - h) / 2;
|
||
|
||
for (final g in gradients) {
|
||
final cx = offsetX + w * (g.center.x + 1) / 2;
|
||
final cy = offsetY + h * (g.center.y + 1) / 2;
|
||
final rx = w * g.radiusX / 2;
|
||
final ry = h * g.radiusY / 2;
|
||
final r = math.max(rx, ry);
|
||
|
||
final gradient = RadialGradient(
|
||
center: Alignment.center,
|
||
radius: 1.0,
|
||
colors: [g.color, g.color.withOpacity(0)],
|
||
stops: g.stops,
|
||
);
|
||
|
||
final rect = Rect.fromCenter(
|
||
center: Offset(cx, cy),
|
||
width: rx * 2,
|
||
height: ry * 2,
|
||
);
|
||
|
||
final paint = Paint()
|
||
..shader = gradient.createShader(rect)
|
||
..blendMode = BlendMode.srcOver;
|
||
|
||
canvas.drawOval(rect, paint);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _GradientLayerPainter oldDelegate) => false;
|
||
}
|