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个页面全部渲染正常
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// 暖记色彩系统 — 7 色 × 浅色/深色模式
|
||||
// 设计规格 v1.2
|
||||
// 暖记色彩系统 — 完整设计 Token
|
||||
// 对齐 Open Design 原型稿 tokens.css
|
||||
// 浅色(暖阳) + 深色 + 松风主题色值
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -7,7 +8,7 @@ import 'package:flutter/material.dart';
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// ===== 浅色模式 =====
|
||||
// ===== 核心七色 · 浅色模式(暖阳 Warm Sun)=====
|
||||
|
||||
/// 奶油白背景 #FFF8F0
|
||||
static const Color bgLight = Color(0xFFFFF8F0);
|
||||
@@ -30,7 +31,45 @@ class AppColors {
|
||||
/// 玫瑰粉 #D4A5A5
|
||||
static const Color rose = Color(0xFFD4A5A5);
|
||||
|
||||
// ===== 深色模式 =====
|
||||
// ===== 中间色 · 浅色模式 =====
|
||||
|
||||
/// 次要文字 #5C4F47
|
||||
static const Color fg2Light = Color(0xFF5C4F47);
|
||||
|
||||
/// 柔和/禁用文字 #7A6D63
|
||||
static const Color mutedLight = Color(0xFF7A6D63);
|
||||
|
||||
/// 辅助说明文字 #8B7E74
|
||||
static const Color metaLight = Color(0xFF8B7E74);
|
||||
|
||||
/// 边框 #E8DDD4
|
||||
static const Color borderLight = Color(0xFFE8DDD4);
|
||||
|
||||
/// 柔和边框 #F0E8DF
|
||||
static const Color borderSoftLight = Color(0xFFF0E8DF);
|
||||
|
||||
/// 主色悬停 #D06A4F
|
||||
static const Color accentHover = Color(0xFFD06A4F);
|
||||
|
||||
/// 主色按下 #C05A3F
|
||||
static const Color accentActive = Color(0xFFC05A3F);
|
||||
|
||||
/// 主色辉光 rgba(224,122,95,0.25)
|
||||
static const Color accentGlow = Color(0x40E07A5F);
|
||||
|
||||
/// 温暖表面 #FFF3E6
|
||||
static const Color surfaceWarmLight = Color(0xFFFFF3E6);
|
||||
|
||||
/// 鼠尾草绿柔和 #D4E8DC
|
||||
static const Color secondarySoftLight = Color(0xFFD4E8DC);
|
||||
|
||||
/// 暖金柔和 #FBE8C8
|
||||
static const Color tertiarySoftLight = Color(0xFFFBE8C8);
|
||||
|
||||
/// 玫瑰柔和 #F0DADA
|
||||
static const Color roseSoftLight = Color(0xFFF0DADA);
|
||||
|
||||
// ===== 核心七色 · 深色模式 =====
|
||||
|
||||
/// 深色背景 #1A1614
|
||||
static const Color bgDark = Color(0xFF1A1614);
|
||||
@@ -53,20 +92,77 @@ class AppColors {
|
||||
/// 深色玫瑰 #C4A0A0
|
||||
static const Color roseDark = Color(0xFFC4A0A0);
|
||||
|
||||
// ===== 功能色 =====
|
||||
// ===== 中间色 · 深色模式 =====
|
||||
|
||||
/// 错误红
|
||||
static const Color error = Color(0xFFD32F2F);
|
||||
/// 深色次要文字 #C4B8AA
|
||||
static const Color fg2Dark = Color(0xFFC4B8AA);
|
||||
|
||||
/// 成功绿
|
||||
static const Color success = Color(0xFF4CAF50);
|
||||
/// 深色柔和文字 #9B8E82
|
||||
static const Color mutedDark = Color(0xFF9B8E82);
|
||||
|
||||
/// 警告黄
|
||||
static const Color warning = Color(0xFFFFA726);
|
||||
/// 深色辅助文字 #7A6D63
|
||||
static const Color metaDark = Color(0xFF7A6D63);
|
||||
|
||||
/// 信息蓝
|
||||
/// 深色边框 #3A3530
|
||||
static const Color borderDark = Color(0xFF3A3530);
|
||||
|
||||
/// 深色柔和边框 #302B26
|
||||
static const Color borderSoftDark = Color(0xFF302B26);
|
||||
|
||||
/// 深色主色悬停 #D07A64
|
||||
static const Color accentHoverDark = Color(0xFFD07A64);
|
||||
|
||||
/// 深色主色按下 #C06A54
|
||||
static const Color accentActiveDark = Color(0xFFC06A54);
|
||||
|
||||
/// 深色主色辉光
|
||||
static const Color accentGlowDark = Color(0x40E8907A);
|
||||
|
||||
/// 深色温暖表面 #332D28
|
||||
static const Color surfaceWarmDark = Color(0xFF332D28);
|
||||
|
||||
/// 深色鼠尾草绿柔和 #2A3A2E
|
||||
static const Color secondarySoftDark = Color(0xFF2A3A2E);
|
||||
|
||||
/// 深色暖金柔和 #302A1E
|
||||
static const Color tertiarySoftDark = Color(0xFF302A1E);
|
||||
|
||||
/// 深色玫瑰柔和 #3A2A2A
|
||||
static const Color roseSoftDark = Color(0xFF3A2A2A);
|
||||
|
||||
// ===== 功能色(对齐设计稿)=====
|
||||
|
||||
/// 错误/危险 #C93D3D
|
||||
static const Color error = Color(0xFFC93D3D);
|
||||
|
||||
/// 成功 #5A9E7E
|
||||
static const Color success = Color(0xFF5A9E7E);
|
||||
|
||||
/// 警告 #D4A843
|
||||
static const Color warning = Color(0xFFD4A843);
|
||||
|
||||
/// 信息 #42A5F5
|
||||
static const Color info = Color(0xFF42A5F5);
|
||||
|
||||
// ===== 功能色 · 深色模式 =====
|
||||
|
||||
/// 深色错误 #D94A4A
|
||||
static const Color errorDark = Color(0xFFD94A4A);
|
||||
|
||||
/// 深色成功 #6AAF8E
|
||||
static const Color successDark = Color(0xFF6AAF8E);
|
||||
|
||||
/// 深色警告 #C4A843
|
||||
static const Color warningDark = Color(0xFFC4A843);
|
||||
|
||||
// ===== 阴影色调 =====
|
||||
|
||||
/// 浅色阴影 rgba(45,36,32,...)
|
||||
static const Color shadowLight = Color(0xFF2D2420);
|
||||
|
||||
/// 深色阴影 rgba(0,0,0,...)
|
||||
static const Color shadowDark = Color(0xFF000000);
|
||||
|
||||
// ===== 心情颜色映射 =====
|
||||
|
||||
/// 心情 → 颜色
|
||||
@@ -78,17 +174,26 @@ class AppColors {
|
||||
'thinking': Color(0xFFB8A9C9),// 🤔 思考 — 淡紫
|
||||
};
|
||||
|
||||
/// 心情 → 日历背景色
|
||||
static const Map<String, Color> moodCellColors = {
|
||||
'happy': secondarySoftLight, // #D4E8DC
|
||||
'love': roseSoftLight, // #F0DADA
|
||||
'calm': tertiarySoftLight, // #FBE8C8
|
||||
'sad': Color(0xFFD4DDE8), // 灰蓝
|
||||
'tired': Color(0xFFE8E4E0), // 灰棕
|
||||
};
|
||||
|
||||
// ===== 浅色主题色彩方案 =====
|
||||
|
||||
static const _light = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: accent,
|
||||
onPrimary: Colors.white,
|
||||
onPrimary: Color(0xFFFFF8F0), // accent-on
|
||||
primaryContainer: Color(0xFFFFE0D6),
|
||||
onPrimaryContainer: fgLight,
|
||||
secondary: secondary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: Color(0xFFD4E8DC),
|
||||
secondaryContainer: secondarySoftLight,
|
||||
onSecondaryContainer: fgLight,
|
||||
tertiary: tertiary,
|
||||
onTertiary: fgLight,
|
||||
@@ -96,6 +201,9 @@ class AppColors {
|
||||
onError: Colors.white,
|
||||
surface: surfaceLight,
|
||||
onSurface: fgLight,
|
||||
onSurfaceVariant: mutedLight, // 次要文字
|
||||
outline: borderLight, // 边框
|
||||
outlineVariant: borderSoftLight, // 柔和边框
|
||||
surfaceContainerHighest: bgLight,
|
||||
);
|
||||
|
||||
@@ -104,19 +212,22 @@ class AppColors {
|
||||
static const _dark = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: accentDark,
|
||||
onPrimary: fgDark,
|
||||
onPrimary: Color(0xFF1A1614), // accent-on dark
|
||||
primaryContainer: Color(0xFF5A2E22),
|
||||
onPrimaryContainer: Color(0xFFFFD6CC),
|
||||
secondary: secondaryDark,
|
||||
onSecondary: fgDark,
|
||||
secondaryContainer: Color(0xFF2A4A38),
|
||||
secondaryContainer: secondarySoftDark,
|
||||
onSecondaryContainer: Color(0xFFD4E8DC),
|
||||
tertiary: tertiaryDark,
|
||||
onTertiary: fgDark,
|
||||
error: Color(0xFFEF5350),
|
||||
error: errorDark,
|
||||
onError: fgDark,
|
||||
surface: surfaceDark,
|
||||
onSurface: fgDark,
|
||||
onSurfaceVariant: mutedDark,
|
||||
outline: borderDark,
|
||||
outlineVariant: borderSoftDark,
|
||||
surfaceContainerHighest: bgDark,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// 暖记圆角系统
|
||||
// 设计规格: 10 / 16 / 22 / 28 / pill
|
||||
// 对齐 Open Design 原型稿 tokens.css: xs(8) / sm(10) / md(16) / lg(22) / xl(28) / pill
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppRadius {
|
||||
AppRadius._();
|
||||
|
||||
/// 极小圆角 8px — 小型元素
|
||||
static const double xs = 8;
|
||||
static BorderRadius get xsBorder => BorderRadius.circular(xs);
|
||||
|
||||
/// 小圆角 10px — 按钮、输入框
|
||||
static const double sm = 10;
|
||||
static BorderRadius get smBorder => BorderRadius.circular(sm);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 暖记主题入口 — 浅色/深色 ThemeData
|
||||
// 对齐 Open Design 原型稿设计系统
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -44,7 +45,7 @@ class AppTheme {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
color: colorScheme.surface,
|
||||
@@ -102,7 +103,7 @@ class AppTheme {
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedItemColor: colorScheme.primary,
|
||||
unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
unselectedItemColor: colorScheme.onSurfaceVariant,
|
||||
backgroundColor: colorScheme.surface,
|
||||
elevation: 8,
|
||||
),
|
||||
@@ -115,15 +116,15 @@ class AppTheme {
|
||||
side: BorderSide.none,
|
||||
),
|
||||
|
||||
// FloatingActionButton
|
||||
// FloatingActionButton — 珊瑚色圆形凸起
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
backgroundColor: isLight ? AppColors.accent : AppColors.accentDark,
|
||||
foregroundColor: isLight ? const Color(0xFFFFF8F0) : const Color(0xFF1A1614),
|
||||
shape: const CircleBorder(),
|
||||
elevation: 4,
|
||||
),
|
||||
|
||||
// Page transitions — 弹性曲线
|
||||
// Page transitions — 弹性曲线 cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||
pageTransitionsTheme: PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: _WarmCurveBuilder(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// 暖记字体系统 — Noto Sans SC + Caveat
|
||||
// 暖记字体系统 — Quicksand(标题) + Nunito(正文) + Caveat(手写装饰)
|
||||
// 对齐 Open Design 原型稿 tokens.css
|
||||
// 字体文件待下载,当前系统字体回退
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -6,99 +8,102 @@ class AppTypography {
|
||||
AppTypography._();
|
||||
|
||||
/// 字体族
|
||||
static const String displayFont = 'Caveat'; // 手写风格(标题装饰)
|
||||
static const String bodyFont = 'NotoSansSC'; // 正文(中文优先)
|
||||
static const String monoFont = 'JetBrains Mono'; // 等宽(暂用系统回退)
|
||||
static const String displayFont = 'Quicksand'; // 标题显示(待下载,系统回退 sans-serif)
|
||||
static const String bodyFont = 'Nunito'; // 正文(待下载,系统回退 sans-serif)
|
||||
static const String handwrittenFont = 'Caveat'; // 手写装饰(已有字体文件)
|
||||
static const String cjkFont = 'NotoSansSC'; // CJK 回退(已有字体文件)
|
||||
static const String monoFont = 'JetBrains Mono'; // 等宽(暂用系统回退)
|
||||
|
||||
/// 浅色主题文字主题
|
||||
/// 字号对齐设计稿: xs=11, sm=13, base=15, md=17, lg=20, xl=24, 2xl=30, 3xl=38, 4xl=48
|
||||
static TextTheme lightTextTheme() => TextTheme(
|
||||
// 大标题 — 手写风格
|
||||
// Display — Quicksand 标题(对应 text-3xl / text-4xl)
|
||||
displayLarge: TextStyle(
|
||||
fontFamily: displayFont,
|
||||
fontSize: 57,
|
||||
fontSize: 48,
|
||||
height: 1.12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontFamily: displayFont,
|
||||
fontSize: 45,
|
||||
fontSize: 38,
|
||||
height: 1.16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
displaySmall: TextStyle(
|
||||
fontFamily: displayFont,
|
||||
fontSize: 36,
|
||||
height: 1.22,
|
||||
fontSize: 30,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
// 标题 — 正文衬线
|
||||
// Headline — Nunito 标题(对应 text-xl / text-2xl)
|
||||
headlineLarge: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 32,
|
||||
fontSize: 30,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 28,
|
||||
fontSize: 24,
|
||||
height: 1.29,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 24,
|
||||
height: 1.33,
|
||||
fontSize: 20,
|
||||
height: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
// 副标题
|
||||
// Title — Nunito 副标题(对应 text-md / text-base)
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 22,
|
||||
height: 1.27,
|
||||
fontSize: 17,
|
||||
height: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 14,
|
||||
height: 1.43,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
// 正文
|
||||
// Body — Nunito 正文(对应 text-base / text-sm)
|
||||
bodyLarge: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 14,
|
||||
height: 1.43,
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 12,
|
||||
height: 1.33,
|
||||
fontSize: 13,
|
||||
height: 1.6,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
// 标签
|
||||
// Label — Nunito 标签(对应 text-base / text-sm / text-xs)
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 14,
|
||||
height: 1.43,
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 12,
|
||||
height: 1.33,
|
||||
fontSize: 13,
|
||||
height: 1.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
@@ -109,6 +114,6 @@ class AppTypography {
|
||||
),
|
||||
);
|
||||
|
||||
/// 深色主题文字主题
|
||||
/// 深色主题文字主题(与浅色共享字号,颜色由 ColorScheme 控制)
|
||||
static TextTheme darkTextTheme() => lightTextTheme();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
import '../bloc/achievement_bloc.dart';
|
||||
|
||||
@@ -129,7 +130,7 @@ class _AchievementProgressCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
color: colorScheme.primaryContainer,
|
||||
child: Padding(
|
||||
@@ -156,7 +157,7 @@ class _AchievementProgressCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 10,
|
||||
@@ -188,7 +189,7 @@ class _AchievementCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(
|
||||
color: achievement.isUnlocked
|
||||
? AppColors.accent.withValues(alpha: 0.4)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// 日历页面 — 月视图 + 日记列表
|
||||
// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
|
||||
// 对齐 Open Design 原型稿: 日历格子用心情颜色填充背景 + 月/周/时间轴切换
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
import '../bloc/calendar_bloc.dart';
|
||||
|
||||
/// 日历页面 — 月视图 + 选中日期的日记列表
|
||||
/// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
|
||||
class CalendarPage extends StatelessWidget {
|
||||
const CalendarPage({super.key});
|
||||
|
||||
@@ -80,27 +82,33 @@ class _CalendarView extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
|
||||
// 星期标题行
|
||||
_WeekdayHeader(colorScheme: colorScheme),
|
||||
|
||||
// 日历网格
|
||||
_CalendarGrid(
|
||||
month: loaded.focusedMonth,
|
||||
selectedDay: loaded.selectedDay,
|
||||
journalsByDate: loaded.journalsByDate,
|
||||
onDaySelected: (day) {
|
||||
context.read<CalendarBloc>().add(CalendarDaySelected(day));
|
||||
},
|
||||
// 视图模式切换
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: SegmentedButton<CalendarViewMode>(
|
||||
segments: const [
|
||||
ButtonSegment(value: CalendarViewMode.month, label: Text('月')),
|
||||
ButtonSegment(value: CalendarViewMode.week, label: Text('周')),
|
||||
ButtonSegment(value: CalendarViewMode.timeline, label: Text('时间轴')),
|
||||
],
|
||||
selected: {loaded.viewMode},
|
||||
onSelectionChanged: (modes) {
|
||||
context.read<CalendarBloc>()
|
||||
.add(CalendarViewModeChanged(modes.first));
|
||||
},
|
||||
style: ButtonStyle(
|
||||
visualDensity: VisualDensity.compact,
|
||||
textStyle: WidgetStatePropertyAll(theme.textTheme.labelSmall),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// 选中日期的日记列表
|
||||
Expanded(
|
||||
child: loaded.selectedDayJournals.isEmpty
|
||||
? _EmptyDayView(selectedDay: loaded.selectedDay)
|
||||
: _DayJournalList(journals: loaded.selectedDayJournals),
|
||||
),
|
||||
// 根据视图模式切换内容
|
||||
switch (loaded.viewMode) {
|
||||
CalendarViewMode.month => _MonthView(loaded: loaded),
|
||||
CalendarViewMode.week => _WeekView(loaded: loaded),
|
||||
CalendarViewMode.timeline => _TimelineView(loaded: loaded),
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -108,7 +116,295 @@ class _CalendarView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 月份导航栏
|
||||
// ===== 月视图 =====
|
||||
|
||||
class _MonthView extends StatelessWidget {
|
||||
const _MonthView({required this.loaded});
|
||||
|
||||
final CalendarLoaded loaded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// 星期标题行
|
||||
_WeekdayHeader(colorScheme: Theme.of(context).colorScheme),
|
||||
|
||||
// 日历网格
|
||||
_CalendarGrid(
|
||||
month: loaded.focusedMonth,
|
||||
selectedDay: loaded.selectedDay,
|
||||
journalsByDate: loaded.journalsByDate,
|
||||
onDaySelected: (day) {
|
||||
context.read<CalendarBloc>().add(CalendarDaySelected(day));
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// 选中日期的日记列表
|
||||
Expanded(
|
||||
child: loaded.selectedDayJournals.isEmpty
|
||||
? _EmptyDayView(selectedDay: loaded.selectedDay)
|
||||
: _DayJournalList(journals: loaded.selectedDayJournals),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 周视图 =====
|
||||
|
||||
class _WeekView extends StatelessWidget {
|
||||
const _WeekView({required this.loaded});
|
||||
|
||||
final CalendarLoaded loaded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// 计算选中日期所在周
|
||||
final selected = loaded.selectedDay;
|
||||
final startOfWeek = selected.subtract(Duration(days: selected.weekday - 1));
|
||||
|
||||
return Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 7 天条目
|
||||
...List.generate(7, (i) {
|
||||
final day = startOfWeek.add(Duration(days: i));
|
||||
final dayKey = DateTime(day.year, day.month, day.day);
|
||||
final journals = loaded.journalsByDate[dayKey] ?? [];
|
||||
final isToday = _isToday(day);
|
||||
final isSelected = _isSameDay(day, loaded.selectedDay);
|
||||
final hasEntry = journals.isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.read<CalendarBloc>().add(CalendarDaySelected(day));
|
||||
},
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: hasEntry
|
||||
? _getMoodBgColor(journals.first.mood.value)
|
||||
: colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(
|
||||
color: isToday
|
||||
? colorScheme.primary
|
||||
: colorScheme.outlineVariant,
|
||||
width: isToday ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 日期
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
_weekdayName(day.weekday),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${day.day}',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
|
||||
color: isToday ? colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 内容
|
||||
Expanded(
|
||||
child: hasEntry
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
journals.first.title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (journals.length > 1)
|
||||
Text(
|
||||
'还有 ${journals.length - 1} 篇日记',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
'无日记',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 心情 emoji
|
||||
if (hasEntry)
|
||||
Text(
|
||||
_moodEmoji(journals.first.mood),
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 时间轴视图 =====
|
||||
|
||||
class _TimelineView extends StatelessWidget {
|
||||
const _TimelineView({required this.loaded});
|
||||
|
||||
final CalendarLoaded loaded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// 获取当月所有日记,按日期排序
|
||||
final allJournals = loaded.journalsByDate.entries
|
||||
.expand((e) => e.value.map((j) => MapEntry(e.key, j)))
|
||||
.toList()
|
||||
..sort((a, b) => b.key.compareTo(a.key));
|
||||
|
||||
if (allJournals.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.timeline_outlined, size: 48,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 16),
|
||||
Text('本月还没有日记', style: theme.textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: allJournals.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = allJournals[index];
|
||||
final date = entry.key;
|
||||
final journal = entry.value;
|
||||
final isLast = index == allJournals.length - 1;
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 时间轴线
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Column(
|
||||
children: [
|
||||
// 圆点 + emoji
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: _getMoodBgColor(journal.mood.value),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 18)),
|
||||
),
|
||||
// 竖线
|
||||
if (!isLast)
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 2,
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 内容卡片
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${date.month}月${date.day}日',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
journal.title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// 日记内容通过 JournalElement 管理,日历视图仅显示标题
|
||||
// 后续可通过 elements 预览首段文字
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 月份导航栏 =====
|
||||
|
||||
class _MonthNavigator extends StatelessWidget {
|
||||
const _MonthNavigator({
|
||||
required this.month,
|
||||
@@ -127,7 +423,7 @@ class _MonthNavigator extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
@@ -160,7 +456,8 @@ class _MonthNavigator extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 星期标题行
|
||||
// ===== 星期标题行 =====
|
||||
|
||||
class _WeekdayHeader extends StatelessWidget {
|
||||
const _WeekdayHeader({required this.colorScheme});
|
||||
|
||||
@@ -191,7 +488,8 @@ class _WeekdayHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 日历网格 — 6行7列
|
||||
// ===== 日历网格 — 6行7列(带心情色彩背景)=====
|
||||
|
||||
class _CalendarGrid extends StatelessWidget {
|
||||
const _CalendarGrid({
|
||||
required this.month,
|
||||
@@ -207,8 +505,6 @@ class _CalendarGrid extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final today = DateTime.now();
|
||||
final days = _generateDays(month);
|
||||
|
||||
return Padding(
|
||||
@@ -217,19 +513,18 @@ class _CalendarGrid extends StatelessWidget {
|
||||
children: days.map((week) {
|
||||
return Row(
|
||||
children: week.map((dayInfo) {
|
||||
final dayKey = DateTime(
|
||||
dayInfo.date.year, dayInfo.date.month, dayInfo.date.day);
|
||||
final journals = journalsByDate[dayKey] ?? [];
|
||||
final moodValue = journals.isNotEmpty ? journals.first.mood.value : null;
|
||||
|
||||
return Expanded(
|
||||
child: _DayCell(
|
||||
dayInfo: dayInfo,
|
||||
isToday: dayInfo.date.year == today.year &&
|
||||
dayInfo.date.month == today.month &&
|
||||
dayInfo.date.day == today.day,
|
||||
isSelected: dayInfo.date.year == selectedDay.year &&
|
||||
dayInfo.date.month == selectedDay.month &&
|
||||
dayInfo.date.day == selectedDay.day,
|
||||
hasJournals: journalsByDate.containsKey(
|
||||
DateTime(dayInfo.date.year, dayInfo.date.month, dayInfo.date.day),
|
||||
),
|
||||
colorScheme: colorScheme,
|
||||
isToday: _isToday(dayInfo.date),
|
||||
isSelected: _isSameDay(dayInfo.date, selectedDay),
|
||||
hasJournals: journals.isNotEmpty,
|
||||
moodValue: moodValue,
|
||||
onTap: () => onDaySelected(dayInfo.date),
|
||||
),
|
||||
);
|
||||
@@ -240,35 +535,26 @@ class _CalendarGrid extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 生成当月日历数据(包含前后补齐)
|
||||
List<List<_DayInfo>> _generateDays(DateTime month) {
|
||||
final firstDay = DateTime(month.year, month.month, 1);
|
||||
// 周一为第一天:weekday 1=Mon...7=Sun
|
||||
final startOffset = firstDay.weekday - 1;
|
||||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||
|
||||
final allDays = <_DayInfo>[];
|
||||
|
||||
// 前面的空白
|
||||
for (var i = 0; i < startOffset; i++) {
|
||||
allDays.add(_DayInfo(date: firstDay.subtract(Duration(days: startOffset - i)), isCurrentMonth: false));
|
||||
}
|
||||
|
||||
// 当月日期
|
||||
for (var d = 1; d <= daysInMonth; d++) {
|
||||
allDays.add(_DayInfo(
|
||||
date: DateTime(month.year, month.month, d),
|
||||
isCurrentMonth: true,
|
||||
));
|
||||
allDays.add(_DayInfo(date: DateTime(month.year, month.month, d), isCurrentMonth: true));
|
||||
}
|
||||
|
||||
// 后面的补齐到完整行
|
||||
while (allDays.length % 7 != 0) {
|
||||
final last = allDays.last.date;
|
||||
allDays.add(_DayInfo(date: last.add(const Duration(days: 1)), isCurrentMonth: false));
|
||||
}
|
||||
|
||||
// 分周
|
||||
return List.generate(allDays.length ~/ 7, (i) => allDays.sublist(i * 7, (i + 1) * 7));
|
||||
}
|
||||
}
|
||||
@@ -279,14 +565,15 @@ class _DayInfo {
|
||||
final bool isCurrentMonth;
|
||||
}
|
||||
|
||||
/// 单日格子
|
||||
// ===== 单日格子(带心情色彩背景)=====
|
||||
|
||||
class _DayCell extends StatelessWidget {
|
||||
const _DayCell({
|
||||
required this.dayInfo,
|
||||
required this.isToday,
|
||||
required this.isSelected,
|
||||
required this.hasJournals,
|
||||
required this.colorScheme,
|
||||
required this.moodValue,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@@ -294,56 +581,63 @@ class _DayCell extends StatelessWidget {
|
||||
final bool isToday;
|
||||
final bool isSelected;
|
||||
final bool hasJournals;
|
||||
final ColorScheme colorScheme;
|
||||
final String? moodValue;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// 心情色彩背景
|
||||
final moodBg = hasJournals && moodValue != null
|
||||
? _getMoodBgColor(moodValue!)
|
||||
: Colors.transparent;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 44,
|
||||
height: 48,
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: moodBg != Colors.transparent
|
||||
? moodBg
|
||||
: isToday
|
||||
? colorScheme.primaryContainer
|
||||
: null,
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
border: isToday && !isSelected
|
||||
? Border.all(color: colorScheme.primary, width: 2)
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: isToday
|
||||
? colorScheme.primaryContainer
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${dayInfo.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal,
|
||||
color: !dayInfo.isCurrentMonth
|
||||
? colorScheme.onSurface.withValues(alpha: 0.3)
|
||||
: isSelected
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
Text(
|
||||
'${dayInfo.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal,
|
||||
color: !dayInfo.isCurrentMonth
|
||||
? colorScheme.onSurface.withValues(alpha: 0.3)
|
||||
: isSelected
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
// 日记指示点
|
||||
if (hasJournals)
|
||||
// 心情小圆点(仅在有日记但无背景色时显示)
|
||||
if (hasJournals && moodBg == Colors.transparent)
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimary
|
||||
: AppColors.accent,
|
||||
color: isSelected ? colorScheme.onPrimary : AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -353,7 +647,8 @@ class _DayCell extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 无日记的空状态
|
||||
// ===== 无日记的空状态 =====
|
||||
|
||||
class _EmptyDayView extends StatelessWidget {
|
||||
const _EmptyDayView({required this.selectedDay});
|
||||
|
||||
@@ -394,7 +689,8 @@ class _EmptyDayView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 日记列表
|
||||
// ===== 日记列表 =====
|
||||
|
||||
class _DayJournalList extends StatelessWidget {
|
||||
const _DayJournalList({required this.journals});
|
||||
|
||||
@@ -416,17 +712,16 @@ class _DayJournalList extends StatelessWidget {
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 心情图标
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
@@ -441,7 +736,6 @@ class _DayJournalList extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 标题和标签
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -478,14 +772,47 @@ class _DayJournalList extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _moodEmoji(Mood mood) {
|
||||
return switch (mood) {
|
||||
Mood.happy => '😊',
|
||||
Mood.calm => '😌',
|
||||
Mood.sad => '😢',
|
||||
Mood.angry => '😠',
|
||||
Mood.thinking => '🤔',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 辅助函数 =====
|
||||
|
||||
/// 获取心情对应的日历格子背景色
|
||||
Color _getMoodBgColor(String mood) {
|
||||
return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight;
|
||||
}
|
||||
|
||||
/// 心情 → emoji
|
||||
String _moodEmoji(Mood mood) {
|
||||
return switch (mood) {
|
||||
Mood.happy => '😊',
|
||||
Mood.calm => '😌',
|
||||
Mood.sad => '😢',
|
||||
Mood.angry => '😠',
|
||||
Mood.thinking => '🤔',
|
||||
};
|
||||
}
|
||||
|
||||
/// 是否是今天
|
||||
bool _isToday(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
return date.year == now.year && date.month == now.month && date.day == now.day;
|
||||
}
|
||||
|
||||
/// 是否同一天
|
||||
bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
/// 周几名称
|
||||
String _weekdayName(int weekday) {
|
||||
return switch (weekday) {
|
||||
1 => '周一',
|
||||
2 => '周二',
|
||||
3 => '周三',
|
||||
4 => '周四',
|
||||
5 => '周五',
|
||||
6 => '周六',
|
||||
7 => '周日',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
634
app/lib/features/calendar/views/monthly_page.dart
Normal file
634
app/lib/features/calendar/views/monthly_page.dart
Normal file
@@ -0,0 +1,634 @@
|
||||
// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记
|
||||
// 对齐 Open Design 原型稿 screens/monthly.html
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/core/theme/app_shadows.dart';
|
||||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||
|
||||
/// 月度概览页面
|
||||
class MonthlyPage extends StatefulWidget {
|
||||
const MonthlyPage({super.key});
|
||||
|
||||
@override
|
||||
State<MonthlyPage> createState() => _MonthlyPageState();
|
||||
}
|
||||
|
||||
class _MonthlyPageState extends State<MonthlyPage> {
|
||||
late DateTime _focusedMonth;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusedMonth = DateTime.now();
|
||||
}
|
||||
|
||||
void _goToPreviousMonth() {
|
||||
setState(() {
|
||||
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
|
||||
});
|
||||
}
|
||||
|
||||
void _goToNextMonth() {
|
||||
setState(() {
|
||||
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 月头部导航
|
||||
_MonthHeader(
|
||||
month: _focusedMonth,
|
||||
onPrevious: _goToPreviousMonth,
|
||||
onNext: _goToNextMonth,
|
||||
),
|
||||
// 可滚动内容区
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
// 心情色彩月历
|
||||
_MoodCalendar(month: _focusedMonth),
|
||||
const SizedBox(height: 20),
|
||||
// 月度统计 2x2
|
||||
const _MonthSummary(),
|
||||
const SizedBox(height: 20),
|
||||
// 精选日记
|
||||
const _Highlights(),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 月头部导航 =====
|
||||
|
||||
class _MonthHeader extends StatelessWidget {
|
||||
const _MonthHeader({
|
||||
required this.month,
|
||||
required this.onPrevious,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final DateTime month;
|
||||
final VoidCallback onPrevious;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final title = '${month.year}年${month.month}月';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 12, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
_NavButton(
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onTap: onPrevious,
|
||||
borderColor: colorScheme.outline,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_NavButton(
|
||||
icon: Icons.chevron_right_rounded,
|
||||
onTap: onNext,
|
||||
borderColor: colorScheme.outline,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆形导航按钮 (44px 触摸目标)
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.borderColor,
|
||||
required this.foregroundColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color borderColor;
|
||||
final Color foregroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: OutlinedButton(
|
||||
onPressed: onTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: const CircleBorder(),
|
||||
side: BorderSide(color: borderColor, width: 1.5),
|
||||
foregroundColor: foregroundColor,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
child: Icon(icon, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 心情色彩月历 =====
|
||||
|
||||
class _MoodCalendar extends StatelessWidget {
|
||||
const _MoodCalendar({required this.month});
|
||||
|
||||
final DateTime month;
|
||||
|
||||
// 心情类型
|
||||
static const _moodTypes = [
|
||||
'happy', 'calm', 'sad', 'tired', 'love',
|
||||
];
|
||||
|
||||
// 心情 → emoji
|
||||
static const _moodEmojis = <String, String>{
|
||||
'happy': '😊',
|
||||
'calm': '😐',
|
||||
'sad': '😢',
|
||||
'tired': '😐',
|
||||
'love': '😡',
|
||||
};
|
||||
|
||||
// 心情 → 背景色
|
||||
static const _moodBgColors = <String, Color>{
|
||||
'happy': AppColors.secondarySoftLight,
|
||||
'love': AppColors.roseSoftLight,
|
||||
'calm': AppColors.tertiarySoftLight,
|
||||
'sad': Color(0xFFD4DDE8),
|
||||
'tired': Color(0xFFE8E4E0),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final now = DateTime.now();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 星期标题行
|
||||
_WeekdayRow(colorScheme: colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
// 7列网格
|
||||
_buildGrid(context, now),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrid(BuildContext context, DateTime now) {
|
||||
final firstDay = DateTime(month.year, month.month, 1);
|
||||
// 周日=0 → 偏移量; weekday 返回 1(周一)..7(周日)
|
||||
final startOffset = firstDay.weekday % 7; // 周日开头
|
||||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||
|
||||
final cells = <Widget>[];
|
||||
|
||||
// 空白填充
|
||||
for (var i = 0; i < startOffset; i++) {
|
||||
cells.add(const SizedBox.shrink());
|
||||
}
|
||||
|
||||
// 模拟心情数据(确定性伪随机,同一天固定同心情)
|
||||
final rng = _SeededRandom(month.year * 100 + month.month);
|
||||
|
||||
for (var d = 1; d <= daysInMonth; d++) {
|
||||
final isToday = now.year == month.year &&
|
||||
now.month == month.month &&
|
||||
now.day == d;
|
||||
|
||||
// 每天随机一个心情
|
||||
final moodIndex = rng.nextInt(5);
|
||||
final mood = _moodTypes[moodIndex];
|
||||
final bgColor = _moodBgColors[mood] ?? Colors.transparent;
|
||||
final emoji = _moodEmojis[mood] ?? '';
|
||||
|
||||
cells.add(
|
||||
_MoodCell(
|
||||
day: d,
|
||||
emoji: emoji,
|
||||
bgColor: bgColor,
|
||||
isToday: isToday,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.count(
|
||||
crossAxisCount: 7,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 3,
|
||||
crossAxisSpacing: 3,
|
||||
childAspectRatio: 1,
|
||||
children: cells,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单确定性伪随机数生成器(仅用于模拟数据)
|
||||
class _SeededRandom {
|
||||
_SeededRandom(int seed) : _state = seed;
|
||||
int _state;
|
||||
|
||||
int nextInt(int max) {
|
||||
_state = (_state * 1103515245 + 12345) & 0x7FFFFFFF;
|
||||
return _state % max;
|
||||
}
|
||||
}
|
||||
|
||||
/// 星期标题行
|
||||
class _WeekdayRow extends StatelessWidget {
|
||||
const _WeekdayRow({required this.colorScheme});
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return Row(
|
||||
children: weekdays.map((day) {
|
||||
return Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
day,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个心情格子
|
||||
class _MoodCell extends StatelessWidget {
|
||||
const _MoodCell({
|
||||
required this.day,
|
||||
required this.emoji,
|
||||
required this.bgColor,
|
||||
required this.isToday,
|
||||
});
|
||||
|
||||
final int day;
|
||||
final String emoji;
|
||||
final Color bgColor;
|
||||
final bool isToday;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// TODO: 选择日期,跳转详情
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: isToday
|
||||
? Border.all(color: AppColors.accent, width: 2)
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$day',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: isToday ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 月度统计 2x2 =====
|
||||
|
||||
class _MonthSummary extends StatelessWidget {
|
||||
const _MonthSummary();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'本月总结',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.2,
|
||||
children: [
|
||||
_StatCard(
|
||||
icon: '📝',
|
||||
value: '28',
|
||||
label: '日记篇数',
|
||||
bgColor: AppColors.tertiarySoftLight,
|
||||
valueColor: const Color(0xFFB8860B),
|
||||
),
|
||||
_StatCard(
|
||||
icon: '🔥',
|
||||
value: '12',
|
||||
label: '最长连续',
|
||||
bgColor: AppColors.secondarySoftLight,
|
||||
valueColor: const Color(0xFF2D7D46),
|
||||
),
|
||||
_StatCard(
|
||||
icon: '😊',
|
||||
value: '72%',
|
||||
label: '好心情占比',
|
||||
bgColor: AppColors.roseSoftLight,
|
||||
valueColor: const Color(0xFF9B4D4D),
|
||||
),
|
||||
_StatCard(
|
||||
icon: '📸',
|
||||
value: '18',
|
||||
label: '照片数量',
|
||||
bgColor: const Color(0xFFD4DDE8),
|
||||
valueColor: const Color(0xFF4A6B8A),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单张统计小卡片
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.bgColor,
|
||||
required this.valueColor,
|
||||
});
|
||||
|
||||
final String icon;
|
||||
final String value;
|
||||
final String label;
|
||||
final Color bgColor;
|
||||
final Color valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(icon, style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 精选日记 =====
|
||||
|
||||
class _Highlights extends StatelessWidget {
|
||||
const _Highlights();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// 模拟精选日记数据
|
||||
const highlights = [
|
||||
(
|
||||
emoji: '😊',
|
||||
emojiBg: AppColors.roseSoftLight,
|
||||
date: '5月14日',
|
||||
title: '和朋友聚餐的欢乐时光',
|
||||
badge: '最佳心情',
|
||||
badgeBg: AppColors.roseSoftLight,
|
||||
badgeFg: Color(0xFF9B4D4D),
|
||||
),
|
||||
(
|
||||
emoji: '⭐',
|
||||
emojiBg: AppColors.tertiarySoftLight,
|
||||
date: '5月21日',
|
||||
title: '完成了第一个小目标',
|
||||
badge: '里程碑',
|
||||
badgeBg: AppColors.tertiarySoftLight,
|
||||
badgeFg: Color(0xFFB8860B),
|
||||
),
|
||||
(
|
||||
emoji: '📚',
|
||||
emojiBg: AppColors.secondarySoftLight,
|
||||
date: '5月28日',
|
||||
title: '期末考试结束',
|
||||
badge: '最详尽记录',
|
||||
badgeBg: AppColors.secondarySoftLight,
|
||||
badgeFg: Color(0xFF2D7D46),
|
||||
),
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'本月精选',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...highlights.map((item) {
|
||||
return _HighlightCard(
|
||||
emoji: item.emoji,
|
||||
emojiBg: item.emojiBg,
|
||||
date: item.date,
|
||||
title: item.title,
|
||||
badge: item.badge,
|
||||
badgeBg: item.badgeBg,
|
||||
badgeFg: item.badgeFg,
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单张精选日记卡片
|
||||
class _HighlightCard extends StatelessWidget {
|
||||
const _HighlightCard({
|
||||
required this.emoji,
|
||||
required this.emojiBg,
|
||||
required this.date,
|
||||
required this.title,
|
||||
required this.badge,
|
||||
required this.badgeBg,
|
||||
required this.badgeFg,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final Color emojiBg;
|
||||
final String date;
|
||||
final String title;
|
||||
final String badge;
|
||||
final Color badgeBg;
|
||||
final Color badgeFg;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 48x48 emoji 圆圈
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: emojiBg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 24)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 标题 + 日期
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// badge pill
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Text(
|
||||
badge,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: badgeFg,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
573
app/lib/features/calendar/views/weekly_page.dart
Normal file
573
app/lib/features/calendar/views/weekly_page.dart
Normal file
@@ -0,0 +1,573 @@
|
||||
// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片
|
||||
// 对齐 Open Design 原型稿 screens/weekly.html
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/core/theme/app_shadows.dart';
|
||||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||
|
||||
/// 周概览页面
|
||||
class WeeklyPage extends StatefulWidget {
|
||||
const WeeklyPage({super.key});
|
||||
|
||||
@override
|
||||
State<WeeklyPage> createState() => _WeeklyPageState();
|
||||
}
|
||||
|
||||
class _WeeklyPageState extends State<WeeklyPage> {
|
||||
late DateTime _focusedWeekStart;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final now = DateTime.now();
|
||||
_focusedWeekStart = now.subtract(Duration(days: now.weekday - 1));
|
||||
}
|
||||
|
||||
void _goToPreviousWeek() {
|
||||
setState(() {
|
||||
_focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7));
|
||||
});
|
||||
}
|
||||
|
||||
void _goToNextWeek() {
|
||||
setState(() {
|
||||
_focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 周头部导航
|
||||
_WeekHeader(
|
||||
weekStart: _focusedWeekStart,
|
||||
onPrevious: _goToPreviousWeek,
|
||||
onNext: _goToNextWeek,
|
||||
),
|
||||
// 可滚动内容区
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
// 7天条目
|
||||
_WeekStrip(weekStart: _focusedWeekStart),
|
||||
const SizedBox(height: 20),
|
||||
// 本周总结
|
||||
const _WeekSummary(),
|
||||
const SizedBox(height: 20),
|
||||
// 每日日记卡片
|
||||
..._buildDayCards(theme, colorScheme),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) {
|
||||
// 模拟数据: 3 张日记卡片
|
||||
return [
|
||||
_DayCard(
|
||||
weekday: '周日',
|
||||
date: '5月31日',
|
||||
moodEmoji: '😊',
|
||||
weatherEmoji: '☀️',
|
||||
body:
|
||||
'今天下午去图书馆自习,阳光从窗外洒进来,暖暖的。喝了抹茶拿铁,虽然期末压力大但看到窗外的樱花还在开,觉得一切都会好的。',
|
||||
tags: const [
|
||||
('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)),
|
||||
('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)),
|
||||
],
|
||||
photoEmoji: '📚',
|
||||
),
|
||||
_DayCard(
|
||||
weekday: '周六',
|
||||
date: '5月30日',
|
||||
moodEmoji: '😊',
|
||||
weatherEmoji: '🌤',
|
||||
body:
|
||||
'今天在图书馆自习,窗外的阳光洒进来,暖暖的。复习了高数第三章,做了两套模拟题感觉还不错。',
|
||||
tags: const [
|
||||
('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)),
|
||||
],
|
||||
photoEmoji: null,
|
||||
),
|
||||
_DayCard(
|
||||
weekday: '周五',
|
||||
date: '5月29日',
|
||||
moodEmoji: '😊',
|
||||
weatherEmoji: '☀️',
|
||||
body:
|
||||
'考完试和舍友们去吃了火锅庆祝,大家都好开心,聊了很多有趣的事。这学期终于结束了!',
|
||||
tags: const [
|
||||
('朋友', AppColors.roseSoftLight, Color(0xFF9B4D4D)),
|
||||
('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)),
|
||||
],
|
||||
photoEmoji: '🍲',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 周头部导航 =====
|
||||
|
||||
class _WeekHeader extends StatelessWidget {
|
||||
const _WeekHeader({
|
||||
required this.weekStart,
|
||||
required this.onPrevious,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final DateTime weekStart;
|
||||
final VoidCallback onPrevious;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// 格式化: "2026年6月 第1周"
|
||||
final monthNames = [
|
||||
'', '1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月',
|
||||
];
|
||||
final title =
|
||||
'${weekStart.year}年${monthNames[weekStart.month]} 第${_weekOfMonth(weekStart)}周';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 12, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 左右箭头导航按钮
|
||||
_NavButton(
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onTap: onPrevious,
|
||||
borderColor: colorScheme.outline,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_NavButton(
|
||||
icon: Icons.chevron_right_rounded,
|
||||
onTap: onNext,
|
||||
borderColor: colorScheme.outline,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 计算是当月第几周
|
||||
int _weekOfMonth(DateTime date) {
|
||||
final firstDay = DateTime(date.year, date.month, 1);
|
||||
final offset = firstDay.weekday - 1;
|
||||
return ((date.day + offset) / 7).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆形导航按钮 (44px 触摸目标)
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.borderColor,
|
||||
required this.foregroundColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color borderColor;
|
||||
final Color foregroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: OutlinedButton(
|
||||
onPressed: onTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: const CircleBorder(),
|
||||
side: BorderSide(color: borderColor, width: 1.5),
|
||||
foregroundColor: foregroundColor,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
child: Icon(icon, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 7天条目 =====
|
||||
|
||||
class _WeekStrip extends StatelessWidget {
|
||||
const _WeekStrip({required this.weekStart});
|
||||
|
||||
final DateTime weekStart;
|
||||
|
||||
// 模拟数据: 每天的心情 emoji
|
||||
static const _mockMoods = ['😊', '😐', '😊', '😊', '😊', '😊', '😊'];
|
||||
static const _weekNames = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
static const _hasEntry = [true, true, true, true, true, true, true];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final now = DateTime.now();
|
||||
|
||||
return Row(
|
||||
children: List.generate(7, (i) {
|
||||
final day = weekStart.add(Duration(days: i));
|
||||
final isToday = day.year == now.year &&
|
||||
day.month == now.month &&
|
||||
day.day == now.day;
|
||||
final hasEntry = _hasEntry[i];
|
||||
final moodEmoji = _mockMoods[i];
|
||||
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
// TODO: 选择某天后刷新下方日记卡片
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? AppColors.accent : null,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 周名
|
||||
Text(
|
||||
_weekNames[i],
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isToday
|
||||
? const Color(0xFFFFF8F0).withValues(alpha: 0.85)
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 日期数字
|
||||
Text(
|
||||
'${day.day}',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isToday
|
||||
? const Color(0xFFFFF8F0) // accent-on
|
||||
: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 心情 emoji
|
||||
Text(moodEmoji, style: const TextStyle(fontSize: 16)),
|
||||
// 有日记: 日期下方4px小圆点
|
||||
if (hasEntry && !isToday)
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
if (hasEntry && isToday)
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFFFFF8F0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 本周总结卡片 =====
|
||||
|
||||
class _WeekSummary extends StatelessWidget {
|
||||
const _WeekSummary();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'本周总结',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 3个统计数字
|
||||
Row(
|
||||
children: [
|
||||
_SummaryItem(
|
||||
value: '6',
|
||||
label: '记录天数',
|
||||
valueColor: AppColors.accent,
|
||||
),
|
||||
_SummaryItem(
|
||||
value: '7',
|
||||
label: '日记篇数',
|
||||
valueColor: AppColors.secondary,
|
||||
),
|
||||
_SummaryItem(
|
||||
value: '12',
|
||||
label: '使用贴纸',
|
||||
valueColor: AppColors.tertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 心情分布条
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF5B7DB1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个统计项
|
||||
class _SummaryItem extends StatelessWidget {
|
||||
const _SummaryItem({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.valueColor,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
final Color valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 每日日记卡片 =====
|
||||
|
||||
class _DayCard extends StatelessWidget {
|
||||
const _DayCard({
|
||||
required this.weekday,
|
||||
required this.date,
|
||||
required this.moodEmoji,
|
||||
required this.weatherEmoji,
|
||||
required this.body,
|
||||
required this.tags,
|
||||
this.photoEmoji,
|
||||
});
|
||||
|
||||
final String weekday;
|
||||
final String date;
|
||||
final String moodEmoji;
|
||||
final String weatherEmoji;
|
||||
final String body;
|
||||
final List<(String, Color, Color)> tags; // (label, bg, fg)
|
||||
final String? photoEmoji;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部: 日期 + 心情/weather emoji
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'$weekday · $date',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$moodEmoji $weatherEmoji',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 正文预览 (3行截断)
|
||||
Text(
|
||||
body,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.6,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// 标签 pills
|
||||
if (tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: tags.map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: tag.$2,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Text(
|
||||
tag.$1,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: tag.$3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
// 照片占位
|
||||
if (photoEmoji != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.smBorder,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.6),
|
||||
colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(photoEmoji!, style: const TextStyle(fontSize: 24)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
import '../bloc/home_bloc.dart';
|
||||
@@ -38,11 +40,17 @@ class _HomeView extends StatelessWidget {
|
||||
title: Text(
|
||||
'暖记',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: 'Caveat',
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 搜索按钮(设计稿要求右上角圆形搜索按钮)
|
||||
IconButton(
|
||||
onPressed: () => context.push('/discover'),
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: '搜索',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/stickers'),
|
||||
icon: const Icon(Icons.emoji_emotions_outlined),
|
||||
@@ -136,7 +144,7 @@ class _QuickMoodCard extends StatelessWidget {
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -152,7 +160,7 @@ class _QuickMoodCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiary.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
),
|
||||
child: Text('🔥 连续 $streakDays 天', style: theme.textTheme.labelSmall),
|
||||
),
|
||||
@@ -161,7 +169,7 @@ class _QuickMoodCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondary.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
),
|
||||
child: Text('✅ 今日已写', style: theme.textTheme.labelSmall),
|
||||
),
|
||||
@@ -221,12 +229,12 @@ class _JournalList extends StatelessWidget {
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
import '../bloc/mood_bloc.dart';
|
||||
@@ -129,7 +130,7 @@ class _StatsOverviewCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
color: colorScheme.primaryContainer,
|
||||
child: Padding(
|
||||
@@ -218,7 +219,7 @@ class _MoodDistributionChart extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -322,7 +323,7 @@ class _StreakCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
color: AppColors.tertiary.withValues(alpha: 0.15),
|
||||
child: Padding(
|
||||
|
||||
495
app/lib/features/onboarding/views/onboarding_page.dart
Normal file
495
app/lib/features/onboarding/views/onboarding_page.dart
Normal 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;
|
||||
}
|
||||
694
app/lib/features/onboarding/views/splash_page.dart
Normal file
694
app/lib/features/onboarding/views/splash_page.dart
Normal 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;
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart';
|
||||
import 'package:nuanji_app/data/models/user.dart';
|
||||
|
||||
/// 个人中心页面
|
||||
class ProfilePage extends StatelessWidget {
|
||||
@@ -25,7 +27,7 @@ class ProfilePage extends StatelessWidget {
|
||||
// 用户头像卡片
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||||
color: colorScheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -132,9 +134,9 @@ class ProfilePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _roleLabel(dynamic role) {
|
||||
String _roleLabel(UserRoleType? role) {
|
||||
if (role == null) return '未选择角色';
|
||||
return switch (role.name) {
|
||||
return switch (role.code) {
|
||||
'teacher' => '老师',
|
||||
'student' => '学生',
|
||||
'parent' => '家长',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart';
|
||||
|
||||
/// 设置页面
|
||||
@@ -92,7 +93,7 @@ class _ThemeSelectorCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -165,7 +166,7 @@ class _AboutCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
@@ -180,7 +181,7 @@ class _AboutCard extends StatelessWidget {
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('📝', style: TextStyle(fontSize: 28)),
|
||||
@@ -294,7 +295,7 @@ class _LegalCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
import '../bloc/sticker_bloc.dart';
|
||||
|
||||
@@ -135,7 +136,7 @@ class _StickerPackCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
@@ -144,7 +145,7 @@ class _StickerPackCard extends StatelessWidget {
|
||||
SnackBar(content: Text('打开贴纸包: ${pack.name}')),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -156,7 +157,7 @@ class _StickerPackCard extends StatelessWidget {
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/data/repositories/class_repository.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
import '../../class_/bloc/class_bloc.dart';
|
||||
@@ -241,12 +242,12 @@ class _ActionCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
import '../bloc/template_bloc.dart';
|
||||
|
||||
@@ -133,7 +134,7 @@ class _TemplateCard extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
@@ -141,7 +142,7 @@ class _TemplateCard extends StatelessWidget {
|
||||
// 使用模板创建日记
|
||||
context.push('/editor?template=${template.id}');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -160,7 +161,7 @@ class _TemplateCard extends StatelessWidget {
|
||||
AppColors.tertiary.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// 暖记响应式骨架 — 三级自适应布局
|
||||
// 手机: 底部 TabBar | 平板: 侧边导航 | 桌面: 三栏
|
||||
// 手机: 底部 TabBar + 中心凸起写日记按钮 | 平板: 侧边导航 | 桌面: 三栏
|
||||
// Web 平台始终使用底部 TabBar(移动端布局)以保证导航交互正常
|
||||
// 对齐 Open Design 原型稿: 首页/日历/写日记(FAB)/发现/我的
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/constants/breakpoints.dart';
|
||||
import '../core/theme/app_colors.dart';
|
||||
import '../core/theme/app_typography.dart';
|
||||
import '../core/theme/app_radius.dart';
|
||||
|
||||
/// 导航项数量(不含中心 FAB)
|
||||
const int kNavItemCount = 4;
|
||||
|
||||
/// 暖记自适应 Scaffold
|
||||
class ResponsiveScaffold extends StatefulWidget {
|
||||
@@ -13,15 +20,23 @@ class ResponsiveScaffold extends StatefulWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.body,
|
||||
this.floatingActionButton,
|
||||
this.onCenterButtonPressed,
|
||||
this.appBarTitle,
|
||||
this.secondaryBody,
|
||||
});
|
||||
|
||||
/// 当前选中的导航索引 (0-3,对应首页/日历/发现/我的)
|
||||
final int selectedIndex;
|
||||
|
||||
/// 导航项被选中回调
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
/// 主内容区
|
||||
final Widget body;
|
||||
final Widget? floatingActionButton;
|
||||
|
||||
/// 中心写日记按钮回调
|
||||
final VoidCallback? onCenterButtonPressed;
|
||||
|
||||
final String? appBarTitle;
|
||||
final Widget? secondaryBody;
|
||||
|
||||
@@ -34,7 +49,6 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
// Web 平台:始终使用移动端底部 TabBar 布局
|
||||
// 原因:Web 上 NavigationRail 点击事件可能被 Flutter CanvasKit 拦截
|
||||
final deviceType = kIsWeb
|
||||
? DeviceType.mobile
|
||||
: Breakpoints.getDeviceType(width);
|
||||
@@ -45,7 +59,7 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
body: widget.body,
|
||||
floatingActionButton: widget.floatingActionButton,
|
||||
onCenterButtonPressed: widget.onCenterButtonPressed,
|
||||
appBarTitle: widget.appBarTitle,
|
||||
);
|
||||
case DeviceType.tablet:
|
||||
@@ -53,6 +67,7 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
body: widget.body,
|
||||
onCenterButtonPressed: widget.onCenterButtonPressed,
|
||||
appBarTitle: widget.appBarTitle,
|
||||
);
|
||||
case DeviceType.desktop:
|
||||
@@ -60,6 +75,7 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
body: widget.body,
|
||||
onCenterButtonPressed: widget.onCenterButtonPressed,
|
||||
secondaryBody: widget.secondaryBody,
|
||||
appBarTitle: widget.appBarTitle,
|
||||
);
|
||||
@@ -67,32 +83,28 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 导航项定义 =====
|
||||
// ===== 导航项定义(4 项,中间由 FAB 占位)=====
|
||||
|
||||
final _navItems = [
|
||||
const _navItems = [
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
selectedIcon: const Icon(Icons.home),
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: '首页',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.calendar_month_outlined),
|
||||
selectedIcon: const Icon(Icons.calendar_month),
|
||||
icon: Icon(Icons.calendar_month_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_month),
|
||||
label: '日历',
|
||||
),
|
||||
// 索引 2: 中心 FAB 占位(写日记)
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.auto_awesome_outlined),
|
||||
selectedIcon: const Icon(Icons.auto_awesome),
|
||||
label: '心情',
|
||||
icon: Icon(Icons.explore_outlined),
|
||||
selectedIcon: Icon(Icons.explore),
|
||||
label: '发现',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.search_outlined),
|
||||
selectedIcon: const Icon(Icons.search),
|
||||
label: '搜索',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.person_outline),
|
||||
selectedIcon: const Icon(Icons.person),
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我的',
|
||||
),
|
||||
];
|
||||
@@ -109,14 +121,9 @@ const _railItems = [
|
||||
label: Text('日历'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.auto_awesome_outlined),
|
||||
selectedIcon: Icon(Icons.auto_awesome),
|
||||
label: Text('心情'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.search_outlined),
|
||||
selectedIcon: Icon(Icons.search),
|
||||
label: Text('搜索'),
|
||||
icon: Icon(Icons.explore_outlined),
|
||||
selectedIcon: Icon(Icons.explore),
|
||||
label: Text('发现'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
@@ -125,21 +132,42 @@ const _railItems = [
|
||||
),
|
||||
];
|
||||
|
||||
// ===== 手机布局 — 底部 TabBar =====
|
||||
// ===== 中心写日记 FAB 按钮 =====
|
||||
|
||||
class _CenterFabButton extends StatelessWidget {
|
||||
const _CenterFabButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton(
|
||||
heroTag: 'center_write',
|
||||
onPressed: onPressed,
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: const Color(0xFFFFF8F0),
|
||||
elevation: 4,
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(Icons.edit_rounded, size: 28),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 手机布局 — 底部 TabBar + 中心凸起 FAB =====
|
||||
|
||||
class _MobileLayout extends StatelessWidget {
|
||||
const _MobileLayout({
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.body,
|
||||
this.floatingActionButton,
|
||||
this.onCenterButtonPressed,
|
||||
this.appBarTitle,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final Widget body;
|
||||
final Widget? floatingActionButton;
|
||||
final VoidCallback? onCenterButtonPressed;
|
||||
final String? appBarTitle;
|
||||
|
||||
@override
|
||||
@@ -149,11 +177,138 @@ class _MobileLayout extends StatelessWidget {
|
||||
? AppBar(title: Text(appBarTitle!))
|
||||
: null,
|
||||
body: body,
|
||||
floatingActionButton: floatingActionButton,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
floatingActionButton: onCenterButtonPressed != null
|
||||
? _CenterFabButton(onPressed: onCenterButtonPressed!)
|
||||
: null,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
bottomNavigationBar: _BottomNavBar(
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: _navItems,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义底部导航栏 — 支持中心凹槽
|
||||
class _BottomNavBar extends StatelessWidget {
|
||||
const _BottomNavBar({
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return BottomAppBar(
|
||||
shape: const CircularNotchedRectangle(),
|
||||
padding: EdgeInsets.zero,
|
||||
height: 64,
|
||||
color: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
// 首页
|
||||
_NavItem(
|
||||
icon: Icons.home_outlined,
|
||||
activeIcon: Icons.home,
|
||||
label: '首页',
|
||||
isSelected: selectedIndex == 0,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(0),
|
||||
),
|
||||
// 日历
|
||||
_NavItem(
|
||||
icon: Icons.calendar_month_outlined,
|
||||
activeIcon: Icons.calendar_month,
|
||||
label: '日历',
|
||||
isSelected: selectedIndex == 1,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(1),
|
||||
),
|
||||
// 中间留给 FAB 凹槽 — 占位
|
||||
const SizedBox(width: 48),
|
||||
// 发现
|
||||
_NavItem(
|
||||
icon: Icons.explore_outlined,
|
||||
activeIcon: Icons.explore,
|
||||
label: '发现',
|
||||
isSelected: selectedIndex == 2,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(2),
|
||||
),
|
||||
// 我的
|
||||
_NavItem(
|
||||
icon: Icons.person_outline,
|
||||
activeIcon: Icons.person,
|
||||
label: '我的',
|
||||
isSelected: selectedIndex == 3,
|
||||
color: colorScheme.primary,
|
||||
inactiveColor: colorScheme.onSurfaceVariant,
|
||||
onTap: () => onDestinationSelected(3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个底部导航项
|
||||
class _NavItem extends StatelessWidget {
|
||||
const _NavItem({
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.color,
|
||||
required this.inactiveColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final IconData activeIcon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final Color color;
|
||||
final Color inactiveColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox(
|
||||
width: 64,
|
||||
height: 56,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? activeIcon : icon,
|
||||
size: 24,
|
||||
color: isSelected ? color : inactiveColor,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? color : inactiveColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -166,16 +321,20 @@ class _TabletLayout extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.body,
|
||||
this.onCenterButtonPressed,
|
||||
this.appBarTitle,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final Widget body;
|
||||
final VoidCallback? onCenterButtonPressed;
|
||||
final String? appBarTitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: appBarTitle != null
|
||||
? AppBar(title: Text(appBarTitle!))
|
||||
@@ -187,11 +346,25 @@ class _TabletLayout extends StatelessWidget {
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: _railItems,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Icon(
|
||||
Icons.edit_note_rounded,
|
||||
size: 32,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_note_rounded,
|
||||
size: 32,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (onCenterButtonPressed != null)
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'rail_write',
|
||||
onPressed: onCenterButtonPressed,
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: const Color(0xFFFFF8F0),
|
||||
child: const Icon(Icons.edit_rounded, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -210,6 +383,7 @@ class _DesktopLayout extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.body,
|
||||
this.onCenterButtonPressed,
|
||||
this.secondaryBody,
|
||||
this.appBarTitle,
|
||||
});
|
||||
@@ -217,11 +391,14 @@ class _DesktopLayout extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final Widget body;
|
||||
final VoidCallback? onCenterButtonPressed;
|
||||
final Widget? secondaryBody;
|
||||
final String? appBarTitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: appBarTitle != null
|
||||
? AppBar(title: Text(appBarTitle!))
|
||||
@@ -234,23 +411,45 @@ class _DesktopLayout extends StatelessWidget {
|
||||
destinations: _railItems,
|
||||
extended: true,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_note_rounded,
|
||||
size: 32,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_note_rounded,
|
||||
size: 32,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'暖记',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'暖记',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: 'Caveat',
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
const SizedBox(height: 12),
|
||||
if (onCenterButtonPressed != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onCenterButtonPressed,
|
||||
icon: const Icon(Icons.edit_rounded, size: 18),
|
||||
label: const Text('写日记'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: const Color(0xFFFFF8F0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user