fix(app): 修复 P2~P4 共 10 项前端问题
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

P2 必须修复:
- 教师布置主题 classId 从硬编码改为班级下拉选择器
- 班级日记墙使用服务端 classId 过滤替代前端过滤
- Profile 统计栏接入 JournalRepository 真实数据
- WeeklyPage 从全硬编码改为 JournalRepository 数据驱动

P3 建议改进:
- 提取 mood_utils.dart 公共函数,消除 4 处重复定义
- 贴纸库搜索框连接 StickerBloc 按名称过滤

P4 细节打磨:
- 家长页多孩子时显示 DropdownButton 选择器
- 搜索结果日记卡片点击跳转 /editor?id=
- MonthlyPage 照片数量从 JournalElement 统计
- calendar_page/mood_page/search_page 统一使用 moodToEmoji/moodToLabel
This commit is contained in:
iven
2026-06-02 20:21:51 +08:00
parent 75db6a7eb7
commit 7e928ae1e1
17 changed files with 2537 additions and 799 deletions

View File

@@ -0,0 +1,22 @@
// 心情公共工具 — 统一 Mood 枚举的 emoji/标签映射
// 消除 calendar_page / mood_page / search_page / monthly_page 中的重复定义
import 'package:nuanji_app/data/models/journal_entry.dart';
/// 心情 → emoji
String moodToEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
/// 心情 → 中文标签
String moodToLabel(Mood mood) => switch (mood) {
Mood.happy => '开心',
Mood.calm => '平静',
Mood.sad => '难过',
Mood.angry => '生气',
Mood.thinking => '思考',
};

View File

@@ -34,6 +34,7 @@ class IsarJournalRepository implements JournalRepository {
int? pageSize, int? pageSize,
String? mood, String? mood,
String? tag, String? tag,
String? classId,
}) async { }) async {
var query = _isar.journalEntryCollections var query = _isar.journalEntryCollections
.where() .where()
@@ -58,6 +59,11 @@ class IsarJournalRepository implements JournalRepository {
query = query.and().tagsJsonContains(tag); query = query.and().tagsJsonContains(tag);
} }
// 班级过滤
if (classId != null) {
query = query.and().classIdEqualTo(classId);
}
// 按日期降序排列 // 按日期降序排列
var results = await query var results = await query
.sortByDateEpochDesc() .sortByDateEpochDesc()
@@ -74,6 +80,15 @@ class IsarJournalRepository implements JournalRepository {
return results.map(_fromCollection).toList(); return results.map(_fromCollection).toList();
} }
@override
Future<int> getJournalCount() async {
return _isar.journalEntryCollections
.where()
.filter()
.isDeletedEqualTo(false)
.count();
}
@override @override
Future<JournalEntry?> getJournal(String id) async { Future<JournalEntry?> getJournal(String id) async {
final col = await _isar.journalEntryCollections final col = await _isar.journalEntryCollections
@@ -262,6 +277,7 @@ class IsarJournalRepository implements JournalRepository {
..isPrivate = entry.isPrivate ..isPrivate = entry.isPrivate
..sharedToClass = entry.sharedToClass ..sharedToClass = entry.sharedToClass
..assignedTopicId = entry.assignedTopicId ..assignedTopicId = entry.assignedTopicId
..contentExcerpt = entry.contentExcerpt
..version = entry.version ..version = entry.version
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch ..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch ..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
@@ -290,6 +306,7 @@ class IsarJournalRepository implements JournalRepository {
isPrivate: col.isPrivate, isPrivate: col.isPrivate,
sharedToClass: col.sharedToClass, sharedToClass: col.sharedToClass,
assignedTopicId: col.assignedTopicId, assignedTopicId: col.assignedTopicId,
contentExcerpt: col.contentExcerpt,
version: col.version, version: col.version,
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch), createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch), updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),

View File

@@ -15,7 +15,7 @@ import '../models/journal_element.dart';
/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间) /// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间)
/// - [page]/[pageSize]: 分页参数,从 1 开始 /// - [page]/[pageSize]: 分页参数,从 1 开始
abstract class JournalRepository { abstract class JournalRepository {
/// 获取日记列表(支持日期范围、心情、标签过滤和分页) /// 获取日记列表(支持日期范围、心情、标签、班级过滤和分页)
Future<List<JournalEntry>> getJournals({ Future<List<JournalEntry>> getJournals({
DateTime? dateFrom, DateTime? dateFrom,
DateTime? dateTo, DateTime? dateTo,
@@ -23,8 +23,12 @@ abstract class JournalRepository {
int? pageSize, int? pageSize,
String? mood, String? mood,
String? tag, String? tag,
String? classId,
}); });
/// 获取日记总数
Future<int> getJournalCount();
/// 获取单篇日记(返回 null 表示不存在) /// 获取单篇日记(返回 null 表示不存在)
Future<JournalEntry?> getJournal(String id); Future<JournalEntry?> getJournal(String id);
@@ -66,6 +70,7 @@ class InMemoryJournalRepository implements JournalRepository {
int? pageSize, int? pageSize,
String? mood, String? mood,
String? tag, String? tag,
String? classId,
}) async { }) async {
var results = _journals.values.toList(); var results = _journals.values.toList();
@@ -87,6 +92,11 @@ class InMemoryJournalRepository implements JournalRepository {
results = results.where((j) => j.tags.contains(tag)).toList(); results = results.where((j) => j.tags.contains(tag)).toList();
} }
// 班级过滤
if (classId != null) {
results = results.where((j) => j.classId == classId).toList();
}
// 按日期降序排列(最新在前) // 按日期降序排列(最新在前)
results.sort((a, b) => b.date.compareTo(a.date)); results.sort((a, b) => b.date.compareTo(a.date));
@@ -101,6 +111,9 @@ class InMemoryJournalRepository implements JournalRepository {
return results; return results;
} }
@override
Future<int> getJournalCount() async => _journals.length;
@override @override
Future<JournalEntry?> getJournal(String id) async { Future<JournalEntry?> getJournal(String id) async {
return _journals[id]; return _journals[id];

View File

@@ -21,6 +21,7 @@ class RemoteJournalRepository implements JournalRepository {
int? pageSize, int? pageSize,
String? mood, String? mood,
String? tag, String? tag,
String? classId,
}) async { }) async {
final queryParams = <String, dynamic>{}; final queryParams = <String, dynamic>{};
// 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒) // 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒)
@@ -34,6 +35,7 @@ class RemoteJournalRepository implements JournalRepository {
if (pageSize != null) queryParams['page_size'] = pageSize; if (pageSize != null) queryParams['page_size'] = pageSize;
if (mood != null) queryParams['mood'] = mood; if (mood != null) queryParams['mood'] = mood;
if (tag != null) queryParams['tag'] = tag; if (tag != null) queryParams['tag'] = tag;
if (classId != null) queryParams['class_id'] = classId;
final response = await _api.get('/diary/journals', queryParams: queryParams); final response = await _api.get('/diary/journals', queryParams: queryParams);
final body = response.data as Map<String, dynamic>; final body = response.data as Map<String, dynamic>;
@@ -43,6 +45,16 @@ class RemoteJournalRepository implements JournalRepository {
.toList(); .toList();
} }
@override
Future<int> getJournalCount() async {
final response = await _api.get('/diary/journals', queryParams: {
'page': 1,
'page_size': 1,
});
final body = response.data as Map<String, dynamic>;
return (body['total'] as int?) ?? 0;
}
@override @override
Future<JournalEntry?> getJournal(String id) async { Future<JournalEntry?> getJournal(String id) async {
try { try {

View File

@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.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_radius.dart';
import 'package:nuanji_app/core/utils/mood_utils.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../bloc/calendar_bloc.dart'; import '../bloc/calendar_bloc.dart';
@@ -128,6 +129,10 @@ class _MonthView extends StatelessWidget {
return Expanded( return Expanded(
child: Column( child: Column(
children: [ children: [
// 本月心情概览柱状图
_MoodSummaryChart(journalsByDate: loaded.journalsByDate),
const SizedBox(height: 8),
// 星期标题行 // 星期标题行
_WeekdayHeader(colorScheme: Theme.of(context).colorScheme), _WeekdayHeader(colorScheme: Theme.of(context).colorScheme),
@@ -155,6 +160,104 @@ class _MonthView extends StatelessWidget {
} }
} }
/// 本月心情概览 — 5 柱状图
class _MoodSummaryChart extends StatelessWidget {
const _MoodSummaryChart({required this.journalsByDate});
final Map<DateTime, List<JournalEntry>> journalsByDate;
static const _moodConfig = [
(Mood.happy, '开心', AppColors.secondary),
(Mood.calm, '平静', AppColors.tertiary),
(Mood.sad, '难过', Color(0xFF5B7DB1)),
(Mood.angry, '生气', AppColors.accent),
(Mood.thinking, '思考', Color(0xFF8B7E74)),
];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// 统计每种心情的数量
final counts = <Mood, int>{};
for (final entry in journalsByDate.entries) {
for (final journal in entry.value) {
counts[journal.mood] = (counts[journal.mood] ?? 0) + 1;
}
}
final maxCount = counts.values.fold(0, (a, b) => a > b ? a : b).toDouble();
final barMaxHeight = 60.0;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.colorScheme.shadow.withValues(alpha: 0.05),
offset: const Offset(0, 2),
blurRadius: 8,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本月心情概览',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: _moodConfig.map((config) {
final count = counts[config.$1] ?? 0;
final barHeight = maxCount > 0 && count > 0
? (count / maxCount * barMaxHeight).clamp(4.0, barMaxHeight)
: 4.0;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: barHeight,
decoration: BoxDecoration(
color: config.$3,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6),
),
),
),
const SizedBox(height: 6),
Text(
config.$2,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}).toList(),
),
],
),
);
}
}
// ===== 周视图 ===== // ===== 周视图 =====
class _WeekView extends StatelessWidget { class _WeekView extends StatelessWidget {
@@ -264,7 +367,7 @@ class _WeekView extends StatelessWidget {
// 心情 emoji // 心情 emoji
if (hasEntry) if (hasEntry)
Text( Text(
_moodEmoji(journals.first.mood), moodToEmoji(journals.first.mood),
style: const TextStyle(fontSize: 24), style: const TextStyle(fontSize: 24),
), ),
], ],
@@ -321,6 +424,13 @@ class _TimelineView extends StatelessWidget {
final journal = entry.value; final journal = entry.value;
final isLast = index == allJournals.length - 1; final isLast = index == allJournals.length - 1;
// 时间格式化 HH:mm
final timeStr =
'${journal.createdAt.hour.toString().padLeft(2, '0')}:${journal.createdAt.minute.toString().padLeft(2, '0')}';
// 摘要文本
final excerpt = journal.contentExcerpt ?? '';
return IntrinsicHeight( return IntrinsicHeight(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -332,14 +442,14 @@ class _TimelineView extends StatelessWidget {
children: [ children: [
// 圆点 + emoji // 圆点 + emoji
Container( Container(
width: 36, width: 40,
height: 36, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getMoodBgColor(journal.mood.value), color: _getMoodBgColor(journal.mood.value),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 18)), child: Text(moodToEmoji(journal.mood), style: const TextStyle(fontSize: 20)),
), ),
// 竖线 // 竖线
if (!isLast) if (!isLast)
@@ -369,6 +479,8 @@ class _TimelineView extends StatelessWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Text( Text(
'${date.month}${date.day}', '${date.month}${date.day}',
@@ -376,6 +488,15 @@ class _TimelineView extends StatelessWidget {
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(width: 8),
Text(
timeStr,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
journal.title, journal.title,
@@ -385,8 +506,18 @@ class _TimelineView extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
// 日记内容通过 JournalElement 管理,日历视图仅显示标题 if (excerpt.isNotEmpty) ...[
// 后续可通过 elements 预览首段文字 const SizedBox(height: 4),
Text(
excerpt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
], ],
), ),
), ),
@@ -426,10 +557,20 @@ class _MonthNavigator extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
IconButton( SizedBox(
width: 44,
height: 44,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
shape: const CircleBorder(),
padding: EdgeInsets.zero,
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
onPressed: onPrevious, onPressed: onPrevious,
icon: const Icon(Icons.chevron_left), child: const Icon(Icons.chevron_left, size: 20),
tooltip: '上个月', ),
), ),
Text( Text(
monthName, monthName,
@@ -437,10 +578,20 @@ class _MonthNavigator extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
IconButton( SizedBox(
width: 44,
height: 44,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
shape: const CircleBorder(),
padding: EdgeInsets.zero,
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
onPressed: onNext, onPressed: onNext,
icon: const Icon(Icons.chevron_right), child: const Icon(Icons.chevron_right, size: 20),
tooltip: '下个月', ),
), ),
], ],
), ),
@@ -614,7 +765,10 @@ class _DayCell extends StatelessWidget {
: null, : null,
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Column( child: Stack(
clipBehavior: Clip.none,
children: [
Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
@@ -632,8 +786,8 @@ class _DayCell extends StatelessWidget {
// 心情小圆点(仅在有日记但无背景色时显示) // 心情小圆点(仅在有日记但无背景色时显示)
if (hasJournals && moodBg == Colors.transparent) if (hasJournals && moodBg == Colors.transparent)
Container( Container(
width: 4, width: 6,
height: 4, height: 6,
margin: const EdgeInsets.only(top: 1), margin: const EdgeInsets.only(top: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -642,6 +796,24 @@ class _DayCell extends StatelessWidget {
), ),
], ],
), ),
// 日记条目指示点
if (hasJournals)
Positioned(
top: 3,
right: 5,
child: Container(
width: 5,
height: 5,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.onPrimary
: AppColors.accent,
),
),
),
],
),
), ),
); );
} }
@@ -731,7 +903,7 @@ class _DayJournalList extends StatelessWidget {
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
_moodEmoji(journal.mood), moodToEmoji(journal.mood),
style: const TextStyle(fontSize: 20), style: const TextStyle(fontSize: 20),
), ),
), ),
@@ -781,17 +953,6 @@ Color _getMoodBgColor(String mood) {
return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight; 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) { bool _isToday(DateTime date) {
final now = DateTime.now(); final now = DateTime.now();

View File

@@ -2,14 +2,19 @@
// 对齐 Open Design 原型稿 screens/monthly.html // 对齐 Open Design 原型稿 screens/monthly.html
import 'package:flutter/material.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_colors.dart';
import 'package:nuanji_app/core/theme/app_radius.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_shadows.dart';
import 'package:nuanji_app/core/theme/app_typography.dart'; import 'package:nuanji_app/core/theme/app_typography.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/journal_element.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
/// 月度概览页面 /// 月度概览页面
class MonthlyPage extends StatefulWidget { class MonthlyPage extends StatefulWidget {
const MonthlyPage({super.key}); final JournalRepository? journalRepository;
const MonthlyPage({super.key, this.journalRepository});
@override @override
State<MonthlyPage> createState() => _MonthlyPageState(); State<MonthlyPage> createState() => _MonthlyPageState();
@@ -17,23 +22,59 @@ class MonthlyPage extends StatefulWidget {
class _MonthlyPageState extends State<MonthlyPage> { class _MonthlyPageState extends State<MonthlyPage> {
late DateTime _focusedMonth; late DateTime _focusedMonth;
List<JournalEntry> _journals = [];
int _photoCount = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_focusedMonth = DateTime.now(); _focusedMonth = DateTime.now();
_loadJournals();
}
JournalRepository get _repo =>
widget.journalRepository ?? context.read<JournalRepository>();
Future<void> _loadJournals() async {
final firstDay = DateTime(_focusedMonth.year, _focusedMonth.month, 1);
// 下月 1 号作为上界(开区间),所以用 month+1
final nextMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 1);
final journals = await _repo.getJournals(
dateFrom: firstDay,
dateTo: nextMonth,
);
// 统计照片元素数量
var photoCount = 0;
for (final journal in journals) {
try {
final elements = await _repo.getElements(journal.id);
photoCount += elements.where((e) => e.elementType == ElementType.image).length;
} catch (_) {
// 单个日记加载元素失败不影响整体统计
}
}
if (mounted) {
setState(() {
_journals = journals;
_photoCount = photoCount;
});
}
} }
void _goToPreviousMonth() { void _goToPreviousMonth() {
setState(() { setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1); _focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
}); });
_loadJournals();
} }
void _goToNextMonth() { void _goToNextMonth() {
setState(() { setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1); _focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
}); });
_loadJournals();
} }
@override @override
@@ -55,13 +96,13 @@ class _MonthlyPageState extends State<MonthlyPage> {
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
// 心情色彩月历 // 心情色彩月历
_MoodCalendar(month: _focusedMonth), _MoodCalendar(month: _focusedMonth, journals: _journals),
const SizedBox(height: 20), const SizedBox(height: 20),
// 月度统计 2x2 // 月度统计 2x2
const _MonthSummary(), _MonthSummary(journals: _journals, photoCount: _photoCount),
const SizedBox(height: 20), const SizedBox(height: 20),
// 精选日记 // 精选日记
const _Highlights(), _Highlights(journals: _journals),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
), ),
@@ -161,31 +202,27 @@ class _NavButton extends StatelessWidget {
// ===== 心情色彩月历 ===== // ===== 心情色彩月历 =====
class _MoodCalendar extends StatelessWidget { class _MoodCalendar extends StatelessWidget {
const _MoodCalendar({required this.month}); const _MoodCalendar({required this.month, required this.journals});
final DateTime month; final DateTime month;
final List<JournalEntry> journals;
// 心情类型 // 心情 → emoji对齐 Mood 枚举: happy/calm/sad/angry/thinking
static const _moodTypes = [ static const _moodEmojis = <Mood, String>{
'happy', 'calm', 'sad', 'tired', 'love', Mood.happy: '😊',
]; Mood.calm: '😌',
Mood.sad: '😢',
// 心情 → emoji Mood.angry: '😡',
static const _moodEmojis = <String, String>{ Mood.thinking: '🤔',
'happy': '😊',
'calm': '😐',
'sad': '😢',
'tired': '😐',
'love': '😡',
}; };
// 心情 → 背景色 // 心情 → 背景色
static const _moodBgColors = <String, Color>{ static const _moodBgColors = <Mood, Color>{
'happy': AppColors.secondarySoftLight, Mood.happy: AppColors.secondarySoftLight,
'love': AppColors.roseSoftLight, Mood.angry: AppColors.roseSoftLight,
'calm': AppColors.tertiarySoftLight, Mood.calm: AppColors.tertiarySoftLight,
'sad': Color(0xFFD4DDE8), Mood.sad: Color(0xFFD4DDE8),
'tired': Color(0xFFE8E4E0), Mood.thinking: Color(0xFFE8E4E0),
}; };
@override @override
@@ -216,10 +253,18 @@ class _MoodCalendar extends StatelessWidget {
Widget _buildGrid(BuildContext context, DateTime now) { Widget _buildGrid(BuildContext context, DateTime now) {
final firstDay = DateTime(month.year, month.month, 1); final firstDay = DateTime(month.year, month.month, 1);
// 周=0 → 偏移量; weekday 返回 1(周一)..7(周日) // 周=0 → 偏移量; weekday 返回 1(周一)..7(周日)
final startOffset = firstDay.weekday % 7; // 周开头 final startOffset = firstDay.weekday - 1; // 周开头
final daysInMonth = DateTime(month.year, month.month + 1, 0).day; final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
// 按日期建索引day → JournalEntry
final journalByDay = <int, JournalEntry>{};
for (final j in journals) {
if (j.date.year == month.year && j.date.month == month.month) {
journalByDay[j.date.day] = j;
}
}
final cells = <Widget>[]; final cells = <Widget>[];
// 空白填充 // 空白填充
@@ -227,19 +272,16 @@ class _MoodCalendar extends StatelessWidget {
cells.add(const SizedBox.shrink()); cells.add(const SizedBox.shrink());
} }
// 模拟心情数据(确定性伪随机,同一天固定同心情)
final rng = _SeededRandom(month.year * 100 + month.month);
for (var d = 1; d <= daysInMonth; d++) { for (var d = 1; d <= daysInMonth; d++) {
final isToday = now.year == month.year && final isToday = now.year == month.year &&
now.month == month.month && now.month == month.month &&
now.day == d; now.day == d;
// 每天随机一个心情 final entry = journalByDay[d];
final moodIndex = rng.nextInt(5); final mood = entry?.mood;
final mood = _moodTypes[moodIndex]; final bgColor =
final bgColor = _moodBgColors[mood] ?? Colors.transparent; mood != null ? (_moodBgColors[mood] ?? Colors.transparent) : Colors.transparent;
final emoji = _moodEmojis[mood] ?? ''; final emoji = mood != null ? (_moodEmojis[mood] ?? '') : '';
cells.add( cells.add(
_MoodCell( _MoodCell(
@@ -263,17 +305,6 @@ class _MoodCalendar extends StatelessWidget {
} }
} }
/// 简单确定性伪随机数生成器(仅用于模拟数据)
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 { class _WeekdayRow extends StatelessWidget {
const _WeekdayRow({required this.colorScheme}); const _WeekdayRow({required this.colorScheme});
@@ -282,7 +313,7 @@ class _WeekdayRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const weekdays = ['', '', '', '', '', '', '']; const weekdays = ['', '', '', '', '', '', ''];
return Row( return Row(
children: weekdays.map((day) { children: weekdays.map((day) {
return Expanded( return Expanded(
@@ -359,7 +390,36 @@ class _MoodCell extends StatelessWidget {
// ===== 月度统计 2x2 ===== // ===== 月度统计 2x2 =====
class _MonthSummary extends StatelessWidget { class _MonthSummary extends StatelessWidget {
const _MonthSummary(); const _MonthSummary({required this.journals, required this.photoCount});
final List<JournalEntry> journals;
final int photoCount;
/// 计算最长连续写日记天数
int _calcLongestStreak() {
if (journals.isEmpty) return 0;
final days = journals.map((j) => j.date.day).toSet().toList()..sort();
int longest = 1;
int current = 1;
for (var i = 1; i < days.length; i++) {
if (days[i] == days[i - 1] + 1) {
current++;
if (current > longest) longest = current;
} else {
current = 1;
}
}
return longest;
}
/// 计算"好心情"happy/calm占比
String _calcGoodMoodPercent() {
if (journals.isEmpty) return '0%';
final good = journals.where(
(j) => j.mood == Mood.happy || j.mood == Mood.calm,
).length;
return '${((good / journals.length) * 100).round()}%';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -386,28 +446,28 @@ class _MonthSummary extends StatelessWidget {
children: [ children: [
_StatCard( _StatCard(
icon: '📝', icon: '📝',
value: '28', value: '${journals.length}',
label: '日记篇数', label: '日记篇数',
bgColor: AppColors.tertiarySoftLight, bgColor: AppColors.tertiarySoftLight,
valueColor: const Color(0xFFB8860B), valueColor: const Color(0xFFB8860B),
), ),
_StatCard( _StatCard(
icon: '🔥', icon: '🔥',
value: '12', value: '${_calcLongestStreak()}',
label: '最长连续', label: '最长连续',
bgColor: AppColors.secondarySoftLight, bgColor: AppColors.secondarySoftLight,
valueColor: const Color(0xFF2D7D46), valueColor: const Color(0xFF2D7D46),
), ),
_StatCard( _StatCard(
icon: '😊', icon: '😊',
value: '72%', value: _calcGoodMoodPercent(),
label: '好心情占比', label: '好心情占比',
bgColor: AppColors.roseSoftLight, bgColor: AppColors.roseSoftLight,
valueColor: const Color(0xFF9B4D4D), valueColor: const Color(0xFF9B4D4D),
), ),
_StatCard( _StatCard(
icon: '📸', icon: '📸',
value: '18', value: '$photoCount',
label: '照片数量', label: '照片数量',
bgColor: const Color(0xFFD4DDE8), bgColor: const Color(0xFFD4DDE8),
valueColor: const Color(0xFF4A6B8A), valueColor: const Color(0xFF4A6B8A),
@@ -475,42 +535,30 @@ class _StatCard extends StatelessWidget {
// ===== 精选日记 ===== // ===== 精选日记 =====
class _Highlights extends StatelessWidget { class _Highlights extends StatelessWidget {
const _Highlights(); const _Highlights({required this.journals});
final List<JournalEntry> journals;
static const _badgeConfig = <Mood, ({String badge, Color bg, Color fg})>{
Mood.happy: (badge: '最佳心情', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
Mood.calm: (badge: '平静时光', bg: AppColors.tertiarySoftLight, fg: Color(0xFFB8860B)),
Mood.sad: (badge: '真实记录', bg: Color(0xFFD4DDE8), fg: Color(0xFF4A6B8A)),
Mood.angry: (badge: '真情流露', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
Mood.thinking: (badge: '深度思考', bg: AppColors.secondarySoftLight, fg: Color(0xFF2D7D46)),
};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
// 模拟精选日记数据 // 按日期降序取前 3 篇
const highlights = [ final top = List<JournalEntry>.from(journals)
( ..sort((a, b) => b.date.compareTo(a.date));
emoji: '😊', final highlights = top.take(3).toList();
emojiBg: AppColors.roseSoftLight,
date: '5月14日', if (highlights.isEmpty) {
title: '和朋友聚餐的欢乐时光', return const SizedBox.shrink();
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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -523,15 +571,22 @@ class _Highlights extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
...highlights.map((item) { ...highlights.map((entry) {
final mood = entry.mood;
final emoji = _MoodCalendar._moodEmojis[mood] ?? '📝';
final emojiBg = _MoodCalendar._moodBgColors[mood] ?? AppColors.tertiarySoftLight;
final cfg = _badgeConfig[mood] ??
(badge: '日记', bg: AppColors.tertiarySoftLight, fg: const Color(0xFFB8860B));
final dateStr = '${entry.date.month}${entry.date.day}';
return _HighlightCard( return _HighlightCard(
emoji: item.emoji, emoji: emoji,
emojiBg: item.emojiBg, emojiBg: emojiBg,
date: item.date, date: dateStr,
title: item.title, title: entry.title,
badge: item.badge, badge: cfg.badge,
badgeBg: item.badgeBg, badgeBg: cfg.bg,
badgeFg: item.badgeFg, badgeFg: cfg.fg,
); );
}), }),
], ],

View File

@@ -1,11 +1,16 @@
// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片 // 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片
// 对齐 Open Design 原型稿 screens/weekly.html // 对齐 Open Design 原型稿 screens/weekly.html
// 接入 JournalRepository 加载真实数据
import 'package:flutter/material.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_colors.dart';
import 'package:nuanji_app/core/theme/app_radius.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_shadows.dart';
import 'package:nuanji_app/core/theme/app_typography.dart'; import 'package:nuanji_app/core/theme/app_typography.dart';
import 'package:nuanji_app/core/utils/mood_utils.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
/// 周概览页面 /// 周概览页面
class WeeklyPage extends StatefulWidget { class WeeklyPage extends StatefulWidget {
@@ -17,24 +22,71 @@ class WeeklyPage extends StatefulWidget {
class _WeeklyPageState extends State<WeeklyPage> { class _WeeklyPageState extends State<WeeklyPage> {
late DateTime _focusedWeekStart; late DateTime _focusedWeekStart;
List<JournalEntry> _journals = [];
bool _isLoading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final now = DateTime.now(); final now = DateTime.now();
_focusedWeekStart = now.subtract(Duration(days: now.weekday - 1)); _focusedWeekStart = _startOfWeek(now);
_loadWeekData();
}
JournalRepository get _repo => context.read<JournalRepository>();
/// 获取某天的周一日期
DateTime _startOfWeek(DateTime date) {
return date.subtract(Duration(days: date.weekday - 1));
}
Future<void> _loadWeekData() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final weekEnd = _focusedWeekStart.add(const Duration(days: 7));
final journals = await _repo.getJournals(
dateFrom: _focusedWeekStart,
dateTo: weekEnd,
);
if (mounted) {
setState(() {
_journals = journals;
_isLoading = false;
});
}
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}
} }
void _goToPreviousWeek() { void _goToPreviousWeek() {
setState(() { setState(() {
_focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7)); _focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7));
}); });
_loadWeekData();
} }
void _goToNextWeek() { void _goToNextWeek() {
setState(() { setState(() {
_focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7)); _focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7));
}); });
_loadWeekData();
}
/// 按日期索引日记: day-of-week (1=周一..7=周日) → JournalEntry 列表
Map<int, List<JournalEntry>> get _journalsByWeekday {
final map = <int, List<JournalEntry>>{};
for (final j in _journals) {
// 判断日记日期是否在本周范围内
final dayKey = j.date.difference(_focusedWeekStart).inDays;
if (dayKey >= 0 && dayKey < 7) {
final weekday = dayKey + 1; // 1=周一, 7=周日
(map[weekday] ??= []).add(j);
}
}
return map;
} }
@override @override
@@ -54,17 +106,22 @@ class _WeeklyPageState extends State<WeeklyPage> {
), ),
// 可滚动内容区 // 可滚动内容区
Expanded( Expanded(
child: ListView( child: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
// 7天条目 // 7天条目(真实数据)
_WeekStrip(weekStart: _focusedWeekStart), _WeekStrip(
weekStart: _focusedWeekStart,
journalsByWeekday: _journalsByWeekday,
),
const SizedBox(height: 20), const SizedBox(height: 20),
// 本周总结 // 本周总结(真实数据)
const _WeekSummary(), _WeekSummary(journals: _journals),
const SizedBox(height: 20), const SizedBox(height: 20),
// 每日日记卡片 // 每日日记卡片(真实数据)
..._buildDayCards(theme, colorScheme), ..._buildDayCards(theme, colorScheme),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
@@ -77,48 +134,67 @@ class _WeeklyPageState extends State<WeeklyPage> {
} }
List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) { List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) {
// 模拟数据: 3 张日记卡片 final byWeekday = _journalsByWeekday;
final cards = <Widget>[];
final weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
// 按日期倒序生成卡片(最新的在上面)
for (var i = 6; i >= 0; i--) {
final weekday = i + 1;
final dayJournals = byWeekday[weekday];
if (dayJournals == null || dayJournals.isEmpty) continue;
final day = _focusedWeekStart.add(Duration(days: i));
final first = dayJournals.first;
cards.add(_DayCard(
weekday: weekNames[i],
date: '${day.month}${day.day}',
moodEmoji: moodToEmoji(first.mood),
weatherEmoji: _weatherEmoji(first.weather),
body: first.contentExcerpt ?? first.title,
tags: first.tags.take(2).map((tag) {
// 根据标签内容选择颜色
return (tag, AppColors.secondarySoftLight, const Color(0xFF2D7D46));
}).toList(),
photoEmoji: dayJournals.any((j) => j.contentExcerpt != null && j.contentExcerpt!.contains('📷'))
? '📷'
: null,
));
}
// 无日记时显示空状态
if (cards.isEmpty) {
return [ return [
_DayCard( SizedBox(
weekday: '周日', height: 200,
date: '5月31日', child: Center(
moodEmoji: '😊', child: Column(
weatherEmoji: '☀️', mainAxisSize: MainAxisSize.min,
body: children: [
'今天下午去图书馆自习,阳光从窗外洒进来,暖暖的。喝了抹茶拿铁,虽然期末压力大但看到窗外的樱花还在开,觉得一切都会好的。', Icon(Icons.edit_note_rounded, size: 48,
tags: const [ color: colorScheme.onSurface.withValues(alpha: 0.2)),
('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)), const SizedBox(height: 12),
('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)), Text('这周还没有日记', style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
)),
], ],
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: '🍲',
), ),
]; ];
} }
return cards;
}
String _weatherEmoji(Weather weather) => switch (weather) {
Weather.sunny => '☀️',
Weather.cloudy => '',
Weather.rainy => '🌧️',
Weather.snowy => '❄️',
Weather.windy => '💨',
};
} }
// ===== 周头部导航 ===== // ===== 周头部导航 =====
@@ -221,32 +297,34 @@ class _NavButton extends StatelessWidget {
} }
} }
// ===== 7天条目 ===== // ===== 7天条目(真实数据)=====
class _WeekStrip extends StatelessWidget { class _WeekStrip extends StatelessWidget {
const _WeekStrip({required this.weekStart}); const _WeekStrip({
required this.weekStart,
required this.journalsByWeekday,
});
final DateTime weekStart; final DateTime weekStart;
final Map<int, List<JournalEntry>> journalsByWeekday;
// 模拟数据: 每天的心情 emoji
static const _mockMoods = ['😊', '😐', '😊', '😊', '😊', '😊', '😊'];
static const _weekNames = ['', '', '', '', '', '', '']; static const _weekNames = ['', '', '', '', '', '', ''];
static const _hasEntry = [true, true, true, true, true, true, true];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colorScheme = Theme.of(context).colorScheme;
final colorScheme = theme.colorScheme;
final now = DateTime.now(); final now = DateTime.now();
return Row( return Row(
children: List.generate(7, (i) { children: List.generate(7, (i) {
final day = weekStart.add(Duration(days: i)); final day = weekStart.add(Duration(days: i));
final weekday = i + 1;
final isToday = day.year == now.year && final isToday = day.year == now.year &&
day.month == now.month && day.month == now.month &&
day.day == now.day; day.day == now.day;
final hasEntry = _hasEntry[i]; final dayJournals = journalsByWeekday[weekday] ?? [];
final moodEmoji = _mockMoods[i]; final hasEntry = dayJournals.isNotEmpty;
final moodEmoji = hasEntry ? moodToEmoji(dayJournals.first.mood) : '·';
return Expanded( return Expanded(
child: GestureDetector( child: GestureDetector(
@@ -286,7 +364,10 @@ class _WeekStrip extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// 心情 emoji // 心情 emoji
Text(moodEmoji, style: const TextStyle(fontSize: 16)), Text(moodEmoji, style: TextStyle(
fontSize: hasEntry ? 16 : 14,
color: hasEntry ? null : colorScheme.onSurface.withValues(alpha: 0.2),
)),
// 有日记: 日期下方4px小圆点 // 有日记: 日期下方4px小圆点
if (hasEntry && !isToday) if (hasEntry && !isToday)
Container( Container(
@@ -318,16 +399,26 @@ class _WeekStrip extends StatelessWidget {
} }
} }
// ===== 本周总结卡片 ===== // ===== 本周总结卡片(真实数据)=====
class _WeekSummary extends StatelessWidget { class _WeekSummary extends StatelessWidget {
const _WeekSummary(); const _WeekSummary({required this.journals});
final List<JournalEntry> journals;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
// 统计真实数据
final recordDays = journals.map((j) => j.date.day).toSet().length;
final journalCount = journals.length;
// 统计贴纸元素 — 从日记标签中估算Phase 1 简化)
final stickerCount = journals.fold<int>(
0, (sum, j) => sum + j.tags.length,
);
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -351,64 +442,79 @@ class _WeekSummary extends StatelessWidget {
Row( Row(
children: [ children: [
_SummaryItem( _SummaryItem(
value: '6', value: '$recordDays',
label: '记录天数', label: '记录天数',
valueColor: AppColors.accent, valueColor: AppColors.accent,
), ),
_SummaryItem( _SummaryItem(
value: '7', value: '$journalCount',
label: '日记篇数', label: '日记篇数',
valueColor: AppColors.secondary, valueColor: AppColors.secondary,
), ),
_SummaryItem( _SummaryItem(
value: '12', value: '$stickerCount',
label: '使用贴纸', label: '使用标签',
valueColor: AppColors.tertiary, valueColor: AppColors.tertiary,
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 心情分布条 // 心情分布条
Row( _MoodDistributionBar(journals: journals),
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 _MoodDistributionBar extends StatelessWidget {
const _MoodDistributionBar({required this.journals});
final List<JournalEntry> journals;
static const _moodConfig = [
(Mood.happy, AppColors.secondary),
(Mood.calm, AppColors.tertiary),
(Mood.sad, Color(0xFF5B7DB1)),
(Mood.angry, AppColors.accent),
(Mood.thinking, Color(0xFF8B7E74)),
];
@override
Widget build(BuildContext context) {
if (journals.isEmpty) {
return Container(
height: 8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
), ),
); );
} }
// 统计各心情数量
final counts = <Mood, int>{};
for (final j in journals) {
counts[j.mood] = (counts[j.mood] ?? 0) + 1;
}
return Row(
children: _moodConfig.where((c) => counts[c.$1] != null).map((config) {
final count = counts[config.$1]!;
return Expanded(
flex: count,
child: Container(
height: 8,
margin: const EdgeInsets.only(right: 2),
decoration: BoxDecoration(
color: config.$2,
borderRadius: BorderRadius.circular(4),
),
),
);
}).toList(),
);
}
} }
/// 单个统计项 /// 单个统计项

View File

@@ -261,11 +261,9 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
emit(current.copyWith(isLoadingWall: true)); emit(current.copyWith(isLoadingWall: true));
try { try {
// 加载属于该班级公开日记 // 服务端过滤:按 classId 查询班级公开日记(后端 API 已支持 ?class_id= 参数)
final journals = await _journalRepo.getJournals(); final journals = await _journalRepo.getJournals(classId: event.classId);
final classJournals = journals final classJournals = journals.where((j) => j.sharedToClass).toList();
.where((j) => j.classId == event.classId && j.sharedToClass)
.toList();
emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false)); emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
} catch (_) { } catch (_) {
@@ -345,9 +343,6 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
TopicAssign event, TopicAssign event,
Emitter<ClassState> emit, Emitter<ClassState> emit,
) async { ) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
try { try {
final dto = await _classRepo.assignTopic( final dto = await _classRepo.assignTopic(
classId: event.classId, classId: event.classId,
@@ -355,6 +350,10 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
description: event.description, description: event.description,
dueDate: event.dueDate, dueDate: event.dueDate,
); );
// 更新本地 topics 列表(仅在班级详情视图中)
if (state is ClassDetailLoaded) {
final current = state as ClassDetailLoaded;
final newTopic = TopicAssignment( final newTopic = TopicAssignment(
id: dto.id, id: dto.id,
classId: dto.classId, classId: dto.classId,
@@ -364,6 +363,7 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
dueDate: dto.dueDate, dueDate: dto.dueDate,
); );
emit(current.copyWith(topics: [newTopic, ...current.topics])); emit(current.copyWith(topics: [newTopic, ...current.topics]));
}
} catch (_) { } catch (_) {
// 静默失败 // 静默失败
} }

View File

@@ -1,15 +1,16 @@
// 心情页面 — 心情统计 + 趋势图 + 连续天数 // 心情页面 — 今日心情 + 天气 + 柱状图 + 统计网格 + 心情洞察
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/core/theme/app_colors.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_radius.dart';
import 'package:nuanji_app/core/utils/mood_utils.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/remote/api_client.dart'; import 'package:nuanji_app/data/remote/api_client.dart';
import '../bloc/mood_bloc.dart'; import '../bloc/mood_bloc.dart';
/// 心情页面 — 统计卡片 + 心情分布饼图 + 详情列表 /// 心情页面 — 今日心情卡片 + 天气选择 + 柱状图 + 统计网格 + 心情洞察
class MoodPage extends StatefulWidget { class MoodPage extends StatefulWidget {
const MoodPage({super.key}); const MoodPage({super.key});
@@ -20,6 +21,9 @@ class MoodPage extends StatefulWidget {
class _MoodPageState extends State<MoodPage> { class _MoodPageState extends State<MoodPage> {
late final MoodBloc _bloc; late final MoodBloc _bloc;
// 天气选择状态
_WeatherType? _selectedWeather;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -70,38 +74,42 @@ class _MoodPageState extends State<MoodPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 统计概览卡片 // 5A: 今日心情卡片
_StatsOverviewCard(stats: state.stats, colorScheme: colorScheme), _TodayMoodCard(stats: state.stats),
const SizedBox(height: 16),
// 5B: 天气卡片
_WeatherCard(
selectedWeather: _selectedWeather,
onWeatherSelected: (w) {
setState(() {
_selectedWeather =
_selectedWeather == w ? null : w;
});
},
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 周期选择器 // 周期选择器
_PeriodSelector( _PeriodPills(
selectedPeriod: state.selectedPeriod, selectedPeriod: state.selectedPeriod,
onPeriodChanged: _bloc.changePeriod, onPeriodChanged: _bloc.changePeriod,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 心情分布饼 // 5C: 柱状
_MoodDistributionChart( _MoodBarChart(
moodCounts: state.stats.moodCounts, moodCounts: state.stats.moodCounts,
colorScheme: colorScheme, selectedPeriod: state.selectedPeriod,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// 心情详情列表 // 5D: 统计网格
Text( _StatsGrid(stats: state.stats),
'心情详情',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
...state.stats.moodCounts.map((mc) => _MoodCountTile(mc: mc)),
const SizedBox(height: 24), const SizedBox(height: 24),
// 连续天数鼓励卡片 // 5E: 心情洞察卡片
_StreakCard(streakDays: state.stats.streakDays), _InsightCard(stats: state.stats),
], ],
), ),
); );
@@ -110,60 +118,89 @@ class _MoodPageState extends State<MoodPage> {
} }
} }
/// 统计概览卡片 // ===== 5A: 今日心情卡片 =====
class _StatsOverviewCard extends StatelessWidget {
const _StatsOverviewCard({ class _TodayMoodCard extends StatelessWidget {
required this.stats, const _TodayMoodCard({required this.stats});
required this.colorScheme,
});
final MoodStats stats; final MoodStats stats;
final ColorScheme colorScheme;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final now = DateTime.now();
final dateStr =
'${now.year}${now.month}${now.day}';
final dominantEmoji = stats.dominantMood != null final dominantEmoji = stats.dominantMood != null
? _moodEmoji(stats.dominantMood!) ? moodToEmoji(stats.dominantMood!)
: '📝'; : '📝';
final dominantLabel = stats.dominantMood != null
? moodToLabel(stats.dominantMood!)
: '暂无记录';
return Card( return Container(
elevation: 0, width: double.infinity,
shape: RoundedRectangleBorder( padding: const EdgeInsets.all(24),
borderRadius: AppRadius.lgBorder, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.lg),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.accent, AppColors.tertiary],
), ),
color: colorScheme.primaryContainer, ),
child: Padding( child: Stack(
padding: const EdgeInsets.all(20),
child: Row(
children: [ children: [
// 主导心情图标 // 装饰圆
Container( Positioned(
width: 56, right: -20,
height: 56, top: -20,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.15), color: Colors.white.withValues(alpha: 0.12),
), ),
alignment: Alignment.center,
child: Text(dominantEmoji, style: const TextStyle(fontSize: 28)),
), ),
),
// 内容
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今日心情 · $dateStr',
style: TextStyle(
fontFamily: 'Caveat',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.85),
),
),
const SizedBox(height: 16),
Row(
children: [
Text(dominantEmoji,
style: const TextStyle(fontSize: 52)),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'心情概览', dominantLabel,
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays}', '${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays}',
style: theme.textTheme.bodySmall?.copyWith( style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.7), fontSize: 13,
color:
Colors.white.withValues(alpha: 0.75),
), ),
), ),
], ],
@@ -171,14 +208,114 @@ class _StatsOverviewCard extends StatelessWidget {
), ),
], ],
), ),
],
),
],
), ),
); );
} }
} }
/// 周期选择器 // ===== 5B: 天气卡片 =====
class _PeriodSelector extends StatelessWidget {
const _PeriodSelector({ enum _WeatherType {
sunny('', '☀️'),
cloudy('多云', ''),
rainy('', '🌧️'),
snowy('', '❄️'),
windy('', '💨');
const _WeatherType(this.label, this.emoji);
final String label;
final String emoji;
}
class _WeatherCard extends StatelessWidget {
const _WeatherCard({
required this.selectedWeather,
required this.onWeatherSelected,
});
final _WeatherType? selectedWeather;
final ValueChanged<_WeatherType> onWeatherSelected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final surfaceColor =
isDark ? AppColors.surfaceDark : AppColors.surfaceLight;
final surfaceWarmColor = isDark
? AppColors.surfaceWarmDark
: AppColors.surfaceWarmLight;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: surfaceColor,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今日天气',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: _WeatherType.values.map((w) {
final isSelected = selectedWeather == w;
return GestureDetector(
onTap: () => onWeatherSelected(w),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isSelected
? AppColors.accent
: (isDark
? AppColors.borderDark
: AppColors.borderLight),
width: 2,
),
color: isSelected
? surfaceWarmColor
: Colors.transparent,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(w.emoji,
style: const TextStyle(fontSize: 20)),
const SizedBox(height: 2),
Text(
w.label,
style: theme.textTheme.labelSmall
?.copyWith(fontSize: 11),
),
],
),
),
);
}).toList(),
),
],
),
);
}
}
// ===== 周期选择胶囊 =====
class _PeriodPills extends StatelessWidget {
const _PeriodPills({
required this.selectedPeriod, required this.selectedPeriod,
required this.onPeriodChanged, required this.onPeriodChanged,
}); });
@@ -188,193 +325,460 @@ class _PeriodSelector extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SegmentedButton<StatsPeriod>( final theme = Theme.of(context);
segments: const [ final isDark = theme.brightness == Brightness.dark;
ButtonSegment(value: StatsPeriod.week, label: Text('')),
ButtonSegment(value: StatsPeriod.month, label: Text('')),
ButtonSegment(value: StatsPeriod.quarter, label: Text('')),
],
selected: {selectedPeriod},
onSelectionChanged: (set) => onPeriodChanged(set.first),
);
}
}
/// 心情分布饼图 final periods = [
class _MoodDistributionChart extends StatelessWidget { (StatsPeriod.week, '7天'),
const _MoodDistributionChart({ (StatsPeriod.month, '30天'),
required this.moodCounts, (StatsPeriod.quarter, '3个月'),
required this.colorScheme, ];
});
final List<MoodCount> moodCounts; return Row(
final ColorScheme colorScheme; children: periods.map((p) {
final isSelected = selectedPeriod == p.$1;
@override return Padding(
Widget build(BuildContext context) { padding: const EdgeInsets.only(right: 8),
if (moodCounts.isEmpty) { child: GestureDetector(
return const SizedBox.shrink(); onTap: () => onPeriodChanged(p.$1),
} child: Container(
padding: const EdgeInsets.symmetric(
return Card( horizontal: 16,
elevation: 0, vertical: 8,
shape: RoundedRectangleBorder( ),
borderRadius: AppRadius.lgBorder, decoration: BoxDecoration(
side: BorderSide(color: colorScheme.outlineVariant), borderRadius: BorderRadius.circular(AppRadius.pill),
color: isSelected
? AppColors.accent
: Colors.transparent,
border: Border.all(
color: isSelected
? AppColors.accent
: (isDark
? AppColors.borderDark
: AppColors.borderLight),
),
),
child: Text(
p.$2,
style: theme.textTheme.bodySmall?.copyWith(
color: isSelected
? Colors.white
: (isDark
? AppColors.fg2Dark
: AppColors.fg2Light),
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
), ),
child: Padding(
padding: const EdgeInsets.all(20),
child: SizedBox(
height: 200,
child: PieChart(
PieChartData(
sections: moodCounts.map((mc) {
final color =
AppColors.moodColors[mc.mood.value] ?? colorScheme.primary;
return PieChartSectionData(
value: mc.count.toDouble(),
color: color,
radius: 50,
title: '${mc.percentage.toStringAsFixed(0)}%',
titleStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimary,
), ),
); );
}).toList(), }).toList(),
sectionsSpace: 2,
centerSpaceRadius: 40,
),
),
),
),
); );
} }
} }
/// 心情计数列表项 // ===== 5C: 柱状图 =====
class _MoodCountTile extends StatelessWidget {
const _MoodCountTile({required this.mc});
final MoodCount mc; class _MoodBarChart extends StatelessWidget {
const _MoodBarChart({
required this.moodCounts,
required this.selectedPeriod,
});
@override final List<MoodCount> moodCounts;
Widget build(BuildContext context) { final StatsPeriod selectedPeriod;
final theme = Theme.of(context);
final color =
AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Text(_moodEmoji(mc.mood), style: const TextStyle(fontSize: 20)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_moodLabel(mc.mood),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: mc.percentage / 100,
backgroundColor: color.withValues(alpha: 0.15),
color: color,
minHeight: 6,
),
),
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 48,
child: Text(
'${mc.count}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
textAlign: TextAlign.end,
),
),
],
),
);
}
}
/// 连续天数鼓励卡片
class _StreakCard extends StatelessWidget {
const _StreakCard({required this.streakDays});
final int streakDays;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Card( if (moodCounts.isEmpty) {
elevation: 0, return const SizedBox.shrink();
shape: RoundedRectangleBorder( }
borderRadius: AppRadius.lgBorder,
), final maxCount = moodCounts
color: AppColors.tertiary.withValues(alpha: 0.15), .fold<int>(0, (max, mc) => mc.count > max ? mc.count : max);
child: Padding(
return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Row( decoration: BoxDecoration(
children: [ color: isDark
const Text('🔥', style: TextStyle(fontSize: 32)), ? AppColors.surfaceDark
const SizedBox(width: 16), : AppColors.surfaceLight,
Expanded( borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'连续 $streakDays', '心情分布',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 16),
Text( SizedBox(
streakDays >= 7 height: 180,
? '太棒了!你已经坚持了一周 ✨' child: BarChart(
: '继续加油,坚持就是胜利!', BarChartData(
style: theme.textTheme.bodySmall?.copyWith( alignment: BarChartAlignment.spaceAround,
color: colorScheme.onSurface.withValues(alpha: 0.7), maxY: (maxCount + 2).toDouble(),
barGroups: moodCounts.asMap().entries.map((entry) {
final index = entry.key;
final mc = entry.value;
final color =
AppColors.moodColors[mc.mood.value] ??
colorScheme.primary;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: mc.count.toDouble(),
color: color,
width: 14,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
), ),
), ),
], ],
showingTooltipIndicators: [0],
);
}).toList(),
titlesData: FlTitlesData(
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 ||
index >= moodCounts.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
moodToEmoji(moodCounts[index].mood),
style: const TextStyle(fontSize: 16),
),
);
},
reservedSize: 28,
),
),
),
borderData: FlBorderData(show: false),
gridData: const FlGridData(show: false),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
if (groupIndex >= moodCounts.length) {
return null;
}
final mc = moodCounts[groupIndex];
return BarTooltipItem(
'${moodToLabel(mc.mood)}: ${mc.count}',
TextStyle(
color: isDark
? AppColors.fgDark
: AppColors.fgLight,
fontWeight: FontWeight.w600,
fontSize: 12,
),
);
},
),
),
),
), ),
), ),
], ],
), ),
),
); );
} }
} }
// ===== 辅助函数 ===== // ===== 5D: 统计网格 =====
String _moodEmoji(Mood mood) => switch (mood) { class _StatsGrid extends StatelessWidget {
Mood.happy => '😊', const _StatsGrid({required this.stats});
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
String _moodLabel(Mood mood) => switch (mood) { final MoodStats stats;
Mood.happy => '开心',
Mood.calm => '平静', @override
Mood.sad => '难过', Widget build(BuildContext context) {
Mood.angry => '生气', final theme = Theme.of(context);
Mood.thinking => '思考', final isDark = theme.brightness == Brightness.dark;
};
// 计算好心情占比
final happyCount = stats.moodCounts
.where((mc) => mc.mood == Mood.happy)
.fold<int>(0, (sum, mc) => sum + mc.count);
final totalCount = stats.moodCounts
.fold<int>(0, (sum, mc) => sum + mc.count);
final goodPercent = totalCount > 0
? (happyCount / totalCount * 100).toStringAsFixed(0)
: '0';
// TODO: 从统计数据获取实际照片数,暂时用 0
const photoCount = 0;
final items = [
_StatItem(
emoji: '📝',
value: '${stats.totalJournals}',
description: '日记总数',
color: AppColors.secondary,
),
_StatItem(
emoji: '🔥',
value: '${stats.streakDays}',
description: '连续天数',
color: AppColors.accent,
),
_StatItem(
emoji: '😊',
value: '$goodPercent%',
description: '好心情占比',
color: AppColors.tertiary,
),
_StatItem(
emoji: '📷',
value: '$photoCount',
description: '照片数量',
color: AppColors.rose,
),
];
return GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.4,
children: items.map((item) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(item.emoji,
style: const TextStyle(fontSize: 28)),
const SizedBox(height: 8),
Text(
item.value,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: item.color,
),
),
const SizedBox(height: 2),
Text(
item.description,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
),
),
],
),
);
}).toList(),
);
}
}
class _StatItem {
final String emoji;
final String value;
final String description;
final Color color;
const _StatItem({
required this.emoji,
required this.value,
required this.description,
required this.color,
});
}
// ===== 5E: 心情洞察卡片 =====
class _InsightCard extends StatelessWidget {
const _InsightCard({required this.stats});
final MoodStats stats;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// 计算最频繁心情
final mostFrequent = stats.moodCounts.isNotEmpty
? stats.moodCounts.reduce(
(a, b) => a.count > b.count ? a : b)
: null;
// 计算心情趋势简化基于好心情vs坏心情比例判断
final happyCount = stats.moodCounts
.where((mc) =>
mc.mood == Mood.happy || mc.mood == Mood.calm)
.fold<int>(0, (sum, mc) => sum + mc.count);
final sadCount = stats.moodCounts
.where((mc) =>
mc.mood == Mood.sad || mc.mood == Mood.angry)
.fold<int>(0, (sum, mc) => sum + mc.count);
final trendLabel = happyCount >= sadCount ? 'improving' : 'declining';
final trendEmoji = happyCount >= sadCount ? '📈' : '📉';
final trendText = happyCount >= sadCount ? '越来越好' : '需要关注';
final insights = [
_InsightItem(
emoji: mostFrequent != null
? moodToEmoji(mostFrequent.mood)
: '📝',
title: '最频繁心情',
detail: mostFrequent != null
? '${moodToLabel(mostFrequent.mood)} · ${mostFrequent.count}'
: '暂无数据',
color: AppColors.secondary,
),
_InsightItem(
emoji: '🔥',
title: '最长连续',
detail: '${stats.streakDays}',
color: AppColors.accent,
),
_InsightItem(
emoji: trendEmoji,
title: '心情趋势',
detail: trendText,
color: trendLabel == 'improving'
? AppColors.secondary
: AppColors.rose,
),
];
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'心情洞察',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
...insights.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
item.color.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(item.emoji,
style: const TextStyle(fontSize: 18)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
item.title,
style:
theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
item.detail,
style:
theme.textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
),
),
],
),
),
],
),
)),
],
),
);
}
}
class _InsightItem {
final String emoji;
final String title;
final String detail;
final Color color;
const _InsightItem({
required this.emoji,
required this.title,
required this.detail,
required this.color,
});
}

View File

@@ -535,7 +535,7 @@ class _ChildCard extends StatelessWidget {
} }
/// 功能操作网格 — 4 个功能按钮 /// 功能操作网格 — 4 个功能按钮
class _ActionGrid extends StatelessWidget { class _ActionGrid extends StatefulWidget {
const _ActionGrid({ const _ActionGrid({
required this.children, required this.children,
required this.onViewJournals, required this.onViewJournals,
@@ -550,18 +550,86 @@ class _ActionGrid extends StatelessWidget {
final void Function(String childId) onDelete; final void Function(String childId) onDelete;
final void Function(String childId) onMoodStats; final void Function(String childId) onMoodStats;
/// 取第一个绑定的孩子 IDPhase 1 简化逻辑) @override
String get _firstChildId => State<_ActionGrid> createState() => _ActionGridState();
children.isNotEmpty ? children.first.childId : ''; }
class _ActionGridState extends State<_ActionGrid> {
late String _selectedChildId;
@override
void initState() {
super.initState();
_selectedChildId = widget.children.isNotEmpty ? widget.children.first.childId : '';
}
@override
void didUpdateWidget(covariant _ActionGrid oldWidget) {
super.didUpdateWidget(oldWidget);
// 当孩子列表变化时更新选中 ID
if (oldWidget.children != widget.children) {
if (!widget.children.any((c) => c.childId == _selectedChildId)) {
_selectedChildId = widget.children.isNotEmpty ? widget.children.first.childId : '';
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 孩子选择器(多孩子时显示)
if (widget.children.length > 1) ...[
Text(
'选择孩子',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
border: Border.all(color: colorScheme.outlineVariant),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedChildId.isEmpty ? null : _selectedChildId,
isExpanded: true,
icon: const Icon(Icons.expand_more, size: 20),
items: widget.children.map((child) {
final shortId = child.childId.length > 8
? child.childId.substring(0, 8)
: child.childId;
return DropdownMenuItem(
value: child.childId,
child: Row(
children: [
const Text('👧', style: TextStyle(fontSize: 18)),
const SizedBox(width: 8),
Text('孩子 $shortId'),
],
),
);
}).toList(),
onChanged: (v) {
if (v != null) setState(() => _selectedChildId = v);
},
),
),
),
const SizedBox(height: 16),
],
Text( Text(
'功能', '功能',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@@ -572,7 +640,7 @@ class _ActionGrid extends StatelessWidget {
iconBgColor: AppColors.accent.withValues(alpha: 0.12), iconBgColor: AppColors.accent.withValues(alpha: 0.12),
title: '日记查看', title: '日记查看',
subtitle: '只读查看孩子的日记和评语', subtitle: '只读查看孩子的日记和评语',
onTap: () => onViewJournals(_firstChildId), onTap: () => widget.onViewJournals(_selectedChildId),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_ActionCard( _ActionCard(
@@ -581,7 +649,7 @@ class _ActionGrid extends StatelessWidget {
iconBgColor: AppColors.secondary.withValues(alpha: 0.12), iconBgColor: AppColors.secondary.withValues(alpha: 0.12),
title: '心情统计', title: '心情统计',
subtitle: '查看孩子的写作频率和心情趋势', subtitle: '查看孩子的写作频率和心情趋势',
onTap: () => onMoodStats(_firstChildId), onTap: () => widget.onMoodStats(_selectedChildId),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_ActionCard( _ActionCard(
@@ -590,7 +658,7 @@ class _ActionGrid extends StatelessWidget {
iconBgColor: AppColors.tertiary.withValues(alpha: 0.12), iconBgColor: AppColors.tertiary.withValues(alpha: 0.12),
title: '数据导出', title: '数据导出',
subtitle: '导出孩子的所有日记数据', subtitle: '导出孩子的所有日记数据',
onTap: () => onExport(_firstChildId), onTap: () => widget.onExport(_selectedChildId),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_ActionCard( _ActionCard(
@@ -599,7 +667,7 @@ class _ActionGrid extends StatelessWidget {
iconBgColor: AppColors.error.withValues(alpha: 0.12), iconBgColor: AppColors.error.withValues(alpha: 0.12),
title: '数据删除', title: '数据删除',
subtitle: '永久删除孩子的日记数据', subtitle: '永久删除孩子的日记数据',
onTap: () => onDelete(_firstChildId), onTap: () => widget.onDelete(_selectedChildId),
), ),
], ],
); );

View File

@@ -5,8 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.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_radius.dart';
import 'package:nuanji_app/core/constants/design_tokens.dart';
import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart'; import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart';
import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart';
import 'package:nuanji_app/data/models/user.dart'; import 'package:nuanji_app/data/models/user.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
/// 个人中心页面 /// 个人中心页面
class ProfilePage extends StatelessWidget { class ProfilePage extends StatelessWidget {
@@ -17,103 +20,161 @@ class ProfilePage extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final authState = context.watch<AuthBloc>().state; final authState = context.watch<AuthBloc>().state;
final displayName = authState is Authenticated ? authState.user.displayLabel : '用户'; final user = authState is Authenticated ? authState.user : null;
final role = authState is Authenticated ? authState.user.primaryRoleType : null; final displayName = user?.displayLabel ?? '用户';
final role = user?.primaryRoleType;
final isDark = theme.brightness == Brightness.dark;
// 柔和背景色(根据明暗模式)
final accentSoft = isDark ? const Color(0xFF3A2A22) : const Color(0xFFFFE0D6);
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
final roseSoft = isDark ? AppColors.roseSoftDark : AppColors.roseSoftLight;
final secondarySoft = isDark ? AppColors.secondarySoftDark : AppColors.secondarySoftLight;
final surfaceWarm = isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight;
final borderSoft = isDark ? AppColors.borderSoftDark : AppColors.borderSoftLight;
final greyBg = isDark ? const Color(0xFF2A2520) : const Color(0xFFF5F0EB);
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
// 用户头像卡片 // ---- 用户头像卡片 ----
Card( Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
color: colorScheme.primaryContainer, color: colorScheme.surface,
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Row(
children: [
CircleAvatar(
radius: 32,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
displayName.characters.first,
style: theme.textTheme.headlineSmall?.copyWith(color: colorScheme.primary),
),
),
const SizedBox(width: 16),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(displayName, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), // 头像 — 渐变背景 + emoji
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.accent, AppColors.tertiary],
),
),
alignment: Alignment.center,
child: const Text('😊', style: TextStyle(fontSize: 36)),
),
const SizedBox(height: 12),
// 用户名
Text(displayName, style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4), const SizedBox(height: 4),
// 角色
Text( Text(
_roleLabel(role), _roleLabel(role),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6), color: colorScheme.onSurface.withValues(alpha: 0.6),
), ),
), ),
], const SizedBox(height: 4),
// 签名
Text(
user?.displayName ?? '这个人很懒,什么都没写',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
), ),
), ),
], ],
), ),
), ),
), ),
const SizedBox(height: 12),
// ---- 统计栏(真实数据) ----
_LiveStatsBar(borderSoft: borderSoft, colorScheme: colorScheme),
const SizedBox(height: 20),
// ---- 成就徽章 ----
Align(
alignment: Alignment.centerLeft,
child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
)),
),
const SizedBox(height: 12),
SizedBox(
height: 100,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_BadgeItem(emoji: '📝', name: '初出茅庐', bgColor: accentSoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🔥', name: '七日连续', bgColor: tertiarySoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🎨', name: '装饰达人', bgColor: roseSoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🌟', name: '人气之星', bgColor: secondarySoft, locked: true),
const SizedBox(width: 12),
_BadgeItem(emoji: '🏆', name: '写作高手', bgColor: accentSoft, locked: true),
const SizedBox(width: 12),
_BadgeItem(emoji: '💎', name: '全能王', bgColor: tertiarySoft, locked: true),
],
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
// 功能入口 // ---- 功能入口 (emoji 图标) ----
_ProfileMenuItem( _EmojiMenuItem(emoji: '🏆', bg: tertiarySoft, title: '我的成就', onTap: () => context.go('/achievements')),
icon: Icons.auto_awesome_outlined, _EmojiMenuItem(emoji: '🎨', bg: roseSoft, title: '贴纸收藏', onTap: () => context.go('/stickers')),
iconColor: AppColors.accent, _EmojiMenuItem(emoji: '📋', bg: secondarySoft, title: '日记模板', onTap: () => context.go('/templates')),
title: '我的成就', _EmojiMenuItem(emoji: '👥', bg: accentSoft, title: '我的班级', onTap: () => context.go('/class')),
onTap: () => context.go('/achievements'), _EmojiMenuItem(emoji: '😊', bg: tertiarySoft, title: '心情统计', onTap: () => context.go('/mood')),
), _EmojiMenuItem(emoji: '⚙️', bg: greyBg, title: '设置', onTap: () => context.go('/settings')),
_ProfileMenuItem(
icon: Icons.emoji_emotions_outlined,
iconColor: AppColors.secondary,
title: '贴纸收藏',
onTap: () => context.go('/stickers'),
),
_ProfileMenuItem(
icon: Icons.dashboard_customize_outlined,
iconColor: AppColors.tertiary,
title: '日记模板',
onTap: () => context.go('/templates'),
),
_ProfileMenuItem(
icon: Icons.groups_outlined,
iconColor: colorScheme.primary,
title: '我的班级',
onTap: () => context.go('/class'),
),
if (role != null && role.name == 'teacher') if (role != null && role.name == 'teacher')
_ProfileMenuItem( _EmojiMenuItem(emoji: '📚', bg: accentSoft, title: '教师管理', onTap: () => context.go('/teacher')),
icon: Icons.school_outlined,
iconColor: AppColors.accent,
title: '教师管理',
onTap: () => context.go('/teacher'),
),
if (role != null && role.name == 'parent') if (role != null && role.name == 'parent')
_ProfileMenuItem( _EmojiMenuItem(emoji: '👨‍👩‍👧', bg: roseSoft, title: '家长中心', onTap: () => context.go('/parent')),
icon: Icons.family_restroom_outlined,
iconColor: AppColors.rose,
title: '家长中心',
onTap: () => context.go('/parent'),
),
const Divider(height: 32), const Divider(height: 32),
_ProfileMenuItem(
icon: Icons.bar_chart_outlined, // ---- 开关项 ----
iconColor: colorScheme.primary, _EmojiToggleItem(
title: '心情统计', emoji: '🔔',
onTap: () => context.go('/mood'), bg: tertiarySoft,
title: '消息通知',
value: true,
onChanged: (v) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(v ? '已开启通知' : '已关闭通知')),
);
},
activeColor: AppColors.accent,
), ),
_ProfileMenuItem( _EmojiToggleItem(
icon: Icons.settings_outlined, emoji: '🌙',
iconColor: colorScheme.onSurface.withValues(alpha: 0.5), bg: surfaceWarm,
title: '设置', title: '深色模式',
onTap: () => context.go('/settings'), value: theme.brightness == Brightness.dark,
onChanged: (v) {
final settings = context.read<SettingsBloc>();
settings.changeTheme(v ? ThemeMode.dark : ThemeMode.light);
},
activeColor: AppColors.accent,
),
const Divider(height: 32),
// ---- 更多设置 ----
_EmojiMenuItem(emoji: '📤', bg: secondarySoft, title: '导出数据', onTap: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('导出功能即将上线')));
}),
_EmojiMenuItem(emoji: '💬', bg: accentSoft, title: '意见反馈', onTap: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('感谢你的反馈')));
}),
_EmojiMenuItem(
emoji: '',
bg: greyBg,
title: '关于',
subtitle: '版本 1.0.0',
onTap: () => context.go('/about'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -129,6 +190,7 @@ class ProfilePage extends StatelessWidget {
child: const Text('退出登录'), child: const Text('退出登录'),
), ),
), ),
const SizedBox(height: DesignTokens.spacing24),
], ],
), ),
); );
@@ -146,28 +208,230 @@ class ProfilePage extends StatelessWidget {
} }
} }
class _ProfileMenuItem extends StatelessWidget { /// 统计项
const _ProfileMenuItem({ class _StatItem extends StatelessWidget {
required this.icon, const _StatItem({required this.label, required this.value, required this.valueColor});
required this.iconColor, final String label;
final String value;
final Color valueColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Expanded(
child: Column(
children: [
Text(value, style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, color: valueColor,
)),
const SizedBox(height: 4),
Text(label, style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme(context).onSurface.withValues(alpha: 0.5),
)),
],
),
);
}
static ColorScheme colorScheme(BuildContext context) => Theme.of(context).colorScheme;
}
/// 成就徽章项
class _BadgeItem extends StatelessWidget {
const _BadgeItem({
required this.emoji,
required this.name,
required this.bgColor,
required this.locked,
});
final String emoji;
final String name;
final Color bgColor;
final bool locked;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: 80,
child: Opacity(
opacity: locked ? 0.5 : 1.0,
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: locked ? theme.colorScheme.outlineVariant.withValues(alpha: 0.3) : bgColor,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(emoji, style: const TextStyle(fontSize: 24)),
),
const SizedBox(height: 6),
Text(name, style: theme.textTheme.labelSmall?.copyWith(fontSize: 11)),
],
),
),
);
}
}
/// emoji 菜单项
class _EmojiMenuItem extends StatelessWidget {
const _EmojiMenuItem({
required this.emoji,
required this.bg,
required this.title, required this.title,
required this.onTap, required this.onTap,
this.subtitle,
}); });
final String emoji;
final IconData icon; final Color bg;
final Color iconColor;
final String title; final String title;
final String? subtitle;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return ListTile( return ListTile(
leading: Icon(icon, color: iconColor), leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: Text(emoji, style: const TextStyle(fontSize: 18)),
),
title: Text(title, style: theme.textTheme.bodyMedium), title: Text(title, style: theme.textTheme.bodyMedium),
subtitle: subtitle != null
? Text(subtitle!, style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
))
: null,
trailing: const Icon(Icons.chevron_right, size: 20), trailing: const Icon(Icons.chevron_right, size: 20),
onTap: onTap, onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 4),
); );
} }
} }
/// emoji 开关菜单项
class _EmojiToggleItem extends StatelessWidget {
const _EmojiToggleItem({
required this.emoji,
required this.bg,
required this.title,
required this.value,
required this.onChanged,
required this.activeColor,
});
final String emoji;
final Color bg;
final String title;
final bool value;
final ValueChanged<bool> onChanged;
final Color activeColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: Text(emoji, style: const TextStyle(fontSize: 18)),
),
title: Text(title, style: theme.textTheme.bodyMedium),
trailing: Switch(
value: value,
onChanged: onChanged,
activeColor: activeColor,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
);
}
}
/// 动态统计栏 — 从 JournalRepository 加载真实数据
class _LiveStatsBar extends StatefulWidget {
const _LiveStatsBar({required this.borderSoft, required this.colorScheme});
final Color borderSoft;
final ColorScheme colorScheme;
@override
State<_LiveStatsBar> createState() => _LiveStatsBarState();
}
class _LiveStatsBarState extends State<_LiveStatsBar> {
int _totalCount = 0;
int _streakDays = 0;
int _monthCount = 0;
@override
void initState() {
super.initState();
_loadStats();
}
Future<void> _loadStats() async {
try {
final repo = context.read<JournalRepository>();
final totalCount = await repo.getJournalCount();
final journals = await repo.getJournals();
if (!mounted) return;
final today = DateTime.now();
final monthCount = journals
.where((j) => j.date.year == today.year && j.date.month == today.month)
.length;
// 推算连续天数
final dates = journals.map((j) => j.date).toSet();
var streak = 0;
var checkDate = today;
while (dates.contains(DateTime(checkDate.year, checkDate.month, checkDate.day))) {
streak++;
checkDate = checkDate.subtract(const Duration(days: 1));
}
setState(() {
_totalCount = totalCount;
_streakDays = streak;
_monthCount = monthCount;
});
} catch (_) {
// 保持默认 0 值
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: widget.colorScheme.surface,
borderRadius: AppRadius.mdBorder,
),
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
_StatItem(label: '总日记', value: '$_totalCount', valueColor: AppColors.accent),
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
_StatItem(label: '连续天数', value: '$_streakDays', valueColor: AppColors.secondary),
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: '--', valueColor: widget.colorScheme.onSurface),
],
),
);
}
}

View File

@@ -1,20 +1,25 @@
// 搜索页面 — 标签+心情筛选日记 // 搜索页面 — 搜索历史 + 热门搜索 + 关键词高亮 + 分类 Tab
// //
// 通过 SearchBloc 驱动搜索状态: // 通过 SearchBloc 驱动搜索状态:
// - 关键词输入 → SearchByKeyword event
// - 标签点击 → SearchByTag event // - 标签点击 → SearchByTag event
// - 心情选择 → SearchByMood event // - 心情选择 → SearchByMood event
// - Tab 切换 → SearchTabChanged event
// - 清除按钮 → SearchClear event // - 清除按钮 → SearchClear event
// 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。 // 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/utils/mood_utils.dart';
import '../../../data/models/journal_entry.dart'; import '../../../data/models/journal_entry.dart';
import '../bloc/search_bloc.dart'; import '../bloc/search_bloc.dart';
/// 搜索页面 — 标签+心情筛选日记 /// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类
class SearchPage extends StatefulWidget { class SearchPage extends StatefulWidget {
const SearchPage({super.key}); const SearchPage({super.key});
@@ -24,14 +29,24 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> { class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final _searchFocusNode = FocusNode();
// Phase 1 占位标签数据 // 热门搜索占位数据
final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情']; final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸'];
final _moodFilters = Mood.values;
@override
void initState() {
super.initState();
// 自动弹出键盘
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_searchFocusNode.dispose();
super.dispose(); super.dispose();
} }
@@ -39,120 +54,341 @@ class _SearchPageState extends State<SearchPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return BlocBuilder<SearchBloc, SearchState>( return BlocBuilder<SearchBloc, SearchState>(
builder: (context, state) { builder: (context, state) {
final hasFilter = state is SearchLoaded && state.hasActiveFilter;
return Scaffold( return Scaffold(
appBar: AppBar( body: SafeArea(
title: TextField( child: Column(
children: [
// 6A: 搜索头部 — 返回 + 输入框 + 取消
_buildSearchHeader(
context, theme, colorScheme, isDark),
// 6C: 结果分类 Tab有结果时显示
if (state case SearchLoaded(:final hasActiveFilter) when hasActiveFilter)
_buildResultTabs(context, state),
// 主体内容
Expanded(
child: _buildBody(
context, theme, colorScheme, isDark, state),
),
],
),
),
);
},
);
}
// ===== 6A: 搜索头部 =====
Widget _buildSearchHeader(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
bool isDark,
) {
final surfaceWarmColor = isDark
? AppColors.surfaceWarmDark
: AppColors.surfaceWarmLight;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
children: [
// 返回按钮
SizedBox(
width: 44,
height: 44,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(
Icons.arrow_back_ios_new,
size: 18,
color: isDark
? AppColors.fgDark
: AppColors.fgLight,
),
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppRadius.pill),
),
),
),
),
const SizedBox(width: 4),
// 搜索输入框
Expanded(
child: SizedBox(
height: 44,
child: TextField(
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration( decoration: InputDecoration(
hintText: '搜索日记...', hintText: '搜索日记...',
hintStyle: theme.textTheme.bodyLarge?.copyWith( hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4), color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
), ),
border: InputBorder.none, prefixIcon: Icon(
prefixIcon: const Icon(Icons.search), Icons.search,
suffixIcon: hasFilter size: 20,
color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.filter_alt_off), icon: Icon(
tooltip: '清除筛选', Icons.clear,
onPressed: _clearSearch, size: 18,
) color: isDark
: (_searchController.text.isNotEmpty ? AppColors.mutedDark
? IconButton( : AppColors.mutedLight,
icon: const Icon(Icons.clear), ),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_clearSearch(); _clearSearch();
}, },
) )
: null), : null,
filled: true,
fillColor: surfaceWarmColor,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(AppRadius.pill),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16),
isDense: true,
), ),
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onSubmitted: (value) { onSubmitted: (value) {
if (value.trim().isNotEmpty) { if (value.trim().isNotEmpty) {
context.read<SearchBloc>().add(SearchByTag(value.trim())); context
.read<SearchBloc>()
.add(SearchByKeyword(value.trim()));
} }
}, },
), ),
), ),
body: _buildBody(context, theme, colorScheme, state), ),
); const SizedBox(width: 4),
}, // 取消按钮
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'取消',
style: theme.textTheme.bodyMedium?.copyWith(
color: AppColors.accent,
fontWeight: FontWeight.w500,
),
),
),
],
),
); );
} }
/// 根据搜索状态构建 body // ===== 6C: 结果分类 Tab =====
Widget _buildResultTabs(
BuildContext context, SearchLoaded state) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: SearchResultTab.values.map((tab) {
final isActive = state.activeTab == tab;
return GestureDetector(
onTap: () {
context
.read<SearchBloc>()
.add(SearchTabChanged(tab));
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isActive
? AppColors.accent
: Colors.transparent,
width: 3,
),
),
),
child: Text(
tab.label,
style: theme.textTheme.bodySmall?.copyWith(
color: isActive
? AppColors.accent
: (isDark
? AppColors.mutedDark
: AppColors.mutedLight),
fontWeight: isActive
? FontWeight.w600
: FontWeight.normal,
),
),
),
);
}).toList(),
),
);
}
// ===== 主体内容 =====
Widget _buildBody( Widget _buildBody(
BuildContext context, BuildContext context,
ThemeData theme, ThemeData theme,
ColorScheme colorScheme, ColorScheme colorScheme,
bool isDark,
SearchState state, SearchState state,
) { ) {
return switch (state) { return switch (state) {
SearchInitial() => _buildSuggestions(context, theme, colorScheme), SearchInitial() => _buildSuggestions(
SearchLoading() => const Center(child: CircularProgressIndicator()), context, theme, colorScheme, isDark, []),
SearchLoaded(:final results, :final activeMood, :final activeTag) => SearchLoading() =>
_hasActiveFilter(activeMood, activeTag) const Center(child: CircularProgressIndicator()),
? _buildResults(context, theme, colorScheme, results) SearchLoaded(
: _buildSuggestions(context, theme, colorScheme), :final results,
SearchError(:final message) => _buildError(colorScheme, message), :final activeMood,
:final activeTag,
:final activeKeyword,
:final activeTab,
:final searchHistory,
) =>
hasActiveFilter(activeMood, activeTag, activeKeyword)
? _buildFilteredResults(
context,
theme,
colorScheme,
isDark,
results,
activeKeyword,
activeTab,
)
: _buildSuggestions(
context, theme, colorScheme, isDark, searchHistory),
SearchError(:final message) =>
_buildError(colorScheme, message),
}; };
} }
bool _hasActiveFilter(String? mood, String? tag) => bool hasActiveFilter(String? mood, String? tag, String? keyword) =>
mood != null || tag != null; mood != null || tag != null || keyword != null;
// ===== 6B: 搜索历史 + 热门搜索 =====
/// 建议区域 — 标签云 + 心情选择
Widget _buildSuggestions( Widget _buildSuggestions(
BuildContext context, BuildContext context,
ThemeData theme, ThemeData theme,
ColorScheme colorScheme, ColorScheme colorScheme,
bool isDark,
List<String> searchHistory,
) { ) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 搜索历史
if (searchHistory.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'常用标签', '最近搜索',
style: style: theme.textTheme.titleSmall?.copyWith(
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), fontWeight: FontWeight.w600,
),
),
GestureDetector(
onTap: _clearSearch,
child: Text(
'清除',
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.accent,
),
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: _recentTags.map((tag) { children: searchHistory.map((keyword) {
return ActionChip( return ActionChip(
label: Text(tag), label: Text(keyword),
onPressed: () { onPressed: () {
_searchController.text = tag; _searchController.text = keyword;
context.read<SearchBloc>().add(SearchByTag(tag)); context
.read<SearchBloc>()
.add(SearchByKeyword(keyword));
}, },
); );
}).toList(), }).toList(),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
],
// 热门搜索
Text(
'热门搜索',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _hotSearches.map((keyword) {
return ActionChip(
label: Text(keyword),
onPressed: () {
_searchController.text = keyword;
context
.read<SearchBloc>()
.add(SearchByKeyword(keyword));
},
);
}).toList(),
),
const SizedBox(height: 24),
// 心情筛选
Text( Text(
'按心情筛选', '按心情筛选',
style: style: theme.textTheme.titleSmall?.copyWith(
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), fontWeight: FontWeight.w600,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( Wrap(
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
children: _moodFilters.map((mood) { children: Mood.values.map((mood) {
final color = final color = AppColors.moodColors[mood.value] ??
AppColors.moodColors[mood.value] ?? colorScheme.primary; colorScheme.primary;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
_searchController.text = _moodLabel(mood); _searchController.text = moodToLabel(mood);
context.read<SearchBloc>().add(SearchByMood(mood)); context
.read<SearchBloc>()
.add(SearchByMood(mood));
}, },
child: Column( child: Column(
children: [ children: [
@@ -164,11 +400,12 @@ class _SearchPageState extends State<SearchPage> {
color: color.withValues(alpha: 0.15), color: color.withValues(alpha: 0.15),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text(_moodEmoji(mood), child: Text(moodToEmoji(mood),
style: const TextStyle(fontSize: 24)), style: const TextStyle(fontSize: 24)),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(_moodLabel(mood), style: theme.textTheme.labelSmall), Text(moodToLabel(mood),
style: theme.textTheme.labelSmall),
], ],
), ),
); );
@@ -179,12 +416,16 @@ class _SearchPageState extends State<SearchPage> {
); );
} }
/// 搜索结果列表 // ===== 按分类 Tab 过滤结果 =====
Widget _buildResults(
Widget _buildFilteredResults(
BuildContext context, BuildContext context,
ThemeData theme, ThemeData theme,
ColorScheme colorScheme, ColorScheme colorScheme,
bool isDark,
List<JournalEntry> results, List<JournalEntry> results,
String? keyword,
SearchResultTab activeTab,
) { ) {
if (results.isEmpty) { if (results.isEmpty) {
return Center( return Center(
@@ -198,7 +439,8 @@ class _SearchPageState extends State<SearchPage> {
Text( Text(
'没有找到匹配的日记', '没有找到匹配的日记',
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5), color:
colorScheme.onSurface.withValues(alpha: 0.5),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -211,17 +453,173 @@ class _SearchPageState extends State<SearchPage> {
); );
} }
// 根据活跃 Tab 选择展示内容
switch (activeTab) {
case SearchResultTab.all:
case SearchResultTab.journal:
return _buildJournalResults(
context, theme, colorScheme, isDark, results, keyword);
case SearchResultTab.template:
return _buildTemplateResults(theme, isDark);
case SearchResultTab.tag:
return _buildTagResults(theme, isDark, results);
}
}
// ===== 日记结果列表 =====
Widget _buildJournalResults(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
bool isDark,
List<JournalEntry> results,
String? keyword,
) {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: results.length, itemCount: results.length,
separatorBuilder: (_, _) => const SizedBox(height: 8), separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = results[index]; final entry = results[index];
return _JournalCard(entry: entry); return _JournalCard(
entry: entry,
keyword: keyword,
);
}, },
); );
} }
// ===== 6E: 模板结果(占位) =====
Widget _buildTemplateResults(ThemeData theme, bool isDark) {
// Phase 1 占位 — 模板功能未实现
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: 4,
itemBuilder: (context, index) {
final gradients = [
const [AppColors.accent, AppColors.tertiary],
const [AppColors.secondary, AppColors.tertiary],
const [AppColors.rose, AppColors.accent],
const [AppColors.tertiary, AppColors.secondary],
];
final labels = ['每日心情', '旅行手账', '读书笔记', '日常记录'];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradients[index],
),
),
child: Stack(
children: [
// 装饰圆
Positioned(
right: -10,
bottom: -10,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
labels[index],
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'即将上线',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
),
],
),
);
},
);
}
// ===== 6E: 标签结果 =====
Widget _buildTagResults(
ThemeData theme, bool isDark, List<JournalEntry> results) {
// 从搜索结果中提取所有标签及其频次
final tagFreq = <String, int>{};
for (final entry in results) {
for (final tag in entry.tags) {
tagFreq[tag] = (tagFreq[tag] ?? 0) + 1;
}
}
final sortedTags = tagFreq.keys.toList()
..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!));
if (sortedTags.isEmpty) {
return Center(
child: Text(
'没有找到相关标签',
style: theme.textTheme.bodyMedium?.copyWith(
color: isDark ? AppColors.mutedDark : AppColors.mutedLight,
),
),
);
}
return Wrap(
spacing: 10,
runSpacing: 10,
children: sortedTags.map((tag) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
),
child: Text(
'$tag (${tagFreq[tag]})',
style: theme.textTheme.bodyMedium,
),
);
}).toList(),
).padAll(16);
}
/// 错误提示 /// 错误提示
Widget _buildError(ColorScheme colorScheme, String message) { Widget _buildError(ColorScheme colorScheme, String message) {
return Center( return Center(
@@ -253,29 +651,50 @@ class _SearchPageState extends State<SearchPage> {
_searchController.clear(); _searchController.clear();
context.read<SearchBloc>().add(const SearchClear()); context.read<SearchBloc>().add(const SearchClear());
} }
String _moodEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
String _moodLabel(Mood mood) => switch (mood) {
Mood.happy => '开心',
Mood.calm => '平静',
Mood.sad => '难过',
Mood.angry => '生气',
Mood.thinking => '思考',
};
} }
/// 日记卡片 — 在搜索结果中展示单条日记摘要 // ===== 6D: 关键词高亮辅助函数 =====
/// 将文本中的关键词部分用高亮样式包裹
Widget _highlightText(String text, String? keyword) {
if (keyword == null || keyword.isEmpty || !text.toLowerCase().contains(keyword.toLowerCase())) {
return Text(text);
}
final lowerText = text.toLowerCase();
final lowerKeyword = keyword.toLowerCase();
final spans = <TextSpan>[];
int start = 0;
while (start < text.length) {
final index = lowerText.indexOf(lowerKeyword, start);
if (index == -1) {
spans.add(TextSpan(text: text.substring(start)));
break;
}
if (index > start) {
spans.add(TextSpan(text: text.substring(start, index)));
}
spans.add(TextSpan(
text: text.substring(index, index + keyword.length),
style: const TextStyle(
color: AppColors.accent,
backgroundColor: AppColors.tertiarySoftLight,
fontWeight: FontWeight.w600,
),
));
start = index + keyword.length;
}
return RichText(text: TextSpan(style: const TextStyle(), children: spans));
}
/// 日记卡片 — 在搜索结果中展示单条日记摘要(支持关键词高亮)
class _JournalCard extends StatelessWidget { class _JournalCard extends StatelessWidget {
final JournalEntry entry; final JournalEntry entry;
final String? keyword;
const _JournalCard({required this.entry}); const _JournalCard({required this.entry, this.keyword});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -293,30 +712,23 @@ class _JournalCard extends StatelessWidget {
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
onTap: () { onTap: () {
// TODO: 导航到日记详情页 context.push('/editor?id=${entry.id}');
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 第一行:心情 emoji + 标题 + 日期 // 第一行:心情 emoji + 标题(高亮)+ 日期
Row( Row(
children: [ children: [
Text( Text(
_moodEmoji(entry.mood), moodToEmoji(entry.mood),
style: const TextStyle(fontSize: 20), style: const TextStyle(fontSize: 20),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: _highlightText(entry.title, keyword),
entry.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
Text( Text(
DateFormat('MM/dd').format(entry.date), DateFormat('MM/dd').format(entry.date),
@@ -360,11 +772,12 @@ class _JournalCard extends StatelessWidget {
); );
} }
String _moodEmoji(Mood mood) => switch (mood) { }
Mood.happy => '😊',
Mood.calm => '😌', /// Padding 扩展 — 简化 Wrap 的 padding
Mood.sad => '😢', extension _PadAll on Widget {
Mood.angry => '😠', Widget padAll(double value) => Padding(
Mood.thinking => '🤔', padding: EdgeInsets.all(value),
}; child: this,
);
} }

View File

@@ -52,21 +52,32 @@ class Sticker {
class StickerState { class StickerState {
final List<StickerPack> packs; final List<StickerPack> packs;
final String selectedCategory; final String selectedCategory;
final String searchQuery;
final bool isLoading; final bool isLoading;
final String? errorMessage; final String? errorMessage;
const StickerState({ const StickerState({
this.packs = const [], this.packs = const [],
this.selectedCategory = '全部', this.selectedCategory = '全部',
this.searchQuery = '',
this.isLoading = false, this.isLoading = false,
this.errorMessage, this.errorMessage,
}); });
/// 按分类过滤贴纸包 /// 按分类 + 搜索关键词过滤贴纸包
List<StickerPack> get filteredPacks => selectedCategory == '全部' List<StickerPack> get filteredPacks {
var result = selectedCategory == '全部'
? packs ? packs
: packs.where((p) => p.category == selectedCategory).toList(); : packs.where((p) => p.category == selectedCategory).toList();
if (searchQuery.isNotEmpty) {
final query = searchQuery.toLowerCase();
result = result.where((p) => p.name.toLowerCase().contains(query)).toList();
}
return result;
}
/// 所有分类(去重 + 加"全部" /// 所有分类(去重 + 加"全部"
List<String> get categories { List<String> get categories {
final cats = packs final cats = packs
@@ -80,12 +91,14 @@ class StickerState {
StickerState copyWith({ StickerState copyWith({
List<StickerPack>? packs, List<StickerPack>? packs,
String? selectedCategory, String? selectedCategory,
String? searchQuery,
bool? isLoading, bool? isLoading,
String? errorMessage, String? errorMessage,
}) => }) =>
StickerState( StickerState(
packs: packs ?? this.packs, packs: packs ?? this.packs,
selectedCategory: selectedCategory ?? this.selectedCategory, selectedCategory: selectedCategory ?? this.selectedCategory,
searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage, errorMessage: errorMessage,
); );
@@ -114,6 +127,12 @@ class StickerBloc extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 搜索贴纸包(按名称前端过滤)
void search(String query) {
_state = _state.copyWith(searchQuery: query);
notifyListeners();
}
/// 按分类加载贴纸包 /// 按分类加载贴纸包
void loadByCategory(String? category) { void loadByCategory(String? category) {
_state = _state.copyWith(isLoading: true); _state = _state.copyWith(isLoading: true);

View File

@@ -17,6 +17,12 @@ class StickerLibraryPage extends StatefulWidget {
class _StickerLibraryPageState extends State<StickerLibraryPage> { class _StickerLibraryPageState extends State<StickerLibraryPage> {
late final StickerBloc _bloc; late final StickerBloc _bloc;
final _searchController = TextEditingController();
/// 设计规格中的 8 个分类
static const _specCategories = [
'推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带',
];
@override @override
void initState() { void initState() {
@@ -28,16 +34,18 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
@override @override
void dispose() { void dispose() {
_bloc.dispose(); _bloc.dispose();
_searchController.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('贴纸库')), body: SafeArea(
body: ListenableBuilder( child: ListenableBuilder(
listenable: _bloc, listenable: _bloc,
builder: (context, _) { builder: (context, _) {
final state = _bloc.state; final state = _bloc.state;
@@ -62,34 +70,94 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
); );
} }
final categories = state.categories;
return Column( return Column(
children: [ children: [
// 分类选择器(横向滚动 Chips // ---- 自定义顶栏 ----
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
Text('贴纸素材', style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
)),
],
),
),
const SizedBox(height: 8),
// ---- 搜索框 ----
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索贴纸...',
prefixIcon: const Icon(Icons.search, size: 20),
filled: true,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: AppRadius.pillBorder,
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
isDense: true,
),
style: theme.textTheme.bodyMedium,
onChanged: (v) {
_bloc.search(v);
},
),
),
const SizedBox(height: 12),
// ---- 分类选择器(设计规格 8 分类) ----
SizedBox( SizedBox(
height: 48, height: 40,
child: ListView( child: ListView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
children: categories.map((cat) { children: _specCategories.map((cat) {
final isSelected = cat == state.selectedCategory; final isSelected = cat == state.selectedCategory ||
(cat == '推荐' && state.selectedCategory == '全部');
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: FilterChip( child: FilterChip(
selected: isSelected, selected: isSelected,
label: Text(cat), label: Text(cat),
onSelected: (_) => _bloc.selectCategory(cat), onSelected: (_) {
selectedColor: colorScheme.primaryContainer, if (cat == '推荐') {
checkmarkColor: colorScheme.primary, _bloc.selectCategory('全部');
} else {
_bloc.selectCategory(cat);
}
},
selectedColor: AppColors.accent.withValues(alpha: 0.15),
checkmarkColor: AppColors.accent,
labelStyle: TextStyle(
color: isSelected ? AppColors.accent : colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
), ),
); );
}).toList(), }).toList(),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 12),
// 贴纸包网格 // ---- 精选贴纸包卡片 ----
if (state.selectedCategory == '全部')
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const _FeaturedPackCard(),
),
if (state.selectedCategory == '全部')
const SizedBox(height: 16),
// ---- 贴纸包网格 ----
Expanded( Expanded(
child: state.filteredPacks.isEmpty child: state.filteredPacks.isEmpty
? const Center(child: Text('暂无贴纸包')) ? const Center(child: Text('暂无贴纸包'))
@@ -115,6 +183,77 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
); );
}, },
), ),
),
);
}
}
/// 精选贴纸包卡片 — 渐变背景 + 限时免费标签
class _FeaturedPackCard extends StatelessWidget {
const _FeaturedPackCard();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('打开精选贴纸包: 治愈小动物')),
);
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.accent, AppColors.tertiary],
),
borderRadius: AppRadius.lgBorder,
),
child: Row(
children: [
// emoji 图标区域
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: AppRadius.mdBorder,
),
alignment: Alignment.center,
child: const Text('🧸', style: TextStyle(fontSize: 36)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('治愈小动物', style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, color: Colors.white,
)),
const SizedBox(height: 4),
Text('超可爱的手绘小动物贴纸', style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.85),
)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.secondary,
borderRadius: AppRadius.pillBorder,
),
child: const Text('限时免费', style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white,
)),
),
],
),
),
],
),
),
); );
} }
} }

View File

@@ -7,6 +7,7 @@ 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_radius.dart';
import 'package:nuanji_app/data/repositories/class_repository.dart'; import 'package:nuanji_app/data/repositories/class_repository.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart';
import 'package:nuanji_app/data/models/school_class.dart';
import '../../class_/bloc/class_bloc.dart'; import '../../class_/bloc/class_bloc.dart';
/// 老师管理页面 — 教师专属功能入口 /// 老师管理页面 — 教师专属功能入口
@@ -162,13 +163,46 @@ class _TeacherView extends StatelessWidget {
final titleController = TextEditingController(); final titleController = TextEditingController();
final descController = TextEditingController(); final descController = TextEditingController();
// 从 ClassBloc 获取已加载的班级列表
final classState = context.read<ClassBloc>().state;
final classes = classState is ClassListLoaded ? classState.classes : <SchoolClass>[];
// 无班级时提示先创建
if (classes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先创建班级后再布置主题')),
);
return;
}
String selectedClassId = classes.first.id;
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('布置主题'), title: const Text('布置主题'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 班级选择下拉框
DropdownButtonFormField<String>(
value: selectedClassId,
decoration: const InputDecoration(
labelText: '选择班级',
border: OutlineInputBorder(),
),
items: classes
.map((c) => DropdownMenuItem(
value: c.id,
child: Text(c.name),
))
.toList(),
onChanged: (v) {
if (v != null) setDialogState(() => selectedClassId = v);
},
),
const SizedBox(height: 12),
TextField( TextField(
controller: titleController, controller: titleController,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -198,7 +232,7 @@ class _TeacherView extends StatelessWidget {
onPressed: () { onPressed: () {
if (titleController.text.trim().isNotEmpty) { if (titleController.text.trim().isNotEmpty) {
context.read<ClassBloc>().add(TopicAssign( context.read<ClassBloc>().add(TopicAssign(
classId: 'class-1', classId: selectedClassId,
title: titleController.text.trim(), title: titleController.text.trim(),
description: descController.text.trim().isEmpty description: descController.text.trim().isEmpty
? null ? null
@@ -214,6 +248,7 @@ class _TeacherView extends StatelessWidget {
), ),
], ],
), ),
),
); );
} }
} }

View File

@@ -202,10 +202,14 @@ class _FailingJournalRepository implements JournalRepository {
int? pageSize, int? pageSize,
String? mood, String? mood,
String? tag, String? tag,
String? classId,
}) async { }) async {
throw Exception('模拟网络错误'); throw Exception('模拟网络错误');
} }
@override
Future<int> getJournalCount() async => 0;
@override @override
Future<JournalEntry?> getJournal(String id) async => null; Future<JournalEntry?> getJournal(String id) async => null;

View File

@@ -222,10 +222,16 @@ class _FailingJournalRepository implements JournalRepository {
int? pageSize, int? pageSize,
String? mood, String? mood,
String? tag, String? tag,
String? classId,
}) async { }) async {
throw Exception('网络不可用'); throw Exception('网络不可用');
} }
@override
Future<int> getJournalCount() async {
throw Exception('网络不可用');
}
@override @override
Future<JournalEntry?> getJournal(String id) async { Future<JournalEntry?> getJournal(String id) async {
throw UnimplementedError(); throw UnimplementedError();