// 引导页 — 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 createState() => _OnboardingPageState(); } class _OnboardingPageState extends State { 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 _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 _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; }