From 7e928ae1e189f764c3b3277a59bb007064d524bd Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 2 Jun 2026 20:21:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E4=BF=AE=E5=A4=8D=20P2~P4=20?= =?UTF-8?q?=E5=85=B1=2010=20=E9=A1=B9=E5=89=8D=E7=AB=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/lib/core/utils/mood_utils.dart | 22 + .../repositories/isar_journal_repository.dart | 17 + .../data/repositories/journal_repository.dart | 15 +- .../remote_journal_repository.dart | 12 + .../calendar/views/calendar_page.dart | 267 ++++-- .../features/calendar/views/monthly_page.dart | 233 +++-- .../features/calendar/views/weekly_page.dart | 324 ++++--- app/lib/features/class_/bloc/class_bloc.dart | 34 +- app/lib/features/mood/views/mood_page.dart | 832 +++++++++++++----- .../features/parent/views/parent_page.dart | 90 +- .../features/profile/views/profile_page.dart | 420 +++++++-- .../features/search/views/search_page.dart | 625 ++++++++++--- .../features/stickers/bloc/sticker_bloc.dart | 27 +- .../stickers/views/sticker_library_page.dart | 285 ++++-- .../features/teacher/views/teacher_page.dart | 123 ++- .../calendar/bloc/calendar_bloc_test.dart | 4 + .../features/home/bloc/home_bloc_test.dart | 6 + 17 files changed, 2537 insertions(+), 799 deletions(-) create mode 100644 app/lib/core/utils/mood_utils.dart diff --git a/app/lib/core/utils/mood_utils.dart b/app/lib/core/utils/mood_utils.dart new file mode 100644 index 0000000..11fa835 --- /dev/null +++ b/app/lib/core/utils/mood_utils.dart @@ -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 => '思考', + }; diff --git a/app/lib/data/repositories/isar_journal_repository.dart b/app/lib/data/repositories/isar_journal_repository.dart index b785359..7b6a706 100644 --- a/app/lib/data/repositories/isar_journal_repository.dart +++ b/app/lib/data/repositories/isar_journal_repository.dart @@ -34,6 +34,7 @@ class IsarJournalRepository implements JournalRepository { int? pageSize, String? mood, String? tag, + String? classId, }) async { var query = _isar.journalEntryCollections .where() @@ -58,6 +59,11 @@ class IsarJournalRepository implements JournalRepository { query = query.and().tagsJsonContains(tag); } + // 班级过滤 + if (classId != null) { + query = query.and().classIdEqualTo(classId); + } + // 按日期降序排列 var results = await query .sortByDateEpochDesc() @@ -74,6 +80,15 @@ class IsarJournalRepository implements JournalRepository { return results.map(_fromCollection).toList(); } + @override + Future getJournalCount() async { + return _isar.journalEntryCollections + .where() + .filter() + .isDeletedEqualTo(false) + .count(); + } + @override Future getJournal(String id) async { final col = await _isar.journalEntryCollections @@ -262,6 +277,7 @@ class IsarJournalRepository implements JournalRepository { ..isPrivate = entry.isPrivate ..sharedToClass = entry.sharedToClass ..assignedTopicId = entry.assignedTopicId + ..contentExcerpt = entry.contentExcerpt ..version = entry.version ..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch ..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch @@ -290,6 +306,7 @@ class IsarJournalRepository implements JournalRepository { isPrivate: col.isPrivate, sharedToClass: col.sharedToClass, assignedTopicId: col.assignedTopicId, + contentExcerpt: col.contentExcerpt, version: col.version, createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch), updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch), diff --git a/app/lib/data/repositories/journal_repository.dart b/app/lib/data/repositories/journal_repository.dart index 30daeb8..9bf6245 100644 --- a/app/lib/data/repositories/journal_repository.dart +++ b/app/lib/data/repositories/journal_repository.dart @@ -15,7 +15,7 @@ import '../models/journal_element.dart'; /// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间) /// - [page]/[pageSize]: 分页参数,从 1 开始 abstract class JournalRepository { - /// 获取日记列表(支持日期范围、心情、标签过滤和分页) + /// 获取日记列表(支持日期范围、心情、标签、班级过滤和分页) Future> getJournals({ DateTime? dateFrom, DateTime? dateTo, @@ -23,8 +23,12 @@ abstract class JournalRepository { int? pageSize, String? mood, String? tag, + String? classId, }); + /// 获取日记总数 + Future getJournalCount(); + /// 获取单篇日记(返回 null 表示不存在) Future getJournal(String id); @@ -66,6 +70,7 @@ class InMemoryJournalRepository implements JournalRepository { int? pageSize, String? mood, String? tag, + String? classId, }) async { var results = _journals.values.toList(); @@ -87,6 +92,11 @@ class InMemoryJournalRepository implements JournalRepository { 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)); @@ -101,6 +111,9 @@ class InMemoryJournalRepository implements JournalRepository { return results; } + @override + Future getJournalCount() async => _journals.length; + @override Future getJournal(String id) async { return _journals[id]; diff --git a/app/lib/data/repositories/remote_journal_repository.dart b/app/lib/data/repositories/remote_journal_repository.dart index 7a9838b..88e8030 100644 --- a/app/lib/data/repositories/remote_journal_repository.dart +++ b/app/lib/data/repositories/remote_journal_repository.dart @@ -21,6 +21,7 @@ class RemoteJournalRepository implements JournalRepository { int? pageSize, String? mood, String? tag, + String? classId, }) async { final queryParams = {}; // 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒) @@ -34,6 +35,7 @@ class RemoteJournalRepository implements JournalRepository { if (pageSize != null) queryParams['page_size'] = pageSize; if (mood != null) queryParams['mood'] = mood; if (tag != null) queryParams['tag'] = tag; + if (classId != null) queryParams['class_id'] = classId; final response = await _api.get('/diary/journals', queryParams: queryParams); final body = response.data as Map; @@ -43,6 +45,16 @@ class RemoteJournalRepository implements JournalRepository { .toList(); } + @override + Future getJournalCount() async { + final response = await _api.get('/diary/journals', queryParams: { + 'page': 1, + 'page_size': 1, + }); + final body = response.data as Map; + return (body['total'] as int?) ?? 0; + } + @override Future getJournal(String id) async { try { diff --git a/app/lib/features/calendar/views/calendar_page.dart b/app/lib/features/calendar/views/calendar_page.dart index 9af5cc3..f023795 100644 --- a/app/lib/features/calendar/views/calendar_page.dart +++ b/app/lib/features/calendar/views/calendar_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; import 'package:nuanji_app/core/theme/app_radius.dart'; +import 'package:nuanji_app/core/utils/mood_utils.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart'; import '../bloc/calendar_bloc.dart'; @@ -128,6 +129,10 @@ class _MonthView extends StatelessWidget { return Expanded( child: Column( children: [ + // 本月心情概览柱状图 + _MoodSummaryChart(journalsByDate: loaded.journalsByDate), + const SizedBox(height: 8), + // 星期标题行 _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> 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 = {}; + 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 { @@ -264,7 +367,7 @@ class _WeekView extends StatelessWidget { // 心情 emoji if (hasEntry) Text( - _moodEmoji(journals.first.mood), + moodToEmoji(journals.first.mood), style: const TextStyle(fontSize: 24), ), ], @@ -321,6 +424,13 @@ class _TimelineView extends StatelessWidget { final journal = entry.value; 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( child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -332,14 +442,14 @@ class _TimelineView extends StatelessWidget { children: [ // 圆点 + emoji Container( - width: 36, - height: 36, + width: 40, + height: 40, decoration: BoxDecoration( color: _getMoodBgColor(journal.mood.value), shape: BoxShape.circle, ), 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) @@ -370,11 +480,22 @@ class _TimelineView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${date.month}月${date.day}日', - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + Row( + children: [ + Text( + '${date.month}月${date.day}日', + style: theme.textTheme.labelSmall?.copyWith( + 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), Text( @@ -385,8 +506,18 @@ class _TimelineView extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - // 日记内容通过 JournalElement 管理,日历视图仅显示标题 - // 后续可通过 elements 预览首段文字 + if (excerpt.isNotEmpty) ...[ + 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( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - onPressed: onPrevious, - icon: const Icon(Icons.chevron_left), - tooltip: '上个月', + 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, + child: const Icon(Icons.chevron_left, size: 20), + ), ), Text( monthName, @@ -437,10 +578,20 @@ class _MonthNavigator extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - IconButton( - onPressed: onNext, - icon: const Icon(Icons.chevron_right), - tooltip: '下个月', + 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, + child: const Icon(Icons.chevron_right, size: 20), + ), ), ], ), @@ -614,30 +765,51 @@ class _DayCell extends StatelessWidget { : null, ), alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, + child: Stack( + clipBehavior: Clip.none, children: [ - Text( - '${dayInfo.date.day}', - style: TextStyle( - fontSize: 14, - fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal, - color: !dayInfo.isCurrentMonth - ? colorScheme.onSurface.withValues(alpha: 0.3) - : isSelected - ? colorScheme.onPrimary - : colorScheme.onSurface, - ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${dayInfo.date.day}', + style: TextStyle( + fontSize: 14, + fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal, + color: !dayInfo.isCurrentMonth + ? colorScheme.onSurface.withValues(alpha: 0.3) + : isSelected + ? colorScheme.onPrimary + : colorScheme.onSurface, + ), + ), + // 心情小圆点(仅在有日记但无背景色时显示) + if (hasJournals && moodBg == Colors.transparent) + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(top: 1), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? colorScheme.onPrimary : AppColors.accent, + ), + ), + ], ), - // 心情小圆点(仅在有日记但无背景色时显示) - if (hasJournals && moodBg == Colors.transparent) - Container( - width: 4, - height: 4, - margin: const EdgeInsets.only(top: 1), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isSelected ? colorScheme.onPrimary : AppColors.accent, + // 日记条目指示点 + 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, child: Text( - _moodEmoji(journal.mood), + moodToEmoji(journal.mood), style: const TextStyle(fontSize: 20), ), ), @@ -781,17 +953,6 @@ Color _getMoodBgColor(String mood) { return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight; } -/// 心情 → emoji -String _moodEmoji(Mood mood) { - return switch (mood) { - Mood.happy => '😊', - Mood.calm => '😌', - Mood.sad => '😢', - Mood.angry => '😠', - Mood.thinking => '🤔', - }; -} - /// 是否是今天 bool _isToday(DateTime date) { final now = DateTime.now(); diff --git a/app/lib/features/calendar/views/monthly_page.dart b/app/lib/features/calendar/views/monthly_page.dart index 32451d9..c98bf72 100644 --- a/app/lib/features/calendar/views/monthly_page.dart +++ b/app/lib/features/calendar/views/monthly_page.dart @@ -2,14 +2,19 @@ // 对齐 Open Design 原型稿 screens/monthly.html import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/core/theme/app_shadows.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 { - const MonthlyPage({super.key}); + final JournalRepository? journalRepository; + const MonthlyPage({super.key, this.journalRepository}); @override State createState() => _MonthlyPageState(); @@ -17,23 +22,59 @@ class MonthlyPage extends StatefulWidget { class _MonthlyPageState extends State { late DateTime _focusedMonth; + List _journals = []; + int _photoCount = 0; @override void initState() { super.initState(); _focusedMonth = DateTime.now(); + _loadJournals(); + } + + JournalRepository get _repo => + widget.journalRepository ?? context.read(); + + Future _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() { setState(() { _focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1); }); + _loadJournals(); } void _goToNextMonth() { setState(() { _focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1); }); + _loadJournals(); } @override @@ -55,13 +96,13 @@ class _MonthlyPageState extends State { children: [ const SizedBox(height: 16), // 心情色彩月历 - _MoodCalendar(month: _focusedMonth), + _MoodCalendar(month: _focusedMonth, journals: _journals), const SizedBox(height: 20), // 月度统计 2x2 - const _MonthSummary(), + _MonthSummary(journals: _journals, photoCount: _photoCount), const SizedBox(height: 20), // 精选日记 - const _Highlights(), + _Highlights(journals: _journals), const SizedBox(height: 32), ], ), @@ -161,31 +202,27 @@ class _NavButton extends StatelessWidget { // ===== 心情色彩月历 ===== class _MoodCalendar extends StatelessWidget { - const _MoodCalendar({required this.month}); + const _MoodCalendar({required this.month, required this.journals}); final DateTime month; + final List journals; - // 心情类型 - static const _moodTypes = [ - 'happy', 'calm', 'sad', 'tired', 'love', - ]; - - // 心情 → emoji - static const _moodEmojis = { - 'happy': '😊', - 'calm': '😐', - 'sad': '😢', - 'tired': '😐', - 'love': '😡', + // 心情 → emoji(对齐 Mood 枚举: happy/calm/sad/angry/thinking) + static const _moodEmojis = { + Mood.happy: '😊', + Mood.calm: '😌', + Mood.sad: '😢', + Mood.angry: '😡', + Mood.thinking: '🤔', }; // 心情 → 背景色 - static const _moodBgColors = { - 'happy': AppColors.secondarySoftLight, - 'love': AppColors.roseSoftLight, - 'calm': AppColors.tertiarySoftLight, - 'sad': Color(0xFFD4DDE8), - 'tired': Color(0xFFE8E4E0), + static const _moodBgColors = { + Mood.happy: AppColors.secondarySoftLight, + Mood.angry: AppColors.roseSoftLight, + Mood.calm: AppColors.tertiarySoftLight, + Mood.sad: Color(0xFFD4DDE8), + Mood.thinking: Color(0xFFE8E4E0), }; @override @@ -216,10 +253,18 @@ class _MoodCalendar extends StatelessWidget { Widget _buildGrid(BuildContext context, DateTime now) { final firstDay = DateTime(month.year, month.month, 1); - // 周日=0 → 偏移量; weekday 返回 1(周一)..7(周日) - final startOffset = firstDay.weekday % 7; // 周日开头 + // 周一=0 → 偏移量; weekday 返回 1(周一)..7(周日) + final startOffset = firstDay.weekday - 1; // 周一开头 final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + // 按日期建索引:day → JournalEntry + final journalByDay = {}; + for (final j in journals) { + if (j.date.year == month.year && j.date.month == month.month) { + journalByDay[j.date.day] = j; + } + } + final cells = []; // 空白填充 @@ -227,19 +272,16 @@ class _MoodCalendar extends StatelessWidget { cells.add(const SizedBox.shrink()); } - // 模拟心情数据(确定性伪随机,同一天固定同心情) - final rng = _SeededRandom(month.year * 100 + month.month); - for (var d = 1; d <= daysInMonth; d++) { final isToday = now.year == month.year && now.month == month.month && now.day == d; - // 每天随机一个心情 - final moodIndex = rng.nextInt(5); - final mood = _moodTypes[moodIndex]; - final bgColor = _moodBgColors[mood] ?? Colors.transparent; - final emoji = _moodEmojis[mood] ?? ''; + final entry = journalByDay[d]; + final mood = entry?.mood; + final bgColor = + mood != null ? (_moodBgColors[mood] ?? Colors.transparent) : Colors.transparent; + final emoji = mood != null ? (_moodEmojis[mood] ?? '') : ''; cells.add( _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 { const _WeekdayRow({required this.colorScheme}); @@ -282,7 +313,7 @@ class _WeekdayRow extends StatelessWidget { @override Widget build(BuildContext context) { - const weekdays = ['日', '一', '二', '三', '四', '五', '六']; + const weekdays = ['一', '二', '三', '四', '五', '六', '日']; return Row( children: weekdays.map((day) { return Expanded( @@ -359,7 +390,36 @@ class _MoodCell extends StatelessWidget { // ===== 月度统计 2x2 ===== class _MonthSummary extends StatelessWidget { - const _MonthSummary(); + const _MonthSummary({required this.journals, required this.photoCount}); + + final List 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 Widget build(BuildContext context) { @@ -386,28 +446,28 @@ class _MonthSummary extends StatelessWidget { children: [ _StatCard( icon: '📝', - value: '28', + value: '${journals.length}', label: '日记篇数', bgColor: AppColors.tertiarySoftLight, valueColor: const Color(0xFFB8860B), ), _StatCard( icon: '🔥', - value: '12', + value: '${_calcLongestStreak()}', label: '最长连续', bgColor: AppColors.secondarySoftLight, valueColor: const Color(0xFF2D7D46), ), _StatCard( icon: '😊', - value: '72%', + value: _calcGoodMoodPercent(), label: '好心情占比', bgColor: AppColors.roseSoftLight, valueColor: const Color(0xFF9B4D4D), ), _StatCard( icon: '📸', - value: '18', + value: '$photoCount', label: '照片数量', bgColor: const Color(0xFFD4DDE8), valueColor: const Color(0xFF4A6B8A), @@ -475,42 +535,30 @@ class _StatCard extends StatelessWidget { // ===== 精选日记 ===== class _Highlights extends StatelessWidget { - const _Highlights(); + const _Highlights({required this.journals}); + + final List journals; + + static const _badgeConfig = { + 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 Widget build(BuildContext context) { final theme = Theme.of(context); - // 模拟精选日记数据 - const highlights = [ - ( - emoji: '😊', - emojiBg: AppColors.roseSoftLight, - date: '5月14日', - title: '和朋友聚餐的欢乐时光', - badge: '最佳心情', - badgeBg: AppColors.roseSoftLight, - badgeFg: Color(0xFF9B4D4D), - ), - ( - emoji: '⭐', - emojiBg: AppColors.tertiarySoftLight, - date: '5月21日', - title: '完成了第一个小目标', - badge: '里程碑', - badgeBg: AppColors.tertiarySoftLight, - badgeFg: Color(0xFFB8860B), - ), - ( - emoji: '📚', - emojiBg: AppColors.secondarySoftLight, - date: '5月28日', - title: '期末考试结束', - badge: '最详尽记录', - badgeBg: AppColors.secondarySoftLight, - badgeFg: Color(0xFF2D7D46), - ), - ]; + // 按日期降序取前 3 篇 + final top = List.from(journals) + ..sort((a, b) => b.date.compareTo(a.date)); + final highlights = top.take(3).toList(); + + if (highlights.isEmpty) { + return const SizedBox.shrink(); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -523,15 +571,22 @@ class _Highlights extends StatelessWidget { ), ), 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( - emoji: item.emoji, - emojiBg: item.emojiBg, - date: item.date, - title: item.title, - badge: item.badge, - badgeBg: item.badgeBg, - badgeFg: item.badgeFg, + emoji: emoji, + emojiBg: emojiBg, + date: dateStr, + title: entry.title, + badge: cfg.badge, + badgeBg: cfg.bg, + badgeFg: cfg.fg, ); }), ], diff --git a/app/lib/features/calendar/views/weekly_page.dart b/app/lib/features/calendar/views/weekly_page.dart index 3906ab3..f3d5037 100644 --- a/app/lib/features/calendar/views/weekly_page.dart +++ b/app/lib/features/calendar/views/weekly_page.dart @@ -1,11 +1,16 @@ // 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片 // 对齐 Open Design 原型稿 screens/weekly.html +// 接入 JournalRepository 加载真实数据 import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/core/theme/app_shadows.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 { @@ -17,24 +22,71 @@ class WeeklyPage extends StatefulWidget { class _WeeklyPageState extends State { late DateTime _focusedWeekStart; + List _journals = []; + bool _isLoading = true; @override void initState() { super.initState(); final now = DateTime.now(); - _focusedWeekStart = now.subtract(Duration(days: now.weekday - 1)); + _focusedWeekStart = _startOfWeek(now); + _loadWeekData(); + } + + JournalRepository get _repo => context.read(); + + /// 获取某天的周一日期 + DateTime _startOfWeek(DateTime date) { + return date.subtract(Duration(days: date.weekday - 1)); + } + + Future _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() { setState(() { _focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7)); }); + _loadWeekData(); } void _goToNextWeek() { setState(() { _focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7)); }); + _loadWeekData(); + } + + /// 按日期索引日记: day-of-week (1=周一..7=周日) → JournalEntry 列表 + Map> get _journalsByWeekday { + final map = >{}; + 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 @@ -54,21 +106,26 @@ class _WeeklyPageState extends State { ), // 可滚动内容区 Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 20), - children: [ - const SizedBox(height: 16), - // 7天条目 - _WeekStrip(weekStart: _focusedWeekStart), - const SizedBox(height: 20), - // 本周总结 - const _WeekSummary(), - const SizedBox(height: 20), - // 每日日记卡片 - ..._buildDayCards(theme, colorScheme), - const SizedBox(height: 32), - ], - ), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + const SizedBox(height: 16), + // 7天条目(真实数据) + _WeekStrip( + weekStart: _focusedWeekStart, + journalsByWeekday: _journalsByWeekday, + ), + const SizedBox(height: 20), + // 本周总结(真实数据) + _WeekSummary(journals: _journals), + const SizedBox(height: 20), + // 每日日记卡片(真实数据) + ..._buildDayCards(theme, colorScheme), + const SizedBox(height: 32), + ], + ), ), ], ), @@ -77,48 +134,67 @@ class _WeeklyPageState extends State { } List _buildDayCards(ThemeData theme, ColorScheme colorScheme) { - // 模拟数据: 3 张日记卡片 - return [ - _DayCard( - weekday: '周日', - date: '5月31日', - moodEmoji: '😊', - weatherEmoji: '☀️', - body: - '今天下午去图书馆自习,阳光从窗外洒进来,暖暖的。喝了抹茶拿铁,虽然期末压力大但看到窗外的樱花还在开,觉得一切都会好的。', - tags: const [ - ('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)), - ('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)), - ], - photoEmoji: '📚', - ), - _DayCard( - weekday: '周六', - date: '5月30日', - moodEmoji: '😊', - weatherEmoji: '🌤', - body: - '今天在图书馆自习,窗外的阳光洒进来,暖暖的。复习了高数第三章,做了两套模拟题感觉还不错。', - tags: const [ - ('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)), - ], - photoEmoji: null, - ), - _DayCard( - weekday: '周五', - date: '5月29日', - moodEmoji: '😊', - weatherEmoji: '☀️', - body: - '考完试和舍友们去吃了火锅庆祝,大家都好开心,聊了很多有趣的事。这学期终于结束了!', - tags: const [ - ('朋友', AppColors.roseSoftLight, Color(0xFF9B4D4D)), - ('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)), - ], - photoEmoji: '🍲', - ), - ]; + final byWeekday = _journalsByWeekday; + final cards = []; + 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 [ + SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_note_rounded, size: 48, + color: colorScheme.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text('这周还没有日记', style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.4), + )), + ], + ), + ), + ), + ]; + } + + 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 { - const _WeekStrip({required this.weekStart}); + const _WeekStrip({ + required this.weekStart, + required this.journalsByWeekday, + }); final DateTime weekStart; + final Map> journalsByWeekday; - // 模拟数据: 每天的心情 emoji - static const _mockMoods = ['😊', '😐', '😊', '😊', '😊', '😊', '😊']; static const _weekNames = ['一', '二', '三', '四', '五', '六', '日']; - static const _hasEntry = [true, true, true, true, true, true, true]; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colorScheme = Theme.of(context).colorScheme; final now = DateTime.now(); return Row( children: List.generate(7, (i) { final day = weekStart.add(Duration(days: i)); + final weekday = i + 1; final isToday = day.year == now.year && day.month == now.month && day.day == now.day; - final hasEntry = _hasEntry[i]; - final moodEmoji = _mockMoods[i]; + final dayJournals = journalsByWeekday[weekday] ?? []; + final hasEntry = dayJournals.isNotEmpty; + final moodEmoji = hasEntry ? moodToEmoji(dayJournals.first.mood) : '·'; return Expanded( child: GestureDetector( @@ -286,7 +364,10 @@ class _WeekStrip extends StatelessWidget { ), const SizedBox(height: 4), // 心情 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小圆点 if (hasEntry && !isToday) Container( @@ -318,16 +399,26 @@ class _WeekStrip extends StatelessWidget { } } -// ===== 本周总结卡片 ===== +// ===== 本周总结卡片(真实数据)===== class _WeekSummary extends StatelessWidget { - const _WeekSummary(); + const _WeekSummary({required this.journals}); + + final List journals; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + // 统计真实数据 + final recordDays = journals.map((j) => j.date.day).toSet().length; + final journalCount = journals.length; + // 统计贴纸元素 — 从日记标签中估算(Phase 1 简化) + final stickerCount = journals.fold( + 0, (sum, j) => sum + j.tags.length, + ); + return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -351,66 +442,81 @@ class _WeekSummary extends StatelessWidget { Row( children: [ _SummaryItem( - value: '6', + value: '$recordDays', label: '记录天数', valueColor: AppColors.accent, ), _SummaryItem( - value: '7', + value: '$journalCount', label: '日记篇数', valueColor: AppColors.secondary, ), _SummaryItem( - value: '12', - label: '使用贴纸', + value: '$stickerCount', + label: '使用标签', valueColor: AppColors.tertiary, ), ], ), const SizedBox(height: 16), // 心情分布条 - Row( - children: [ - Expanded( - flex: 3, - child: Container( - height: 8, - decoration: BoxDecoration( - color: AppColors.secondary, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: Container( - height: 8, - decoration: BoxDecoration( - color: AppColors.tertiary, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 1, - child: Container( - height: 8, - decoration: BoxDecoration( - color: const Color(0xFF5B7DB1), - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ], - ), + _MoodDistributionBar(journals: journals), ], ), ); } } +/// 心情分布条 — 从日记数据计算各心情占比 +class _MoodDistributionBar extends StatelessWidget { + const _MoodDistributionBar({required this.journals}); + final List 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 = {}; + 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(), + ); + } +} + /// 单个统计项 class _SummaryItem extends StatelessWidget { const _SummaryItem({ diff --git a/app/lib/features/class_/bloc/class_bloc.dart b/app/lib/features/class_/bloc/class_bloc.dart index 3b1fef6..868c249 100644 --- a/app/lib/features/class_/bloc/class_bloc.dart +++ b/app/lib/features/class_/bloc/class_bloc.dart @@ -261,11 +261,9 @@ class ClassBloc extends Bloc { emit(current.copyWith(isLoadingWall: true)); try { - // 加载属于该班级的公开日记 - final journals = await _journalRepo.getJournals(); - final classJournals = journals - .where((j) => j.classId == event.classId && j.sharedToClass) - .toList(); + // 服务端过滤:按 classId 查询班级公开日记(后端 API 已支持 ?class_id= 参数) + final journals = await _journalRepo.getJournals(classId: event.classId); + final classJournals = journals.where((j) => j.sharedToClass).toList(); emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false)); } catch (_) { @@ -345,9 +343,6 @@ class ClassBloc extends Bloc { TopicAssign event, Emitter emit, ) async { - if (state is! ClassDetailLoaded) return; - final current = state as ClassDetailLoaded; - try { final dto = await _classRepo.assignTopic( classId: event.classId, @@ -355,15 +350,20 @@ class ClassBloc extends Bloc { description: event.description, dueDate: event.dueDate, ); - final newTopic = TopicAssignment( - id: dto.id, - classId: dto.classId, - teacherId: dto.teacherId, - title: dto.title, - description: dto.description, - dueDate: dto.dueDate, - ); - emit(current.copyWith(topics: [newTopic, ...current.topics])); + + // 更新本地 topics 列表(仅在班级详情视图中) + if (state is ClassDetailLoaded) { + final current = state as ClassDetailLoaded; + final newTopic = TopicAssignment( + id: dto.id, + classId: dto.classId, + teacherId: dto.teacherId, + title: dto.title, + description: dto.description, + dueDate: dto.dueDate, + ); + emit(current.copyWith(topics: [newTopic, ...current.topics])); + } } catch (_) { // 静默失败 } diff --git a/app/lib/features/mood/views/mood_page.dart b/app/lib/features/mood/views/mood_page.dart index 8cb76d8..e36f394 100644 --- a/app/lib/features/mood/views/mood_page.dart +++ b/app/lib/features/mood/views/mood_page.dart @@ -1,15 +1,16 @@ -// 心情页面 — 心情统计 + 趋势图 + 连续天数 +// 心情页面 — 今日心情 + 天气 + 柱状图 + 统计网格 + 心情洞察 import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; import 'package:nuanji_app/core/theme/app_radius.dart'; +import 'package:nuanji_app/core/utils/mood_utils.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/remote/api_client.dart'; import '../bloc/mood_bloc.dart'; -/// 心情页面 — 统计卡片 + 心情分布饼图 + 详情列表 +/// 心情页面 — 今日心情卡片 + 天气选择 + 柱状图 + 统计网格 + 心情洞察 class MoodPage extends StatefulWidget { const MoodPage({super.key}); @@ -20,6 +21,9 @@ class MoodPage extends StatefulWidget { class _MoodPageState extends State { late final MoodBloc _bloc; + // 天气选择状态 + _WeatherType? _selectedWeather; + @override void initState() { super.initState(); @@ -70,38 +74,42 @@ class _MoodPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 统计概览卡片 - _StatsOverviewCard(stats: state.stats, colorScheme: colorScheme), + // 5A: 今日心情卡片 + _TodayMoodCard(stats: state.stats), + const SizedBox(height: 16), + + // 5B: 天气卡片 + _WeatherCard( + selectedWeather: _selectedWeather, + onWeatherSelected: (w) { + setState(() { + _selectedWeather = + _selectedWeather == w ? null : w; + }); + }, + ), const SizedBox(height: 16), // 周期选择器 - _PeriodSelector( + _PeriodPills( selectedPeriod: state.selectedPeriod, onPeriodChanged: _bloc.changePeriod, ), const SizedBox(height: 16), - // 心情分布饼图 - _MoodDistributionChart( + // 5C: 柱状图 + _MoodBarChart( moodCounts: state.stats.moodCounts, - colorScheme: colorScheme, + selectedPeriod: state.selectedPeriod, ), const SizedBox(height: 24), - // 心情详情列表 - Text( - '心情详情', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - ...state.stats.moodCounts.map((mc) => _MoodCountTile(mc: mc)), - + // 5D: 统计网格 + _StatsGrid(stats: state.stats), const SizedBox(height: 24), - // 连续天数鼓励卡片 - _StreakCard(streakDays: state.stats.streakDays), + // 5E: 心情洞察卡片 + _InsightCard(stats: state.stats), ], ), ); @@ -110,75 +118,204 @@ class _MoodPageState extends State { } } -/// 统计概览卡片 -class _StatsOverviewCard extends StatelessWidget { - const _StatsOverviewCard({ - required this.stats, - required this.colorScheme, - }); +// ===== 5A: 今日心情卡片 ===== + +class _TodayMoodCard extends StatelessWidget { + const _TodayMoodCard({required this.stats}); final MoodStats stats; - final ColorScheme colorScheme; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final now = DateTime.now(); + final dateStr = + '${now.year}年${now.month}月${now.day}日'; final dominantEmoji = stats.dominantMood != null - ? _moodEmoji(stats.dominantMood!) + ? moodToEmoji(stats.dominantMood!) : '📝'; + final dominantLabel = stats.dominantMood != null + ? moodToLabel(stats.dominantMood!) + : '暂无记录'; - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: AppRadius.lgBorder, + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + 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( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - // 主导心情图标 - Container( - width: 56, - height: 56, + child: Stack( + children: [ + // 装饰圆 + Positioned( + right: -20, + top: -20, + child: Container( + width: 100, + height: 100, decoration: BoxDecoration( 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)), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + // 内容 + 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( - '心情概览', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - '共 ${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays} 天', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.7), + Text(dominantEmoji, + style: const TextStyle(fontSize: 52)), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dominantLabel, + style: theme.textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + '共 ${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays} 天', + style: TextStyle( + fontSize: 13, + color: + Colors.white.withValues(alpha: 0.75), + ), + ), + ], ), ), ], ), - ), - ], - ), + ], + ), + ], ), ); } } -/// 周期选择器 -class _PeriodSelector extends StatelessWidget { - const _PeriodSelector({ +// ===== 5B: 天气卡片 ===== + +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.onPeriodChanged, }); @@ -188,119 +325,190 @@ class _PeriodSelector extends StatelessWidget { @override Widget build(BuildContext context) { - return SegmentedButton( - segments: const [ - 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 theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + final periods = [ + (StatsPeriod.week, '7天'), + (StatsPeriod.month, '30天'), + (StatsPeriod.quarter, '3个月'), + ]; + + return Row( + children: periods.map((p) { + final isSelected = selectedPeriod == p.$1; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => onPeriodChanged(p.$1), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + 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, + ), + ), + ), + ), + ); + }).toList(), ); } } -/// 心情分布饼图 -class _MoodDistributionChart extends StatelessWidget { - const _MoodDistributionChart({ +// ===== 5C: 柱状图 ===== + +class _MoodBarChart extends StatelessWidget { + const _MoodBarChart({ required this.moodCounts, - required this.colorScheme, + required this.selectedPeriod, }); final List moodCounts; - final ColorScheme colorScheme; - - @override - Widget build(BuildContext context) { - if (moodCounts.isEmpty) { - return const SizedBox.shrink(); - } - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: AppRadius.lgBorder, - side: BorderSide(color: colorScheme.outlineVariant), - ), - 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(), - sectionsSpace: 2, - centerSpaceRadius: 40, - ), - ), - ), - ), - ); - } -} - -/// 心情计数列表项 -class _MoodCountTile extends StatelessWidget { - const _MoodCountTile({required this.mc}); - - final MoodCount mc; + final StatsPeriod selectedPeriod; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = - AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary; + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( + if (moodCounts.isEmpty) { + return const SizedBox.shrink(); + } + + final maxCount = moodCounts + .fold(0, (max, mc) => mc.count > max ? mc.count : max); + + return Container( + 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(_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, - ), - ), - ], + Text( + '心情分布', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), ), - const SizedBox(width: 12), + const SizedBox(height: 16), SizedBox( - width: 48, - child: Text( - '${mc.count} 篇', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + height: 180, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + 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, + ), + ); + }, + ), + ), ), - textAlign: TextAlign.end, ), ), ], @@ -309,72 +517,268 @@ class _MoodCountTile extends StatelessWidget { } } -/// 连续天数鼓励卡片 -class _StreakCard extends StatelessWidget { - const _StreakCard({required this.streakDays}); +// ===== 5D: 统计网格 ===== - final int streakDays; +class _StatsGrid extends StatelessWidget { + const _StatsGrid({required this.stats}); + + final MoodStats stats; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: AppRadius.lgBorder, + // 计算好心情占比 + final happyCount = stats.moodCounts + .where((mc) => mc.mood == Mood.happy) + .fold(0, (sum, mc) => sum + mc.count); + final totalCount = stats.moodCounts + .fold(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, ), - color: AppColors.tertiary.withValues(alpha: 0.15), - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - const Text('🔥', style: TextStyle(fontSize: 32)), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '连续 $streakDays 天', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - streakDays >= 7 - ? '太棒了!你已经坚持了一周 ✨' - : '继续加油,坚持就是胜利!', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), + _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(0, (sum, mc) => sum + mc.count); + final sadCount = stats.moodCounts + .where((mc) => + mc.mood == Mood.sad || mc.mood == Mood.angry) + .fold(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; -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 => '思考', - }; + const _InsightItem({ + required this.emoji, + required this.title, + required this.detail, + required this.color, + }); +} diff --git a/app/lib/features/parent/views/parent_page.dart b/app/lib/features/parent/views/parent_page.dart index 02e32d5..285939c 100644 --- a/app/lib/features/parent/views/parent_page.dart +++ b/app/lib/features/parent/views/parent_page.dart @@ -535,7 +535,7 @@ class _ChildCard extends StatelessWidget { } /// 功能操作网格 — 4 个功能按钮 -class _ActionGrid extends StatelessWidget { +class _ActionGrid extends StatefulWidget { const _ActionGrid({ required this.children, required this.onViewJournals, @@ -550,20 +550,88 @@ class _ActionGrid extends StatelessWidget { final void Function(String childId) onDelete; final void Function(String childId) onMoodStats; - /// 取第一个绑定的孩子 ID(Phase 1 简化逻辑) - String get _firstChildId => - children.isNotEmpty ? children.first.childId : ''; + @override + State<_ActionGrid> createState() => _ActionGridState(); +} + +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 Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Column( crossAxisAlignment: CrossAxisAlignment.start, 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( + 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( '功能', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 12), _ActionCard( @@ -572,7 +640,7 @@ class _ActionGrid extends StatelessWidget { iconBgColor: AppColors.accent.withValues(alpha: 0.12), title: '日记查看', subtitle: '只读查看孩子的日记和评语', - onTap: () => onViewJournals(_firstChildId), + onTap: () => widget.onViewJournals(_selectedChildId), ), const SizedBox(height: 12), _ActionCard( @@ -581,7 +649,7 @@ class _ActionGrid extends StatelessWidget { iconBgColor: AppColors.secondary.withValues(alpha: 0.12), title: '心情统计', subtitle: '查看孩子的写作频率和心情趋势', - onTap: () => onMoodStats(_firstChildId), + onTap: () => widget.onMoodStats(_selectedChildId), ), const SizedBox(height: 12), _ActionCard( @@ -590,7 +658,7 @@ class _ActionGrid extends StatelessWidget { iconBgColor: AppColors.tertiary.withValues(alpha: 0.12), title: '数据导出', subtitle: '导出孩子的所有日记数据', - onTap: () => onExport(_firstChildId), + onTap: () => widget.onExport(_selectedChildId), ), const SizedBox(height: 12), _ActionCard( @@ -599,7 +667,7 @@ class _ActionGrid extends StatelessWidget { iconBgColor: AppColors.error.withValues(alpha: 0.12), title: '数据删除', subtitle: '永久删除孩子的日记数据', - onTap: () => onDelete(_firstChildId), + onTap: () => widget.onDelete(_selectedChildId), ), ], ); diff --git a/app/lib/features/profile/views/profile_page.dart b/app/lib/features/profile/views/profile_page.dart index 3f7bf96..e68aa76 100644 --- a/app/lib/features/profile/views/profile_page.dart +++ b/app/lib/features/profile/views/profile_page.dart @@ -5,8 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; import 'package:nuanji_app/core/theme/app_radius.dart'; +import 'package:nuanji_app/core/constants/design_tokens.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/repositories/journal_repository.dart'; /// 个人中心页面 class ProfilePage extends StatelessWidget { @@ -17,103 +20,161 @@ class ProfilePage extends StatelessWidget { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final authState = context.watch().state; - final displayName = authState is Authenticated ? authState.user.displayLabel : '用户'; - final role = authState is Authenticated ? authState.user.primaryRoleType : null; + final user = authState is Authenticated ? authState.user : 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( padding: const EdgeInsets.all(16), child: Column( children: [ - // 用户头像卡片 + // ---- 用户头像卡片 ---- Card( elevation: 0, shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), - color: colorScheme.primaryContainer, + color: colorScheme.surface, child: Padding( padding: const EdgeInsets.all(24), - child: Row( + child: Column( children: [ - CircleAvatar( - radius: 32, - backgroundColor: colorScheme.primary.withValues(alpha: 0.2), - child: Text( - displayName.characters.first, - style: theme.textTheme.headlineSmall?.copyWith(color: colorScheme.primary), + // 头像 — 渐变背景 + 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), + // 角色 + Text( + _roleLabel(role), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), ), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(displayName, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Text( - _roleLabel(role), - style: theme.textTheme.bodySmall?.copyWith( - 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), - // 功能入口 - _ProfileMenuItem( - icon: Icons.auto_awesome_outlined, - iconColor: AppColors.accent, - title: '我的成就', - onTap: () => context.go('/achievements'), + // ---- 成就徽章 ---- + Align( + alignment: Alignment.centerLeft, + child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + )), ), - _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'), + 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), + + // ---- 功能入口 (emoji 图标) ---- + _EmojiMenuItem(emoji: '🏆', bg: tertiarySoft, title: '我的成就', onTap: () => context.go('/achievements')), + _EmojiMenuItem(emoji: '🎨', bg: roseSoft, title: '贴纸收藏', onTap: () => context.go('/stickers')), + _EmojiMenuItem(emoji: '📋', bg: secondarySoft, title: '日记模板', onTap: () => context.go('/templates')), + _EmojiMenuItem(emoji: '👥', bg: accentSoft, title: '我的班级', onTap: () => context.go('/class')), + _EmojiMenuItem(emoji: '😊', bg: tertiarySoft, title: '心情统计', onTap: () => context.go('/mood')), + _EmojiMenuItem(emoji: '⚙️', bg: greyBg, title: '设置', onTap: () => context.go('/settings')), + if (role != null && role.name == 'teacher') - _ProfileMenuItem( - icon: Icons.school_outlined, - iconColor: AppColors.accent, - title: '教师管理', - onTap: () => context.go('/teacher'), - ), + _EmojiMenuItem(emoji: '📚', bg: accentSoft, title: '教师管理', onTap: () => context.go('/teacher')), if (role != null && role.name == 'parent') - _ProfileMenuItem( - icon: Icons.family_restroom_outlined, - iconColor: AppColors.rose, - title: '家长中心', - onTap: () => context.go('/parent'), - ), + _EmojiMenuItem(emoji: '👨‍👩‍👧', bg: roseSoft, title: '家长中心', onTap: () => context.go('/parent')), + const Divider(height: 32), - _ProfileMenuItem( - icon: Icons.bar_chart_outlined, - iconColor: colorScheme.primary, - title: '心情统计', - onTap: () => context.go('/mood'), + + // ---- 开关项 ---- + _EmojiToggleItem( + emoji: '🔔', + bg: tertiarySoft, + title: '消息通知', + value: true, + onChanged: (v) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(v ? '已开启通知' : '已关闭通知')), + ); + }, + activeColor: AppColors.accent, ), - _ProfileMenuItem( - icon: Icons.settings_outlined, - iconColor: colorScheme.onSurface.withValues(alpha: 0.5), - title: '设置', - onTap: () => context.go('/settings'), + _EmojiToggleItem( + emoji: '🌙', + bg: surfaceWarm, + title: '深色模式', + value: theme.brightness == Brightness.dark, + onChanged: (v) { + final settings = context.read(); + 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), @@ -129,6 +190,7 @@ class ProfilePage extends StatelessWidget { child: const Text('退出登录'), ), ), + const SizedBox(height: DesignTokens.spacing24), ], ), ); @@ -146,28 +208,230 @@ class ProfilePage extends StatelessWidget { } } -class _ProfileMenuItem extends StatelessWidget { - const _ProfileMenuItem({ - required this.icon, - required this.iconColor, +/// 统计项 +class _StatItem extends StatelessWidget { + const _StatItem({required this.label, required this.value, required this.valueColor}); + 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.onTap, + this.subtitle, }); - - final IconData icon; - final Color iconColor; + final String emoji; + final Color bg; final String title; + final String? subtitle; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); 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), + 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), onTap: onTap, 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 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 _loadStats() async { + try { + final repo = context.read(); + 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), + ], + ), + ); + } +} diff --git a/app/lib/features/search/views/search_page.dart b/app/lib/features/search/views/search_page.dart index 09ca6e4..d96d59b 100644 --- a/app/lib/features/search/views/search_page.dart +++ b/app/lib/features/search/views/search_page.dart @@ -1,20 +1,25 @@ -// 搜索页面 — 标签+心情筛选日记 +// 搜索页面 — 搜索历史 + 热门搜索 + 关键词高亮 + 分类 Tab // // 通过 SearchBloc 驱动搜索状态: +// - 关键词输入 → SearchByKeyword event // - 标签点击 → SearchByTag event // - 心情选择 → SearchByMood event +// - Tab 切换 → SearchTabChanged event // - 清除按钮 → SearchClear event // 搜索结果由 BlocBuilder 响应式渲染。 import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.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 '../bloc/search_bloc.dart'; -/// 搜索页面 — 标签+心情筛选日记 +/// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类 class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -24,14 +29,24 @@ class SearchPage extends StatefulWidget { class _SearchPageState extends State { final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); - // Phase 1 占位标签数据 - final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情']; - final _moodFilters = Mood.values; + // 热门搜索占位数据 + final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸']; + + @override + void initState() { + super.initState(); + // 自动弹出键盘 + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + } @override void dispose() { _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } @@ -39,120 +54,341 @@ class _SearchPageState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; return BlocBuilder( builder: (context, state) { - final hasFilter = state is SearchLoaded && state.hasActiveFilter; - return Scaffold( - appBar: AppBar( - title: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: '搜索日记...', - hintStyle: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.4), + body: SafeArea( + 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), ), - border: InputBorder.none, - prefixIcon: const Icon(Icons.search), - suffixIcon: hasFilter - ? IconButton( - icon: const Icon(Icons.filter_alt_off), - tooltip: '清除筛选', - onPressed: _clearSearch, - ) - : (_searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - _clearSearch(); - }, - ) - : null), - ), - textInputAction: TextInputAction.search, - onSubmitted: (value) { - if (value.trim().isNotEmpty) { - context.read().add(SearchByTag(value.trim())); - } - }, + ], ), ), - body: _buildBody(context, theme, colorScheme, state), ); }, ); } - /// 根据搜索状态构建 body + // ===== 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, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: '搜索日记...', + hintStyle: theme.textTheme.bodyLarge?.copyWith( + color: isDark + ? AppColors.mutedDark + : AppColors.mutedLight, + ), + prefixIcon: Icon( + Icons.search, + size: 20, + color: isDark + ? AppColors.mutedDark + : AppColors.mutedLight, + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: Icon( + Icons.clear, + size: 18, + color: isDark + ? AppColors.mutedDark + : AppColors.mutedLight, + ), + onPressed: () { + _searchController.clear(); + _clearSearch(); + }, + ) + : 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, + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + context + .read() + .add(SearchByKeyword(value.trim())); + } + }, + ), + ), + ), + const SizedBox(width: 4), + // 取消按钮 + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + '取消', + style: theme.textTheme.bodyMedium?.copyWith( + color: AppColors.accent, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + // ===== 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() + .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( BuildContext context, ThemeData theme, ColorScheme colorScheme, + bool isDark, SearchState state, ) { return switch (state) { - SearchInitial() => _buildSuggestions(context, theme, colorScheme), - SearchLoading() => const Center(child: CircularProgressIndicator()), - SearchLoaded(:final results, :final activeMood, :final activeTag) => - _hasActiveFilter(activeMood, activeTag) - ? _buildResults(context, theme, colorScheme, results) - : _buildSuggestions(context, theme, colorScheme), - SearchError(:final message) => _buildError(colorScheme, message), + SearchInitial() => _buildSuggestions( + context, theme, colorScheme, isDark, []), + SearchLoading() => + const Center(child: CircularProgressIndicator()), + SearchLoaded( + :final results, + :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) => - mood != null || tag != null; + bool hasActiveFilter(String? mood, String? tag, String? keyword) => + mood != null || tag != null || keyword != null; + + // ===== 6B: 搜索历史 + 热门搜索 ===== - /// 建议区域 — 标签云 + 心情选择 Widget _buildSuggestions( BuildContext context, ThemeData theme, ColorScheme colorScheme, + bool isDark, + List searchHistory, ) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 搜索历史 + if (searchHistory.isNotEmpty) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '最近搜索', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + GestureDetector( + onTap: _clearSearch, + child: Text( + '清除', + style: theme.textTheme.bodySmall?.copyWith( + color: AppColors.accent, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: searchHistory.map((keyword) { + return ActionChip( + label: Text(keyword), + onPressed: () { + _searchController.text = keyword; + context + .read() + .add(SearchByKeyword(keyword)); + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + ], + + // 热门搜索 Text( - '常用标签', - style: - theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + '热门搜索', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, - children: _recentTags.map((tag) { + children: _hotSearches.map((keyword) { return ActionChip( - label: Text(tag), + label: Text(keyword), onPressed: () { - _searchController.text = tag; - context.read().add(SearchByTag(tag)); + _searchController.text = keyword; + context + .read() + .add(SearchByKeyword(keyword)); }, ); }).toList(), ), const SizedBox(height: 24), + + // 心情筛选 Text( '按心情筛选', - style: - theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 12, - children: _moodFilters.map((mood) { - final color = - AppColors.moodColors[mood.value] ?? colorScheme.primary; + children: Mood.values.map((mood) { + final color = AppColors.moodColors[mood.value] ?? + colorScheme.primary; return GestureDetector( onTap: () { - _searchController.text = _moodLabel(mood); - context.read().add(SearchByMood(mood)); + _searchController.text = moodToLabel(mood); + context + .read() + .add(SearchByMood(mood)); }, child: Column( children: [ @@ -164,11 +400,12 @@ class _SearchPageState extends State { color: color.withValues(alpha: 0.15), ), alignment: Alignment.center, - child: Text(_moodEmoji(mood), + child: Text(moodToEmoji(mood), style: const TextStyle(fontSize: 24)), ), 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 { ); } - /// 搜索结果列表 - Widget _buildResults( + // ===== 按分类 Tab 过滤结果 ===== + + Widget _buildFilteredResults( BuildContext context, ThemeData theme, ColorScheme colorScheme, + bool isDark, List results, + String? keyword, + SearchResultTab activeTab, ) { if (results.isEmpty) { return Center( @@ -198,7 +439,8 @@ class _SearchPageState extends State { Text( '没有找到匹配的日记', style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.5), + color: + colorScheme.onSurface.withValues(alpha: 0.5), ), ), const SizedBox(height: 16), @@ -211,17 +453,173 @@ class _SearchPageState extends State { ); } + // 根据活跃 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 results, + String? keyword, + ) { return ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), itemCount: results.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, 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 results) { + // 从搜索结果中提取所有标签及其频次 + final tagFreq = {}; + 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) { return Center( @@ -253,29 +651,50 @@ class _SearchPageState extends State { _searchController.clear(); context.read().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 = []; + 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 { final JournalEntry entry; + final String? keyword; - const _JournalCard({required this.entry}); + const _JournalCard({required this.entry, this.keyword}); @override Widget build(BuildContext context) { @@ -293,30 +712,23 @@ class _JournalCard extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(16), onTap: () { - // TODO: 导航到日记详情页 + context.push('/editor?id=${entry.id}'); }, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 第一行:心情 emoji + 标题 + 日期 + // 第一行:心情 emoji + 标题(高亮)+ 日期 Row( children: [ Text( - _moodEmoji(entry.mood), + moodToEmoji(entry.mood), style: const TextStyle(fontSize: 20), ), const SizedBox(width: 8), Expanded( - child: Text( - entry.title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + child: _highlightText(entry.title, keyword), ), Text( DateFormat('MM/dd').format(entry.date), @@ -360,11 +772,12 @@ class _JournalCard extends StatelessWidget { ); } - String _moodEmoji(Mood mood) => switch (mood) { - Mood.happy => '😊', - Mood.calm => '😌', - Mood.sad => '😢', - Mood.angry => '😠', - Mood.thinking => '🤔', - }; +} + +/// Padding 扩展 — 简化 Wrap 的 padding +extension _PadAll on Widget { + Widget padAll(double value) => Padding( + padding: EdgeInsets.all(value), + child: this, + ); } diff --git a/app/lib/features/stickers/bloc/sticker_bloc.dart b/app/lib/features/stickers/bloc/sticker_bloc.dart index 04e121f..e0d2fa5 100644 --- a/app/lib/features/stickers/bloc/sticker_bloc.dart +++ b/app/lib/features/stickers/bloc/sticker_bloc.dart @@ -52,20 +52,31 @@ class Sticker { class StickerState { final List packs; final String selectedCategory; + final String searchQuery; final bool isLoading; final String? errorMessage; const StickerState({ this.packs = const [], this.selectedCategory = '全部', + this.searchQuery = '', this.isLoading = false, this.errorMessage, }); - /// 按分类过滤贴纸包 - List get filteredPacks => selectedCategory == '全部' - ? packs - : packs.where((p) => p.category == selectedCategory).toList(); + /// 按分类 + 搜索关键词过滤贴纸包 + List get filteredPacks { + var result = selectedCategory == '全部' + ? packs + : 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 get categories { @@ -80,12 +91,14 @@ class StickerState { StickerState copyWith({ List? packs, String? selectedCategory, + String? searchQuery, bool? isLoading, String? errorMessage, }) => StickerState( packs: packs ?? this.packs, selectedCategory: selectedCategory ?? this.selectedCategory, + searchQuery: searchQuery ?? this.searchQuery, isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, ); @@ -114,6 +127,12 @@ class StickerBloc extends ChangeNotifier { notifyListeners(); } + /// 搜索贴纸包(按名称前端过滤) + void search(String query) { + _state = _state.copyWith(searchQuery: query); + notifyListeners(); + } + /// 按分类加载贴纸包 void loadByCategory(String? category) { _state = _state.copyWith(isLoading: true); diff --git a/app/lib/features/stickers/views/sticker_library_page.dart b/app/lib/features/stickers/views/sticker_library_page.dart index c3e4214..cc1db94 100644 --- a/app/lib/features/stickers/views/sticker_library_page.dart +++ b/app/lib/features/stickers/views/sticker_library_page.dart @@ -17,6 +17,12 @@ class StickerLibraryPage extends StatefulWidget { class _StickerLibraryPageState extends State { late final StickerBloc _bloc; + final _searchController = TextEditingController(); + + /// 设计规格中的 8 个分类 + static const _specCategories = [ + '推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带', + ]; @override void initState() { @@ -28,92 +34,225 @@ class _StickerLibraryPageState extends State { @override void dispose() { _bloc.dispose(); + _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Scaffold( - appBar: AppBar(title: const Text('贴纸库')), - body: ListenableBuilder( - listenable: _bloc, - builder: (context, _) { - final state = _bloc.state; + body: SafeArea( + child: ListenableBuilder( + listenable: _bloc, + builder: (context, _) { + final state = _bloc.state; - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } - if (state.errorMessage != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 48, color: colorScheme.error), + if (state.errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: colorScheme.error), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: _bloc.load, + child: const Text('重试'), + ), + ], + ), + ); + } + + return Column( + children: [ + // ---- 自定义顶栏 ---- + 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( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: _specCategories.map((cat) { + final isSelected = cat == state.selectedCategory || + (cat == '推荐' && state.selectedCategory == '全部'); + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + selected: isSelected, + label: Text(cat), + onSelected: (_) { + if (cat == '推荐') { + _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(), + ), + ), + const SizedBox(height: 12), + + // ---- 精选贴纸包卡片 ---- + if (state.selectedCategory == '全部') + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const _FeaturedPackCard(), + ), + if (state.selectedCategory == '全部') const SizedBox(height: 16), - FilledButton.tonal( - onPressed: _bloc.load, - child: const Text('重试'), + + // ---- 贴纸包网格 ---- + Expanded( + child: state.filteredPacks.isEmpty + ? const Center(child: Text('暂无贴纸包')) + : GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.85, + ), + itemCount: state.filteredPacks.length, + itemBuilder: (context, index) { + return _StickerPackCard( + pack: state.filteredPacks[index], + colorScheme: colorScheme, + ); + }, + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +/// 精选贴纸包卡片 — 渐变背景 + 限时免费标签 +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, + )), ), ], ), - ); - } - - final categories = state.categories; - - return Column( - children: [ - // 分类选择器(横向滚动 Chips) - SizedBox( - height: 48, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: categories.map((cat) { - final isSelected = cat == state.selectedCategory; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - selected: isSelected, - label: Text(cat), - onSelected: (_) => _bloc.selectCategory(cat), - selectedColor: colorScheme.primaryContainer, - checkmarkColor: colorScheme.primary, - ), - ); - }).toList(), - ), - ), - const SizedBox(height: 8), - - // 贴纸包网格 - Expanded( - child: state.filteredPacks.isEmpty - ? const Center(child: Text('暂无贴纸包')) - : GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.85, - ), - itemCount: state.filteredPacks.length, - itemBuilder: (context, index) { - return _StickerPackCard( - pack: state.filteredPacks[index], - colorScheme: colorScheme, - ); - }, - ), - ), - ], - ); - }, + ), + ], + ), ), ); } diff --git a/app/lib/features/teacher/views/teacher_page.dart b/app/lib/features/teacher/views/teacher_page.dart index acf0833..7439af4 100644 --- a/app/lib/features/teacher/views/teacher_page.dart +++ b/app/lib/features/teacher/views/teacher_page.dart @@ -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/data/repositories/class_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'; /// 老师管理页面 — 教师专属功能入口 @@ -162,57 +163,91 @@ class _TeacherView extends StatelessWidget { final titleController = TextEditingController(); final descController = TextEditingController(); + // 从 ClassBloc 获取已加载的班级列表 + final classState = context.read().state; + final classes = classState is ClassListLoaded ? classState.classes : []; + + // 无班级时提示先创建 + if (classes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请先创建班级后再布置主题')), + ); + return; + } + + String selectedClassId = classes.first.id; + showDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('布置主题'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: titleController, - decoration: const InputDecoration( - labelText: '主题标题', - hintText: '例如: 我的周末', - border: OutlineInputBorder(), + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('布置主题'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 班级选择下拉框 + DropdownButtonFormField( + 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( + controller: titleController, + decoration: const InputDecoration( + labelText: '主题标题', + hintText: '例如: 我的周末', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: descController, + decoration: const InputDecoration( + labelText: '描述(可选)', + hintText: '主题要求和说明', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('取消'), ), - const SizedBox(height: 12), - TextField( - controller: descController, - decoration: const InputDecoration( - labelText: '描述(可选)', - hintText: '主题要求和说明', - border: OutlineInputBorder(), - ), - maxLines: 3, + FilledButton( + onPressed: () { + if (titleController.text.trim().isNotEmpty) { + context.read().add(TopicAssign( + classId: selectedClassId, + title: titleController.text.trim(), + description: descController.text.trim().isEmpty + ? null + : descController.text.trim(), + )); + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('主题布置成功!')), + ); + } + }, + child: const Text('布置'), ), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('取消'), - ), - FilledButton( - onPressed: () { - if (titleController.text.trim().isNotEmpty) { - context.read().add(TopicAssign( - classId: 'class-1', - title: titleController.text.trim(), - description: descController.text.trim().isEmpty - ? null - : descController.text.trim(), - )); - Navigator.pop(dialogContext); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('主题布置成功!')), - ); - } - }, - child: const Text('布置'), - ), - ], ), ); } diff --git a/app/test/features/calendar/bloc/calendar_bloc_test.dart b/app/test/features/calendar/bloc/calendar_bloc_test.dart index 7c41816..9ada21f 100644 --- a/app/test/features/calendar/bloc/calendar_bloc_test.dart +++ b/app/test/features/calendar/bloc/calendar_bloc_test.dart @@ -202,10 +202,14 @@ class _FailingJournalRepository implements JournalRepository { int? pageSize, String? mood, String? tag, + String? classId, }) async { throw Exception('模拟网络错误'); } + @override + Future getJournalCount() async => 0; + @override Future getJournal(String id) async => null; diff --git a/app/test/features/home/bloc/home_bloc_test.dart b/app/test/features/home/bloc/home_bloc_test.dart index 1e56b15..930b44e 100644 --- a/app/test/features/home/bloc/home_bloc_test.dart +++ b/app/test/features/home/bloc/home_bloc_test.dart @@ -222,10 +222,16 @@ class _FailingJournalRepository implements JournalRepository { int? pageSize, String? mood, String? tag, + String? classId, }) async { throw Exception('网络不可用'); } + @override + Future getJournalCount() async { + throw Exception('网络不可用'); + } + @override Future getJournal(String id) async { throw UnimplementedError();