Files
nj/app/lib/features/onboarding/views/onboarding_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

496 lines
14 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.
// 引导页 — 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;
}