fix(app): 修复 P2~P4 共 10 项前端问题
P2 必须修复: - 教师布置主题 classId 从硬编码改为班级下拉选择器 - 班级日记墙使用服务端 classId 过滤替代前端过滤 - Profile 统计栏接入 JournalRepository 真实数据 - WeeklyPage 从全硬编码改为 JournalRepository 数据驱动 P3 建议改进: - 提取 mood_utils.dart 公共函数,消除 4 处重复定义 - 贴纸库搜索框连接 StickerBloc 按名称过滤 P4 细节打磨: - 家长页多孩子时显示 DropdownButton 选择器 - 搜索结果日记卡片点击跳转 /editor?id= - MonthlyPage 照片数量从 JournalElement 统计 - calendar_page/mood_page/search_page 统一使用 moodToEmoji/moodToLabel
This commit is contained in:
22
app/lib/core/utils/mood_utils.dart
Normal file
22
app/lib/core/utils/mood_utils.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// 心情公共工具 — 统一 Mood 枚举的 emoji/标签映射
|
||||||
|
// 消除 calendar_page / mood_page / search_page / monthly_page 中的重复定义
|
||||||
|
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
|
||||||
|
/// 心情 → emoji
|
||||||
|
String moodToEmoji(Mood mood) => switch (mood) {
|
||||||
|
Mood.happy => '😊',
|
||||||
|
Mood.calm => '😌',
|
||||||
|
Mood.sad => '😢',
|
||||||
|
Mood.angry => '😠',
|
||||||
|
Mood.thinking => '🤔',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 心情 → 中文标签
|
||||||
|
String moodToLabel(Mood mood) => switch (mood) {
|
||||||
|
Mood.happy => '开心',
|
||||||
|
Mood.calm => '平静',
|
||||||
|
Mood.sad => '难过',
|
||||||
|
Mood.angry => '生气',
|
||||||
|
Mood.thinking => '思考',
|
||||||
|
};
|
||||||
@@ -34,6 +34,7 @@ class IsarJournalRepository implements JournalRepository {
|
|||||||
int? pageSize,
|
int? pageSize,
|
||||||
String? mood,
|
String? mood,
|
||||||
String? tag,
|
String? tag,
|
||||||
|
String? classId,
|
||||||
}) async {
|
}) async {
|
||||||
var query = _isar.journalEntryCollections
|
var query = _isar.journalEntryCollections
|
||||||
.where()
|
.where()
|
||||||
@@ -58,6 +59,11 @@ class IsarJournalRepository implements JournalRepository {
|
|||||||
query = query.and().tagsJsonContains(tag);
|
query = query.and().tagsJsonContains(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 班级过滤
|
||||||
|
if (classId != null) {
|
||||||
|
query = query.and().classIdEqualTo(classId);
|
||||||
|
}
|
||||||
|
|
||||||
// 按日期降序排列
|
// 按日期降序排列
|
||||||
var results = await query
|
var results = await query
|
||||||
.sortByDateEpochDesc()
|
.sortByDateEpochDesc()
|
||||||
@@ -74,6 +80,15 @@ class IsarJournalRepository implements JournalRepository {
|
|||||||
return results.map(_fromCollection).toList();
|
return results.map(_fromCollection).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() async {
|
||||||
|
return _isar.journalEntryCollections
|
||||||
|
.where()
|
||||||
|
.filter()
|
||||||
|
.isDeletedEqualTo(false)
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<JournalEntry?> getJournal(String id) async {
|
Future<JournalEntry?> getJournal(String id) async {
|
||||||
final col = await _isar.journalEntryCollections
|
final col = await _isar.journalEntryCollections
|
||||||
@@ -262,6 +277,7 @@ class IsarJournalRepository implements JournalRepository {
|
|||||||
..isPrivate = entry.isPrivate
|
..isPrivate = entry.isPrivate
|
||||||
..sharedToClass = entry.sharedToClass
|
..sharedToClass = entry.sharedToClass
|
||||||
..assignedTopicId = entry.assignedTopicId
|
..assignedTopicId = entry.assignedTopicId
|
||||||
|
..contentExcerpt = entry.contentExcerpt
|
||||||
..version = entry.version
|
..version = entry.version
|
||||||
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
|
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
|
||||||
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
|
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
|
||||||
@@ -290,6 +306,7 @@ class IsarJournalRepository implements JournalRepository {
|
|||||||
isPrivate: col.isPrivate,
|
isPrivate: col.isPrivate,
|
||||||
sharedToClass: col.sharedToClass,
|
sharedToClass: col.sharedToClass,
|
||||||
assignedTopicId: col.assignedTopicId,
|
assignedTopicId: col.assignedTopicId,
|
||||||
|
contentExcerpt: col.contentExcerpt,
|
||||||
version: col.version,
|
version: col.version,
|
||||||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import '../models/journal_element.dart';
|
|||||||
/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间)
|
/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间)
|
||||||
/// - [page]/[pageSize]: 分页参数,从 1 开始
|
/// - [page]/[pageSize]: 分页参数,从 1 开始
|
||||||
abstract class JournalRepository {
|
abstract class JournalRepository {
|
||||||
/// 获取日记列表(支持日期范围、心情、标签过滤和分页)
|
/// 获取日记列表(支持日期范围、心情、标签、班级过滤和分页)
|
||||||
Future<List<JournalEntry>> getJournals({
|
Future<List<JournalEntry>> getJournals({
|
||||||
DateTime? dateFrom,
|
DateTime? dateFrom,
|
||||||
DateTime? dateTo,
|
DateTime? dateTo,
|
||||||
@@ -23,8 +23,12 @@ abstract class JournalRepository {
|
|||||||
int? pageSize,
|
int? pageSize,
|
||||||
String? mood,
|
String? mood,
|
||||||
String? tag,
|
String? tag,
|
||||||
|
String? classId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 获取日记总数
|
||||||
|
Future<int> getJournalCount();
|
||||||
|
|
||||||
/// 获取单篇日记(返回 null 表示不存在)
|
/// 获取单篇日记(返回 null 表示不存在)
|
||||||
Future<JournalEntry?> getJournal(String id);
|
Future<JournalEntry?> getJournal(String id);
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ class InMemoryJournalRepository implements JournalRepository {
|
|||||||
int? pageSize,
|
int? pageSize,
|
||||||
String? mood,
|
String? mood,
|
||||||
String? tag,
|
String? tag,
|
||||||
|
String? classId,
|
||||||
}) async {
|
}) async {
|
||||||
var results = _journals.values.toList();
|
var results = _journals.values.toList();
|
||||||
|
|
||||||
@@ -87,6 +92,11 @@ class InMemoryJournalRepository implements JournalRepository {
|
|||||||
results = results.where((j) => j.tags.contains(tag)).toList();
|
results = results.where((j) => j.tags.contains(tag)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 班级过滤
|
||||||
|
if (classId != null) {
|
||||||
|
results = results.where((j) => j.classId == classId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// 按日期降序排列(最新在前)
|
// 按日期降序排列(最新在前)
|
||||||
results.sort((a, b) => b.date.compareTo(a.date));
|
results.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
|
||||||
@@ -101,6 +111,9 @@ class InMemoryJournalRepository implements JournalRepository {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() async => _journals.length;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<JournalEntry?> getJournal(String id) async {
|
Future<JournalEntry?> getJournal(String id) async {
|
||||||
return _journals[id];
|
return _journals[id];
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class RemoteJournalRepository implements JournalRepository {
|
|||||||
int? pageSize,
|
int? pageSize,
|
||||||
String? mood,
|
String? mood,
|
||||||
String? tag,
|
String? tag,
|
||||||
|
String? classId,
|
||||||
}) async {
|
}) async {
|
||||||
final queryParams = <String, dynamic>{};
|
final queryParams = <String, dynamic>{};
|
||||||
// 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒)
|
// 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒)
|
||||||
@@ -34,6 +35,7 @@ class RemoteJournalRepository implements JournalRepository {
|
|||||||
if (pageSize != null) queryParams['page_size'] = pageSize;
|
if (pageSize != null) queryParams['page_size'] = pageSize;
|
||||||
if (mood != null) queryParams['mood'] = mood;
|
if (mood != null) queryParams['mood'] = mood;
|
||||||
if (tag != null) queryParams['tag'] = tag;
|
if (tag != null) queryParams['tag'] = tag;
|
||||||
|
if (classId != null) queryParams['class_id'] = classId;
|
||||||
|
|
||||||
final response = await _api.get('/diary/journals', queryParams: queryParams);
|
final response = await _api.get('/diary/journals', queryParams: queryParams);
|
||||||
final body = response.data as Map<String, dynamic>;
|
final body = response.data as Map<String, dynamic>;
|
||||||
@@ -43,6 +45,16 @@ class RemoteJournalRepository implements JournalRepository {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() async {
|
||||||
|
final response = await _api.get('/diary/journals', queryParams: {
|
||||||
|
'page': 1,
|
||||||
|
'page_size': 1,
|
||||||
|
});
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
return (body['total'] as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<JournalEntry?> getJournal(String id) async {
|
Future<JournalEntry?> getJournal(String id) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
|
import 'package:nuanji_app/core/utils/mood_utils.dart';
|
||||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
import '../bloc/calendar_bloc.dart';
|
import '../bloc/calendar_bloc.dart';
|
||||||
@@ -128,6 +129,10 @@ class _MonthView extends StatelessWidget {
|
|||||||
return Expanded(
|
return Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// 本月心情概览柱状图
|
||||||
|
_MoodSummaryChart(journalsByDate: loaded.journalsByDate),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// 星期标题行
|
// 星期标题行
|
||||||
_WeekdayHeader(colorScheme: Theme.of(context).colorScheme),
|
_WeekdayHeader(colorScheme: Theme.of(context).colorScheme),
|
||||||
|
|
||||||
@@ -155,6 +160,104 @@ class _MonthView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 本月心情概览 — 5 柱状图
|
||||||
|
class _MoodSummaryChart extends StatelessWidget {
|
||||||
|
const _MoodSummaryChart({required this.journalsByDate});
|
||||||
|
|
||||||
|
final Map<DateTime, List<JournalEntry>> journalsByDate;
|
||||||
|
|
||||||
|
static const _moodConfig = [
|
||||||
|
(Mood.happy, '开心', AppColors.secondary),
|
||||||
|
(Mood.calm, '平静', AppColors.tertiary),
|
||||||
|
(Mood.sad, '难过', Color(0xFF5B7DB1)),
|
||||||
|
(Mood.angry, '生气', AppColors.accent),
|
||||||
|
(Mood.thinking, '思考', Color(0xFF8B7E74)),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
// 统计每种心情的数量
|
||||||
|
final counts = <Mood, int>{};
|
||||||
|
for (final entry in journalsByDate.entries) {
|
||||||
|
for (final journal in entry.value) {
|
||||||
|
counts[journal.mood] = (counts[journal.mood] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final maxCount = counts.values.fold(0, (a, b) => a > b ? a : b).toDouble();
|
||||||
|
final barMaxHeight = 60.0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: theme.colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
blurRadius: 8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'本月心情概览',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: _moodConfig.map((config) {
|
||||||
|
final count = counts[config.$1] ?? 0;
|
||||||
|
final barHeight = maxCount > 0 && count > 0
|
||||||
|
? (count / maxCount * barMaxHeight).clamp(4.0, barMaxHeight)
|
||||||
|
: 4.0;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: barHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: config.$3,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
config.$2,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 周视图 =====
|
// ===== 周视图 =====
|
||||||
|
|
||||||
class _WeekView extends StatelessWidget {
|
class _WeekView extends StatelessWidget {
|
||||||
@@ -264,7 +367,7 @@ class _WeekView extends StatelessWidget {
|
|||||||
// 心情 emoji
|
// 心情 emoji
|
||||||
if (hasEntry)
|
if (hasEntry)
|
||||||
Text(
|
Text(
|
||||||
_moodEmoji(journals.first.mood),
|
moodToEmoji(journals.first.mood),
|
||||||
style: const TextStyle(fontSize: 24),
|
style: const TextStyle(fontSize: 24),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -321,6 +424,13 @@ class _TimelineView extends StatelessWidget {
|
|||||||
final journal = entry.value;
|
final journal = entry.value;
|
||||||
final isLast = index == allJournals.length - 1;
|
final isLast = index == allJournals.length - 1;
|
||||||
|
|
||||||
|
// 时间格式化 HH:mm
|
||||||
|
final timeStr =
|
||||||
|
'${journal.createdAt.hour.toString().padLeft(2, '0')}:${journal.createdAt.minute.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
// 摘要文本
|
||||||
|
final excerpt = journal.contentExcerpt ?? '';
|
||||||
|
|
||||||
return IntrinsicHeight(
|
return IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -332,14 +442,14 @@ class _TimelineView extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// 圆点 + emoji
|
// 圆点 + emoji
|
||||||
Container(
|
Container(
|
||||||
width: 36,
|
width: 40,
|
||||||
height: 36,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getMoodBgColor(journal.mood.value),
|
color: _getMoodBgColor(journal.mood.value),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 18)),
|
child: Text(moodToEmoji(journal.mood), style: const TextStyle(fontSize: 20)),
|
||||||
),
|
),
|
||||||
// 竖线
|
// 竖线
|
||||||
if (!isLast)
|
if (!isLast)
|
||||||
@@ -369,6 +479,8 @@ class _TimelineView extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${date.month}月${date.day}日',
|
'${date.month}月${date.day}日',
|
||||||
@@ -376,6 +488,15 @@ class _TimelineView extends StatelessWidget {
|
|||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
timeStr,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
journal.title,
|
journal.title,
|
||||||
@@ -385,8 +506,18 @@ class _TimelineView extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
// 日记内容通过 JournalElement 管理,日历视图仅显示标题
|
if (excerpt.isNotEmpty) ...[
|
||||||
// 后续可通过 elements 预览首段文字
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
excerpt,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -426,10 +557,20 @@ class _MonthNavigator extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: onPrevious,
|
onPressed: onPrevious,
|
||||||
icon: const Icon(Icons.chevron_left),
|
child: const Icon(Icons.chevron_left, size: 20),
|
||||||
tooltip: '上个月',
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
monthName,
|
monthName,
|
||||||
@@ -437,10 +578,20 @@ class _MonthNavigator extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: onNext,
|
onPressed: onNext,
|
||||||
icon: const Icon(Icons.chevron_right),
|
child: const Icon(Icons.chevron_right, size: 20),
|
||||||
tooltip: '下个月',
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -614,7 +765,10 @@ class _DayCell extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Column(
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@@ -632,8 +786,8 @@ class _DayCell extends StatelessWidget {
|
|||||||
// 心情小圆点(仅在有日记但无背景色时显示)
|
// 心情小圆点(仅在有日记但无背景色时显示)
|
||||||
if (hasJournals && moodBg == Colors.transparent)
|
if (hasJournals && moodBg == Colors.transparent)
|
||||||
Container(
|
Container(
|
||||||
width: 4,
|
width: 6,
|
||||||
height: 4,
|
height: 6,
|
||||||
margin: const EdgeInsets.only(top: 1),
|
margin: const EdgeInsets.only(top: 1),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
@@ -642,6 +796,24 @@ class _DayCell extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// 日记条目指示点
|
||||||
|
if (hasJournals)
|
||||||
|
Positioned(
|
||||||
|
top: 3,
|
||||||
|
right: 5,
|
||||||
|
child: Container(
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimary
|
||||||
|
: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -731,7 +903,7 @@ class _DayJournalList extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
_moodEmoji(journal.mood),
|
moodToEmoji(journal.mood),
|
||||||
style: const TextStyle(fontSize: 20),
|
style: const TextStyle(fontSize: 20),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -781,17 +953,6 @@ Color _getMoodBgColor(String mood) {
|
|||||||
return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight;
|
return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 心情 → emoji
|
|
||||||
String _moodEmoji(Mood mood) {
|
|
||||||
return switch (mood) {
|
|
||||||
Mood.happy => '😊',
|
|
||||||
Mood.calm => '😌',
|
|
||||||
Mood.sad => '😢',
|
|
||||||
Mood.angry => '😠',
|
|
||||||
Mood.thinking => '🤔',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 是否是今天
|
/// 是否是今天
|
||||||
bool _isToday(DateTime date) {
|
bool _isToday(DateTime date) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
// 对齐 Open Design 原型稿 screens/monthly.html
|
// 对齐 Open Design 原型稿 screens/monthly.html
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_shadows.dart';
|
import 'package:nuanji_app/core/theme/app_shadows.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_element.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
|
||||||
/// 月度概览页面
|
/// 月度概览页面
|
||||||
class MonthlyPage extends StatefulWidget {
|
class MonthlyPage extends StatefulWidget {
|
||||||
const MonthlyPage({super.key});
|
final JournalRepository? journalRepository;
|
||||||
|
const MonthlyPage({super.key, this.journalRepository});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MonthlyPage> createState() => _MonthlyPageState();
|
State<MonthlyPage> createState() => _MonthlyPageState();
|
||||||
@@ -17,23 +22,59 @@ class MonthlyPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _MonthlyPageState extends State<MonthlyPage> {
|
class _MonthlyPageState extends State<MonthlyPage> {
|
||||||
late DateTime _focusedMonth;
|
late DateTime _focusedMonth;
|
||||||
|
List<JournalEntry> _journals = [];
|
||||||
|
int _photoCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_focusedMonth = DateTime.now();
|
_focusedMonth = DateTime.now();
|
||||||
|
_loadJournals();
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalRepository get _repo =>
|
||||||
|
widget.journalRepository ?? context.read<JournalRepository>();
|
||||||
|
|
||||||
|
Future<void> _loadJournals() async {
|
||||||
|
final firstDay = DateTime(_focusedMonth.year, _focusedMonth.month, 1);
|
||||||
|
// 下月 1 号作为上界(开区间),所以用 month+1
|
||||||
|
final nextMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 1);
|
||||||
|
final journals = await _repo.getJournals(
|
||||||
|
dateFrom: firstDay,
|
||||||
|
dateTo: nextMonth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 统计照片元素数量
|
||||||
|
var photoCount = 0;
|
||||||
|
for (final journal in journals) {
|
||||||
|
try {
|
||||||
|
final elements = await _repo.getElements(journal.id);
|
||||||
|
photoCount += elements.where((e) => e.elementType == ElementType.image).length;
|
||||||
|
} catch (_) {
|
||||||
|
// 单个日记加载元素失败不影响整体统计
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_journals = journals;
|
||||||
|
_photoCount = photoCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToPreviousMonth() {
|
void _goToPreviousMonth() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
|
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
|
||||||
});
|
});
|
||||||
|
_loadJournals();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToNextMonth() {
|
void _goToNextMonth() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
|
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
|
||||||
});
|
});
|
||||||
|
_loadJournals();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -55,13 +96,13 @@ class _MonthlyPageState extends State<MonthlyPage> {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 心情色彩月历
|
// 心情色彩月历
|
||||||
_MoodCalendar(month: _focusedMonth),
|
_MoodCalendar(month: _focusedMonth, journals: _journals),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 月度统计 2x2
|
// 月度统计 2x2
|
||||||
const _MonthSummary(),
|
_MonthSummary(journals: _journals, photoCount: _photoCount),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 精选日记
|
// 精选日记
|
||||||
const _Highlights(),
|
_Highlights(journals: _journals),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -161,31 +202,27 @@ class _NavButton extends StatelessWidget {
|
|||||||
// ===== 心情色彩月历 =====
|
// ===== 心情色彩月历 =====
|
||||||
|
|
||||||
class _MoodCalendar extends StatelessWidget {
|
class _MoodCalendar extends StatelessWidget {
|
||||||
const _MoodCalendar({required this.month});
|
const _MoodCalendar({required this.month, required this.journals});
|
||||||
|
|
||||||
final DateTime month;
|
final DateTime month;
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
|
||||||
// 心情类型
|
// 心情 → emoji(对齐 Mood 枚举: happy/calm/sad/angry/thinking)
|
||||||
static const _moodTypes = [
|
static const _moodEmojis = <Mood, String>{
|
||||||
'happy', 'calm', 'sad', 'tired', 'love',
|
Mood.happy: '😊',
|
||||||
];
|
Mood.calm: '😌',
|
||||||
|
Mood.sad: '😢',
|
||||||
// 心情 → emoji
|
Mood.angry: '😡',
|
||||||
static const _moodEmojis = <String, String>{
|
Mood.thinking: '🤔',
|
||||||
'happy': '😊',
|
|
||||||
'calm': '😐',
|
|
||||||
'sad': '😢',
|
|
||||||
'tired': '😐',
|
|
||||||
'love': '😡',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 心情 → 背景色
|
// 心情 → 背景色
|
||||||
static const _moodBgColors = <String, Color>{
|
static const _moodBgColors = <Mood, Color>{
|
||||||
'happy': AppColors.secondarySoftLight,
|
Mood.happy: AppColors.secondarySoftLight,
|
||||||
'love': AppColors.roseSoftLight,
|
Mood.angry: AppColors.roseSoftLight,
|
||||||
'calm': AppColors.tertiarySoftLight,
|
Mood.calm: AppColors.tertiarySoftLight,
|
||||||
'sad': Color(0xFFD4DDE8),
|
Mood.sad: Color(0xFFD4DDE8),
|
||||||
'tired': Color(0xFFE8E4E0),
|
Mood.thinking: Color(0xFFE8E4E0),
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -216,10 +253,18 @@ class _MoodCalendar extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildGrid(BuildContext context, DateTime now) {
|
Widget _buildGrid(BuildContext context, DateTime now) {
|
||||||
final firstDay = DateTime(month.year, month.month, 1);
|
final firstDay = DateTime(month.year, month.month, 1);
|
||||||
// 周日=0 → 偏移量; weekday 返回 1(周一)..7(周日)
|
// 周一=0 → 偏移量; weekday 返回 1(周一)..7(周日)
|
||||||
final startOffset = firstDay.weekday % 7; // 周日开头
|
final startOffset = firstDay.weekday - 1; // 周一开头
|
||||||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||||
|
|
||||||
|
// 按日期建索引:day → JournalEntry
|
||||||
|
final journalByDay = <int, JournalEntry>{};
|
||||||
|
for (final j in journals) {
|
||||||
|
if (j.date.year == month.year && j.date.month == month.month) {
|
||||||
|
journalByDay[j.date.day] = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final cells = <Widget>[];
|
final cells = <Widget>[];
|
||||||
|
|
||||||
// 空白填充
|
// 空白填充
|
||||||
@@ -227,19 +272,16 @@ class _MoodCalendar extends StatelessWidget {
|
|||||||
cells.add(const SizedBox.shrink());
|
cells.add(const SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟心情数据(确定性伪随机,同一天固定同心情)
|
|
||||||
final rng = _SeededRandom(month.year * 100 + month.month);
|
|
||||||
|
|
||||||
for (var d = 1; d <= daysInMonth; d++) {
|
for (var d = 1; d <= daysInMonth; d++) {
|
||||||
final isToday = now.year == month.year &&
|
final isToday = now.year == month.year &&
|
||||||
now.month == month.month &&
|
now.month == month.month &&
|
||||||
now.day == d;
|
now.day == d;
|
||||||
|
|
||||||
// 每天随机一个心情
|
final entry = journalByDay[d];
|
||||||
final moodIndex = rng.nextInt(5);
|
final mood = entry?.mood;
|
||||||
final mood = _moodTypes[moodIndex];
|
final bgColor =
|
||||||
final bgColor = _moodBgColors[mood] ?? Colors.transparent;
|
mood != null ? (_moodBgColors[mood] ?? Colors.transparent) : Colors.transparent;
|
||||||
final emoji = _moodEmojis[mood] ?? '';
|
final emoji = mood != null ? (_moodEmojis[mood] ?? '') : '';
|
||||||
|
|
||||||
cells.add(
|
cells.add(
|
||||||
_MoodCell(
|
_MoodCell(
|
||||||
@@ -263,17 +305,6 @@ class _MoodCalendar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 简单确定性伪随机数生成器(仅用于模拟数据)
|
|
||||||
class _SeededRandom {
|
|
||||||
_SeededRandom(int seed) : _state = seed;
|
|
||||||
int _state;
|
|
||||||
|
|
||||||
int nextInt(int max) {
|
|
||||||
_state = (_state * 1103515245 + 12345) & 0x7FFFFFFF;
|
|
||||||
return _state % max;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 星期标题行
|
/// 星期标题行
|
||||||
class _WeekdayRow extends StatelessWidget {
|
class _WeekdayRow extends StatelessWidget {
|
||||||
const _WeekdayRow({required this.colorScheme});
|
const _WeekdayRow({required this.colorScheme});
|
||||||
@@ -282,7 +313,7 @@ class _WeekdayRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
return Row(
|
return Row(
|
||||||
children: weekdays.map((day) {
|
children: weekdays.map((day) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
@@ -359,7 +390,36 @@ class _MoodCell extends StatelessWidget {
|
|||||||
// ===== 月度统计 2x2 =====
|
// ===== 月度统计 2x2 =====
|
||||||
|
|
||||||
class _MonthSummary extends StatelessWidget {
|
class _MonthSummary extends StatelessWidget {
|
||||||
const _MonthSummary();
|
const _MonthSummary({required this.journals, required this.photoCount});
|
||||||
|
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
final int photoCount;
|
||||||
|
|
||||||
|
/// 计算最长连续写日记天数
|
||||||
|
int _calcLongestStreak() {
|
||||||
|
if (journals.isEmpty) return 0;
|
||||||
|
final days = journals.map((j) => j.date.day).toSet().toList()..sort();
|
||||||
|
int longest = 1;
|
||||||
|
int current = 1;
|
||||||
|
for (var i = 1; i < days.length; i++) {
|
||||||
|
if (days[i] == days[i - 1] + 1) {
|
||||||
|
current++;
|
||||||
|
if (current > longest) longest = current;
|
||||||
|
} else {
|
||||||
|
current = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return longest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算"好心情"(happy/calm)占比
|
||||||
|
String _calcGoodMoodPercent() {
|
||||||
|
if (journals.isEmpty) return '0%';
|
||||||
|
final good = journals.where(
|
||||||
|
(j) => j.mood == Mood.happy || j.mood == Mood.calm,
|
||||||
|
).length;
|
||||||
|
return '${((good / journals.length) * 100).round()}%';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -386,28 +446,28 @@ class _MonthSummary extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_StatCard(
|
_StatCard(
|
||||||
icon: '📝',
|
icon: '📝',
|
||||||
value: '28',
|
value: '${journals.length}',
|
||||||
label: '日记篇数',
|
label: '日记篇数',
|
||||||
bgColor: AppColors.tertiarySoftLight,
|
bgColor: AppColors.tertiarySoftLight,
|
||||||
valueColor: const Color(0xFFB8860B),
|
valueColor: const Color(0xFFB8860B),
|
||||||
),
|
),
|
||||||
_StatCard(
|
_StatCard(
|
||||||
icon: '🔥',
|
icon: '🔥',
|
||||||
value: '12',
|
value: '${_calcLongestStreak()}',
|
||||||
label: '最长连续',
|
label: '最长连续',
|
||||||
bgColor: AppColors.secondarySoftLight,
|
bgColor: AppColors.secondarySoftLight,
|
||||||
valueColor: const Color(0xFF2D7D46),
|
valueColor: const Color(0xFF2D7D46),
|
||||||
),
|
),
|
||||||
_StatCard(
|
_StatCard(
|
||||||
icon: '😊',
|
icon: '😊',
|
||||||
value: '72%',
|
value: _calcGoodMoodPercent(),
|
||||||
label: '好心情占比',
|
label: '好心情占比',
|
||||||
bgColor: AppColors.roseSoftLight,
|
bgColor: AppColors.roseSoftLight,
|
||||||
valueColor: const Color(0xFF9B4D4D),
|
valueColor: const Color(0xFF9B4D4D),
|
||||||
),
|
),
|
||||||
_StatCard(
|
_StatCard(
|
||||||
icon: '📸',
|
icon: '📸',
|
||||||
value: '18',
|
value: '$photoCount',
|
||||||
label: '照片数量',
|
label: '照片数量',
|
||||||
bgColor: const Color(0xFFD4DDE8),
|
bgColor: const Color(0xFFD4DDE8),
|
||||||
valueColor: const Color(0xFF4A6B8A),
|
valueColor: const Color(0xFF4A6B8A),
|
||||||
@@ -475,42 +535,30 @@ class _StatCard extends StatelessWidget {
|
|||||||
// ===== 精选日记 =====
|
// ===== 精选日记 =====
|
||||||
|
|
||||||
class _Highlights extends StatelessWidget {
|
class _Highlights extends StatelessWidget {
|
||||||
const _Highlights();
|
const _Highlights({required this.journals});
|
||||||
|
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
|
||||||
|
static const _badgeConfig = <Mood, ({String badge, Color bg, Color fg})>{
|
||||||
|
Mood.happy: (badge: '最佳心情', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
|
||||||
|
Mood.calm: (badge: '平静时光', bg: AppColors.tertiarySoftLight, fg: Color(0xFFB8860B)),
|
||||||
|
Mood.sad: (badge: '真实记录', bg: Color(0xFFD4DDE8), fg: Color(0xFF4A6B8A)),
|
||||||
|
Mood.angry: (badge: '真情流露', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
|
||||||
|
Mood.thinking: (badge: '深度思考', bg: AppColors.secondarySoftLight, fg: Color(0xFF2D7D46)),
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
// 模拟精选日记数据
|
// 按日期降序取前 3 篇
|
||||||
const highlights = [
|
final top = List<JournalEntry>.from(journals)
|
||||||
(
|
..sort((a, b) => b.date.compareTo(a.date));
|
||||||
emoji: '😊',
|
final highlights = top.take(3).toList();
|
||||||
emojiBg: AppColors.roseSoftLight,
|
|
||||||
date: '5月14日',
|
if (highlights.isEmpty) {
|
||||||
title: '和朋友聚餐的欢乐时光',
|
return const SizedBox.shrink();
|
||||||
badge: '最佳心情',
|
}
|
||||||
badgeBg: AppColors.roseSoftLight,
|
|
||||||
badgeFg: Color(0xFF9B4D4D),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
emoji: '⭐',
|
|
||||||
emojiBg: AppColors.tertiarySoftLight,
|
|
||||||
date: '5月21日',
|
|
||||||
title: '完成了第一个小目标',
|
|
||||||
badge: '里程碑',
|
|
||||||
badgeBg: AppColors.tertiarySoftLight,
|
|
||||||
badgeFg: Color(0xFFB8860B),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
emoji: '📚',
|
|
||||||
emojiBg: AppColors.secondarySoftLight,
|
|
||||||
date: '5月28日',
|
|
||||||
title: '期末考试结束',
|
|
||||||
badge: '最详尽记录',
|
|
||||||
badgeBg: AppColors.secondarySoftLight,
|
|
||||||
badgeFg: Color(0xFF2D7D46),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -523,15 +571,22 @@ class _Highlights extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
...highlights.map((item) {
|
...highlights.map((entry) {
|
||||||
|
final mood = entry.mood;
|
||||||
|
final emoji = _MoodCalendar._moodEmojis[mood] ?? '📝';
|
||||||
|
final emojiBg = _MoodCalendar._moodBgColors[mood] ?? AppColors.tertiarySoftLight;
|
||||||
|
final cfg = _badgeConfig[mood] ??
|
||||||
|
(badge: '日记', bg: AppColors.tertiarySoftLight, fg: const Color(0xFFB8860B));
|
||||||
|
final dateStr = '${entry.date.month}月${entry.date.day}日';
|
||||||
|
|
||||||
return _HighlightCard(
|
return _HighlightCard(
|
||||||
emoji: item.emoji,
|
emoji: emoji,
|
||||||
emojiBg: item.emojiBg,
|
emojiBg: emojiBg,
|
||||||
date: item.date,
|
date: dateStr,
|
||||||
title: item.title,
|
title: entry.title,
|
||||||
badge: item.badge,
|
badge: cfg.badge,
|
||||||
badgeBg: item.badgeBg,
|
badgeBg: cfg.bg,
|
||||||
badgeFg: item.badgeFg,
|
badgeFg: cfg.fg,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片
|
// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片
|
||||||
// 对齐 Open Design 原型稿 screens/weekly.html
|
// 对齐 Open Design 原型稿 screens/weekly.html
|
||||||
|
// 接入 JournalRepository 加载真实数据
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_shadows.dart';
|
import 'package:nuanji_app/core/theme/app_shadows.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||||
|
import 'package:nuanji_app/core/utils/mood_utils.dart';
|
||||||
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
|
||||||
/// 周概览页面
|
/// 周概览页面
|
||||||
class WeeklyPage extends StatefulWidget {
|
class WeeklyPage extends StatefulWidget {
|
||||||
@@ -17,24 +22,71 @@ class WeeklyPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _WeeklyPageState extends State<WeeklyPage> {
|
class _WeeklyPageState extends State<WeeklyPage> {
|
||||||
late DateTime _focusedWeekStart;
|
late DateTime _focusedWeekStart;
|
||||||
|
List<JournalEntry> _journals = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_focusedWeekStart = now.subtract(Duration(days: now.weekday - 1));
|
_focusedWeekStart = _startOfWeek(now);
|
||||||
|
_loadWeekData();
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalRepository get _repo => context.read<JournalRepository>();
|
||||||
|
|
||||||
|
/// 获取某天的周一日期
|
||||||
|
DateTime _startOfWeek(DateTime date) {
|
||||||
|
return date.subtract(Duration(days: date.weekday - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWeekData() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final weekEnd = _focusedWeekStart.add(const Duration(days: 7));
|
||||||
|
final journals = await _repo.getJournals(
|
||||||
|
dateFrom: _focusedWeekStart,
|
||||||
|
dateTo: weekEnd,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_journals = journals;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToPreviousWeek() {
|
void _goToPreviousWeek() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7));
|
_focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7));
|
||||||
});
|
});
|
||||||
|
_loadWeekData();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToNextWeek() {
|
void _goToNextWeek() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7));
|
_focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7));
|
||||||
});
|
});
|
||||||
|
_loadWeekData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按日期索引日记: day-of-week (1=周一..7=周日) → JournalEntry 列表
|
||||||
|
Map<int, List<JournalEntry>> get _journalsByWeekday {
|
||||||
|
final map = <int, List<JournalEntry>>{};
|
||||||
|
for (final j in _journals) {
|
||||||
|
// 判断日记日期是否在本周范围内
|
||||||
|
final dayKey = j.date.difference(_focusedWeekStart).inDays;
|
||||||
|
if (dayKey >= 0 && dayKey < 7) {
|
||||||
|
final weekday = dayKey + 1; // 1=周一, 7=周日
|
||||||
|
(map[weekday] ??= []).add(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -54,17 +106,22 @@ class _WeeklyPageState extends State<WeeklyPage> {
|
|||||||
),
|
),
|
||||||
// 可滚动内容区
|
// 可滚动内容区
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 7天条目
|
// 7天条目(真实数据)
|
||||||
_WeekStrip(weekStart: _focusedWeekStart),
|
_WeekStrip(
|
||||||
|
weekStart: _focusedWeekStart,
|
||||||
|
journalsByWeekday: _journalsByWeekday,
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 本周总结
|
// 本周总结(真实数据)
|
||||||
const _WeekSummary(),
|
_WeekSummary(journals: _journals),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 每日日记卡片
|
// 每日日记卡片(真实数据)
|
||||||
..._buildDayCards(theme, colorScheme),
|
..._buildDayCards(theme, colorScheme),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
@@ -77,48 +134,67 @@ class _WeeklyPageState extends State<WeeklyPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) {
|
List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) {
|
||||||
// 模拟数据: 3 张日记卡片
|
final byWeekday = _journalsByWeekday;
|
||||||
|
final cards = <Widget>[];
|
||||||
|
final weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
|
||||||
|
// 按日期倒序生成卡片(最新的在上面)
|
||||||
|
for (var i = 6; i >= 0; i--) {
|
||||||
|
final weekday = i + 1;
|
||||||
|
final dayJournals = byWeekday[weekday];
|
||||||
|
if (dayJournals == null || dayJournals.isEmpty) continue;
|
||||||
|
|
||||||
|
final day = _focusedWeekStart.add(Duration(days: i));
|
||||||
|
final first = dayJournals.first;
|
||||||
|
|
||||||
|
cards.add(_DayCard(
|
||||||
|
weekday: weekNames[i],
|
||||||
|
date: '${day.month}月${day.day}日',
|
||||||
|
moodEmoji: moodToEmoji(first.mood),
|
||||||
|
weatherEmoji: _weatherEmoji(first.weather),
|
||||||
|
body: first.contentExcerpt ?? first.title,
|
||||||
|
tags: first.tags.take(2).map((tag) {
|
||||||
|
// 根据标签内容选择颜色
|
||||||
|
return (tag, AppColors.secondarySoftLight, const Color(0xFF2D7D46));
|
||||||
|
}).toList(),
|
||||||
|
photoEmoji: dayJournals.any((j) => j.contentExcerpt != null && j.contentExcerpt!.contains('📷'))
|
||||||
|
? '📷'
|
||||||
|
: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无日记时显示空状态
|
||||||
|
if (cards.isEmpty) {
|
||||||
return [
|
return [
|
||||||
_DayCard(
|
SizedBox(
|
||||||
weekday: '周日',
|
height: 200,
|
||||||
date: '5月31日',
|
child: Center(
|
||||||
moodEmoji: '😊',
|
child: Column(
|
||||||
weatherEmoji: '☀️',
|
mainAxisSize: MainAxisSize.min,
|
||||||
body:
|
children: [
|
||||||
'今天下午去图书馆自习,阳光从窗外洒进来,暖暖的。喝了抹茶拿铁,虽然期末压力大但看到窗外的樱花还在开,觉得一切都会好的。',
|
Icon(Icons.edit_note_rounded, size: 48,
|
||||||
tags: const [
|
color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||||||
('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)),
|
const SizedBox(height: 12),
|
||||||
('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)),
|
Text('这周还没有日记', style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
photoEmoji: '📚',
|
|
||||||
),
|
),
|
||||||
_DayCard(
|
|
||||||
weekday: '周六',
|
|
||||||
date: '5月30日',
|
|
||||||
moodEmoji: '😊',
|
|
||||||
weatherEmoji: '🌤',
|
|
||||||
body:
|
|
||||||
'今天在图书馆自习,窗外的阳光洒进来,暖暖的。复习了高数第三章,做了两套模拟题感觉还不错。',
|
|
||||||
tags: const [
|
|
||||||
('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)),
|
|
||||||
],
|
|
||||||
photoEmoji: null,
|
|
||||||
),
|
),
|
||||||
_DayCard(
|
|
||||||
weekday: '周五',
|
|
||||||
date: '5月29日',
|
|
||||||
moodEmoji: '😊',
|
|
||||||
weatherEmoji: '☀️',
|
|
||||||
body:
|
|
||||||
'考完试和舍友们去吃了火锅庆祝,大家都好开心,聊了很多有趣的事。这学期终于结束了!',
|
|
||||||
tags: const [
|
|
||||||
('朋友', AppColors.roseSoftLight, Color(0xFF9B4D4D)),
|
|
||||||
('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)),
|
|
||||||
],
|
|
||||||
photoEmoji: '🍲',
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _weatherEmoji(Weather weather) => switch (weather) {
|
||||||
|
Weather.sunny => '☀️',
|
||||||
|
Weather.cloudy => '⛅',
|
||||||
|
Weather.rainy => '🌧️',
|
||||||
|
Weather.snowy => '❄️',
|
||||||
|
Weather.windy => '💨',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 周头部导航 =====
|
// ===== 周头部导航 =====
|
||||||
@@ -221,32 +297,34 @@ class _NavButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 7天条目 =====
|
// ===== 7天条目(真实数据)=====
|
||||||
|
|
||||||
class _WeekStrip extends StatelessWidget {
|
class _WeekStrip extends StatelessWidget {
|
||||||
const _WeekStrip({required this.weekStart});
|
const _WeekStrip({
|
||||||
|
required this.weekStart,
|
||||||
|
required this.journalsByWeekday,
|
||||||
|
});
|
||||||
|
|
||||||
final DateTime weekStart;
|
final DateTime weekStart;
|
||||||
|
final Map<int, List<JournalEntry>> journalsByWeekday;
|
||||||
|
|
||||||
// 模拟数据: 每天的心情 emoji
|
|
||||||
static const _mockMoods = ['😊', '😐', '😊', '😊', '😊', '😊', '😊'];
|
|
||||||
static const _weekNames = ['一', '二', '三', '四', '五', '六', '日'];
|
static const _weekNames = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
static const _hasEntry = [true, true, true, true, true, true, true];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: List.generate(7, (i) {
|
children: List.generate(7, (i) {
|
||||||
final day = weekStart.add(Duration(days: i));
|
final day = weekStart.add(Duration(days: i));
|
||||||
|
final weekday = i + 1;
|
||||||
final isToday = day.year == now.year &&
|
final isToday = day.year == now.year &&
|
||||||
day.month == now.month &&
|
day.month == now.month &&
|
||||||
day.day == now.day;
|
day.day == now.day;
|
||||||
final hasEntry = _hasEntry[i];
|
final dayJournals = journalsByWeekday[weekday] ?? [];
|
||||||
final moodEmoji = _mockMoods[i];
|
final hasEntry = dayJournals.isNotEmpty;
|
||||||
|
final moodEmoji = hasEntry ? moodToEmoji(dayJournals.first.mood) : '·';
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -286,7 +364,10 @@ class _WeekStrip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// 心情 emoji
|
// 心情 emoji
|
||||||
Text(moodEmoji, style: const TextStyle(fontSize: 16)),
|
Text(moodEmoji, style: TextStyle(
|
||||||
|
fontSize: hasEntry ? 16 : 14,
|
||||||
|
color: hasEntry ? null : colorScheme.onSurface.withValues(alpha: 0.2),
|
||||||
|
)),
|
||||||
// 有日记: 日期下方4px小圆点
|
// 有日记: 日期下方4px小圆点
|
||||||
if (hasEntry && !isToday)
|
if (hasEntry && !isToday)
|
||||||
Container(
|
Container(
|
||||||
@@ -318,16 +399,26 @@ class _WeekStrip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 本周总结卡片 =====
|
// ===== 本周总结卡片(真实数据)=====
|
||||||
|
|
||||||
class _WeekSummary extends StatelessWidget {
|
class _WeekSummary extends StatelessWidget {
|
||||||
const _WeekSummary();
|
const _WeekSummary({required this.journals});
|
||||||
|
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
// 统计真实数据
|
||||||
|
final recordDays = journals.map((j) => j.date.day).toSet().length;
|
||||||
|
final journalCount = journals.length;
|
||||||
|
// 统计贴纸元素 — 从日记标签中估算(Phase 1 简化)
|
||||||
|
final stickerCount = journals.fold<int>(
|
||||||
|
0, (sum, j) => sum + j.tags.length,
|
||||||
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -351,64 +442,79 @@ class _WeekSummary extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_SummaryItem(
|
_SummaryItem(
|
||||||
value: '6',
|
value: '$recordDays',
|
||||||
label: '记录天数',
|
label: '记录天数',
|
||||||
valueColor: AppColors.accent,
|
valueColor: AppColors.accent,
|
||||||
),
|
),
|
||||||
_SummaryItem(
|
_SummaryItem(
|
||||||
value: '7',
|
value: '$journalCount',
|
||||||
label: '日记篇数',
|
label: '日记篇数',
|
||||||
valueColor: AppColors.secondary,
|
valueColor: AppColors.secondary,
|
||||||
),
|
),
|
||||||
_SummaryItem(
|
_SummaryItem(
|
||||||
value: '12',
|
value: '$stickerCount',
|
||||||
label: '使用贴纸',
|
label: '使用标签',
|
||||||
valueColor: AppColors.tertiary,
|
valueColor: AppColors.tertiary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 心情分布条
|
// 心情分布条
|
||||||
Row(
|
_MoodDistributionBar(journals: journals),
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: Container(
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.secondary,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: Container(
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.tertiary,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: Container(
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF5B7DB1),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 心情分布条 — 从日记数据计算各心情占比
|
||||||
|
class _MoodDistributionBar extends StatelessWidget {
|
||||||
|
const _MoodDistributionBar({required this.journals});
|
||||||
|
final List<JournalEntry> journals;
|
||||||
|
|
||||||
|
static const _moodConfig = [
|
||||||
|
(Mood.happy, AppColors.secondary),
|
||||||
|
(Mood.calm, AppColors.tertiary),
|
||||||
|
(Mood.sad, Color(0xFF5B7DB1)),
|
||||||
|
(Mood.angry, AppColors.accent),
|
||||||
|
(Mood.thinking, Color(0xFF8B7E74)),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (journals.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统计各心情数量
|
||||||
|
final counts = <Mood, int>{};
|
||||||
|
for (final j in journals) {
|
||||||
|
counts[j.mood] = (counts[j.mood] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: _moodConfig.where((c) => counts[c.$1] != null).map((config) {
|
||||||
|
final count = counts[config.$1]!;
|
||||||
|
return Expanded(
|
||||||
|
flex: count,
|
||||||
|
child: Container(
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(right: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: config.$2,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 单个统计项
|
/// 单个统计项
|
||||||
|
|||||||
@@ -261,11 +261,9 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
emit(current.copyWith(isLoadingWall: true));
|
emit(current.copyWith(isLoadingWall: true));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加载属于该班级的公开日记
|
// 服务端过滤:按 classId 查询班级公开日记(后端 API 已支持 ?class_id= 参数)
|
||||||
final journals = await _journalRepo.getJournals();
|
final journals = await _journalRepo.getJournals(classId: event.classId);
|
||||||
final classJournals = journals
|
final classJournals = journals.where((j) => j.sharedToClass).toList();
|
||||||
.where((j) => j.classId == event.classId && j.sharedToClass)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
|
emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -345,9 +343,6 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
TopicAssign event,
|
TopicAssign event,
|
||||||
Emitter<ClassState> emit,
|
Emitter<ClassState> emit,
|
||||||
) async {
|
) async {
|
||||||
if (state is! ClassDetailLoaded) return;
|
|
||||||
final current = state as ClassDetailLoaded;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final dto = await _classRepo.assignTopic(
|
final dto = await _classRepo.assignTopic(
|
||||||
classId: event.classId,
|
classId: event.classId,
|
||||||
@@ -355,6 +350,10 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
description: event.description,
|
description: event.description,
|
||||||
dueDate: event.dueDate,
|
dueDate: event.dueDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 更新本地 topics 列表(仅在班级详情视图中)
|
||||||
|
if (state is ClassDetailLoaded) {
|
||||||
|
final current = state as ClassDetailLoaded;
|
||||||
final newTopic = TopicAssignment(
|
final newTopic = TopicAssignment(
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
classId: dto.classId,
|
classId: dto.classId,
|
||||||
@@ -364,6 +363,7 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
|
|||||||
dueDate: dto.dueDate,
|
dueDate: dto.dueDate,
|
||||||
);
|
);
|
||||||
emit(current.copyWith(topics: [newTopic, ...current.topics]));
|
emit(current.copyWith(topics: [newTopic, ...current.topics]));
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
// 心情页面 — 心情统计 + 趋势图 + 连续天数
|
// 心情页面 — 今日心情 + 天气 + 柱状图 + 统计网格 + 心情洞察
|
||||||
|
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
|
import 'package:nuanji_app/core/utils/mood_utils.dart';
|
||||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||||
import '../bloc/mood_bloc.dart';
|
import '../bloc/mood_bloc.dart';
|
||||||
|
|
||||||
/// 心情页面 — 统计卡片 + 心情分布饼图 + 详情列表
|
/// 心情页面 — 今日心情卡片 + 天气选择 + 柱状图 + 统计网格 + 心情洞察
|
||||||
class MoodPage extends StatefulWidget {
|
class MoodPage extends StatefulWidget {
|
||||||
const MoodPage({super.key});
|
const MoodPage({super.key});
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ class MoodPage extends StatefulWidget {
|
|||||||
class _MoodPageState extends State<MoodPage> {
|
class _MoodPageState extends State<MoodPage> {
|
||||||
late final MoodBloc _bloc;
|
late final MoodBloc _bloc;
|
||||||
|
|
||||||
|
// 天气选择状态
|
||||||
|
_WeatherType? _selectedWeather;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -70,38 +74,42 @@ class _MoodPageState extends State<MoodPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 统计概览卡片
|
// 5A: 今日心情卡片
|
||||||
_StatsOverviewCard(stats: state.stats, colorScheme: colorScheme),
|
_TodayMoodCard(stats: state.stats),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 5B: 天气卡片
|
||||||
|
_WeatherCard(
|
||||||
|
selectedWeather: _selectedWeather,
|
||||||
|
onWeatherSelected: (w) {
|
||||||
|
setState(() {
|
||||||
|
_selectedWeather =
|
||||||
|
_selectedWeather == w ? null : w;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 周期选择器
|
// 周期选择器
|
||||||
_PeriodSelector(
|
_PeriodPills(
|
||||||
selectedPeriod: state.selectedPeriod,
|
selectedPeriod: state.selectedPeriod,
|
||||||
onPeriodChanged: _bloc.changePeriod,
|
onPeriodChanged: _bloc.changePeriod,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 心情分布饼图
|
// 5C: 柱状图
|
||||||
_MoodDistributionChart(
|
_MoodBarChart(
|
||||||
moodCounts: state.stats.moodCounts,
|
moodCounts: state.stats.moodCounts,
|
||||||
colorScheme: colorScheme,
|
selectedPeriod: state.selectedPeriod,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 心情详情列表
|
// 5D: 统计网格
|
||||||
Text(
|
_StatsGrid(stats: state.stats),
|
||||||
'心情详情',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
...state.stats.moodCounts.map((mc) => _MoodCountTile(mc: mc)),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 连续天数鼓励卡片
|
// 5E: 心情洞察卡片
|
||||||
_StreakCard(streakDays: state.stats.streakDays),
|
_InsightCard(stats: state.stats),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -110,60 +118,89 @@ class _MoodPageState extends State<MoodPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计概览卡片
|
// ===== 5A: 今日心情卡片 =====
|
||||||
class _StatsOverviewCard extends StatelessWidget {
|
|
||||||
const _StatsOverviewCard({
|
class _TodayMoodCard extends StatelessWidget {
|
||||||
required this.stats,
|
const _TodayMoodCard({required this.stats});
|
||||||
required this.colorScheme,
|
|
||||||
});
|
|
||||||
|
|
||||||
final MoodStats stats;
|
final MoodStats stats;
|
||||||
final ColorScheme colorScheme;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final dateStr =
|
||||||
|
'${now.year}年${now.month}月${now.day}日';
|
||||||
final dominantEmoji = stats.dominantMood != null
|
final dominantEmoji = stats.dominantMood != null
|
||||||
? _moodEmoji(stats.dominantMood!)
|
? moodToEmoji(stats.dominantMood!)
|
||||||
: '📝';
|
: '📝';
|
||||||
|
final dominantLabel = stats.dominantMood != null
|
||||||
|
? moodToLabel(stats.dominantMood!)
|
||||||
|
: '暂无记录';
|
||||||
|
|
||||||
return Card(
|
return Container(
|
||||||
elevation: 0,
|
width: double.infinity,
|
||||||
shape: RoundedRectangleBorder(
|
padding: const EdgeInsets.all(24),
|
||||||
borderRadius: AppRadius.lgBorder,
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [AppColors.accent, AppColors.tertiary],
|
||||||
),
|
),
|
||||||
color: colorScheme.primaryContainer,
|
),
|
||||||
child: Padding(
|
child: Stack(
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
// 主导心情图标
|
// 装饰圆
|
||||||
Container(
|
Positioned(
|
||||||
width: 56,
|
right: -20,
|
||||||
height: 56,
|
top: -20,
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: colorScheme.primary.withValues(alpha: 0.15),
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(dominantEmoji, style: const TextStyle(fontSize: 28)),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
// 内容
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'今日心情 · $dateStr',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Caveat',
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.white.withValues(alpha: 0.85),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(dominantEmoji,
|
||||||
|
style: const TextStyle(fontSize: 52)),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'心情概览',
|
dominantLabel,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'共 ${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays} 天',
|
'共 ${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays} 天',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: TextStyle(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
fontSize: 13,
|
||||||
|
color:
|
||||||
|
Colors.white.withValues(alpha: 0.75),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -171,14 +208,114 @@ class _StatsOverviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 周期选择器
|
// ===== 5B: 天气卡片 =====
|
||||||
class _PeriodSelector extends StatelessWidget {
|
|
||||||
const _PeriodSelector({
|
enum _WeatherType {
|
||||||
|
sunny('晴', '☀️'),
|
||||||
|
cloudy('多云', '⛅'),
|
||||||
|
rainy('雨', '🌧️'),
|
||||||
|
snowy('雪', '❄️'),
|
||||||
|
windy('风', '💨');
|
||||||
|
|
||||||
|
const _WeatherType(this.label, this.emoji);
|
||||||
|
final String label;
|
||||||
|
final String emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeatherCard extends StatelessWidget {
|
||||||
|
const _WeatherCard({
|
||||||
|
required this.selectedWeather,
|
||||||
|
required this.onWeatherSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _WeatherType? selectedWeather;
|
||||||
|
final ValueChanged<_WeatherType> onWeatherSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
final surfaceColor =
|
||||||
|
isDark ? AppColors.surfaceDark : AppColors.surfaceLight;
|
||||||
|
final surfaceWarmColor = isDark
|
||||||
|
? AppColors.surfaceWarmDark
|
||||||
|
: AppColors.surfaceWarmLight;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: surfaceColor,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'今日天气',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: _WeatherType.values.map((w) {
|
||||||
|
final isSelected = selectedWeather == w;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onWeatherSelected(w),
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? AppColors.accent
|
||||||
|
: (isDark
|
||||||
|
? AppColors.borderDark
|
||||||
|
: AppColors.borderLight),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
color: isSelected
|
||||||
|
? surfaceWarmColor
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(w.emoji,
|
||||||
|
style: const TextStyle(fontSize: 20)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
w.label,
|
||||||
|
style: theme.textTheme.labelSmall
|
||||||
|
?.copyWith(fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 周期选择胶囊 =====
|
||||||
|
|
||||||
|
class _PeriodPills extends StatelessWidget {
|
||||||
|
const _PeriodPills({
|
||||||
required this.selectedPeriod,
|
required this.selectedPeriod,
|
||||||
required this.onPeriodChanged,
|
required this.onPeriodChanged,
|
||||||
});
|
});
|
||||||
@@ -188,193 +325,460 @@ class _PeriodSelector extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SegmentedButton<StatsPeriod>(
|
final theme = Theme.of(context);
|
||||||
segments: const [
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
ButtonSegment(value: StatsPeriod.week, label: Text('周')),
|
|
||||||
ButtonSegment(value: StatsPeriod.month, label: Text('月')),
|
|
||||||
ButtonSegment(value: StatsPeriod.quarter, label: Text('季')),
|
|
||||||
],
|
|
||||||
selected: {selectedPeriod},
|
|
||||||
onSelectionChanged: (set) => onPeriodChanged(set.first),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 心情分布饼图
|
final periods = [
|
||||||
class _MoodDistributionChart extends StatelessWidget {
|
(StatsPeriod.week, '7天'),
|
||||||
const _MoodDistributionChart({
|
(StatsPeriod.month, '30天'),
|
||||||
required this.moodCounts,
|
(StatsPeriod.quarter, '3个月'),
|
||||||
required this.colorScheme,
|
];
|
||||||
});
|
|
||||||
|
|
||||||
final List<MoodCount> moodCounts;
|
return Row(
|
||||||
final ColorScheme colorScheme;
|
children: periods.map((p) {
|
||||||
|
final isSelected = selectedPeriod == p.$1;
|
||||||
@override
|
return Padding(
|
||||||
Widget build(BuildContext context) {
|
padding: const EdgeInsets.only(right: 8),
|
||||||
if (moodCounts.isEmpty) {
|
child: GestureDetector(
|
||||||
return const SizedBox.shrink();
|
onTap: () => onPeriodChanged(p.$1),
|
||||||
}
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
return Card(
|
horizontal: 16,
|
||||||
elevation: 0,
|
vertical: 8,
|
||||||
shape: RoundedRectangleBorder(
|
),
|
||||||
borderRadius: AppRadius.lgBorder,
|
decoration: BoxDecoration(
|
||||||
side: BorderSide(color: colorScheme.outlineVariant),
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
color: isSelected
|
||||||
|
? AppColors.accent
|
||||||
|
: Colors.transparent,
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? AppColors.accent
|
||||||
|
: (isDark
|
||||||
|
? AppColors.borderDark
|
||||||
|
: AppColors.borderLight),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
p.$2,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: (isDark
|
||||||
|
? AppColors.fg2Dark
|
||||||
|
: AppColors.fg2Light),
|
||||||
|
fontWeight:
|
||||||
|
isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 200,
|
|
||||||
child: PieChart(
|
|
||||||
PieChartData(
|
|
||||||
sections: moodCounts.map((mc) {
|
|
||||||
final color =
|
|
||||||
AppColors.moodColors[mc.mood.value] ?? colorScheme.primary;
|
|
||||||
return PieChartSectionData(
|
|
||||||
value: mc.count.toDouble(),
|
|
||||||
color: color,
|
|
||||||
radius: 50,
|
|
||||||
title: '${mc.percentage.toStringAsFixed(0)}%',
|
|
||||||
titleStyle: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
sectionsSpace: 2,
|
|
||||||
centerSpaceRadius: 40,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 心情计数列表项
|
// ===== 5C: 柱状图 =====
|
||||||
class _MoodCountTile extends StatelessWidget {
|
|
||||||
const _MoodCountTile({required this.mc});
|
|
||||||
|
|
||||||
final MoodCount mc;
|
class _MoodBarChart extends StatelessWidget {
|
||||||
|
const _MoodBarChart({
|
||||||
|
required this.moodCounts,
|
||||||
|
required this.selectedPeriod,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
final List<MoodCount> moodCounts;
|
||||||
Widget build(BuildContext context) {
|
final StatsPeriod selectedPeriod;
|
||||||
final theme = Theme.of(context);
|
|
||||||
final color =
|
|
||||||
AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(_moodEmoji(mc.mood), style: const TextStyle(fontSize: 20)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_moodLabel(mc.mood),
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: mc.percentage / 100,
|
|
||||||
backgroundColor: color.withValues(alpha: 0.15),
|
|
||||||
color: color,
|
|
||||||
minHeight: 6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
SizedBox(
|
|
||||||
width: 48,
|
|
||||||
child: Text(
|
|
||||||
'${mc.count} 篇',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.end,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 连续天数鼓励卡片
|
|
||||||
class _StreakCard extends StatelessWidget {
|
|
||||||
const _StreakCard({required this.streakDays});
|
|
||||||
|
|
||||||
final int streakDays;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
return Card(
|
if (moodCounts.isEmpty) {
|
||||||
elevation: 0,
|
return const SizedBox.shrink();
|
||||||
shape: RoundedRectangleBorder(
|
}
|
||||||
borderRadius: AppRadius.lgBorder,
|
|
||||||
),
|
final maxCount = moodCounts
|
||||||
color: AppColors.tertiary.withValues(alpha: 0.15),
|
.fold<int>(0, (max, mc) => mc.count > max ? mc.count : max);
|
||||||
child: Padding(
|
|
||||||
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: isDark
|
||||||
const Text('🔥', style: TextStyle(fontSize: 32)),
|
? AppColors.surfaceDark
|
||||||
const SizedBox(width: 16),
|
: AppColors.surfaceLight,
|
||||||
Expanded(
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.borderDark
|
||||||
|
: AppColors.borderLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'连续 $streakDays 天',
|
'心情分布',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 16),
|
||||||
Text(
|
SizedBox(
|
||||||
streakDays >= 7
|
height: 180,
|
||||||
? '太棒了!你已经坚持了一周 ✨'
|
child: BarChart(
|
||||||
: '继续加油,坚持就是胜利!',
|
BarChartData(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
alignment: BarChartAlignment.spaceAround,
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
maxY: (maxCount + 2).toDouble(),
|
||||||
|
barGroups: moodCounts.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final mc = entry.value;
|
||||||
|
final color =
|
||||||
|
AppColors.moodColors[mc.mood.value] ??
|
||||||
|
colorScheme.primary;
|
||||||
|
return BarChartGroupData(
|
||||||
|
x: index,
|
||||||
|
barRods: [
|
||||||
|
BarChartRodData(
|
||||||
|
toY: mc.count.toDouble(),
|
||||||
|
color: color,
|
||||||
|
width: 14,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(4),
|
||||||
|
topRight: Radius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
showingTooltipIndicators: [0],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
leftTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
final index = value.toInt();
|
||||||
|
if (index < 0 ||
|
||||||
|
index >= moodCounts.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
moodToEmoji(moodCounts[index].mood),
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
reservedSize: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
gridData: const FlGridData(show: false),
|
||||||
|
barTouchData: BarTouchData(
|
||||||
|
touchTooltipData: BarTouchTooltipData(
|
||||||
|
getTooltipItem:
|
||||||
|
(group, groupIndex, rod, rodIndex) {
|
||||||
|
if (groupIndex >= moodCounts.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final mc = moodCounts[groupIndex];
|
||||||
|
return BarTooltipItem(
|
||||||
|
'${moodToLabel(mc.mood)}: ${mc.count} 篇',
|
||||||
|
TextStyle(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.fgDark
|
||||||
|
: AppColors.fgLight,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 辅助函数 =====
|
// ===== 5D: 统计网格 =====
|
||||||
|
|
||||||
String _moodEmoji(Mood mood) => switch (mood) {
|
class _StatsGrid extends StatelessWidget {
|
||||||
Mood.happy => '😊',
|
const _StatsGrid({required this.stats});
|
||||||
Mood.calm => '😌',
|
|
||||||
Mood.sad => '😢',
|
|
||||||
Mood.angry => '😠',
|
|
||||||
Mood.thinking => '🤔',
|
|
||||||
};
|
|
||||||
|
|
||||||
String _moodLabel(Mood mood) => switch (mood) {
|
final MoodStats stats;
|
||||||
Mood.happy => '开心',
|
|
||||||
Mood.calm => '平静',
|
@override
|
||||||
Mood.sad => '难过',
|
Widget build(BuildContext context) {
|
||||||
Mood.angry => '生气',
|
final theme = Theme.of(context);
|
||||||
Mood.thinking => '思考',
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
};
|
|
||||||
|
// 计算好心情占比
|
||||||
|
final happyCount = stats.moodCounts
|
||||||
|
.where((mc) => mc.mood == Mood.happy)
|
||||||
|
.fold<int>(0, (sum, mc) => sum + mc.count);
|
||||||
|
final totalCount = stats.moodCounts
|
||||||
|
.fold<int>(0, (sum, mc) => sum + mc.count);
|
||||||
|
final goodPercent = totalCount > 0
|
||||||
|
? (happyCount / totalCount * 100).toStringAsFixed(0)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
// TODO: 从统计数据获取实际照片数,暂时用 0
|
||||||
|
const photoCount = 0;
|
||||||
|
|
||||||
|
final items = [
|
||||||
|
_StatItem(
|
||||||
|
emoji: '📝',
|
||||||
|
value: '${stats.totalJournals}',
|
||||||
|
description: '日记总数',
|
||||||
|
color: AppColors.secondary,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
emoji: '🔥',
|
||||||
|
value: '${stats.streakDays}',
|
||||||
|
description: '连续天数',
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
emoji: '😊',
|
||||||
|
value: '$goodPercent%',
|
||||||
|
description: '好心情占比',
|
||||||
|
color: AppColors.tertiary,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
emoji: '📷',
|
||||||
|
value: '$photoCount',
|
||||||
|
description: '照片数量',
|
||||||
|
color: AppColors.rose,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
childAspectRatio: 1.4,
|
||||||
|
children: items.map((item) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.surfaceDark
|
||||||
|
: AppColors.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.borderDark
|
||||||
|
: AppColors.borderLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(item.emoji,
|
||||||
|
style: const TextStyle(fontSize: 28)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item.value,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: item.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
item.description,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.mutedDark
|
||||||
|
: AppColors.mutedLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatItem {
|
||||||
|
final String emoji;
|
||||||
|
final String value;
|
||||||
|
final String description;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _StatItem({
|
||||||
|
required this.emoji,
|
||||||
|
required this.value,
|
||||||
|
required this.description,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 5E: 心情洞察卡片 =====
|
||||||
|
|
||||||
|
class _InsightCard extends StatelessWidget {
|
||||||
|
const _InsightCard({required this.stats});
|
||||||
|
|
||||||
|
final MoodStats stats;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// 计算最频繁心情
|
||||||
|
final mostFrequent = stats.moodCounts.isNotEmpty
|
||||||
|
? stats.moodCounts.reduce(
|
||||||
|
(a, b) => a.count > b.count ? a : b)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 计算心情趋势(简化:基于好心情vs坏心情比例判断)
|
||||||
|
final happyCount = stats.moodCounts
|
||||||
|
.where((mc) =>
|
||||||
|
mc.mood == Mood.happy || mc.mood == Mood.calm)
|
||||||
|
.fold<int>(0, (sum, mc) => sum + mc.count);
|
||||||
|
final sadCount = stats.moodCounts
|
||||||
|
.where((mc) =>
|
||||||
|
mc.mood == Mood.sad || mc.mood == Mood.angry)
|
||||||
|
.fold<int>(0, (sum, mc) => sum + mc.count);
|
||||||
|
final trendLabel = happyCount >= sadCount ? 'improving' : 'declining';
|
||||||
|
final trendEmoji = happyCount >= sadCount ? '📈' : '📉';
|
||||||
|
final trendText = happyCount >= sadCount ? '越来越好' : '需要关注';
|
||||||
|
|
||||||
|
final insights = [
|
||||||
|
_InsightItem(
|
||||||
|
emoji: mostFrequent != null
|
||||||
|
? moodToEmoji(mostFrequent.mood)
|
||||||
|
: '📝',
|
||||||
|
title: '最频繁心情',
|
||||||
|
detail: mostFrequent != null
|
||||||
|
? '${moodToLabel(mostFrequent.mood)} · ${mostFrequent.count} 篇'
|
||||||
|
: '暂无数据',
|
||||||
|
color: AppColors.secondary,
|
||||||
|
),
|
||||||
|
_InsightItem(
|
||||||
|
emoji: '🔥',
|
||||||
|
title: '最长连续',
|
||||||
|
detail: '${stats.streakDays} 天',
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
_InsightItem(
|
||||||
|
emoji: trendEmoji,
|
||||||
|
title: '心情趋势',
|
||||||
|
detail: trendText,
|
||||||
|
color: trendLabel == 'improving'
|
||||||
|
? AppColors.secondary
|
||||||
|
: AppColors.rose,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.surfaceDark
|
||||||
|
: AppColors.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.borderDark
|
||||||
|
: AppColors.borderLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'心情洞察',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...insights.map((item) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color:
|
||||||
|
item.color.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(item.emoji,
|
||||||
|
style: const TextStyle(fontSize: 18)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style:
|
||||||
|
theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
item.detail,
|
||||||
|
style:
|
||||||
|
theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.mutedDark
|
||||||
|
: AppColors.mutedLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InsightItem {
|
||||||
|
final String emoji;
|
||||||
|
final String title;
|
||||||
|
final String detail;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _InsightItem({
|
||||||
|
required this.emoji,
|
||||||
|
required this.title,
|
||||||
|
required this.detail,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ class _ChildCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 功能操作网格 — 4 个功能按钮
|
/// 功能操作网格 — 4 个功能按钮
|
||||||
class _ActionGrid extends StatelessWidget {
|
class _ActionGrid extends StatefulWidget {
|
||||||
const _ActionGrid({
|
const _ActionGrid({
|
||||||
required this.children,
|
required this.children,
|
||||||
required this.onViewJournals,
|
required this.onViewJournals,
|
||||||
@@ -550,18 +550,86 @@ class _ActionGrid extends StatelessWidget {
|
|||||||
final void Function(String childId) onDelete;
|
final void Function(String childId) onDelete;
|
||||||
final void Function(String childId) onMoodStats;
|
final void Function(String childId) onMoodStats;
|
||||||
|
|
||||||
/// 取第一个绑定的孩子 ID(Phase 1 简化逻辑)
|
@override
|
||||||
String get _firstChildId =>
|
State<_ActionGrid> createState() => _ActionGridState();
|
||||||
children.isNotEmpty ? children.first.childId : '';
|
}
|
||||||
|
|
||||||
|
class _ActionGridState extends State<_ActionGrid> {
|
||||||
|
late String _selectedChildId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedChildId = widget.children.isNotEmpty ? widget.children.first.childId : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _ActionGrid oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// 当孩子列表变化时更新选中 ID
|
||||||
|
if (oldWidget.children != widget.children) {
|
||||||
|
if (!widget.children.any((c) => c.childId == _selectedChildId)) {
|
||||||
|
_selectedChildId = widget.children.isNotEmpty ? widget.children.first.childId : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// 孩子选择器(多孩子时显示)
|
||||||
|
if (widget.children.length > 1) ...[
|
||||||
|
Text(
|
||||||
|
'选择孩子',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _selectedChildId.isEmpty ? null : _selectedChildId,
|
||||||
|
isExpanded: true,
|
||||||
|
icon: const Icon(Icons.expand_more, size: 20),
|
||||||
|
items: widget.children.map((child) {
|
||||||
|
final shortId = child.childId.length > 8
|
||||||
|
? child.childId.substring(0, 8)
|
||||||
|
: child.childId;
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: child.childId,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('👧', style: TextStyle(fontSize: 18)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('孩子 $shortId'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) setState(() => _selectedChildId = v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'功能',
|
'功能',
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -572,7 +640,7 @@ class _ActionGrid extends StatelessWidget {
|
|||||||
iconBgColor: AppColors.accent.withValues(alpha: 0.12),
|
iconBgColor: AppColors.accent.withValues(alpha: 0.12),
|
||||||
title: '日记查看',
|
title: '日记查看',
|
||||||
subtitle: '只读查看孩子的日记和评语',
|
subtitle: '只读查看孩子的日记和评语',
|
||||||
onTap: () => onViewJournals(_firstChildId),
|
onTap: () => widget.onViewJournals(_selectedChildId),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_ActionCard(
|
_ActionCard(
|
||||||
@@ -581,7 +649,7 @@ class _ActionGrid extends StatelessWidget {
|
|||||||
iconBgColor: AppColors.secondary.withValues(alpha: 0.12),
|
iconBgColor: AppColors.secondary.withValues(alpha: 0.12),
|
||||||
title: '心情统计',
|
title: '心情统计',
|
||||||
subtitle: '查看孩子的写作频率和心情趋势',
|
subtitle: '查看孩子的写作频率和心情趋势',
|
||||||
onTap: () => onMoodStats(_firstChildId),
|
onTap: () => widget.onMoodStats(_selectedChildId),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_ActionCard(
|
_ActionCard(
|
||||||
@@ -590,7 +658,7 @@ class _ActionGrid extends StatelessWidget {
|
|||||||
iconBgColor: AppColors.tertiary.withValues(alpha: 0.12),
|
iconBgColor: AppColors.tertiary.withValues(alpha: 0.12),
|
||||||
title: '数据导出',
|
title: '数据导出',
|
||||||
subtitle: '导出孩子的所有日记数据',
|
subtitle: '导出孩子的所有日记数据',
|
||||||
onTap: () => onExport(_firstChildId),
|
onTap: () => widget.onExport(_selectedChildId),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_ActionCard(
|
_ActionCard(
|
||||||
@@ -599,7 +667,7 @@ class _ActionGrid extends StatelessWidget {
|
|||||||
iconBgColor: AppColors.error.withValues(alpha: 0.12),
|
iconBgColor: AppColors.error.withValues(alpha: 0.12),
|
||||||
title: '数据删除',
|
title: '数据删除',
|
||||||
subtitle: '永久删除孩子的日记数据',
|
subtitle: '永久删除孩子的日记数据',
|
||||||
onTap: () => onDelete(_firstChildId),
|
onTap: () => widget.onDelete(_selectedChildId),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
|
import 'package:nuanji_app/core/constants/design_tokens.dart';
|
||||||
import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart';
|
import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart';
|
||||||
|
import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart';
|
||||||
import 'package:nuanji_app/data/models/user.dart';
|
import 'package:nuanji_app/data/models/user.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
|
||||||
/// 个人中心页面
|
/// 个人中心页面
|
||||||
class ProfilePage extends StatelessWidget {
|
class ProfilePage extends StatelessWidget {
|
||||||
@@ -17,103 +20,161 @@ class ProfilePage extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final authState = context.watch<AuthBloc>().state;
|
final authState = context.watch<AuthBloc>().state;
|
||||||
final displayName = authState is Authenticated ? authState.user.displayLabel : '用户';
|
final user = authState is Authenticated ? authState.user : null;
|
||||||
final role = authState is Authenticated ? authState.user.primaryRoleType : null;
|
final displayName = user?.displayLabel ?? '用户';
|
||||||
|
final role = user?.primaryRoleType;
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// 柔和背景色(根据明暗模式)
|
||||||
|
final accentSoft = isDark ? const Color(0xFF3A2A22) : const Color(0xFFFFE0D6);
|
||||||
|
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
|
||||||
|
final roseSoft = isDark ? AppColors.roseSoftDark : AppColors.roseSoftLight;
|
||||||
|
final secondarySoft = isDark ? AppColors.secondarySoftDark : AppColors.secondarySoftLight;
|
||||||
|
final surfaceWarm = isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight;
|
||||||
|
final borderSoft = isDark ? AppColors.borderSoftDark : AppColors.borderSoftLight;
|
||||||
|
final greyBg = isDark ? const Color(0xFF2A2520) : const Color(0xFFF5F0EB);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 用户头像卡片
|
// ---- 用户头像卡片 ----
|
||||||
Card(
|
Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.surface,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 32,
|
|
||||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
|
||||||
child: Text(
|
|
||||||
displayName.characters.first,
|
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(color: colorScheme.primary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(displayName, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
// 头像 — 渐变背景 + emoji
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [AppColors.accent, AppColors.tertiary],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Text('😊', style: TextStyle(fontSize: 36)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 用户名
|
||||||
|
Text(displayName, style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
// 角色
|
||||||
Text(
|
Text(
|
||||||
_roleLabel(role),
|
_roleLabel(role),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 4),
|
||||||
|
// 签名
|
||||||
|
Text(
|
||||||
|
user?.displayName ?? '这个人很懒,什么都没写',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ---- 统计栏(真实数据) ----
|
||||||
|
_LiveStatsBar(borderSoft: borderSoft, colorScheme: colorScheme),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ---- 成就徽章 ----
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 100,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [
|
||||||
|
_BadgeItem(emoji: '📝', name: '初出茅庐', bgColor: accentSoft, locked: false),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_BadgeItem(emoji: '🔥', name: '七日连续', bgColor: tertiarySoft, locked: false),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_BadgeItem(emoji: '🎨', name: '装饰达人', bgColor: roseSoft, locked: false),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_BadgeItem(emoji: '🌟', name: '人气之星', bgColor: secondarySoft, locked: true),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_BadgeItem(emoji: '🏆', name: '写作高手', bgColor: accentSoft, locked: true),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_BadgeItem(emoji: '💎', name: '全能王', bgColor: tertiarySoft, locked: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 功能入口
|
// ---- 功能入口 (emoji 图标) ----
|
||||||
_ProfileMenuItem(
|
_EmojiMenuItem(emoji: '🏆', bg: tertiarySoft, title: '我的成就', onTap: () => context.go('/achievements')),
|
||||||
icon: Icons.auto_awesome_outlined,
|
_EmojiMenuItem(emoji: '🎨', bg: roseSoft, title: '贴纸收藏', onTap: () => context.go('/stickers')),
|
||||||
iconColor: AppColors.accent,
|
_EmojiMenuItem(emoji: '📋', bg: secondarySoft, title: '日记模板', onTap: () => context.go('/templates')),
|
||||||
title: '我的成就',
|
_EmojiMenuItem(emoji: '👥', bg: accentSoft, title: '我的班级', onTap: () => context.go('/class')),
|
||||||
onTap: () => context.go('/achievements'),
|
_EmojiMenuItem(emoji: '😊', bg: tertiarySoft, title: '心情统计', onTap: () => context.go('/mood')),
|
||||||
),
|
_EmojiMenuItem(emoji: '⚙️', bg: greyBg, title: '设置', onTap: () => context.go('/settings')),
|
||||||
_ProfileMenuItem(
|
|
||||||
icon: Icons.emoji_emotions_outlined,
|
|
||||||
iconColor: AppColors.secondary,
|
|
||||||
title: '贴纸收藏',
|
|
||||||
onTap: () => context.go('/stickers'),
|
|
||||||
),
|
|
||||||
_ProfileMenuItem(
|
|
||||||
icon: Icons.dashboard_customize_outlined,
|
|
||||||
iconColor: AppColors.tertiary,
|
|
||||||
title: '日记模板',
|
|
||||||
onTap: () => context.go('/templates'),
|
|
||||||
),
|
|
||||||
_ProfileMenuItem(
|
|
||||||
icon: Icons.groups_outlined,
|
|
||||||
iconColor: colorScheme.primary,
|
|
||||||
title: '我的班级',
|
|
||||||
onTap: () => context.go('/class'),
|
|
||||||
),
|
|
||||||
if (role != null && role.name == 'teacher')
|
if (role != null && role.name == 'teacher')
|
||||||
_ProfileMenuItem(
|
_EmojiMenuItem(emoji: '📚', bg: accentSoft, title: '教师管理', onTap: () => context.go('/teacher')),
|
||||||
icon: Icons.school_outlined,
|
|
||||||
iconColor: AppColors.accent,
|
|
||||||
title: '教师管理',
|
|
||||||
onTap: () => context.go('/teacher'),
|
|
||||||
),
|
|
||||||
if (role != null && role.name == 'parent')
|
if (role != null && role.name == 'parent')
|
||||||
_ProfileMenuItem(
|
_EmojiMenuItem(emoji: '👨👩👧', bg: roseSoft, title: '家长中心', onTap: () => context.go('/parent')),
|
||||||
icon: Icons.family_restroom_outlined,
|
|
||||||
iconColor: AppColors.rose,
|
|
||||||
title: '家长中心',
|
|
||||||
onTap: () => context.go('/parent'),
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
_ProfileMenuItem(
|
|
||||||
icon: Icons.bar_chart_outlined,
|
// ---- 开关项 ----
|
||||||
iconColor: colorScheme.primary,
|
_EmojiToggleItem(
|
||||||
title: '心情统计',
|
emoji: '🔔',
|
||||||
onTap: () => context.go('/mood'),
|
bg: tertiarySoft,
|
||||||
|
title: '消息通知',
|
||||||
|
value: true,
|
||||||
|
onChanged: (v) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(v ? '已开启通知' : '已关闭通知')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
activeColor: AppColors.accent,
|
||||||
),
|
),
|
||||||
_ProfileMenuItem(
|
_EmojiToggleItem(
|
||||||
icon: Icons.settings_outlined,
|
emoji: '🌙',
|
||||||
iconColor: colorScheme.onSurface.withValues(alpha: 0.5),
|
bg: surfaceWarm,
|
||||||
title: '设置',
|
title: '深色模式',
|
||||||
onTap: () => context.go('/settings'),
|
value: theme.brightness == Brightness.dark,
|
||||||
|
onChanged: (v) {
|
||||||
|
final settings = context.read<SettingsBloc>();
|
||||||
|
settings.changeTheme(v ? ThemeMode.dark : ThemeMode.light);
|
||||||
|
},
|
||||||
|
activeColor: AppColors.accent,
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(height: 32),
|
||||||
|
|
||||||
|
// ---- 更多设置 ----
|
||||||
|
_EmojiMenuItem(emoji: '📤', bg: secondarySoft, title: '导出数据', onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('导出功能即将上线')));
|
||||||
|
}),
|
||||||
|
_EmojiMenuItem(emoji: '💬', bg: accentSoft, title: '意见反馈', onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('感谢你的反馈')));
|
||||||
|
}),
|
||||||
|
_EmojiMenuItem(
|
||||||
|
emoji: 'ℹ️',
|
||||||
|
bg: greyBg,
|
||||||
|
title: '关于',
|
||||||
|
subtitle: '版本 1.0.0',
|
||||||
|
onTap: () => context.go('/about'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -129,6 +190,7 @@ class ProfilePage extends StatelessWidget {
|
|||||||
child: const Text('退出登录'),
|
child: const Text('退出登录'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: DesignTokens.spacing24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -146,28 +208,230 @@ class ProfilePage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProfileMenuItem extends StatelessWidget {
|
/// 统计项
|
||||||
const _ProfileMenuItem({
|
class _StatItem extends StatelessWidget {
|
||||||
required this.icon,
|
const _StatItem({required this.label, required this.value, required this.valueColor});
|
||||||
required this.iconColor,
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color valueColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(value, style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold, color: valueColor,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(label, style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme(context).onSurface.withValues(alpha: 0.5),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ColorScheme colorScheme(BuildContext context) => Theme.of(context).colorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成就徽章项
|
||||||
|
class _BadgeItem extends StatelessWidget {
|
||||||
|
const _BadgeItem({
|
||||||
|
required this.emoji,
|
||||||
|
required this.name,
|
||||||
|
required this.bgColor,
|
||||||
|
required this.locked,
|
||||||
|
});
|
||||||
|
final String emoji;
|
||||||
|
final String name;
|
||||||
|
final Color bgColor;
|
||||||
|
final bool locked;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: locked ? 0.5 : 1.0,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: locked ? theme.colorScheme.outlineVariant.withValues(alpha: 0.3) : bgColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(emoji, style: const TextStyle(fontSize: 24)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(name, style: theme.textTheme.labelSmall?.copyWith(fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// emoji 菜单项
|
||||||
|
class _EmojiMenuItem extends StatelessWidget {
|
||||||
|
const _EmojiMenuItem({
|
||||||
|
required this.emoji,
|
||||||
|
required this.bg,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.subtitle,
|
||||||
});
|
});
|
||||||
|
final String emoji;
|
||||||
final IconData icon;
|
final Color bg;
|
||||||
final Color iconColor;
|
|
||||||
final String title;
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(icon, color: iconColor),
|
leading: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(emoji, style: const TextStyle(fontSize: 18)),
|
||||||
|
),
|
||||||
title: Text(title, style: theme.textTheme.bodyMedium),
|
title: Text(title, style: theme.textTheme.bodyMedium),
|
||||||
|
subtitle: subtitle != null
|
||||||
|
? Text(subtitle!, style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
))
|
||||||
|
: null,
|
||||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// emoji 开关菜单项
|
||||||
|
class _EmojiToggleItem extends StatelessWidget {
|
||||||
|
const _EmojiToggleItem({
|
||||||
|
required this.emoji,
|
||||||
|
required this.bg,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.activeColor,
|
||||||
|
});
|
||||||
|
final String emoji;
|
||||||
|
final Color bg;
|
||||||
|
final String title;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
final Color activeColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(emoji, style: const TextStyle(fontSize: 18)),
|
||||||
|
),
|
||||||
|
title: Text(title, style: theme.textTheme.bodyMedium),
|
||||||
|
trailing: Switch(
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
activeColor: activeColor,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 动态统计栏 — 从 JournalRepository 加载真实数据
|
||||||
|
class _LiveStatsBar extends StatefulWidget {
|
||||||
|
const _LiveStatsBar({required this.borderSoft, required this.colorScheme});
|
||||||
|
final Color borderSoft;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_LiveStatsBar> createState() => _LiveStatsBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LiveStatsBarState extends State<_LiveStatsBar> {
|
||||||
|
int _totalCount = 0;
|
||||||
|
int _streakDays = 0;
|
||||||
|
int _monthCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStats() async {
|
||||||
|
try {
|
||||||
|
final repo = context.read<JournalRepository>();
|
||||||
|
final totalCount = await repo.getJournalCount();
|
||||||
|
final journals = await repo.getJournals();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final today = DateTime.now();
|
||||||
|
final monthCount = journals
|
||||||
|
.where((j) => j.date.year == today.year && j.date.month == today.month)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
// 推算连续天数
|
||||||
|
final dates = journals.map((j) => j.date).toSet();
|
||||||
|
var streak = 0;
|
||||||
|
var checkDate = today;
|
||||||
|
while (dates.contains(DateTime(checkDate.year, checkDate.month, checkDate.day))) {
|
||||||
|
streak++;
|
||||||
|
checkDate = checkDate.subtract(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_totalCount = totalCount;
|
||||||
|
_streakDays = streak;
|
||||||
|
_monthCount = monthCount;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// 保持默认 0 值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.colorScheme.surface,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_StatItem(label: '总日记', value: '$_totalCount', valueColor: AppColors.accent),
|
||||||
|
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||||||
|
_StatItem(label: '连续天数', value: '$_streakDays', valueColor: AppColors.secondary),
|
||||||
|
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||||||
|
_StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface),
|
||||||
|
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||||||
|
_StatItem(label: '贴纸数', value: '--', valueColor: widget.colorScheme.onSurface),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
// 搜索页面 — 标签+心情筛选日记
|
// 搜索页面 — 搜索历史 + 热门搜索 + 关键词高亮 + 分类 Tab
|
||||||
//
|
//
|
||||||
// 通过 SearchBloc 驱动搜索状态:
|
// 通过 SearchBloc 驱动搜索状态:
|
||||||
|
// - 关键词输入 → SearchByKeyword event
|
||||||
// - 标签点击 → SearchByTag event
|
// - 标签点击 → SearchByTag event
|
||||||
// - 心情选择 → SearchByMood event
|
// - 心情选择 → SearchByMood event
|
||||||
|
// - Tab 切换 → SearchTabChanged event
|
||||||
// - 清除按钮 → SearchClear event
|
// - 清除按钮 → SearchClear event
|
||||||
// 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。
|
// 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_radius.dart';
|
||||||
|
import '../../../core/utils/mood_utils.dart';
|
||||||
import '../../../data/models/journal_entry.dart';
|
import '../../../data/models/journal_entry.dart';
|
||||||
import '../bloc/search_bloc.dart';
|
import '../bloc/search_bloc.dart';
|
||||||
|
|
||||||
/// 搜索页面 — 标签+心情筛选日记
|
/// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类
|
||||||
class SearchPage extends StatefulWidget {
|
class SearchPage extends StatefulWidget {
|
||||||
const SearchPage({super.key});
|
const SearchPage({super.key});
|
||||||
|
|
||||||
@@ -24,14 +29,24 @@ class SearchPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _SearchPageState extends State<SearchPage> {
|
class _SearchPageState extends State<SearchPage> {
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
|
final _searchFocusNode = FocusNode();
|
||||||
|
|
||||||
// Phase 1 占位标签数据
|
// 热门搜索占位数据
|
||||||
final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情'];
|
final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸'];
|
||||||
final _moodFilters = Mood.values;
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// 自动弹出键盘
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_searchFocusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_searchFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,120 +54,341 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
return BlocBuilder<SearchBloc, SearchState>(
|
return BlocBuilder<SearchBloc, SearchState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final hasFilter = state is SearchLoaded && state.hasActiveFilter;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
body: SafeArea(
|
||||||
title: TextField(
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 6A: 搜索头部 — 返回 + 输入框 + 取消
|
||||||
|
_buildSearchHeader(
|
||||||
|
context, theme, colorScheme, isDark),
|
||||||
|
// 6C: 结果分类 Tab(有结果时显示)
|
||||||
|
if (state case SearchLoaded(:final hasActiveFilter) when hasActiveFilter)
|
||||||
|
_buildResultTabs(context, state),
|
||||||
|
// 主体内容
|
||||||
|
Expanded(
|
||||||
|
child: _buildBody(
|
||||||
|
context, theme, colorScheme, isDark, state),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 6A: 搜索头部 =====
|
||||||
|
|
||||||
|
Widget _buildSearchHeader(
|
||||||
|
BuildContext context,
|
||||||
|
ThemeData theme,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
bool isDark,
|
||||||
|
) {
|
||||||
|
final surfaceWarmColor = isDark
|
||||||
|
? AppColors.surfaceWarmDark
|
||||||
|
: AppColors.surfaceWarmLight;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 返回按钮
|
||||||
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.arrow_back_ios_new,
|
||||||
|
size: 18,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.fgDark
|
||||||
|
: AppColors.fgLight,
|
||||||
|
),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(AppRadius.pill),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// 搜索输入框
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
|
focusNode: _searchFocusNode,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: '搜索日记...',
|
hintText: '搜索日记...',
|
||||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
color: isDark
|
||||||
|
? AppColors.mutedDark
|
||||||
|
: AppColors.mutedLight,
|
||||||
),
|
),
|
||||||
border: InputBorder.none,
|
prefixIcon: Icon(
|
||||||
prefixIcon: const Icon(Icons.search),
|
Icons.search,
|
||||||
suffixIcon: hasFilter
|
size: 20,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.mutedDark
|
||||||
|
: AppColors.mutedLight,
|
||||||
|
),
|
||||||
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.filter_alt_off),
|
icon: Icon(
|
||||||
tooltip: '清除筛选',
|
Icons.clear,
|
||||||
onPressed: _clearSearch,
|
size: 18,
|
||||||
)
|
color: isDark
|
||||||
: (_searchController.text.isNotEmpty
|
? AppColors.mutedDark
|
||||||
? IconButton(
|
: AppColors.mutedLight,
|
||||||
icon: const Icon(Icons.clear),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_clearSearch();
|
_clearSearch();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null),
|
: null,
|
||||||
|
filled: true,
|
||||||
|
fillColor: surfaceWarmColor,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(AppRadius.pill),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
isDense: true,
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onSubmitted: (value) {
|
onSubmitted: (value) {
|
||||||
if (value.trim().isNotEmpty) {
|
if (value.trim().isNotEmpty) {
|
||||||
context.read<SearchBloc>().add(SearchByTag(value.trim()));
|
context
|
||||||
|
.read<SearchBloc>()
|
||||||
|
.add(SearchByKeyword(value.trim()));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: _buildBody(context, theme, colorScheme, state),
|
),
|
||||||
);
|
const SizedBox(width: 4),
|
||||||
},
|
// 取消按钮
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(
|
||||||
|
'取消',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据搜索状态构建 body
|
// ===== 6C: 结果分类 Tab =====
|
||||||
|
|
||||||
|
Widget _buildResultTabs(
|
||||||
|
BuildContext context, SearchLoaded state) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: SearchResultTab.values.map((tab) {
|
||||||
|
final isActive = state.activeTab == tab;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
context
|
||||||
|
.read<SearchBloc>()
|
||||||
|
.add(SearchTabChanged(tab));
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: isActive
|
||||||
|
? AppColors.accent
|
||||||
|
: Colors.transparent,
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
tab.label,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: isActive
|
||||||
|
? AppColors.accent
|
||||||
|
: (isDark
|
||||||
|
? AppColors.mutedDark
|
||||||
|
: AppColors.mutedLight),
|
||||||
|
fontWeight: isActive
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 主体内容 =====
|
||||||
|
|
||||||
Widget _buildBody(
|
Widget _buildBody(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ThemeData theme,
|
ThemeData theme,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
bool isDark,
|
||||||
SearchState state,
|
SearchState state,
|
||||||
) {
|
) {
|
||||||
return switch (state) {
|
return switch (state) {
|
||||||
SearchInitial() => _buildSuggestions(context, theme, colorScheme),
|
SearchInitial() => _buildSuggestions(
|
||||||
SearchLoading() => const Center(child: CircularProgressIndicator()),
|
context, theme, colorScheme, isDark, []),
|
||||||
SearchLoaded(:final results, :final activeMood, :final activeTag) =>
|
SearchLoading() =>
|
||||||
_hasActiveFilter(activeMood, activeTag)
|
const Center(child: CircularProgressIndicator()),
|
||||||
? _buildResults(context, theme, colorScheme, results)
|
SearchLoaded(
|
||||||
: _buildSuggestions(context, theme, colorScheme),
|
:final results,
|
||||||
SearchError(:final message) => _buildError(colorScheme, message),
|
:final activeMood,
|
||||||
|
:final activeTag,
|
||||||
|
:final activeKeyword,
|
||||||
|
:final activeTab,
|
||||||
|
:final searchHistory,
|
||||||
|
) =>
|
||||||
|
hasActiveFilter(activeMood, activeTag, activeKeyword)
|
||||||
|
? _buildFilteredResults(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
colorScheme,
|
||||||
|
isDark,
|
||||||
|
results,
|
||||||
|
activeKeyword,
|
||||||
|
activeTab,
|
||||||
|
)
|
||||||
|
: _buildSuggestions(
|
||||||
|
context, theme, colorScheme, isDark, searchHistory),
|
||||||
|
SearchError(:final message) =>
|
||||||
|
_buildError(colorScheme, message),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _hasActiveFilter(String? mood, String? tag) =>
|
bool hasActiveFilter(String? mood, String? tag, String? keyword) =>
|
||||||
mood != null || tag != null;
|
mood != null || tag != null || keyword != null;
|
||||||
|
|
||||||
|
// ===== 6B: 搜索历史 + 热门搜索 =====
|
||||||
|
|
||||||
/// 建议区域 — 标签云 + 心情选择
|
|
||||||
Widget _buildSuggestions(
|
Widget _buildSuggestions(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ThemeData theme,
|
ThemeData theme,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
bool isDark,
|
||||||
|
List<String> searchHistory,
|
||||||
) {
|
) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 搜索历史
|
||||||
|
if (searchHistory.isNotEmpty) ...[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'常用标签',
|
'最近搜索',
|
||||||
style:
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _clearSearch,
|
||||||
|
child: Text(
|
||||||
|
'清除',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: _recentTags.map((tag) {
|
children: searchHistory.map((keyword) {
|
||||||
return ActionChip(
|
return ActionChip(
|
||||||
label: Text(tag),
|
label: Text(keyword),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.text = tag;
|
_searchController.text = keyword;
|
||||||
context.read<SearchBloc>().add(SearchByTag(tag));
|
context
|
||||||
|
.read<SearchBloc>()
|
||||||
|
.add(SearchByKeyword(keyword));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 热门搜索
|
||||||
|
Text(
|
||||||
|
'热门搜索',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _hotSearches.map((keyword) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(keyword),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.text = keyword;
|
||||||
|
context
|
||||||
|
.read<SearchBloc>()
|
||||||
|
.add(SearchByKeyword(keyword));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 心情筛选
|
||||||
Text(
|
Text(
|
||||||
'按心情筛选',
|
'按心情筛选',
|
||||||
style:
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 12,
|
runSpacing: 12,
|
||||||
children: _moodFilters.map((mood) {
|
children: Mood.values.map((mood) {
|
||||||
final color =
|
final color = AppColors.moodColors[mood.value] ??
|
||||||
AppColors.moodColors[mood.value] ?? colorScheme.primary;
|
colorScheme.primary;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_searchController.text = _moodLabel(mood);
|
_searchController.text = moodToLabel(mood);
|
||||||
context.read<SearchBloc>().add(SearchByMood(mood));
|
context
|
||||||
|
.read<SearchBloc>()
|
||||||
|
.add(SearchByMood(mood));
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -164,11 +400,12 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
color: color.withValues(alpha: 0.15),
|
color: color.withValues(alpha: 0.15),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(_moodEmoji(mood),
|
child: Text(moodToEmoji(mood),
|
||||||
style: const TextStyle(fontSize: 24)),
|
style: const TextStyle(fontSize: 24)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(_moodLabel(mood), style: theme.textTheme.labelSmall),
|
Text(moodToLabel(mood),
|
||||||
|
style: theme.textTheme.labelSmall),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -179,12 +416,16 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 搜索结果列表
|
// ===== 按分类 Tab 过滤结果 =====
|
||||||
Widget _buildResults(
|
|
||||||
|
Widget _buildFilteredResults(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ThemeData theme,
|
ThemeData theme,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
bool isDark,
|
||||||
List<JournalEntry> results,
|
List<JournalEntry> results,
|
||||||
|
String? keyword,
|
||||||
|
SearchResultTab activeTab,
|
||||||
) {
|
) {
|
||||||
if (results.isEmpty) {
|
if (results.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -198,7 +439,8 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
Text(
|
Text(
|
||||||
'没有找到匹配的日记',
|
'没有找到匹配的日记',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
color:
|
||||||
|
colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -211,17 +453,173 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据活跃 Tab 选择展示内容
|
||||||
|
switch (activeTab) {
|
||||||
|
case SearchResultTab.all:
|
||||||
|
case SearchResultTab.journal:
|
||||||
|
return _buildJournalResults(
|
||||||
|
context, theme, colorScheme, isDark, results, keyword);
|
||||||
|
case SearchResultTab.template:
|
||||||
|
return _buildTemplateResults(theme, isDark);
|
||||||
|
case SearchResultTab.tag:
|
||||||
|
return _buildTagResults(theme, isDark, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 日记结果列表 =====
|
||||||
|
|
||||||
|
Widget _buildJournalResults(
|
||||||
|
BuildContext context,
|
||||||
|
ThemeData theme,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
bool isDark,
|
||||||
|
List<JournalEntry> results,
|
||||||
|
String? keyword,
|
||||||
|
) {
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
itemCount: results.length,
|
itemCount: results.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final entry = results[index];
|
final entry = results[index];
|
||||||
return _JournalCard(entry: entry);
|
return _JournalCard(
|
||||||
|
entry: entry,
|
||||||
|
keyword: keyword,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 6E: 模板结果(占位) =====
|
||||||
|
|
||||||
|
Widget _buildTemplateResults(ThemeData theme, bool isDark) {
|
||||||
|
// Phase 1 占位 — 模板功能未实现
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
),
|
||||||
|
itemCount: 4,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final gradients = [
|
||||||
|
const [AppColors.accent, AppColors.tertiary],
|
||||||
|
const [AppColors.secondary, AppColors.tertiary],
|
||||||
|
const [AppColors.rose, AppColors.accent],
|
||||||
|
const [AppColors.tertiary, AppColors.secondary],
|
||||||
|
];
|
||||||
|
final labels = ['每日心情', '旅行手账', '读书笔记', '日常记录'];
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: gradients[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// 装饰圆
|
||||||
|
Positioned(
|
||||||
|
right: -10,
|
||||||
|
bottom: -10,
|
||||||
|
child: Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
labels[index],
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'即将上线',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 6E: 标签结果 =====
|
||||||
|
|
||||||
|
Widget _buildTagResults(
|
||||||
|
ThemeData theme, bool isDark, List<JournalEntry> results) {
|
||||||
|
// 从搜索结果中提取所有标签及其频次
|
||||||
|
final tagFreq = <String, int>{};
|
||||||
|
for (final entry in results) {
|
||||||
|
for (final tag in entry.tags) {
|
||||||
|
tagFreq[tag] = (tagFreq[tag] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final sortedTags = tagFreq.keys.toList()
|
||||||
|
..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!));
|
||||||
|
|
||||||
|
if (sortedTags.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'没有找到相关标签',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: isDark ? AppColors.mutedDark : AppColors.mutedLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: sortedTags.map((tag) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.surfaceDark
|
||||||
|
: AppColors.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.borderDark
|
||||||
|
: AppColors.borderLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$tag (${tagFreq[tag]})',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
).padAll(16);
|
||||||
|
}
|
||||||
|
|
||||||
/// 错误提示
|
/// 错误提示
|
||||||
Widget _buildError(ColorScheme colorScheme, String message) {
|
Widget _buildError(ColorScheme colorScheme, String message) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -253,29 +651,50 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
context.read<SearchBloc>().add(const SearchClear());
|
context.read<SearchBloc>().add(const SearchClear());
|
||||||
}
|
}
|
||||||
|
|
||||||
String _moodEmoji(Mood mood) => switch (mood) {
|
|
||||||
Mood.happy => '😊',
|
|
||||||
Mood.calm => '😌',
|
|
||||||
Mood.sad => '😢',
|
|
||||||
Mood.angry => '😠',
|
|
||||||
Mood.thinking => '🤔',
|
|
||||||
};
|
|
||||||
|
|
||||||
String _moodLabel(Mood mood) => switch (mood) {
|
|
||||||
Mood.happy => '开心',
|
|
||||||
Mood.calm => '平静',
|
|
||||||
Mood.sad => '难过',
|
|
||||||
Mood.angry => '生气',
|
|
||||||
Mood.thinking => '思考',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 日记卡片 — 在搜索结果中展示单条日记摘要
|
// ===== 6D: 关键词高亮辅助函数 =====
|
||||||
|
|
||||||
|
/// 将文本中的关键词部分用高亮样式包裹
|
||||||
|
Widget _highlightText(String text, String? keyword) {
|
||||||
|
if (keyword == null || keyword.isEmpty || !text.toLowerCase().contains(keyword.toLowerCase())) {
|
||||||
|
return Text(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
final lowerText = text.toLowerCase();
|
||||||
|
final lowerKeyword = keyword.toLowerCase();
|
||||||
|
final spans = <TextSpan>[];
|
||||||
|
int start = 0;
|
||||||
|
|
||||||
|
while (start < text.length) {
|
||||||
|
final index = lowerText.indexOf(lowerKeyword, start);
|
||||||
|
if (index == -1) {
|
||||||
|
spans.add(TextSpan(text: text.substring(start)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (index > start) {
|
||||||
|
spans.add(TextSpan(text: text.substring(start, index)));
|
||||||
|
}
|
||||||
|
spans.add(TextSpan(
|
||||||
|
text: text.substring(index, index + keyword.length),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.accent,
|
||||||
|
backgroundColor: AppColors.tertiarySoftLight,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
start = index + keyword.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RichText(text: TextSpan(style: const TextStyle(), children: spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日记卡片 — 在搜索结果中展示单条日记摘要(支持关键词高亮)
|
||||||
class _JournalCard extends StatelessWidget {
|
class _JournalCard extends StatelessWidget {
|
||||||
final JournalEntry entry;
|
final JournalEntry entry;
|
||||||
|
final String? keyword;
|
||||||
|
|
||||||
const _JournalCard({required this.entry});
|
const _JournalCard({required this.entry, this.keyword});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -293,30 +712,23 @@ class _JournalCard extends StatelessWidget {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: 导航到日记详情页
|
context.push('/editor?id=${entry.id}');
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 第一行:心情 emoji + 标题 + 日期
|
// 第一行:心情 emoji + 标题(高亮)+ 日期
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_moodEmoji(entry.mood),
|
moodToEmoji(entry.mood),
|
||||||
style: const TextStyle(fontSize: 20),
|
style: const TextStyle(fontSize: 20),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: _highlightText(entry.title, keyword),
|
||||||
entry.title,
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
DateFormat('MM/dd').format(entry.date),
|
DateFormat('MM/dd').format(entry.date),
|
||||||
@@ -360,11 +772,12 @@ class _JournalCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _moodEmoji(Mood mood) => switch (mood) {
|
}
|
||||||
Mood.happy => '😊',
|
|
||||||
Mood.calm => '😌',
|
/// Padding 扩展 — 简化 Wrap 的 padding
|
||||||
Mood.sad => '😢',
|
extension _PadAll on Widget {
|
||||||
Mood.angry => '😠',
|
Widget padAll(double value) => Padding(
|
||||||
Mood.thinking => '🤔',
|
padding: EdgeInsets.all(value),
|
||||||
};
|
child: this,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,21 +52,32 @@ class Sticker {
|
|||||||
class StickerState {
|
class StickerState {
|
||||||
final List<StickerPack> packs;
|
final List<StickerPack> packs;
|
||||||
final String selectedCategory;
|
final String selectedCategory;
|
||||||
|
final String searchQuery;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
const StickerState({
|
const StickerState({
|
||||||
this.packs = const [],
|
this.packs = const [],
|
||||||
this.selectedCategory = '全部',
|
this.selectedCategory = '全部',
|
||||||
|
this.searchQuery = '',
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 按分类过滤贴纸包
|
/// 按分类 + 搜索关键词过滤贴纸包
|
||||||
List<StickerPack> get filteredPacks => selectedCategory == '全部'
|
List<StickerPack> get filteredPacks {
|
||||||
|
var result = selectedCategory == '全部'
|
||||||
? packs
|
? packs
|
||||||
: packs.where((p) => p.category == selectedCategory).toList();
|
: packs.where((p) => p.category == selectedCategory).toList();
|
||||||
|
|
||||||
|
if (searchQuery.isNotEmpty) {
|
||||||
|
final query = searchQuery.toLowerCase();
|
||||||
|
result = result.where((p) => p.name.toLowerCase().contains(query)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// 所有分类(去重 + 加"全部")
|
/// 所有分类(去重 + 加"全部")
|
||||||
List<String> get categories {
|
List<String> get categories {
|
||||||
final cats = packs
|
final cats = packs
|
||||||
@@ -80,12 +91,14 @@ class StickerState {
|
|||||||
StickerState copyWith({
|
StickerState copyWith({
|
||||||
List<StickerPack>? packs,
|
List<StickerPack>? packs,
|
||||||
String? selectedCategory,
|
String? selectedCategory,
|
||||||
|
String? searchQuery,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) =>
|
}) =>
|
||||||
StickerState(
|
StickerState(
|
||||||
packs: packs ?? this.packs,
|
packs: packs ?? this.packs,
|
||||||
selectedCategory: selectedCategory ?? this.selectedCategory,
|
selectedCategory: selectedCategory ?? this.selectedCategory,
|
||||||
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
);
|
);
|
||||||
@@ -114,6 +127,12 @@ class StickerBloc extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 搜索贴纸包(按名称前端过滤)
|
||||||
|
void search(String query) {
|
||||||
|
_state = _state.copyWith(searchQuery: query);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// 按分类加载贴纸包
|
/// 按分类加载贴纸包
|
||||||
void loadByCategory(String? category) {
|
void loadByCategory(String? category) {
|
||||||
_state = _state.copyWith(isLoading: true);
|
_state = _state.copyWith(isLoading: true);
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ class StickerLibraryPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
||||||
late final StickerBloc _bloc;
|
late final StickerBloc _bloc;
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
|
||||||
|
/// 设计规格中的 8 个分类
|
||||||
|
static const _specCategories = [
|
||||||
|
'推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带',
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -28,16 +34,18 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_bloc.dispose();
|
_bloc.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('贴纸库')),
|
body: SafeArea(
|
||||||
body: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: _bloc,
|
listenable: _bloc,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final state = _bloc.state;
|
final state = _bloc.state;
|
||||||
@@ -62,34 +70,94 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final categories = state.categories;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// 分类选择器(横向滚动 Chips)
|
// ---- 自定义顶栏 ----
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Text('贴纸素材', style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// ---- 搜索框 ----
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '搜索贴纸...',
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surface,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
onChanged: (v) {
|
||||||
|
_bloc.search(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ---- 分类选择器(设计规格 8 分类) ----
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 48,
|
height: 40,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
children: categories.map((cat) {
|
children: _specCategories.map((cat) {
|
||||||
final isSelected = cat == state.selectedCategory;
|
final isSelected = cat == state.selectedCategory ||
|
||||||
|
(cat == '推荐' && state.selectedCategory == '全部');
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
label: Text(cat),
|
label: Text(cat),
|
||||||
onSelected: (_) => _bloc.selectCategory(cat),
|
onSelected: (_) {
|
||||||
selectedColor: colorScheme.primaryContainer,
|
if (cat == '推荐') {
|
||||||
checkmarkColor: colorScheme.primary,
|
_bloc.selectCategory('全部');
|
||||||
|
} else {
|
||||||
|
_bloc.selectCategory(cat);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.accent.withValues(alpha: 0.15),
|
||||||
|
checkmarkColor: AppColors.accent,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? AppColors.accent : colorScheme.onSurface,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 贴纸包网格
|
// ---- 精选贴纸包卡片 ----
|
||||||
|
if (state.selectedCategory == '全部')
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: const _FeaturedPackCard(),
|
||||||
|
),
|
||||||
|
if (state.selectedCategory == '全部')
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ---- 贴纸包网格 ----
|
||||||
Expanded(
|
Expanded(
|
||||||
child: state.filteredPacks.isEmpty
|
child: state.filteredPacks.isEmpty
|
||||||
? const Center(child: Text('暂无贴纸包'))
|
? const Center(child: Text('暂无贴纸包'))
|
||||||
@@ -115,6 +183,77 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 精选贴纸包卡片 — 渐变背景 + 限时免费标签
|
||||||
|
class _FeaturedPackCard extends StatelessWidget {
|
||||||
|
const _FeaturedPackCard();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('打开精选贴纸包: 治愈小动物')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [AppColors.accent, AppColors.tertiary],
|
||||||
|
),
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// emoji 图标区域
|
||||||
|
Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Text('🧸', style: TextStyle(fontSize: 36)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('治愈小动物', style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700, color: Colors.white,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('超可爱的手绘小动物贴纸', style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.white.withValues(alpha: 0.85),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.secondary,
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
child: const Text('限时免费', style: TextStyle(
|
||||||
|
fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:nuanji_app/core/theme/app_colors.dart';
|
|||||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||||
import 'package:nuanji_app/data/repositories/class_repository.dart';
|
import 'package:nuanji_app/data/repositories/class_repository.dart';
|
||||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||||
|
import 'package:nuanji_app/data/models/school_class.dart';
|
||||||
import '../../class_/bloc/class_bloc.dart';
|
import '../../class_/bloc/class_bloc.dart';
|
||||||
|
|
||||||
/// 老师管理页面 — 教师专属功能入口
|
/// 老师管理页面 — 教师专属功能入口
|
||||||
@@ -162,13 +163,46 @@ class _TeacherView extends StatelessWidget {
|
|||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final descController = TextEditingController();
|
final descController = TextEditingController();
|
||||||
|
|
||||||
|
// 从 ClassBloc 获取已加载的班级列表
|
||||||
|
final classState = context.read<ClassBloc>().state;
|
||||||
|
final classes = classState is ClassListLoaded ? classState.classes : <SchoolClass>[];
|
||||||
|
|
||||||
|
// 无班级时提示先创建
|
||||||
|
if (classes.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请先创建班级后再布置主题')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String selectedClassId = classes.first.id;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
title: const Text('布置主题'),
|
title: const Text('布置主题'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// 班级选择下拉框
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: selectedClassId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '选择班级',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: classes
|
||||||
|
.map((c) => DropdownMenuItem(
|
||||||
|
value: c.id,
|
||||||
|
child: Text(c.name),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) setDialogState(() => selectedClassId = v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: titleController,
|
controller: titleController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -198,7 +232,7 @@ class _TeacherView extends StatelessWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (titleController.text.trim().isNotEmpty) {
|
if (titleController.text.trim().isNotEmpty) {
|
||||||
context.read<ClassBloc>().add(TopicAssign(
|
context.read<ClassBloc>().add(TopicAssign(
|
||||||
classId: 'class-1',
|
classId: selectedClassId,
|
||||||
title: titleController.text.trim(),
|
title: titleController.text.trim(),
|
||||||
description: descController.text.trim().isEmpty
|
description: descController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
@@ -214,6 +248,7 @@ class _TeacherView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,10 +202,14 @@ class _FailingJournalRepository implements JournalRepository {
|
|||||||
int? pageSize,
|
int? pageSize,
|
||||||
String? mood,
|
String? mood,
|
||||||
String? tag,
|
String? tag,
|
||||||
|
String? classId,
|
||||||
}) async {
|
}) async {
|
||||||
throw Exception('模拟网络错误');
|
throw Exception('模拟网络错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() async => 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<JournalEntry?> getJournal(String id) async => null;
|
Future<JournalEntry?> getJournal(String id) async => null;
|
||||||
|
|
||||||
|
|||||||
@@ -222,10 +222,16 @@ class _FailingJournalRepository implements JournalRepository {
|
|||||||
int? pageSize,
|
int? pageSize,
|
||||||
String? mood,
|
String? mood,
|
||||||
String? tag,
|
String? tag,
|
||||||
|
String? classId,
|
||||||
}) async {
|
}) async {
|
||||||
throw Exception('网络不可用');
|
throw Exception('网络不可用');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getJournalCount() async {
|
||||||
|
throw Exception('网络不可用');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<JournalEntry?> getJournal(String id) async {
|
Future<JournalEntry?> getJournal(String id) async {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
|
|||||||
Reference in New Issue
Block a user