feat(app): 共享 UI 组件 + 4 个关键 UX bug 修复
Phase 0 — 共享组件: - EmptyStateWidget: 统一空状态 (icon + title + subtitle + CTA) - ErrorStateWidget: 统一错误状态 (message + retry) - SkeletonBox + SkeletonList: 统一骨架屏加载 (shimmer 动画) Phase 1 — Bug 修复: - 班级评论按 journalId 过滤,避免显示在错误日记卡片下 - moodCellColors key 修正: love/tired → angry/thinking - 日历非 CalendarLoaded 状态改为加载指示器 (不再 SizedBox.shrink) - 贴纸数统计改为 '--' 占位 (之前错误显示日记总数)
This commit is contained in:
@@ -177,12 +177,13 @@ class AppColors {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// 心情 → 日历单元格背景色
|
/// 心情 → 日历单元格背景色
|
||||||
|
/// key 必须与 Mood 枚举值一致: happy/calm/sad/angry/thinking
|
||||||
static const Map<String, Color> moodCellColors = {
|
static const Map<String, Color> moodCellColors = {
|
||||||
'happy': secondarySoftLight, // #D4E8DC
|
'happy': secondarySoftLight, // 😊 开心 — 鼠尾草绿 #D4E8DC
|
||||||
'love': roseSoftLight, // #F0DADA
|
'calm': tertiarySoftLight, // 😌 平静 — 暖金 #FBE8C8
|
||||||
'calm': tertiarySoftLight, // #FBE8C8
|
'sad': Color(0xFFD4DDE8), // 😢 难过 — 灰蓝
|
||||||
'sad': Color(0xFFD4DDE8), // 灰蓝
|
'angry': Color(0xFFFFE0D6), // 😠 生气 — 暖珊瑚 (与 primaryContainer 一致)
|
||||||
'tired': Color(0xFFE8E4E0), // 灰棕
|
'thinking': Color(0xFFE8E4E0), // 🤔 思考 — 灰棕
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== 浅色主题色彩方案 =====
|
// ===== 浅色主题色彩方案 =====
|
||||||
|
|||||||
@@ -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;
|
final loaded = state;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
|||||||
@@ -344,31 +344,35 @@ class _DiaryWallCard extends StatelessWidget {
|
|||||||
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 评语
|
// 评语(按 journalId 过滤,避免显示在错误卡片下)
|
||||||
if (comments.isNotEmpty) ...[
|
...(() {
|
||||||
const SizedBox(height: 8),
|
final journalComments = comments.where((c) => c.journalId == journal.id).toList();
|
||||||
Container(
|
if (journalComments.isEmpty) return <Widget>[];
|
||||||
padding: const EdgeInsets.all(8),
|
return [
|
||||||
decoration: BoxDecoration(
|
const SizedBox(height: 8),
|
||||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
Container(
|
||||||
borderRadius: AppRadius.smBorder,
|
padding: const EdgeInsets.all(8),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Row(
|
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
children: [
|
borderRadius: AppRadius.smBorder,
|
||||||
const Icon(Icons.rate_review_rounded, size: 14),
|
),
|
||||||
const SizedBox(width: 4),
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: Text(
|
const Icon(Icons.rate_review_rounded, size: 14),
|
||||||
comments.first.content,
|
const SizedBox(width: 4),
|
||||||
style: theme.textTheme.bodySmall,
|
Expanded(
|
||||||
maxLines: 2,
|
child: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
journalComments.first.content,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
];
|
||||||
],
|
})(),
|
||||||
// 写评语按钮(仅老师可见)
|
// 写评语按钮(仅老师可见)
|
||||||
if (_isTeacher(context)) ...[
|
if (_isTeacher(context)) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ class _LiveStatsBarState extends State<_LiveStatsBar> {
|
|||||||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||||||
_StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface),
|
_StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface),
|
||||||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
95
app/lib/widgets/empty_state_widget.dart
Normal file
95
app/lib/widgets/empty_state_widget.dart
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/lib/widgets/error_state_widget.dart
Normal file
72
app/lib/widgets/error_state_widget.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
app/lib/widgets/skeleton_loading.dart
Normal file
156
app/lib/widgets/skeleton_loading.dart
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user