diff --git a/app/lib/core/theme/app_colors.dart b/app/lib/core/theme/app_colors.dart index f87dc35..4fe90a9 100644 --- a/app/lib/core/theme/app_colors.dart +++ b/app/lib/core/theme/app_colors.dart @@ -177,12 +177,13 @@ class AppColors { }; /// 心情 → 日历单元格背景色 + /// key 必须与 Mood 枚举值一致: happy/calm/sad/angry/thinking static const Map moodCellColors = { - 'happy': secondarySoftLight, // #D4E8DC - 'love': roseSoftLight, // #F0DADA - 'calm': tertiarySoftLight, // #FBE8C8 - 'sad': Color(0xFFD4DDE8), // 灰蓝 - 'tired': Color(0xFFE8E4E0), // 灰棕 + 'happy': secondarySoftLight, // 😊 开心 — 鼠尾草绿 #D4E8DC + 'calm': tertiarySoftLight, // 😌 平静 — 暖金 #FBE8C8 + 'sad': Color(0xFFD4DDE8), // 😢 难过 — 灰蓝 + 'angry': Color(0xFFFFE0D6), // 😠 生气 — 暖珊瑚 (与 primaryContainer 一致) + 'thinking': Color(0xFFE8E4E0), // 🤔 思考 — 灰棕 }; // ===== 浅色主题色彩方案 ===== diff --git a/app/lib/features/calendar/views/calendar_page.dart b/app/lib/features/calendar/views/calendar_page.dart index f023795..df0166f 100644 --- a/app/lib/features/calendar/views/calendar_page.dart +++ b/app/lib/features/calendar/views/calendar_page.dart @@ -59,7 +59,9 @@ class _CalendarView extends StatelessWidget { ); } - if (state is! CalendarLoaded) return const SizedBox.shrink(); + if (state is! CalendarLoaded) { + return const Center(child: CircularProgressIndicator()); + } final loaded = state; return Column( diff --git a/app/lib/features/class_/views/class_page.dart b/app/lib/features/class_/views/class_page.dart index b286994..d69154d 100644 --- a/app/lib/features/class_/views/class_page.dart +++ b/app/lib/features/class_/views/class_page.dart @@ -344,31 +344,35 @@ class _DiaryWallCard extends StatelessWidget { color: colorScheme.onSurface.withValues(alpha: 0.4), ), ), - // 评语 - if (comments.isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.3), - borderRadius: AppRadius.smBorder, - ), - child: Row( - children: [ - const Icon(Icons.rate_review_rounded, size: 14), - const SizedBox(width: 4), - Expanded( - child: Text( - comments.first.content, - style: theme.textTheme.bodySmall, - maxLines: 2, - overflow: TextOverflow.ellipsis, + // 评语(按 journalId 过滤,避免显示在错误卡片下) + ...(() { + final journalComments = comments.where((c) => c.journalId == journal.id).toList(); + if (journalComments.isEmpty) return []; + return [ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: AppRadius.smBorder, + ), + child: Row( + children: [ + const Icon(Icons.rate_review_rounded, size: 14), + const SizedBox(width: 4), + Expanded( + child: Text( + journalComments.first.content, + style: theme.textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ), - ), - ], + ], + ), ), - ), - ], + ]; + })(), // 写评语按钮(仅老师可见) if (_isTeacher(context)) ...[ const SizedBox(height: 8), diff --git a/app/lib/features/profile/views/profile_page.dart b/app/lib/features/profile/views/profile_page.dart index b5cd80f..067bd94 100644 --- a/app/lib/features/profile/views/profile_page.dart +++ b/app/lib/features/profile/views/profile_page.dart @@ -425,7 +425,7 @@ class _LiveStatsBarState extends State<_LiveStatsBar> { VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft), _StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface), VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft), - _StatItem(label: '贴纸数', value: _totalCount > 0 ? '$_totalCount' : '0', valueColor: widget.colorScheme.onSurface), + _StatItem(label: '贴纸数', value: '--', valueColor: widget.colorScheme.onSurface), ], ), ); diff --git a/app/lib/widgets/empty_state_widget.dart b/app/lib/widgets/empty_state_widget.dart new file mode 100644 index 0000000..d0c88a9 --- /dev/null +++ b/app/lib/widgets/empty_state_widget.dart @@ -0,0 +1,95 @@ +// 共享空状态组件 — 统一所有页面的空数据展示 +// +// 使用: EmptyStateWidget(icon: Icons.xxx, title: '暂无数据', subtitle: '下拉刷新试试') +// 样式参考: home_page._EmptyJournalState — 大图标淡化 + 标题 + 副标题 + 暖色按钮 + +import 'package:flutter/material.dart'; + +import '../core/constants/design_tokens.dart'; +import '../core/theme/app_colors.dart'; +import '../core/theme/app_radius.dart'; + +/// 统一空状态组件 — 图标 + 标题 + 可选副标题 + 可选操作按钮 +class EmptyStateWidget extends StatelessWidget { + const EmptyStateWidget({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.actionLabel, + this.onAction, + this.iconSize = 64, + }); + + /// 主图标 + final IconData icon; + + /// 图标大小 + final double iconSize; + + /// 标题文字 + final String title; + + /// 副标题(灰色小字) + final String? subtitle; + + /// 操作按钮文字(不传则不显示按钮) + final String? actionLabel; + + /// 操作按钮回调 + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: iconSize, + color: theme.colorScheme.onSurface.withValues(alpha: 0.2), + ), + const SizedBox(height: DesignTokens.spacing16), + Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + const SizedBox(height: DesignTokens.spacing8), + Text( + subtitle!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: DesignTokens.spacing24), + FilledButton.icon( + onPressed: onAction, + icon: const Icon(Icons.add_rounded), + label: Text(actionLabel!), + style: FilledButton.styleFrom( + backgroundColor: AppColors.accent, + foregroundColor: AppColors.bgLight, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.pillBorder, + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/error_state_widget.dart b/app/lib/widgets/error_state_widget.dart new file mode 100644 index 0000000..6380e0e --- /dev/null +++ b/app/lib/widgets/error_state_widget.dart @@ -0,0 +1,72 @@ +// 共享错误状态组件 — 统一所有页面的错误展示 +// +// 使用: ErrorStateWidget(message: '加载失败', onRetry: () => ...) +// 样式: 云朵图标 + 错误信息 + 重试按钮 + +import 'package:flutter/material.dart'; + +import '../core/constants/design_tokens.dart'; +import '../core/theme/app_radius.dart'; + +/// 统一错误状态组件 — 图标 + 错误信息 + 可选重试按钮 +class ErrorStateWidget extends StatelessWidget { + const ErrorStateWidget({ + super.key, + required this.message, + this.onRetry, + this.icon = Icons.cloud_off_rounded, + }); + + /// 错误描述文字 + final String message; + + /// 重试回调(不传则不显示重试按钮) + final VoidCallback? onRetry; + + /// 主图标(默认云朵离线图标) + final IconData icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.spacing24, + vertical: DesignTokens.spacing40, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 56, + color: theme.colorScheme.onSurface.withValues(alpha: 0.25), + ), + const SizedBox(height: DesignTokens.spacing16), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: DesignTokens.spacing20), + TextButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('重试'), + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: AppRadius.pillBorder, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/skeleton_loading.dart b/app/lib/widgets/skeleton_loading.dart new file mode 100644 index 0000000..90e63b9 --- /dev/null +++ b/app/lib/widgets/skeleton_loading.dart @@ -0,0 +1,156 @@ +// 共享骨架屏加载组件 — 替代 CircularProgressIndicator +// +// 使用: SkeletonBox(height: 80) 或 SkeletonBox(height: 48, shimmer: true) +// 样式: 灰色圆角矩形 + 可选 shimmer 微光动画 + +import 'package:flutter/material.dart'; + +import '../core/theme/app_radius.dart'; + +/// 骨架屏加载占位块 +class SkeletonBox extends StatelessWidget { + const SkeletonBox({ + super.key, + required this.height, + this.width, + this.shimmer = false, + this.borderRadius, + }); + + /// 高度 + final double height; + + /// 宽度(默认撑满父容器) + final double? width; + + /// 是否启用 shimmer 微光动画 + final bool shimmer; + + /// 自定义圆角(默认 AppRadius.md) + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bgColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3); + final radius = borderRadius ?? AppRadius.mdBorder; + + if (shimmer) { + return _ShimmerBox( + height: height, + width: width, + bgColor: bgColor, + borderRadius: radius, + ); + } + + return Container( + height: height, + width: width, + decoration: BoxDecoration( + color: bgColor, + borderRadius: radius, + ), + ); + } +} + +/// 带微光动画的骨架屏 +class _ShimmerBox extends StatefulWidget { + const _ShimmerBox({ + required this.height, + this.width, + required this.bgColor, + required this.borderRadius, + }); + + final double height; + final double? width; + final Color bgColor; + final BorderRadius borderRadius; + + @override + State<_ShimmerBox> createState() => _ShimmerBoxState(); +} + +class _ShimmerBoxState extends State<_ShimmerBox> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Container( + height: widget.height, + width: widget.width, + decoration: BoxDecoration( + borderRadius: widget.borderRadius, + gradient: LinearGradient( + begin: Alignment(-1.0 + 2.0 * _controller.value, 0), + end: Alignment(1.0 + 2.0 * _controller.value, 0), + colors: [ + widget.bgColor, + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.15), + widget.bgColor, + ], + ), + ), + ); + }, + ); + } +} + +/// 通用列表骨架屏 — 显示 N 行占位 +class SkeletonList extends StatelessWidget { + const SkeletonList({ + super.key, + this.itemCount = 5, + this.itemHeight = 72, + this.spacing = 12, + this.shimmer = false, + }); + + /// 骨架行数 + final int itemCount; + + /// 每行高度 + final double itemHeight; + + /// 行间距 + final double spacing; + + /// 是否 shimmer + final bool shimmer; + + @override + Widget build(BuildContext context) { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: itemCount, + itemBuilder: (_, _) => Padding( + padding: EdgeInsets.only(bottom: spacing), + child: SkeletonBox(height: itemHeight, shimmer: shimmer), + ), + ); + } +}