前端修复: - 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个页面全部渲染正常
496 lines
14 KiB
Dart
496 lines
14 KiB
Dart
// 引导页 — 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;
|
||
}
|