- 全局字体统一(Outfit/DM Sans), 头部/按钮/Toast规范化 - 故事详情页: Genie Suck吸入动画(标题+卡片一起缩小模糊消失) - 书架页: bookPop弹出+粒子效果(三段式动画完整链路) - 音乐页面: 心情卡片emoji换Material图标+彩色圆块横排布局 - 音乐页面: 进度条胶囊宽度对齐, 播放按钮位置修复, 间距均匀化 - 音乐播放: 接入just_audio, 支持播放暂停进度拖拽自动切歌 - 新增: iOS风格毛玻璃Toast, 渐变背景组件, 通知页面 - 阶段总结文档更新 Co-authored-by: Cursor <cursoragent@cursor.com>
177 lines
5.1 KiB
Dart
177 lines
5.1 KiB
Dart
import 'dart:ui';
|
||
import 'package:flutter/material.dart';
|
||
|
||
/// 全局 iOS 风格顶部浮动 Toast
|
||
/// 用法: AppToast.show(context, '提示内容');
|
||
/// AppToast.show(context, '出错了', isError: true);
|
||
class AppToast {
|
||
static OverlayEntry? _currentEntry;
|
||
|
||
/// 显示 toast,自动消失
|
||
static void show(
|
||
BuildContext context,
|
||
String message, {
|
||
bool isError = false,
|
||
}) {
|
||
// 如果已有 toast,先移除
|
||
_currentEntry?.remove();
|
||
_currentEntry = null;
|
||
|
||
final overlay = Overlay.of(context);
|
||
late OverlayEntry entry;
|
||
|
||
entry = OverlayEntry(
|
||
builder: (context) => _IOSToastWidget(
|
||
message: message,
|
||
isError: isError,
|
||
onDismiss: () {
|
||
entry.remove();
|
||
if (_currentEntry == entry) _currentEntry = null;
|
||
},
|
||
),
|
||
);
|
||
|
||
_currentEntry = entry;
|
||
overlay.insert(entry);
|
||
}
|
||
}
|
||
|
||
class _IOSToastWidget extends StatefulWidget {
|
||
final String message;
|
||
final bool isError;
|
||
final VoidCallback onDismiss;
|
||
|
||
const _IOSToastWidget({
|
||
required this.message,
|
||
required this.isError,
|
||
required this.onDismiss,
|
||
});
|
||
|
||
@override
|
||
State<_IOSToastWidget> createState() => _IOSToastWidgetState();
|
||
}
|
||
|
||
class _IOSToastWidgetState extends State<_IOSToastWidget>
|
||
with SingleTickerProviderStateMixin {
|
||
late AnimationController _controller;
|
||
late Animation<double> _slideAnimation;
|
||
late Animation<double> _fadeAnimation;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = AnimationController(
|
||
duration: const Duration(milliseconds: 350),
|
||
reverseDuration: const Duration(milliseconds: 250),
|
||
vsync: this,
|
||
);
|
||
|
||
_slideAnimation = Tween<double>(begin: -1.0, end: 0.0).animate(
|
||
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
|
||
);
|
||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||
);
|
||
|
||
_controller.forward();
|
||
|
||
// Auto dismiss after 2.5s
|
||
Future.delayed(const Duration(milliseconds: 2500), () {
|
||
if (mounted) {
|
||
_controller.reverse().then((_) {
|
||
if (mounted) widget.onDismiss();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final topPadding = MediaQuery.of(context).padding.top;
|
||
|
||
return Positioned(
|
||
top: topPadding + 12,
|
||
left: 24,
|
||
right: 24,
|
||
child: AnimatedBuilder(
|
||
animation: _controller,
|
||
builder: (context, child) {
|
||
return Transform.translate(
|
||
offset: Offset(0, _slideAnimation.value * 60),
|
||
child: Opacity(
|
||
opacity: _fadeAnimation.value,
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: Center(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||
child: Container(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: widget.isError
|
||
? const Color(0xFFFEF2F2).withOpacity(0.85)
|
||
: Colors.white.withOpacity(0.85),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: widget.isError
|
||
? const Color(0xFFFCA5A5).withOpacity(0.4)
|
||
: Colors.white.withOpacity(0.6),
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.08),
|
||
offset: const Offset(0, 4),
|
||
blurRadius: 20,
|
||
),
|
||
],
|
||
),
|
||
child: DefaultTextStyle(
|
||
style: const TextStyle(decoration: TextDecoration.none),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
widget.isError
|
||
? Icons.info_outline_rounded
|
||
: Icons.check_circle_outline_rounded,
|
||
size: 20,
|
||
color: widget.isError
|
||
? const Color(0xFFEF4444)
|
||
: const Color(0xFF22C55E),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Flexible(
|
||
child: Text(
|
||
widget.message,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: widget.isError
|
||
? const Color(0xFFDC2626)
|
||
: const Color(0xFF374151),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|