fix(app): 全链路验证修复 — 编译错误/CORS/迁移/启动脚本
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

前端修复:
- 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个页面全部渲染正常
This commit is contained in:
iven
2026-06-02 01:03:58 +08:00
parent 749ef55b89
commit b320641d9c
56 changed files with 20696 additions and 239 deletions

View File

@@ -0,0 +1,495 @@
// 引导页 — 3 页水平滑动引导 + 完成标记
// 对齐 Open Design 原型稿 screens/onboarding.html
//
// 流程:
// Step 1: "用手账的方式记录每一天" — 笔记本+笔+贴纸 插图
// Step 2: "海量贴纸与模板随心装饰" — 贴纸面板 插图
// Step 3: "回顾成长轨迹看见自己的变化" — 心情日历 插图
//
// 完成后: SharedPreferences 记录 onboarding_complete=true → 跳转 /login
import 'package:flutter/material.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';
/// 引导页 — 首次使用时展示功能介绍
class OnboardingPage extends StatefulWidget {
const OnboardingPage({super.key});
@override
State<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController();
int _currentPage = 0;
static const int _totalPages = 3;
static const String _kOnboardingComplete = 'onboarding_complete';
static const _OnboardingStep _step1 = _OnboardingStep(
pageNumber: '01',
emoji: '📝',
titleLine1: '用手账的方式',
titleLine2: '记录每一天',
description: '文字、贴纸、涂鸦、照片 — 选择你喜欢的方式,把日常变成一本温暖的手账',
illustrationType: _IllustrationType.notebook,
);
static const _OnboardingStep _step2 = _OnboardingStep(
pageNumber: '02',
emoji: '🎨',
titleLine1: '海量贴纸与模板',
titleLine2: '随心装饰',
description: '数百款手绘贴纸、精美模板和装饰素材,让你的日记独一无二',
illustrationType: _IllustrationType.stickers,
);
static const _OnboardingStep _step3 = _OnboardingStep(
pageNumber: '03',
emoji: '🌱',
titleLine1: '回顾成长轨迹',
titleLine2: '看见自己的变化',
description: '心情追踪、日历回顾、统计洞察 — 不仅仅是记录,更是了解自己的旅程',
illustrationType: _IllustrationType.growth,
);
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
Future<void> _completeOnboarding() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kOnboardingComplete, true);
if (mounted) {
context.go('/login');
}
}
void _goToPage(int index) {
_pageController.animateToPage(
index,
duration: DesignTokens.animSlow,
curve: DesignTokens.warmCurve,
);
}
void _nextSlide() {
if (_currentPage < _totalPages - 1) {
_goToPage(_currentPage + 1);
} else {
_completeOnboarding();
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Scaffold(
body: Stack(
children: [
// 页面内容
PageView(
controller: _pageController,
onPageChanged: (index) {
setState(() => _currentPage = index);
},
children: [
_buildSlidePage(context, _step1, isDark),
_buildSlidePage(context, _step2, isDark),
_buildSlidePage(context, _step3, isDark),
],
),
// 跳过按钮 — 右上角
Positioned(
top: MediaQuery.of(context).padding.top + 8,
right: 20,
child: Semantics(
button: true,
label: '跳过引导',
child: GestureDetector(
onTap: _completeOnboarding,
behavior: HitTestBehavior.opaque,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
constraints:
const BoxConstraints(minWidth: 44, minHeight: 44),
alignment: Alignment.center,
child: Text(
'跳过',
style: TextStyle(
fontFamily: AppTypography.bodyFont,
fontSize: 15,
fontWeight: FontWeight.w500,
color:
isDark ? AppColors.mutedDark : AppColors.mutedLight,
),
),
),
),
),
),
// 底部控制区
Positioned(
left: 0,
right: 0,
bottom: bottomPadding + 24,
child: _buildBottomControls(isDark),
),
],
),
);
}
/// 单页引导内容
Widget _buildSlidePage(
BuildContext context,
_OnboardingStep step,
bool isDark,
) {
final topPadding = MediaQuery.of(context).padding.top;
return Padding(
padding: EdgeInsets.only(
top: topPadding + 48,
left: 32,
right: 32,
bottom: MediaQuery.of(context).padding.bottom + 120,
),
child: Column(
children: [
// 页码水印
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 20),
child: Text(
step.pageNumber,
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 120,
fontWeight: FontWeight.w800,
color: (isDark ? AppColors.fgDark : AppColors.fgLight)
.withValues(alpha: 0.04),
height: 1,
),
),
),
),
const Spacer(flex: 1),
// 插图
_buildIllustration(step, isDark),
const SizedBox(height: 40),
// 标题(两行)
Text(
'${step.titleLine1}\n${step.titleLine2}',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 24,
fontWeight: FontWeight.w700,
color: isDark ? AppColors.fgDark : AppColors.fgLight,
height: 1.3,
),
),
const SizedBox(height: 12),
// 描述
SizedBox(
width: 280,
child: Text(
step.description,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.bodyFont,
fontSize: 15,
fontWeight: FontWeight.w400,
color:
isDark ? AppColors.mutedDark : AppColors.mutedLight,
height: 1.6,
),
),
),
const Spacer(flex: 2),
],
),
);
}
/// 插图圆形区域
Widget _buildIllustration(_OnboardingStep step, bool isDark) {
// 每个步骤不同的渐变背景
final gradientColors = _getIllustrationGradient(step.illustrationType, isDark);
return SizedBox(
width: 260,
height: 260,
child: Stack(
alignment: Alignment.center,
children: [
// 外围虚线旋转圆
_DashedCircleBorder(
size: 286,
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
// 渐变圆形背景
Container(
width: 260,
height: 260,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradientColors,
),
),
alignment: Alignment.center,
child: _buildIllustrationContent(step, isDark),
),
],
),
);
}
List<Color> _getIllustrationGradient(
_IllustrationType type,
bool isDark,
) {
switch (type) {
case _IllustrationType.notebook:
return [
isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight,
(isDark ? AppColors.accentDark : AppColors.accent)
.withValues(alpha: 0.3),
];
case _IllustrationType.stickers:
return [
isDark ? AppColors.secondarySoftDark : AppColors.secondarySoftLight,
(isDark ? AppColors.secondaryDark : AppColors.secondary)
.withValues(alpha: 0.15),
];
case _IllustrationType.growth:
return [
isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight,
(isDark ? AppColors.tertiaryDark : AppColors.tertiary)
.withValues(alpha: 0.2),
];
}
}
/// 插图内容 — emoji 大图标
Widget _buildIllustrationContent(_OnboardingStep step, bool isDark) {
return Text(
step.emoji,
style: const TextStyle(fontSize: 80),
);
}
/// 底部控制区 — 指示点 + 按钮
Widget _buildBottomControls(bool isDark) {
final isLastPage = _currentPage == _totalPages - 1;
final accentColor = isDark ? AppColors.accentDark : AppColors.accent;
final accentOnColor =
isDark ? const Color(0xFF1A1614) : const Color(0xFFFFF8F0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 指示点
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_totalPages, (index) {
final isActive = index == _currentPage;
return AnimatedContainer(
duration: DesignTokens.animNormal,
curve: DesignTokens.warmCurve,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 28 : 8,
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isActive
? accentColor
: (isDark
? AppColors.borderDark
: AppColors.borderLight),
),
);
}),
),
const SizedBox(height: 24),
// 下一步 / 开始使用 按钮
GestureDetector(
onTap: _nextSlide,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: accentColor,
borderRadius: AppRadius.pillBorder,
boxShadow: [
BoxShadow(
color: accentColor.withValues(alpha: 0.3),
offset: const Offset(0, 4),
blurRadius: 20,
),
],
),
alignment: Alignment.center,
child: Text(
isLastPage ? '开始使用' : '下一步',
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 20,
fontWeight: FontWeight.w600,
color: accentOnColor,
),
),
),
),
],
),
);
}
}
// ===== 数据模型 =====
/// 引导步骤数据
class _OnboardingStep {
final String pageNumber;
final String emoji;
final String titleLine1;
final String titleLine2;
final String description;
final _IllustrationType illustrationType;
const _OnboardingStep({
required this.pageNumber,
required this.emoji,
required this.titleLine1,
required this.titleLine2,
required this.description,
required this.illustrationType,
});
}
/// 插图类型
enum _IllustrationType {
notebook,
stickers,
growth,
}
// ===== 虚线圆圈装饰 =====
/// 外围虚线旋转圆(对齐 onboarding.html 的 dashed border
class _DashedCircleBorder extends StatefulWidget {
final double size;
final Color color;
const _DashedCircleBorder({
required this.size,
required this.color,
});
@override
State<_DashedCircleBorder> createState() => _DashedCircleBorderState();
}
class _DashedCircleBorderState extends State<_DashedCircleBorder>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 20),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 6.2832, // 2 * PI
child: child,
);
},
child: CustomPaint(
size: Size(widget.size, widget.size),
painter: _DashedCirclePainter(color: widget.color),
),
);
}
}
/// 虚线圆绘制器
class _DashedCirclePainter extends CustomPainter {
final Color color;
_DashedCirclePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2;
final cx = size.width / 2;
final cy = size.height / 2;
final radius = size.width / 2 - 1;
const dashCount = 36;
const dashAngle = 6.2832 / dashCount; // 2 * PI / dashCount
const dashLength = dashAngle * 0.6;
for (var i = 0; i < dashCount; i++) {
final startAngle = i * dashAngle;
canvas.drawArc(
Rect.fromCircle(center: Offset(cx, cy), radius: radius),
startAngle,
dashLength,
false,
paint,
);
}
}
@override
bool shouldRepaint(covariant _DashedCirclePainter oldDelegate) =>
color != oldDelegate.color;
}

View File

@@ -0,0 +1,694 @@
// 启动页 — 全屏渐变背景 + 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;
}