// 启动页 — 全屏渐变背景 + App Logo + 自动跳转 // 对齐 Open Design 原型稿 screens/splash.html // // 跳转逻辑: // - 2 秒自动跳转或点击"开始记录"按钮 // - 已认证 → /home // - 首次使用(未完成引导) → /onboarding // - 其他 → /login import 'dart:async'; import 'dart:math' show cos, sin; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../core/constants/design_tokens.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_typography.dart'; import '../../auth/bloc/auth_bloc.dart'; /// 启动页 — App 打开后第一个画面 class SplashPage extends StatefulWidget { const SplashPage({super.key}); @override State createState() => _SplashPageState(); } class _SplashPageState extends State with TickerProviderStateMixin { late final AnimationController _scaleController; late final Animation _scaleAnimation; late final AnimationController _fadeController; late final Animation _fadeAnimation; Timer? _autoNavigateTimer; bool _hasNavigated = false; static const String _kOnboardingComplete = 'onboarding_complete'; @override void initState() { super.initState(); // 中心内容弹性缩放入场 _scaleController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _scaleAnimation = CurvedAnimation( parent: _scaleController, curve: DesignTokens.warmCurve, ); // 底部区域淡入(延迟 500ms) _fadeController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1000), ); _fadeAnimation = CurvedAnimation( parent: _fadeController, curve: Curves.easeOut, ); _scaleController.forward(); Future.delayed(const Duration(milliseconds: 500), () { if (mounted) _fadeController.forward(); }); // 2 秒后自动跳转 _autoNavigateTimer = Timer(const Duration(seconds: 2), _navigate); } @override void dispose() { _autoNavigateTimer?.cancel(); _scaleController.dispose(); _fadeController.dispose(); super.dispose(); } Future _navigate() async { if (_hasNavigated || !mounted) return; _hasNavigated = true; final authState = context.read().state; // 已认证 → 直接进入首页 if (authState is Authenticated) { if (authState.needsRoleSelection) { context.go('/role-selection'); } else if (authState.needsClassCode) { context.go('/class-code'); } else { context.go('/home'); } return; } // 检查是否已完成引导 final prefs = await SharedPreferences.getInstance(); final onboardingComplete = prefs.getBool(_kOnboardingComplete) ?? false; if (!mounted) return; if (onboardingComplete) { context.go('/login'); } else { context.go('/onboarding'); } } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( body: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, stops: const [0.0, 0.4, 1.0], colors: [ isDark ? AppColors.bgDark : AppColors.bgLight, isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight, isDark ? AppColors.accentDark.withValues(alpha: 0.5) : AppColors.accent.withValues(alpha: 0.5), ], ), ), child: Stack( children: [ // 浮动装饰元素 _buildDecorations(isDark), // 中心内容 Center( child: ScaleTransition( scale: _scaleAnimation, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildAppIcon(isDark), const SizedBox(height: 28), _buildAppName(isDark), const SizedBox(height: 12), _buildTagline(isDark), ], ), ), ), // 底部区域 Positioned( left: 0, right: 0, bottom: MediaQuery.of(context).padding.bottom + 40, child: FadeTransition( opacity: _fadeAnimation, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildHandwrittenTagline(isDark), const SizedBox(height: 20), _buildEnterButton(isDark), const SizedBox(height: 16), _buildHint(isDark), ], ), ), ), ], ), ), ); } /// 浮动装饰星星和圆圈 Widget _buildDecorations(bool isDark) { return Stack( children: [ // 星星 — 左上 _FloatingStar( top: 120, left: 60, size: 28, color: isDark ? AppColors.accentDark : AppColors.accent, animDelay: Duration.zero, ), // 星星 — 右上 _FloatingStar( top: 200, right: 50, size: 28, color: isDark ? AppColors.secondaryDark : AppColors.secondary, animDelay: const Duration(milliseconds: 1500), ), // 圆圈 — 右侧 _FloatingCircle( top: 160, right: 80, size: 80, color: isDark ? AppColors.tertiaryDark : AppColors.tertiary, animDelay: const Duration(milliseconds: 2000), ), // 圆圈 — 左下 _FloatingCircle( bottom: 300, left: 40, size: 50, color: isDark ? AppColors.roseDark : AppColors.rose, animDelay: const Duration(milliseconds: 3000), ), // 星星 — 右下 _FloatingStar( bottom: 350, right: 70, size: 28, color: isDark ? AppColors.tertiaryDark : AppColors.tertiary, animDelay: const Duration(milliseconds: 2500), ), ], ); } /// App 图标 — 120x120, 圆角 32px, accent→tertiary 渐变 Widget _buildAppIcon(bool isDark) { return Container( width: 120, height: 120, decoration: BoxDecoration( borderRadius: BorderRadius.circular(32), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ isDark ? AppColors.accentDark : AppColors.accent, isDark ? AppColors.tertiaryDark : AppColors.tertiary, ], ), boxShadow: [ BoxShadow( color: (isDark ? AppColors.accentDark : AppColors.accent) .withValues(alpha: 0.3), offset: const Offset(0, 8), blurRadius: 32, ), ], ), child: Stack( alignment: Alignment.center, children: [ // 高光圆 Positioned( top: -20, right: -20, child: Container( width: 72, height: 72, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.2), ), ), ), // 笔记本图标 CustomPaint( size: const Size(56, 56), painter: _NotebookIconPainter( accentColor: isDark ? AppColors.accentDark : AppColors.accent, tertiaryColor: isDark ? AppColors.tertiaryDark : AppColors.tertiary, ), ), ], ), ); } /// App 名 — "暖记" 42px/700, letter-spacing 2px Widget _buildAppName(bool isDark) { return Text( '暖记', style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 42, fontWeight: FontWeight.w700, color: isDark ? AppColors.fgDark : AppColors.fgLight, letterSpacing: 2, ), ); } /// Tagline — "用温暖的方式,记录每一天" Widget _buildTagline(bool isDark) { return Text( '用温暖的方式,记录每一天', style: TextStyle( fontFamily: AppTypography.bodyFont, fontSize: 17, fontWeight: FontWeight.w400, color: isDark ? AppColors.mutedDark : AppColors.mutedLight, letterSpacing: 1, ), ); } /// 手写 tagline — "每一页,都是你的故事" Widget _buildHandwrittenTagline(bool isDark) { return Text( '每一页,都是你的故事', style: TextStyle( fontFamily: AppTypography.handwrittenFont, fontSize: 22, color: isDark ? AppColors.accentDark : AppColors.accent, letterSpacing: 1, ), ); } /// "开始记录" 按钮 — pill 形状, accent 背景色 Widget _buildEnterButton(bool isDark) { final accentColor = isDark ? AppColors.accentDark : AppColors.accent; final accentOnColor = isDark ? const Color(0xFF1A1614) : const Color(0xFFFFF8F0); return SizedBox( width: double.infinity, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 48), child: GestureDetector( onTap: _navigate, child: Container( padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), decoration: BoxDecoration( color: accentColor, borderRadius: AppRadius.pillBorder, boxShadow: [ BoxShadow( color: accentColor.withValues(alpha: 0.3), offset: const Offset(0, 4), blurRadius: 20, ), ], ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '开始记录', style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 20, fontWeight: FontWeight.w600, color: accentOnColor, ), ), const SizedBox(width: 8), Icon( Icons.arrow_forward_rounded, size: 20, color: accentOnColor, ), ], ), ), ), ), ); } /// 底部提示 Widget _buildHint(bool isDark) { return Text( '每一天都值得被温柔记录', style: TextStyle( fontFamily: AppTypography.bodyFont, fontSize: 13, fontWeight: FontWeight.w400, color: isDark ? AppColors.metaDark : AppColors.metaLight, ), ); } } // ===== 浮动装饰组件 ===== /// 浮动星星装饰 class _FloatingStar extends StatefulWidget { final double? top; final double? bottom; final double? left; final double? right; final double size; final Color color; final Duration animDelay; const _FloatingStar({ this.top, this.bottom, this.left, this.right, required this.size, required this.color, required this.animDelay, }); @override State<_FloatingStar> createState() => _FloatingStarState(); } class _FloatingStarState extends State<_FloatingStar> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(seconds: 4), ); _animation = Tween(begin: 0.0, end: 1.0).animate(_controller); Future.delayed(widget.animDelay, () { if (mounted) _controller.repeat(); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Positioned( top: widget.top, bottom: widget.bottom, left: widget.left, right: widget.right, child: AnimatedBuilder( animation: _animation, builder: (context, child) { final offset = _animation.value < 0.5 ? _animation.value * -10 : (_animation.value - 0.5) * -10 + 10; final rotation = _animation.value * 0.14; // ~5deg return Transform.translate( offset: Offset(0, -offset + 5), child: Transform.rotate( angle: rotation, child: child, ), ); }, child: Opacity( opacity: 0.25, child: CustomPaint( size: Size(widget.size, widget.size), painter: _StarPainter(color: widget.color), ), ), ), ); } } /// 浮动圆形装饰 class _FloatingCircle extends StatefulWidget { final double? top; final double? bottom; final double? left; final double? right; final double size; final Color color; final Duration animDelay; const _FloatingCircle({ this.top, this.bottom, this.left, this.right, required this.size, required this.color, required this.animDelay, }); @override State<_FloatingCircle> createState() => _FloatingCircleState(); } class _FloatingCircleState extends State<_FloatingCircle> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(seconds: 5), ); _animation = Tween(begin: 0.0, end: 1.0).animate(_controller); Future.delayed(widget.animDelay, () { if (mounted) _controller.repeat(reverse: true); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Positioned( top: widget.top, bottom: widget.bottom, left: widget.left, right: widget.right, child: AnimatedBuilder( animation: _animation, builder: (context, child) { final offset = _animation.value * -10; return Transform.translate( offset: Offset(0, offset), child: child, ); }, child: Opacity( opacity: 0.12, child: Container( width: widget.size, height: widget.size, decoration: BoxDecoration( shape: BoxShape.circle, color: widget.color, ), ), ), ), ); } } // ===== CustomPainter — 笔记本图标 ===== /// 笔记本图标绘制器(对齐 splash.html 中的 SVG) class _NotebookIconPainter extends CustomPainter { final Color accentColor; final Color tertiaryColor; _NotebookIconPainter({ required this.accentColor, required this.tertiaryColor, }); @override void paint(Canvas canvas, Size size) { final s = size.width / 56; // 基准 56px 缩放 // 白色笔记本主体 final bodyPaint = Paint() ..color = Colors.white.withValues(alpha: 0.9) ..style = PaintingStyle.fill; final bodyRect = RRect.fromRectAndRadius( Rect.fromLTWH(10 * s, 6 * s, 36 * s, 44 * s), Radius.circular(4 * s), ); canvas.drawRRect(bodyRect, bodyPaint); // 脊部装饰线 final spinePaint = Paint() ..color = Colors.white.withValues(alpha: 0.3) ..style = PaintingStyle.fill; canvas.drawRect( Rect.fromLTWH(14 * s, 6 * s, 4 * s, 44 * s), spinePaint, ); // 文字横线 final linePaint = Paint() ..color = accentColor ..style = PaintingStyle.stroke ..strokeWidth = 2 * s ..strokeCap = StrokeCap.round; canvas.drawLine( Offset(20 * s, 18 * s), Offset(38 * s, 18 * s), linePaint, ); canvas.drawLine( Offset(20 * s, 24 * s), Offset(34 * s, 24 * s), linePaint, ); canvas.drawLine( Offset(20 * s, 30 * s), Offset(38 * s, 30 * s), linePaint, ); canvas.drawLine( Offset(20 * s, 36 * s), Offset(30 * s, 36 * s), linePaint, ); // 右上角勾选标记圆形 final checkCirclePaint = Paint() ..color = tertiaryColor.withValues(alpha: 0.6) ..style = PaintingStyle.fill; canvas.drawCircle( Offset(42 * s, 14 * s), 8 * s, checkCirclePaint, ); // 勾选符号 final checkPaint = Paint() ..color = accentColor ..style = PaintingStyle.stroke ..strokeWidth = 1.5 * s ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; final checkPath = Path() ..moveTo(39 * s, 14 * s) ..lineTo(41 * s, 16 * s) ..lineTo(45 * s, 12 * s); canvas.drawPath(checkPath, checkPaint); } @override bool shouldRepaint(covariant _NotebookIconPainter oldDelegate) => accentColor != oldDelegate.accentColor || tertiaryColor != oldDelegate.tertiaryColor; } // ===== CustomPainter — 五角星 ===== /// 五角星绘制器(装饰元素) class _StarPainter extends CustomPainter { final Color color; _StarPainter({required this.color}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..style = PaintingStyle.fill; final path = Path(); final cx = size.width / 2; final cy = size.height / 2; final outerRadius = size.width / 2; final innerRadius = outerRadius * 0.4; for (var i = 0; i < 5; i++) { final outerAngle = (i * 72 - 90) * 3.14159 / 180; final innerAngle = ((i * 72) + 36 - 90) * 3.14159 / 180; final ox = cx + outerRadius * cos(outerAngle); final oy = cy + outerRadius * sin(outerAngle); final ix = cx + innerRadius * cos(innerAngle); final iy = cy + innerRadius * sin(innerAngle); if (i == 0) { path.moveTo(ox, oy); } else { path.lineTo(ox, oy); } path.lineTo(ix, iy); } path.close(); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant _StarPainter oldDelegate) => color != oldDelegate.color; }