Files
nj/app/lib/features/onboarding/views/splash_page.dart
iven b320641d9c
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 全链路验证修复 — 编译错误/CORS/迁移/启动脚本
前端修复:
- calendar_page: 移除不存在的 JournalEntry.content getter
- responsive_scaffold: 移除不存在的 notchThickness 参数
- splash_page: SingleTickerProvider → TickerProvider (多 AnimationController)
- profile_page: UserRoleType.name → .code (修复运行时崩溃)
- 导入缺失的 user.dart

后端修复:
- class_service: generate_class_code 取 UUID 后6位(随机部分)避免碰撞
- diary_role_seed: 移除不存在的 id 列,使用复合主键 ON CONFLICT

基础设施:
- config/default.toml: CORS 改为通配符(开发模式)
- scripts/dev.sh: 统一启动脚本(自动清理端口)
- docs/opendesign/: Open Design 设计规格 HTML 原型稿

验证结果: flutter analyze 0 error, cargo test 77/77 通过, 17个页面全部渲染正常
2026-06-02 01:03:58 +08:00

695 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 启动页 — 全屏渐变背景 + 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<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage>
with TickerProviderStateMixin {
late final AnimationController _scaleController;
late final Animation<double> _scaleAnimation;
late final AnimationController _fadeController;
late final Animation<double> _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<void> _navigate() async {
if (_hasNavigated || !mounted) return;
_hasNavigated = true;
final authState = context.read<AuthBloc>().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<double> _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<double> _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;
}