前端修复: - 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个页面全部渲染正常
695 lines
18 KiB
Dart
695 lines
18 KiB
Dart
// 启动页 — 全屏渐变背景 + 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;
|
||
}
|