fix(app): Phase 1.1 紧急修复 — SyncEngine 接入 + authorId + catch 异常处理
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

- feat(sync): SyncEngine 接入 EditorPage, 保存时 enqueue + 网络恢复自动 trySync
- fix(editor): authorId 从 AuthBloc 获取, 替代硬编码 'local'
- fix(bloc): class_bloc/calendar/profile/parent catch(_).全部改为 debugPrint
- feat(editor): 编辑器工具栏拆分 (brush_panel/tag_panel/text_format_bar/dot_grid_painter)
- feat(editor): EditorBloc 扩展 + EditorPage 增强
- feat(search): SearchBloc 扩展搜索功能
- feat(home): HomeBloc/HomePage 增强
- feat(auth): LoginPage 增强
- feat(templates): TemplateGalleryPage 重构
- fix(web): 管理端班级/日记页面修复
- fix(server): comment_service + theme_handler 修复
- docs: 添加全链路审计报告和验证截图
This commit is contained in:
iven
2026-06-02 21:21:43 +08:00
parent 7e928ae1e1
commit 49d4aa36a7
55 changed files with 2738 additions and 677 deletions

View File

@@ -58,6 +58,8 @@ class NuanjiApp extends StatelessWidget {
// 异步恢复 SyncEngine 持久化队列fire-and-forget不阻塞 UI
syncEngine.restorePendingQueue();
// 启动网络监听 — 网络恢复时自动触发 trySync()
syncEngine.startAutoSync();
// 认证状态监听:登出时清除 token
// 注意:登录时 token 由 AuthRepository.login() 直接注入 ApiClient

View File

@@ -16,7 +16,7 @@ extension GetJournalElementCollectionCollection on Isar {
const JournalElementCollectionSchema = CollectionSchema(
name: r'JournalElementCollection',
id: 5678901234567001,
id: -1002,
properties: {
r'contentJson': PropertySchema(
id: 0,
@@ -96,7 +96,7 @@ const JournalElementCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: 5678901234567002,
id: -2002,
name: r'id',
unique: false,
replace: false,
@@ -109,7 +109,7 @@ const JournalElementCollectionSchema = CollectionSchema(
],
),
r'journalId': IndexSchema(
id: 5678901234567003,
id: 3001,
name: r'journalId',
unique: false,
replace: false,

View File

@@ -46,6 +46,9 @@ class JournalEntryCollection {
/// 关联主题 ID可选
String? assignedTopicId;
/// 内容摘要(自动从文本元素提取)
String? contentExcerpt;
/// 版本号(乐观锁)
int version = 1;

View File

@@ -16,7 +16,7 @@ extension GetJournalEntryCollectionCollection on Isar {
const JournalEntryCollectionSchema = CollectionSchema(
name: r'JournalEntryCollection',
id: 5678901234567004,
id: -1001,
properties: {
r'assignedTopicId': PropertySchema(
id: 0,
@@ -33,63 +33,68 @@ const JournalEntryCollectionSchema = CollectionSchema(
name: r'classId',
type: IsarType.string,
),
r'createdAtEpoch': PropertySchema(
r'contentExcerpt': PropertySchema(
id: 3,
name: r'contentExcerpt',
type: IsarType.string,
),
r'createdAtEpoch': PropertySchema(
id: 4,
name: r'createdAtEpoch',
type: IsarType.long,
),
r'dateEpoch': PropertySchema(
id: 4,
id: 5,
name: r'dateEpoch',
type: IsarType.long,
),
r'id': PropertySchema(
id: 5,
id: 6,
name: r'id',
type: IsarType.string,
),
r'isDeleted': PropertySchema(
id: 6,
id: 7,
name: r'isDeleted',
type: IsarType.bool,
),
r'isPrivate': PropertySchema(
id: 7,
id: 8,
name: r'isPrivate',
type: IsarType.bool,
),
r'mood': PropertySchema(
id: 8,
id: 9,
name: r'mood',
type: IsarType.string,
),
r'sharedToClass': PropertySchema(
id: 9,
id: 10,
name: r'sharedToClass',
type: IsarType.bool,
),
r'tagsJson': PropertySchema(
id: 10,
id: 11,
name: r'tagsJson',
type: IsarType.string,
),
r'title': PropertySchema(
id: 11,
id: 12,
name: r'title',
type: IsarType.string,
),
r'updatedAtEpoch': PropertySchema(
id: 12,
id: 13,
name: r'updatedAtEpoch',
type: IsarType.long,
),
r'version': PropertySchema(
id: 13,
id: 14,
name: r'version',
type: IsarType.long,
),
r'weather': PropertySchema(
id: 14,
id: 15,
name: r'weather',
type: IsarType.string,
)
@@ -101,7 +106,7 @@ const JournalEntryCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: 5678901234567002,
id: -2001,
name: r'id',
unique: false,
replace: false,
@@ -141,6 +146,12 @@ int _journalEntryCollectionEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.contentExcerpt;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
bytesCount += 3 + object.id.length * 3;
bytesCount += 3 + object.mood.length * 3;
bytesCount += 3 + object.tagsJson.length * 3;
@@ -158,18 +169,19 @@ void _journalEntryCollectionSerialize(
writer.writeString(offsets[0], object.assignedTopicId);
writer.writeString(offsets[1], object.authorId);
writer.writeString(offsets[2], object.classId);
writer.writeLong(offsets[3], object.createdAtEpoch);
writer.writeLong(offsets[4], object.dateEpoch);
writer.writeString(offsets[5], object.id);
writer.writeBool(offsets[6], object.isDeleted);
writer.writeBool(offsets[7], object.isPrivate);
writer.writeString(offsets[8], object.mood);
writer.writeBool(offsets[9], object.sharedToClass);
writer.writeString(offsets[10], object.tagsJson);
writer.writeString(offsets[11], object.title);
writer.writeLong(offsets[12], object.updatedAtEpoch);
writer.writeLong(offsets[13], object.version);
writer.writeString(offsets[14], object.weather);
writer.writeString(offsets[3], object.contentExcerpt);
writer.writeLong(offsets[4], object.createdAtEpoch);
writer.writeLong(offsets[5], object.dateEpoch);
writer.writeString(offsets[6], object.id);
writer.writeBool(offsets[7], object.isDeleted);
writer.writeBool(offsets[8], object.isPrivate);
writer.writeString(offsets[9], object.mood);
writer.writeBool(offsets[10], object.sharedToClass);
writer.writeString(offsets[11], object.tagsJson);
writer.writeString(offsets[12], object.title);
writer.writeLong(offsets[13], object.updatedAtEpoch);
writer.writeLong(offsets[14], object.version);
writer.writeString(offsets[15], object.weather);
}
JournalEntryCollection _journalEntryCollectionDeserialize(
@@ -182,19 +194,20 @@ JournalEntryCollection _journalEntryCollectionDeserialize(
object.assignedTopicId = reader.readStringOrNull(offsets[0]);
object.authorId = reader.readString(offsets[1]);
object.classId = reader.readStringOrNull(offsets[2]);
object.createdAtEpoch = reader.readLong(offsets[3]);
object.dateEpoch = reader.readLong(offsets[4]);
object.id = reader.readString(offsets[5]);
object.isDeleted = reader.readBool(offsets[6]);
object.isPrivate = reader.readBool(offsets[7]);
object.contentExcerpt = reader.readStringOrNull(offsets[3]);
object.createdAtEpoch = reader.readLong(offsets[4]);
object.dateEpoch = reader.readLong(offsets[5]);
object.id = reader.readString(offsets[6]);
object.isDeleted = reader.readBool(offsets[7]);
object.isPrivate = reader.readBool(offsets[8]);
object.isarId = id;
object.mood = reader.readString(offsets[8]);
object.sharedToClass = reader.readBool(offsets[9]);
object.tagsJson = reader.readString(offsets[10]);
object.title = reader.readString(offsets[11]);
object.updatedAtEpoch = reader.readLong(offsets[12]);
object.version = reader.readLong(offsets[13]);
object.weather = reader.readString(offsets[14]);
object.mood = reader.readString(offsets[9]);
object.sharedToClass = reader.readBool(offsets[10]);
object.tagsJson = reader.readString(offsets[11]);
object.title = reader.readString(offsets[12]);
object.updatedAtEpoch = reader.readLong(offsets[13]);
object.version = reader.readLong(offsets[14]);
object.weather = reader.readString(offsets[15]);
return object;
}
@@ -212,28 +225,30 @@ P _journalEntryCollectionDeserializeProp<P>(
case 2:
return (reader.readStringOrNull(offset)) as P;
case 3:
return (reader.readLong(offset)) as P;
return (reader.readStringOrNull(offset)) as P;
case 4:
return (reader.readLong(offset)) as P;
case 5:
return (reader.readString(offset)) as P;
return (reader.readLong(offset)) as P;
case 6:
return (reader.readBool(offset)) as P;
return (reader.readString(offset)) as P;
case 7:
return (reader.readBool(offset)) as P;
case 8:
return (reader.readString(offset)) as P;
case 9:
return (reader.readBool(offset)) as P;
case 10:
case 9:
return (reader.readString(offset)) as P;
case 10:
return (reader.readBool(offset)) as P;
case 11:
return (reader.readString(offset)) as P;
case 12:
return (reader.readLong(offset)) as P;
return (reader.readString(offset)) as P;
case 13:
return (reader.readLong(offset)) as P;
case 14:
return (reader.readLong(offset)) as P;
case 15:
return (reader.readString(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -832,6 +847,162 @@ extension JournalEntryCollectionQueryFilter on QueryBuilder<
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'contentExcerpt',
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'contentExcerpt',
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'contentExcerpt',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition>
contentExcerptContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'contentExcerpt',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition>
contentExcerptMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'contentExcerpt',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'contentExcerpt',
value: '',
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> contentExcerptIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'contentExcerpt',
value: '',
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterFilterCondition> createdAtEpochEqualTo(int value) {
return QueryBuilder.apply(this, (query) {
@@ -1883,6 +2054,20 @@ extension JournalEntryCollectionQuerySortBy
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
sortByContentExcerpt() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'contentExcerpt', Sort.asc);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
sortByContentExcerptDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'contentExcerpt', Sort.desc);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
sortByCreatedAtEpoch() {
return QueryBuilder.apply(this, (query) {
@@ -2096,6 +2281,20 @@ extension JournalEntryCollectionQuerySortThenBy on QueryBuilder<
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
thenByContentExcerpt() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'contentExcerpt', Sort.asc);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
thenByContentExcerptDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'contentExcerpt', Sort.desc);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterSortBy>
thenByCreatedAtEpoch() {
return QueryBuilder.apply(this, (query) {
@@ -2303,6 +2502,14 @@ extension JournalEntryCollectionQueryWhereDistinct
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QDistinct>
distinctByContentExcerpt({bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'contentExcerpt',
caseSensitive: caseSensitive);
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QDistinct>
distinctByCreatedAtEpoch() {
return QueryBuilder.apply(this, (query) {
@@ -2417,6 +2624,13 @@ extension JournalEntryCollectionQueryProperty on QueryBuilder<
});
}
QueryBuilder<JournalEntryCollection, String?, QQueryOperations>
contentExcerptProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'contentExcerpt');
});
}
QueryBuilder<JournalEntryCollection, int, QQueryOperations>
createdAtEpochProperty() {
return QueryBuilder.apply(this, (query) {

View File

@@ -16,7 +16,7 @@ extension GetPendingOperationCollectionCollection on Isar {
const PendingOperationCollectionSchema = CollectionSchema(
name: r'PendingOperationCollection',
id: 5678901234567005,
id: -1003,
properties: {
r'createdAtEpoch': PropertySchema(
id: 0,
@@ -61,7 +61,7 @@ const PendingOperationCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: 5678901234567002,
id: -2003,
name: r'id',
unique: false,
replace: false,

View File

@@ -42,6 +42,10 @@ class JournalEntry {
final bool isPrivate;
final bool sharedToClass;
final String? assignedTopicId;
/// 内容摘要 — 自动从文本元素提取,用于列表预览
final String? contentExcerpt;
final int version;
final DateTime createdAt;
final DateTime updatedAt;
@@ -58,6 +62,7 @@ class JournalEntry {
this.isPrivate = true,
this.sharedToClass = false,
this.assignedTopicId,
this.contentExcerpt,
this.version = 1,
required this.createdAt,
required this.updatedAt,
@@ -77,6 +82,8 @@ class JournalEntry {
bool? sharedToClass,
String? assignedTopicId,
bool clearAssignedTopicId = false,
String? contentExcerpt,
bool clearContentExcerpt = false,
int? version,
DateTime? createdAt,
DateTime? updatedAt,
@@ -94,6 +101,9 @@ class JournalEntry {
sharedToClass: sharedToClass ?? this.sharedToClass,
assignedTopicId:
clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId),
contentExcerpt: clearContentExcerpt
? null
: (contentExcerpt ?? this.contentExcerpt),
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
@@ -111,6 +121,7 @@ class JournalEntry {
'is_private': isPrivate,
'shared_to_class': sharedToClass,
'assigned_topic_id': assignedTopicId,
'content_excerpt': contentExcerpt,
'version': version,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
@@ -134,6 +145,7 @@ class JournalEntry {
isPrivate: (json['is_private'] as bool?) ?? true,
sharedToClass: (json['shared_to_class'] as bool?) ?? false,
assignedTopicId: json['assigned_topic_id'] as String?,
contentExcerpt: json['content_excerpt'] as String?,
version: (json['version'] as int?) ?? 1,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),

View File

@@ -39,7 +39,7 @@ class SseNotificationService {
SseNotificationService({
required String token,
String baseUrl = 'http://localhost:3000/api/v1',
required String baseUrl,
}) : _token = token,
_baseUrl = baseUrl;

View File

@@ -12,10 +12,12 @@
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
import 'dart:async';
import 'dart:convert';
import 'dart:collection';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:isar/isar.dart';
import '../local/isar_database.dart';
@@ -114,6 +116,7 @@ class PendingOperation {
class SyncEngine {
final ApiClient _apiClient;
final Queue<PendingOperation> _pendingQueue = Queue();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
@@ -289,6 +292,26 @@ class SyncEngine {
}
}
/// 启动网络监听 — 网络恢复时自动触发同步
///
/// 在 app.dart 中创建 SyncEngine 后调用一次。
/// 调用 [dispose] 停止监听。
void startAutoSync() {
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
trySync();
}
});
}
/// 停止网络监听并清理资源
void dispose() {
_connectivitySub?.cancel();
_connectivitySub = null;
}
// ============================================================
// 转换函数
// ============================================================

View File

@@ -11,6 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/design_tokens.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart';
import '../bloc/auth_bloc.dart';
@@ -30,6 +31,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
bool _isRegister = false;
bool _obscurePassword = true;
bool _agreedToTerms = false;
late final AnimationController _animController;
late final Animation<double> _fadeAnim;
@@ -60,6 +62,13 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
void _submit() {
if (!_formKey.currentState!.validate()) return;
if (_isRegister && !_agreedToTerms) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先阅读并同意用户协议和隐私政策')),
);
return;
}
if (_isRegister) {
context.read<AuthBloc>().add(RegisterRequested(
username: _usernameController.text.trim(),
@@ -106,13 +115,20 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildHeader(context, colorScheme),
const SizedBox(height: DesignTokens.spacing48),
const SizedBox(height: DesignTokens.spacing32),
_buildForm(context, theme, colorScheme),
const SizedBox(height: DesignTokens.spacing24),
_buildSubmitButton(context, colorScheme),
const SizedBox(height: DesignTokens.spacing16),
_buildModeToggle(context, colorScheme),
const SizedBox(height: DesignTokens.spacing32),
const SizedBox(height: DesignTokens.spacing24),
// 协议复选框(注册模式下显示)
if (_isRegister) ...[
_buildAgreementRow(context, colorScheme),
const SizedBox(height: DesignTokens.spacing16),
],
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthError) {
@@ -121,6 +137,13 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
return const SizedBox.shrink();
},
),
// 社交登录分割线
const SizedBox(height: DesignTokens.spacing24),
_buildSocialLoginDivider(context, colorScheme),
const SizedBox(height: DesignTokens.spacing16),
_buildSocialLoginButtons(context, colorScheme),
const SizedBox(height: DesignTokens.spacing32),
],
),
),
@@ -132,37 +155,83 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
}
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
return Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: AppRadius.lgBorder,
),
child: Icon(
Icons.edit_note_rounded,
size: 44,
color: colorScheme.primary,
),
final isDark = Theme.of(context).brightness == Brightness.dark;
final bgColor = isDark ? const Color(0xFF1A1614) : const Color(0xFFFFF8F0);
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 40),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [bgColor, tertiarySoft],
),
const SizedBox(height: DesignTokens.spacing16),
Text(
'暖记',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
child: Stack(
clipBehavior: Clip.none,
children: [
// 装饰圆圈
Positioned(left: 20, top: -10, child: _decorCircle(60, AppColors.accent, 0.15)),
Positioned(right: 30, top: 20, child: _decorCircle(40, AppColors.secondary, 0.12)),
Positioned(left: 80, bottom: -20, child: _decorCircle(30, AppColors.tertiary, 0.18)),
Positioned(right: 60, bottom: 10, child: _decorCircle(20, AppColors.accent, 0.10)),
Positioned(left: -10, top: 50, child: _decorCircle(25, AppColors.secondary, 0.13)),
Column(
children: [
// Logo — 自定义笔记本图标
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
border: Border.all(color: AppColors.accent, width: 3),
borderRadius: AppRadius.lgBorder,
color: colorScheme.surface.withValues(alpha: 0.5),
),
child: const Icon(
Icons.edit_note_rounded,
size: 44,
color: AppColors.accent,
),
),
),
const SizedBox(height: DesignTokens.spacing4),
Text(
'记录温暖,书写成长',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
const SizedBox(height: DesignTokens.spacing16),
// 品牌名
Text(
'暖记',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.accent,
),
),
),
],
const SizedBox(height: DesignTokens.spacing4),
// 标语 — Caveat 手写风格
Text(
'记录温暖,书写成长',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: AppColors.accent,
fontFamily: 'Caveat',
),
),
],
),
],
),
);
}
/// 装饰圆圈
Widget _decorCircle(double size, Color color, double opacity) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color.withValues(alpha: opacity),
shape: BoxShape.circle,
),
);
}
@@ -319,4 +388,160 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
),
);
}
/// 社交登录分割线
Widget _buildSocialLoginDivider(BuildContext context, ColorScheme colorScheme) {
final dividerColor = colorScheme.onSurface.withValues(alpha: 0.15);
return Row(
children: [
Expanded(child: Divider(color: dividerColor)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text('其他登录方式', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
)),
),
Expanded(child: Divider(color: dividerColor)),
],
);
}
/// 社交登录按钮行
Widget _buildSocialLoginButtons(BuildContext context, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 微信
_SocialButton(
bgColor: const Color(0xFF07C160),
icon: Icons.chat_bubble,
semanticLabel: '微信登录',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('微信登录即将支持')),
);
},
),
const SizedBox(width: 24),
// Apple
_SocialButton(
bgColor: const Color(0xFF1D1D1F),
icon: Icons.apple,
semanticLabel: 'Apple 登录',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Apple 登录即将支持')),
);
},
),
const SizedBox(width: 24),
// Google
_SocialButton(
bgColor: colorScheme.surface,
borderColor: colorScheme.outlineVariant,
child: const Text('G', style: TextStyle(
fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF4285F4),
)),
semanticLabel: 'Google 登录',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Google 登录即将支持')),
);
},
),
],
);
}
/// 协议复选框行
Widget _buildAgreementRow(BuildContext context, ColorScheme colorScheme) {
return Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: _agreedToTerms,
onChanged: (v) => setState(() => _agreedToTerms = v ?? false),
activeColor: AppColors.accent,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 8),
Expanded(
child: Wrap(
children: [
Text('我已阅读并同意', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
)),
GestureDetector(
onTap: () {
// TODO: 打开用户协议
},
child: Text('《用户协议》', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.accent, fontWeight: FontWeight.w500,
)),
),
Text('', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
)),
GestureDetector(
onTap: () {
// TODO: 打开隐私政策
},
child: Text('《隐私政策》', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.accent, fontWeight: FontWeight.w500,
)),
),
],
),
),
],
);
}
}
/// 社交登录圆形按钮
class _SocialButton extends StatelessWidget {
const _SocialButton({
required this.bgColor,
required this.semanticLabel,
required this.onTap,
this.icon,
this.child,
this.borderColor,
});
final Color bgColor;
final Color? borderColor;
final IconData? icon;
final Widget? child;
final String semanticLabel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 56,
height: 56,
child: Material(
color: bgColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
side: borderColor != null
? BorderSide(color: borderColor!, width: 1.5)
: BorderSide.none,
),
child: InkWell(
onTap: onTap,
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
child: Center(
child: child ??
Icon(icon, size: 28, color: Colors.white),
),
),
),
);
}
}

View File

@@ -50,7 +50,8 @@ class _MonthlyPageState extends State<MonthlyPage> {
try {
final elements = await _repo.getElements(journal.id);
photoCount += elements.where((e) => e.elementType == ElementType.image).length;
} catch (_) {
} catch (e) {
debugPrint('MonthlyPage: 加载日记 ${journal.id} 元素失败: $e');
// 单个日记加载元素失败不影响整体统计
}
}

View File

@@ -56,7 +56,8 @@ class _WeeklyPageState extends State<WeeklyPage> {
_isLoading = false;
});
}
} catch (_) {
} catch (e) {
debugPrint('WeeklyPage._loadWeekData 失败: $e');
if (mounted) setState(() => _isLoading = false);
}
}

View File

@@ -1,5 +1,6 @@
// 班级 BLoC — 通过 ClassRepository 管理班级数据
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.dart';
@@ -117,9 +118,14 @@ final class ClassLoading extends ClassState {
final class ClassListLoaded extends ClassState {
final List<SchoolClass> classes;
final bool isLoading;
const ClassListLoaded({this.classes = const [], this.isLoading = false});
ClassListLoaded copyWith({List<SchoolClass>? classes, bool? isLoading}) =>
ClassListLoaded(classes: classes ?? this.classes, isLoading: isLoading ?? this.isLoading);
final String? error;
const ClassListLoaded({this.classes = const [], this.isLoading = false, this.error});
ClassListLoaded copyWith({List<SchoolClass>? classes, bool? isLoading, String? error, bool clearError = false}) =>
ClassListLoaded(
classes: classes ?? this.classes,
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
);
}
final class ClassDetailLoaded extends ClassState {
@@ -209,7 +215,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
final classes = await _classRepo.getMyClasses();
emit(ClassListLoaded(classes: classes));
} catch (e) {
emit(ClassListLoaded(classes: const []));
debugPrint('ClassBloc._onLoadMyClasses 失败: $e');
emit(const ClassListLoaded());
}
}
@@ -224,7 +231,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
add(ClassLoadMembers(event.classId));
add(ClassLoadTopics(event.classId));
} catch (e) {
emit(ClassError('加载班级失败: $e'));
debugPrint('ClassBloc._onClassSelected 失败: $e');
emit(const ClassError('加载班级失败,请重试'));
}
}
@@ -247,7 +255,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
))
.toList();
emit(current.copyWith(members: members, isLoadingMembers: false));
} catch (_) {
} catch (e) {
debugPrint('ClassBloc._onLoadMembers 失败: $e');
emit(current.copyWith(isLoadingMembers: false));
}
}
@@ -266,7 +275,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
final classJournals = journals.where((j) => j.sharedToClass).toList();
emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
} catch (_) {
} catch (e) {
debugPrint('ClassBloc._onLoadDiaryWall 失败: $e');
emit(current.copyWith(isLoadingWall: false));
}
}
@@ -292,8 +302,9 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
))
.toList();
emit(current.copyWith(topics: topics));
} catch (_) {
// 静默失败,保留空列表
} catch (e) {
debugPrint('ClassBloc._onLoadTopics 失败: $e');
// 保留空列表
}
}
@@ -316,7 +327,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
))
.toList();
emit(current.copyWith(comments: comments, selectedJournalId: event.journalId));
} catch (_) {
} catch (e) {
debugPrint('ClassBloc._onLoadComments 失败: $e');
emit(current.copyWith(selectedJournalId: event.journalId));
}
}
@@ -335,7 +347,11 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
emit(current.copyWith(classes: [...current.classes, newClass]));
}
} catch (e) {
// 创建失败不改变状态
debugPrint('ClassBloc._onCreateClass 失败: $e');
// 创建失败不改变状态,但通知 UI
if (state is ClassListLoaded) {
emit((state as ClassListLoaded).copyWith(error: '创建班级失败,请重试'));
}
}
}
@@ -364,8 +380,12 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
);
emit(current.copyWith(topics: [newTopic, ...current.topics]));
}
} catch (_) {
// 静默失败
} catch (e) {
debugPrint('ClassBloc._onTopicAssign 失败: $e');
// 通知 UI 布置失败
if (state is ClassDetailLoaded) {
emit((state as ClassDetailLoaded).copyWith(error: '话题布置失败,请重试'));
}
}
}
@@ -378,7 +398,8 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
// 加入成功后刷新列表
add(const ClassLoadMyClasses());
} catch (e) {
emit(ClassError('加入班级失败: $e'));
debugPrint('ClassBloc._onJoinClass 失败: $e');
emit(const ClassError('加入班级失败,请检查班级码'));
}
}
@@ -397,6 +418,7 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
// 创建成功后重新加载评语列表
add(ClassLoadComments(event.journalId));
} catch (e) {
debugPrint('ClassBloc._onCommentCreate 失败: $e');
emit(currentState.copyWith(error: '评语发布失败'));
}
}

View File

@@ -4,13 +4,16 @@
// - 手写层:笔画列表 + 画笔设置 + 撤销/重做栈
// - 元素层:贴纸/照片/文字元素列表 + 选中元素 + 拖拽状态
// - 工具栏:当前活动工具 + 颜色面板 + 笔刷大小
// - 标签/心情:日记标签管理 + 心情选择 + 标题编辑
// - 自动保存:笔画/元素变更 debounce → 触发保存回调
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../widgets/stroke_model.dart';
import '../../../data/models/journal_entry.dart';
import '../../../data/models/journal_element.dart';
// ============================================================
@@ -110,20 +113,69 @@ class ElementsLoaded extends EditorEvent {
ElementsLoaded(this.elements);
}
// --- 标签/心情/标题事件 ---
/// 添加标签
class TagAdded extends EditorEvent {
final String tag;
TagAdded(this.tag);
}
/// 移除标签
class TagRemoved extends EditorEvent {
final String tag;
TagRemoved(this.tag);
}
/// 加载已有标签
class TagsLoaded extends EditorEvent {
final List<String> tags;
TagsLoaded(this.tags);
}
/// 心情变更
class MoodChanged extends EditorEvent {
final Mood mood;
MoodChanged(this.mood);
}
/// 标题变更
class TitleChanged extends EditorEvent {
final String title;
TitleChanged(this.title);
}
/// 文字格式变更
class TextFormatChanged extends EditorEvent {
final String elementId;
final bool? bold;
final bool? italic;
final bool? underline;
final String? color;
final TextAlign? alignment;
TextFormatChanged({
required this.elementId,
this.bold,
this.italic,
this.underline,
this.color,
this.alignment,
});
}
// ============================================================
// 状态
// ============================================================
/// 编辑器工具枚举
/// 编辑器工具枚举 — 对应底部 6 个工具按钮 + 内部 select 模式
enum EditorTool {
pen, // 钢笔
pencil, // 铅笔
marker, // 马克笔
eraser, // 橡皮擦
select, // 选择/移动元素
text, // 文字输入
sticker, // 贴纸
image, // 照片
template, // 模板
brush, // 画笔(含钢笔/铅笔/马克笔/橡皮子类型)
photo, // 照片
text, // 文字
more, // 更多
select, // 选择/移动元素(内部模式,非 UI 按钮)
}
/// 编辑器状态
@@ -134,6 +186,7 @@ class EditorState {
final BrushType brushType;
final String brushColor;
final double brushWidth;
final double brushOpacity;
final int maxUndoSteps;
// 元素层
@@ -143,6 +196,11 @@ class EditorState {
// 工具栏
final EditorTool activeTool;
// 标签/心情/标题
final List<String> tags;
final Mood selectedMood;
final String title;
// 自动保存
final bool isDirty;
final DateTime? lastSavedAt;
@@ -153,10 +211,14 @@ class EditorState {
this.brushType = BrushType.pen,
this.brushColor = '#2D2420',
this.brushWidth = 3.0,
this.brushOpacity = 1.0,
this.maxUndoSteps = 50,
this.elements = const [],
this.selectedElementId,
this.activeTool = EditorTool.pen,
this.activeTool = EditorTool.brush,
this.tags = const [],
this.selectedMood = Mood.calm,
this.title = '',
this.isDirty = false,
this.lastSavedAt,
});
@@ -167,10 +229,14 @@ class EditorState {
BrushType? brushType,
String? brushColor,
double? brushWidth,
double? brushOpacity,
List<JournalElement>? elements,
String? selectedElementId,
bool clearSelection = false,
EditorTool? activeTool,
List<String>? tags,
Mood? selectedMood,
String? title,
bool? isDirty,
DateTime? lastSavedAt,
}) =>
@@ -180,27 +246,28 @@ class EditorState {
brushType: brushType ?? this.brushType,
brushColor: brushColor ?? this.brushColor,
brushWidth: brushWidth ?? this.brushWidth,
brushOpacity: brushOpacity ?? this.brushOpacity,
maxUndoSteps: maxUndoSteps,
elements: elements ?? this.elements,
selectedElementId: clearSelection ? null : (selectedElementId ?? this.selectedElementId),
selectedElementId:
clearSelection ? null : (selectedElementId ?? this.selectedElementId),
activeTool: activeTool ?? this.activeTool,
tags: tags ?? this.tags,
selectedMood: selectedMood ?? this.selectedMood,
title: title ?? this.title,
isDirty: isDirty ?? this.isDirty,
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
);
/// 是否处于手写模式(画笔/橡皮工具)
bool get isDrawingMode =>
activeTool == EditorTool.pen ||
activeTool == EditorTool.pencil ||
activeTool == EditorTool.marker ||
activeTool == EditorTool.eraser;
/// 是否处于手写模式
bool get isDrawingMode => activeTool == EditorTool.brush;
/// 是否处于元素操作模式
bool get isElementMode =>
activeTool == EditorTool.select ||
activeTool == EditorTool.text ||
activeTool == EditorTool.sticker ||
activeTool == EditorTool.image;
activeTool == EditorTool.photo;
}
// ============================================================
@@ -238,6 +305,14 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
// 工具栏事件
on<ToolChanged>(_onToolChanged);
// 标签/心情/标题事件
on<TagAdded>(_onTagAdded);
on<TagRemoved>(_onTagRemoved);
on<TagsLoaded>(_onTagsLoaded);
on<MoodChanged>(_onMoodChanged);
on<TitleChanged>(_onTitleChanged);
on<TextFormatChanged>(_onTextFormatChanged);
}
@override
@@ -321,7 +396,8 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
// ============================================================
void _onElementAdded(ElementAdded event, Emitter<EditorState> emit) {
final updated = List<JournalElement>.from(state.elements)..add(event.element);
final updated =
List<JournalElement>.from(state.elements)..add(event.element);
emit(state.copyWith(
elements: updated,
selectedElementId: event.element.id,
@@ -394,6 +470,58 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
));
}
// ============================================================
// 标签/心情/标题事件处理
// ============================================================
void _onTagAdded(TagAdded event, Emitter<EditorState> emit) {
if (state.tags.contains(event.tag)) return;
if (state.tags.length >= 10) return; // 设计 Token: maxTags=10
final updated = List<String>.from(state.tags)..add(event.tag);
emit(state.copyWith(tags: updated, isDirty: true));
_scheduleAutoSave();
}
void _onTagRemoved(TagRemoved event, Emitter<EditorState> emit) {
final updated = List<String>.from(state.tags)..remove(event.tag);
emit(state.copyWith(tags: updated, isDirty: true));
_scheduleAutoSave();
}
void _onTagsLoaded(TagsLoaded event, Emitter<EditorState> emit) {
emit(state.copyWith(tags: event.tags));
}
void _onMoodChanged(MoodChanged event, Emitter<EditorState> emit) {
emit(state.copyWith(selectedMood: event.mood, isDirty: true));
_scheduleAutoSave();
}
void _onTitleChanged(TitleChanged event, Emitter<EditorState> emit) {
emit(state.copyWith(title: event.title, isDirty: true));
_scheduleAutoSave();
}
void _onTextFormatChanged(
TextFormatChanged event,
Emitter<EditorState> emit,
) {
final updated = state.elements.map((e) {
if (e.id != event.elementId) return e;
final content = Map<String, dynamic>.from(e.content);
if (event.bold != null) content['bold'] = event.bold;
if (event.italic != null) content['italic'] = event.italic;
if (event.underline != null) content['underline'] = event.underline;
if (event.color != null) content['color'] = event.color;
if (event.alignment != null) {
content['alignment'] = event.alignment!.index;
}
return e.copyWith(content: content);
}).toList();
emit(state.copyWith(elements: updated, isDirty: true));
_scheduleAutoSave();
}
// ============================================================
// 自动保存
// ============================================================

View File

@@ -14,10 +14,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/design_tokens.dart';
import '../../../core/theme/app_colors.dart';
import '../../../data/models/journal_element.dart';
import '../../../data/models/journal_entry.dart';
import '../../../data/models/journal_entry.dart' show JournalEntry, Mood;
import '../../../data/repositories/journal_repository.dart';
import '../../../data/repositories/class_repository.dart';
import '../../../data/services/sync_engine.dart';
import '../../auth/bloc/auth_bloc.dart';
import '../bloc/editor_bloc.dart';
import '../widgets/handwriting_canvas.dart';
import '../widgets/stroke_model.dart';
@@ -27,6 +30,9 @@ import '../widgets/text_input_overlay.dart';
import '../widgets/image_picker_handler.dart';
import '../widgets/sticker_picker_sheet.dart';
import '../widgets/share_bottom_sheet.dart';
import '../widgets/tag_panel.dart';
import '../widgets/brush_panel.dart';
import '../widgets/dot_grid_painter.dart';
/// 手账编辑器页面
class EditorPage extends StatelessWidget {
@@ -39,6 +45,8 @@ class EditorPage extends StatelessWidget {
Widget build(BuildContext context) {
// 从 Provider 树获取 JournalRepositoryIsarJournalRepository
final repo = context.read<JournalRepository>();
// 从 Provider 树获取 SyncEngine同步到后端
final syncEngine = context.read<SyncEngine>();
// 可变闭包变量:跟踪已保存的日记 ID
// 新建日记首次保存后赋值,后续自动更新使用此 ID
@@ -48,7 +56,18 @@ class EditorPage extends StatelessWidget {
create: (_) => EditorBloc(
onSave: (state) async {
try {
await _persistState(repo, state, (id) => savedJournalId = id, savedJournalId);
// 从 AuthBloc 获取真实用户 ID
String authorId = 'local';
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
authorId = authState.user.id;
}
await _persistState(
repo, state, (id) => savedJournalId = id, savedJournalId,
syncEngine: syncEngine,
authorId: authorId,
);
} catch (e) {
debugPrint('自动保存失败: $e');
}
@@ -66,24 +85,27 @@ class EditorPage extends StatelessWidget {
);
}
/// 持久化编辑器状态到 Isar
/// 持久化编辑器状态到 Isar,并同步到后端
///
/// 策略:
/// - 首次保存savedJournalId == null→ createJournal + addElement
/// - 后续保存 → updateJournal + upsert 元素
/// - 笔画序列化为 handwriting_ref 元素
/// - 保存成功后入队 SyncEngine 等待网络同步
Future<void> _persistState(
JournalRepository repo,
EditorState state,
void Function(String) setId,
String? savedJournalId,
) async {
String? savedJournalId, {
required SyncEngine syncEngine,
String authorId = 'local',
}) async {
final now = DateTime.now();
if (savedJournalId == null) {
// --- 新建日记 ---
final entry = JournalEntry.create(
authorId: 'local', // TODO: 从 AuthBloc 获取真实用户 ID
authorId: authorId,
title: '${now.month}${now.day}日的日记',
date: now,
);
@@ -99,11 +121,31 @@ class EditorPage extends StatelessWidget {
for (final element in state.elements) {
await repo.addElement(element.copyWith(journalId: entry.id));
}
// 入队 SyncEngine 等待同步到后端
syncEngine.enqueue(PendingOperation(
id: entry.id,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: entry.toJson(),
version: entry.version,
createdAt: now,
));
} else {
// --- 更新已有日记 ---
final existing = await repo.getJournal(savedJournalId);
if (existing != null) {
await repo.updateJournal(existing);
// 入队 SyncEngine 等待同步到后端
syncEngine.enqueue(PendingOperation(
id: existing.id,
type: SyncOperationType.update,
endpoint: '/diary/journals/${existing.id}',
data: existing.toJson(),
version: existing.version,
createdAt: now,
));
}
// 更新笔画
@@ -154,21 +196,28 @@ class EditorPage extends StatelessWidget {
}
/// 显示分享面板并在用户选择后导航
static void _showShareSheetAndNavigate(
static Future<void> _showShareSheetAndNavigate(
BuildContext context,
JournalRepository repo,
String? savedJournalId,
) {
) async {
// 尝试获取用户的班级信息
String? userClassId;
String userClassName = '我的班级';
try {
context.read<ClassRepository>();
// Phase 1 简化:不等待异步调用,使用默认值
userClassId = null; // TODO: 从 AuthBloc/ClassBloc 获取真实班级 ID
} catch (_) {
// ClassRepository 不可用(未注入)
// 从 AuthBloc 获取用户关联的班级
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
final classRepo = context.read<ClassRepository>();
final classes = await classRepo.getMyClasses();
if (classes.isNotEmpty) {
userClassId = classes.first.id;
userClassName = classes.first.name;
}
}
} catch (e) {
debugPrint('获取班级信息失败: $e');
}
showModalBottomSheet(
@@ -226,94 +275,251 @@ class _EditorView extends StatelessWidget {
return Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
// 顶栏
_buildTopBar(context),
body: Column(
children: [
// 顶栏(自带状态栏安全区)
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return _buildTopBar(context, state);
},
),
// 编辑区域(三层 Stack
Expanded(
child: BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return _EditorStack(state: state, journalId: journalId);
},
),
),
// 底部工具栏
BlocBuilder<EditorBloc, EditorState>(
// 编辑区域(三层 Stack
Expanded(
child: BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return EditorToolbar(
state: state,
onEvent: (event) => context.read<EditorBloc>().add(event),
);
return _EditorStack(state: state, journalId: journalId);
},
),
],
),
),
// 底部工具栏(自带底部安全区)
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return EditorToolbar(
state: state,
onEvent: (event) => context.read<EditorBloc>().add(event),
);
},
),
],
),
);
}
/// 顶部操作栏 — 返回/日记标题/完成
Widget _buildTopBar(BuildContext context) {
/// 顶部操作栏 — 日期/撤销重做/标签/心情/完成
Widget _buildTopBar(BuildContext context, EditorState state) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
bottom: BorderSide(color: colorScheme.outline.withValues(alpha: 0.1)),
bottom: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
),
),
child: Row(
child: Column(
children: [
// 返回按钮
IconButton(
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/home');
}
},
icon: const Icon(Icons.arrow_back_rounded),
tooltip: '返回',
),
const SizedBox(width: DesignTokens.spacing8),
// 日记标题
Expanded(
child: Text(
journalId != null
? '编辑日记'
: templateId != null
? '从模板新建'
: '新建日记',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
// 主顶栏行 (44px)
SizedBox(
height: 44,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
// 返回按钮
IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => _handleBack(context),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
iconSize: 22,
),
// 日期显示
Expanded(
child: Center(
child: Text(
_formatDate(state),
style: TextStyle(
fontFamily: 'Quicksand',
fontSize: 15,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
),
),
// 撤销
IconButton(
icon: const Icon(Icons.undo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Undo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 重做
IconButton(
icon: const Icon(Icons.redo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Redo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 自动保存状态
_buildAutosaveIndicator(state),
// 标签按钮
IconButton(
icon: const Icon(Icons.sell_rounded, size: 18),
onPressed: () => _showTagPanel(context, state),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 完成/保存按钮
Padding(
padding: const EdgeInsets.only(left: 4),
child: FilledButton.tonal(
onPressed: () => _handleSave(context, state),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
minimumSize: const Size(0, 32),
),
child: const Text('完成', style: TextStyle(fontSize: 14)),
),
),
],
),
),
),
// 日期 + 心情条 (40px)
_buildDateMoodStrip(context, state),
],
),
);
}
// 完成按钮
FilledButton.tonal(
onPressed: onSaveComplete,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
/// 返回处理
void _handleBack(BuildContext context) {
if (context.canPop()) {
context.pop();
} else {
context.go('/home');
}
}
/// 保存处理
void _handleSave(BuildContext context, EditorState state) {
onSaveComplete();
}
/// 格式化日期显示
String _formatDate(EditorState state) {
final now = DateTime.now();
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return '${now.month}${now.day}日 · ${weekdays[now.weekday - 1]}';
}
/// 自动保存状态指示器
Widget _buildAutosaveIndicator(EditorState state) {
if (state.lastSavedAt == null) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'未保存',
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
),
child: const Text('完成'),
),
const SizedBox(width: 4),
Text(
'已保存',
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
),
],
),
);
}
/// 日期时间 + 心情选择条
Widget _buildDateMoodStrip(BuildContext context, EditorState state) {
final now = DateTime.now();
final timeStr =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
final moods = [
(Mood.happy, '😊'),
(Mood.calm, '😐'),
(Mood.sad, '😢'),
(Mood.angry, '😡'),
(Mood.thinking, '🤔'),
];
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: Theme.of(context).colorScheme.surface,
child: Row(
children: [
Text(
timeStr,
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
),
// 心情快捷按钮
const Spacer(),
...moods.map((m) {
final isSelected = state.selectedMood == m.$1;
return GestureDetector(
onTap: () =>
context.read<EditorBloc>().add(MoodChanged(m.$1)),
child: Container(
width: 24,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected
? Border.all(color: AppColors.accent, width: 1.5)
: null,
color: isSelected ? const Color(0xFFFFF3E6) : null,
),
alignment: Alignment.center,
child: Text(m.$2, style: const TextStyle(fontSize: 12)),
),
);
}),
],
),
);
}
/// 显示标签面板
void _showTagPanel(BuildContext context, EditorState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => BlocProvider.value(
value: context.read<EditorBloc>(),
child: BlocBuilder<EditorBloc, EditorState>(
builder: (ctx, state) => TagPanel(
selectedTags: state.tags,
onTagAdded: (tag) {
context.read<EditorBloc>().add(TagAdded(tag));
},
onTagRemoved: (tag) {
context.read<EditorBloc>().add(TagRemoved(tag));
},
),
),
),
);
}
}
// ============================================================
@@ -337,16 +543,46 @@ class _EditorStack extends StatefulWidget {
class _EditorStackState extends State<_EditorStack> {
EditorTool? _lastTool;
late final TextEditingController _titleController;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.state.title);
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant _EditorStack oldWidget) {
super.didUpdateWidget(oldWidget);
final currentTool = widget.state.activeTool;
// 贴纸工具刚被激活时弹出底部面板(防止重复弹窗)
if (currentTool == EditorTool.sticker && _lastTool != EditorTool.sticker) {
// 防止重复弹窗:只在工具切换时触发
if (currentTool != _lastTool) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showStickerPicker();
if (!mounted) return;
switch (currentTool) {
// 贴纸工具 → 弹出贴纸选择面板
case EditorTool.sticker:
_showStickerPicker();
// 画笔工具 → 弹出画笔设置面板
case EditorTool.brush:
_showBrushPanel();
// 模板工具 → 导航到模板页
case EditorTool.template:
context.go('/templates');
// 更多工具 → 弹出分享/导出选项
case EditorTool.more:
_showMoreSheet();
default:
break;
}
});
}
_lastTool = currentTool;
@@ -376,24 +612,162 @@ class _EditorStackState extends State<_EditorStack> {
);
}
/// 显示画笔设置底部面板
void _showBrushPanel() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => BlocProvider.value(
value: context.read<EditorBloc>(),
child: BlocBuilder<EditorBloc, EditorState>(
builder: (ctx, state) => BrushPanel(
activeBrushType: state.brushType,
activeColor: state.brushColor,
activeWidth: state.brushWidth,
activeOpacity: state.brushOpacity,
onBrushTypeChanged: (type) => context.read<EditorBloc>().add(
BrushChanged(
type: type,
color: state.brushColor,
width: state.brushWidth,
),
),
onColorChanged: (color) => context.read<EditorBloc>().add(
BrushChanged(
type: state.brushType,
color: color,
width: state.brushWidth,
),
),
onWidthChanged: (width) => context.read<EditorBloc>().add(
BrushChanged(
type: state.brushType,
color: state.brushColor,
width: width,
),
),
onOpacityChanged: (opacity) {
// Phase 1 简化opacity 仅在 marker 模式下生效
// 暂无 opacity 事件,后续扩展
},
),
),
),
);
}
/// 显示更多选项底部面板(分享/导出/清除)
void _showMoreSheet() {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 拖拽指示条
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
),
// 清除画布
ListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: const Text('清除画布'),
onTap: () {
Navigator.pop(context);
context.read<EditorBloc>().add(ClearCanvas());
},
),
// 分享
ListTile(
leading: const Icon(Icons.share_rounded),
title: const Text('分享日记'),
onTap: () {
Navigator.pop(context);
// 委托给外层的分享逻辑
},
),
const SizedBox(height: 16),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final state = widget.state;
final colorScheme = Theme.of(context).colorScheme;
return Stack(
fit: StackFit.expand,
children: [
// Layer 1: 手写画布(底层)
// Layer 0: 点阵背景(最底层)
CustomPaint(
painter: const DotGridPainter(),
size: Size.infinite,
),
// Layer 1: 手写画布 + 内嵌标题
IgnorePointer(
ignoring: !state.isDrawingMode,
child: HandwritingCanvas(
brushType: state.brushType,
brushColor: state.brushColor,
brushWidth: state.brushWidth,
strokes: state.strokes,
onStrokeCompleted: (stroke) {
context.read<EditorBloc>().add(StrokeCompleted(stroke));
},
child: Column(
children: [
// 内嵌标题输入框
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: TextField(
controller: _titleController,
style: TextStyle(
fontFamily: 'Quicksand',
fontSize: 18,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: '给日记起个标题吧...',
hintStyle: TextStyle(
fontFamily: 'Quicksand',
fontSize: 18,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withValues(alpha: 0.25),
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
onChanged: (value) {
context.read<EditorBloc>().add(TitleChanged(value));
},
),
),
// 画布区域
Expanded(
child: HandwritingCanvas(
brushType: state.brushType,
brushColor: state.brushColor,
brushWidth: state.brushWidth,
strokes: state.strokes,
onStrokeCompleted: (stroke) {
context.read<EditorBloc>().add(StrokeCompleted(stroke));
},
),
),
],
),
),
@@ -426,7 +800,7 @@ class _EditorStackState extends State<_EditorStack> {
),
// 图片选择覆盖层(图片工具激活时显示)
if (state.activeTool == EditorTool.image)
if (state.activeTool == EditorTool.photo)
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -0,0 +1,215 @@
// 画笔面板 -- 底部抽屉
// 提供画笔类型/粗细/颜色/透明度设置
// 遵循 StickerPickerSheet 底部面板模式
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../bloc/editor_bloc.dart';
import 'stroke_model.dart';
/// 画笔面板 -- 底部抽屉
class BrushPanel extends StatelessWidget {
final BrushType activeBrushType;
final String activeColor;
final double activeWidth;
final double activeOpacity;
final void Function(BrushType type) onBrushTypeChanged;
final void Function(String color) onColorChanged;
final void Function(double width) onWidthChanged;
final void Function(double opacity) onOpacityChanged;
const BrushPanel({
super.key,
required this.activeBrushType,
required this.activeColor,
required this.activeWidth,
required this.activeOpacity,
required this.onBrushTypeChanged,
required this.onColorChanged,
required this.onWidthChanged,
required this.onOpacityChanged,
});
static const _brushTypes = [
(BrushType.pen, '钢笔', Icons.gesture_rounded),
(BrushType.pencil, '铅笔', Icons.edit_rounded),
(BrushType.marker, '马克笔', Icons.brush_rounded),
(BrushType.eraser, '橡皮', Icons.auto_fix_high_rounded),
];
static const _colors = [
'#2D2420', '#E07A5F', '#81B29A', '#F2CC8F',
'#D4A5A5', '#42A5F5', '#9C27B0', '#FFFFFF',
];
@override
Widget build(BuildContext context) {
return Container(
height: 280,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
),
child: Column(
children: [
// 拖拽指示条
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
),
// 画笔类型行
_buildBrushTypeRow(context),
// 粗细滑块
_buildSizeSlider(context),
// 颜色行
_buildColorRow(context),
// 透明度滑块(仅马克笔)
if (activeBrushType == BrushType.marker)
_buildOpacitySlider(context),
],
),
);
}
Widget _buildBrushTypeRow(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: _brushTypes.map((bt) {
final isActive = activeBrushType == bt.$1;
return GestureDetector(
onTap: () => onBrushTypeChanged(bt.$1),
child: Container(
width: 64,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: isActive
? Border.all(color: AppColors.accent, width: 2)
: Border.all(color: Colors.transparent),
color: isActive
? AppColors.accent.withValues(alpha: 0.1)
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
bt.$3,
size: 20,
color: isActive ? AppColors.accent : Colors.grey[600],
),
const SizedBox(height: 2),
Text(
bt.$2,
style: TextStyle(
fontSize: 10,
color: isActive ? AppColors.accent : Colors.grey[600],
fontWeight:
isActive ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}).toList(),
),
);
}
Widget _buildSizeSlider(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
child: Row(
children: [
Text('粗细',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
Expanded(
child: Slider(
value: activeWidth,
min: 1,
max: 20,
divisions: 19,
activeColor: AppColors.accent,
label: activeWidth.round().toString(),
onChanged: onWidthChanged,
),
),
Text(
activeWidth.round().toString(),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
);
}
Widget _buildColorRow(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: _colors.map((c) {
final isActive = activeColor == c;
final color = _parseHexColor(c);
return GestureDetector(
onTap: () => onColorChanged(c),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
border: isActive
? Border.all(color: AppColors.accent, width: 2)
: (c == '#FFFFFF'
? Border.all(color: Colors.grey[300]!)
: null),
),
),
);
}).toList(),
),
);
}
Widget _buildOpacitySlider(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
child: Row(
children: [
Text('透明度',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
Expanded(
child: Slider(
value: activeOpacity,
min: 0.1,
max: 1.0,
activeColor: AppColors.accent,
onChanged: onOpacityChanged,
),
),
Text(
'${(activeOpacity * 100).round()}%',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
);
}
Color _parseHexColor(String hex) {
final code = hex.replaceFirst('#', '');
return Color(int.parse('FF$code', radix: 16));
}
}

View File

@@ -0,0 +1,28 @@
// 点阵背景画笔 — 24x24px 间距1px 圆点
// 用于日记编辑区域提供纸质感背景
import 'package:flutter/material.dart';
/// 点阵背景画笔 -- 24x24px 间距1px 圆点
class DotGridPainter extends CustomPainter {
const DotGridPainter();
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xFF2D2420).withOpacity(0.15)
..style = PaintingStyle.fill;
const spacing = 24.0;
const dotRadius = 1.0;
for (double x = spacing; x < size.width; x += spacing) {
for (double y = spacing; y < size.height; y += spacing) {
canvas.drawCircle(Offset(x, y), dotRadius, paint);
}
}
}
@override
bool shouldRepaint(covariant DotGridPainter oldDelegate) => false;
}

View File

@@ -1,22 +1,22 @@
// 编辑器工具栏 — 底部工具面板
// 编辑器工具栏 — 底部单行 6 按钮面板
//
// 三段式布局:
// - 工具选择行(画笔/选择/文字/贴纸/照片
// - 工具选项行(颜色/大小 — 根据当前工具动态变化)
// - 操作行(撤销/重做/清除)
// 精简布局:
// - 单行 6 个工具按钮(贴纸/模板/画笔/照片/文字/更多
// - 高度 72px + 底部安全区
// - 每个按钮Column(icon 20px + label 10px),最小 36x36
//
// 详细选项已移至独立面板:
// - 画笔选项 → BrushPanel底部抽屉
// - 撤销/重做 → 顶栏
// - 清除 → 顶栏
//
// 设计规范:触摸目标 ≥ 44px圆角 22px (pill)
import 'package:flutter/material.dart';
import '../../../core/constants/design_tokens.dart';
import '../../../core/theme/app_colors.dart';
import '../bloc/editor_bloc.dart';
/// 工具栏高度
const double _toolbarHeight = 160;
/// 编辑器工具栏
/// 编辑器工具栏 — 精简版
class EditorToolbar extends StatelessWidget {
final EditorState state;
final ValueChanged<EditorEvent> onEvent;
@@ -30,61 +30,35 @@ class EditorToolbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
height: _toolbarHeight,
height: 72 + MediaQuery.of(context).padding.bottom,
decoration: BoxDecoration(
color: colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
border: Border(
top: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
),
),
child: Column(
children: [
// 工具选择行
_buildToolRow(context, colorScheme),
const Divider(height: 1),
// 工具选项行(颜色/大小)
_buildOptionsRow(context, colorScheme),
const Divider(height: 1),
// 操作行(撤销/重做/清除)
_buildActionRow(context, colorScheme),
],
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_toolBtn(context, EditorTool.sticker, Icons.emoji_emotions_rounded, '贴纸'),
_toolBtn(context, EditorTool.template, Icons.dashboard_customize_rounded, '模板'),
_toolBtn(context, EditorTool.brush, Icons.gesture_rounded, '画笔'),
_toolBtn(context, EditorTool.photo, Icons.add_photo_alternate_rounded, '照片'),
_toolBtn(context, EditorTool.text, Icons.text_fields_rounded, '文字'),
_toolBtn(context, EditorTool.more, Icons.more_horiz_rounded, '更多'),
],
),
),
);
}
// ============================================================
// 工具选择行
// ============================================================
Widget _buildToolRow(BuildContext context, ColorScheme colorScheme) {
return SizedBox(
height: 52,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_toolButton(context, EditorTool.pen, Icons.gesture_rounded, '钢笔'),
_toolButton(context, EditorTool.pencil, Icons.edit_rounded, '铅笔'),
_toolButton(context, EditorTool.marker, Icons.brush_rounded, '马克笔'),
_toolButton(context, EditorTool.eraser, Icons.auto_fix_high_rounded, '橡皮'),
_toolButton(context, EditorTool.select, Icons.near_me_rounded, '选择'),
_toolButton(context, EditorTool.text, Icons.text_fields_rounded, '文字'),
_toolButton(context, EditorTool.sticker, Icons.emoji_emotions_rounded, '贴纸'),
_toolButton(context, EditorTool.image, Icons.add_photo_alternate_rounded, '照片'),
],
),
);
}
Widget _toolButton(
/// 工具按钮 — icon + label 垂直排列
Widget _toolBtn(
BuildContext context,
EditorTool tool,
IconData icon,
@@ -93,265 +67,37 @@ class EditorToolbar extends StatelessWidget {
final isActive = state.activeTool == tool;
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: 44,
height: 44,
child: IconButton(
onPressed: () => onEvent(ToolChanged(tool)),
icon: Icon(icon, size: 22),
color: isActive ? colorScheme.primary : colorScheme.onSurface.withValues(alpha: 0.5),
style: IconButton.styleFrom(
backgroundColor: isActive
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
tooltip: label,
),
);
}
// ============================================================
// 工具选项行(颜色 + 大小)
// ============================================================
static const _colors = [
'#2D2420', // 主文字
'#E07A5F', // 珊瑚
'#81B29A', // 鼠尾草绿
'#F2CC8F', // 暖金
'#D4A5A5', // 玫瑰粉
'#42A5F5', // 信息蓝
'#9C27B0', // 紫色
'#FFFFFF', // 白色
];
static const _widths = [1.5, 3.0, 5.0, 8.0, 12.0];
Widget _buildOptionsRow(BuildContext context, ColorScheme colorScheme) {
// 画笔模式:颜色 + 粗细
if (state.isDrawingMode) {
return _buildBrushOptions(context, colorScheme);
}
// 文字工具提示
if (state.activeTool == EditorTool.text) {
return const SizedBox(
height: 44,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.text_fields, size: 16),
SizedBox(width: 8),
Text('点击画布输入文字', style: TextStyle(fontSize: 13)),
],
),
),
);
}
// 贴纸工具提示
if (state.activeTool == EditorTool.sticker) {
return const SizedBox(
height: 44,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.emoji_emotions_outlined, size: 16),
SizedBox(width: 8),
Text('选择一个贴纸放到日记上', style: TextStyle(fontSize: 13)),
],
),
),
);
}
// 图片工具提示
if (state.activeTool == EditorTool.image) {
return const SizedBox(
height: 44,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_photo_alternate_outlined, size: 16),
SizedBox(width: 8),
Text('选择照片添加到日记', style: TextStyle(fontSize: 13)),
],
),
),
);
}
// 选择工具
return const SizedBox(
height: 44,
child: Center(child: Text('选择元素或添加内容')),
);
}
/// 画笔模式选项 — 颜色 + 粗细
Widget _buildBrushOptions(BuildContext context, ColorScheme colorScheme) {
return SizedBox(
height: 44,
child: Row(
children: [
// 颜色选择
Expanded(
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing12),
itemCount: _colors.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (context, index) {
final color = _colors[index];
final isActive = state.brushColor == color;
return GestureDetector(
onTap: () => onEvent(BrushChanged(
type: state.brushType,
color: color,
width: state.brushWidth,
)),
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: _parseHexColor(color),
shape: BoxShape.circle,
border: isActive
? Border.all(color: colorScheme.primary, width: 2.5)
: Border.all(color: Colors.grey.shade300, width: 1),
),
child: color == '#FFFFFF'
? const Icon(Icons.check, size: 16, color: Colors.grey)
: null,
),
);
},
return GestureDetector(
onTap: () => onEvent(ToolChanged(tool)),
behavior: HitTestBehavior.opaque,
child: Container(
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 20,
color: isActive
? colorScheme.primary
: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
// 分隔线
Container(width: 1, height: 24, color: colorScheme.outline.withValues(alpha: 0.2)),
// 笔刷大小
SizedBox(
width: 160,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: _widths.map((w) {
final isActive = (state.brushWidth - w).abs() < 0.5;
return GestureDetector(
onTap: () => onEvent(BrushChanged(
type: state.brushType,
color: state.brushColor,
width: w,
)),
child: Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isActive
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
child: Container(
width: (w / 12 * 16 + 4).clamp(4, 20),
height: (w / 12 * 16 + 4).clamp(4, 20),
decoration: BoxDecoration(
color: _parseHexColor(state.brushColor),
shape: BoxShape.circle,
),
),
),
);
}).toList(),
),
),
],
),
);
}
// ============================================================
// 操作行
// ============================================================
Widget _buildActionRow(BuildContext context, ColorScheme colorScheme) {
return SizedBox(
height: 44,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// 撤销
IconButton(
onPressed: state.strokes.isNotEmpty
? () => onEvent(Undo())
: null,
icon: const Icon(Icons.undo_rounded),
tooltip: '撤销',
),
// 重做
IconButton(
onPressed: state.redoStack.isNotEmpty
? () => onEvent(Redo())
: null,
icon: const Icon(Icons.redo_rounded),
tooltip: '重做',
),
// 清除
IconButton(
onPressed: state.strokes.isNotEmpty || state.elements.isNotEmpty
? () => onEvent(ClearCanvas())
: null,
icon: const Icon(Icons.delete_outline_rounded),
tooltip: '清除',
),
// 保存状态指示
if (state.isDirty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
child: Text(
'未保存',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
)
else if (state.lastSavedAt != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
child: Text(
'已保存',
style: TextStyle(
fontSize: 12,
color: AppColors.success,
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 10,
color: isActive
? colorScheme.primary
: colorScheme.onSurface.withValues(alpha: 0.5),
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
),
],
],
),
),
);
}
// ============================================================
// 工具函数
// ============================================================
Color _parseHexColor(String hex) {
final hexStr = hex.replaceFirst('#', '');
if (hexStr.length != 6) return const Color(0xFF2D2420);
final value = int.tryParse(hexStr, radix: 16);
if (value == null) return const Color(0xFF2D2420);
return Color(0xFF000000 + value);
}
}

View File

@@ -0,0 +1,157 @@
// 标签面板 -- 底部抽屉
// 支持添加/移除自定义标签 + 推荐标签快捷选择
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// 标签面板 -- 底部抽屉
class TagPanel extends StatefulWidget {
final List<String> selectedTags;
final void Function(String tag) onTagAdded;
final void Function(String tag) onTagRemoved;
const TagPanel({
super.key,
required this.selectedTags,
required this.onTagAdded,
required this.onTagRemoved,
});
@override
State<TagPanel> createState() => _TagPanelState();
}
class _TagPanelState extends State<TagPanel> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
static const _suggestedTags = [
'日常', '学习', '读书', '心情', '学校', '旅行',
'美食', '运动', '音乐', '梦想',
];
@override
void initState() {
super.initState();
_focusNode.requestFocus();
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _submitTag() {
final text = _controller.text.trim();
if (text.isNotEmpty) {
widget.onTagAdded(text);
_controller.clear();
}
}
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 240),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 拖拽指示条
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
),
// 已选标签区
if (widget.selectedTags.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
child: Wrap(
spacing: 8,
runSpacing: 6,
children: widget.selectedTags
.map((tag) => Chip(
label: Text('#$tag',
style: const TextStyle(fontSize: 13)),
backgroundColor: const Color(0xFFFFF3E6),
labelStyle: const TextStyle(color: AppColors.accent),
deleteIconColor: AppColors.accent,
onDeleted: () => widget.onTagRemoved(tag),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
))
.toList(),
),
),
// 输入框
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: TextField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: '添加标签,回车确认',
hintStyle: TextStyle(fontSize: 14, color: Colors.grey[400]),
prefixIcon:
const Icon(Icons.tag, size: 20, color: AppColors.accent),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.accent),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
isDense: true,
),
style: const TextStyle(fontSize: 14),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submitTag(),
),
),
// 推荐标签
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
child: Wrap(
spacing: 8,
runSpacing: 6,
children: _suggestedTags
.where((t) => !widget.selectedTags.contains(t))
.map((tag) => ActionChip(
label:
Text('#$tag', style: const TextStyle(fontSize: 12)),
backgroundColor: Colors.grey[100],
onPressed: () => widget.onTagAdded(tag),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
))
.toList(),
),
),
const SizedBox(height: 16),
],
),
);
}
}

View File

@@ -0,0 +1,143 @@
// 文字格式栏 -- 浮动在选中文字元素上方
// 提供加粗/斜体/下划线/颜色/对齐 切换
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// 文字格式栏 -- 浮动在选中文字元素上方
class TextFormatBar extends StatelessWidget {
final bool bold;
final bool italic;
final bool underline;
final String? color;
final int alignment; // 0=left, 1=center, 2=right
final void Function({
bool? bold,
bool? italic,
bool? underline,
String? color,
int? alignment,
}) onFormatChanged;
const TextFormatBar({
super.key,
this.bold = false,
this.italic = false,
this.underline = false,
this.color,
this.alignment = 0,
required this.onFormatChanged,
});
static const _colors = ['#2D2420', '#E07A5F', '#81B29A', '#42A5F5'];
@override
Widget build(BuildContext context) {
return Container(
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
border: Border.all(color: Colors.grey[200]!),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// B/I/U toggles
_toggleBtn('B', bold, () => onFormatChanged(bold: !bold)),
_toggleBtn('I', italic, () => onFormatChanged(italic: !italic)),
_toggleBtn(
'U', underline, () => onFormatChanged(underline: !underline)),
const SizedBox(width: 4),
Container(width: 1, height: 20, color: Colors.grey[300]),
const SizedBox(width: 4),
// Color dots
..._colors.map((c) => _colorDot(c, color == c)),
const SizedBox(width: 4),
Container(width: 1, height: 20, color: Colors.grey[300]),
const SizedBox(width: 4),
// Alignment
_alignBtn(Icons.format_align_left, 0),
_alignBtn(Icons.format_align_center, 1),
_alignBtn(Icons.format_align_right, 2),
const SizedBox(width: 4),
],
),
);
}
Widget _toggleBtn(String label, bool active, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: active ? AppColors.accent : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: active ? Colors.white : Colors.grey[700],
fontStyle: label == 'I' ? FontStyle.italic : FontStyle.normal,
decoration:
label == 'U' ? TextDecoration.underline : TextDecoration.none,
),
),
),
);
}
Widget _colorDot(String hex, bool active) {
final code = hex.replaceFirst('#', '');
final color = Color(int.parse('FF$code', radix: 16));
return GestureDetector(
onTap: () => onFormatChanged(color: hex),
child: Container(
width: 20,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
border:
active ? Border.all(color: AppColors.accent, width: 2) : null,
),
),
);
}
Widget _alignBtn(IconData icon, int align) {
final active = alignment == align;
return GestureDetector(
onTap: () => onFormatChanged(alignment: align),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color:
active ? AppColors.accent.withValues(alpha: 0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Icon(
icon,
size: 18,
color: active ? AppColors.accent : Colors.grey[600],
),
),
);
}
}

View File

@@ -54,6 +54,9 @@ final class HomeLoaded extends HomeState {
/// 总日记数spec §3.4 quick-stats
final int totalCount;
/// 今日天气从今日日记中提取null 则默认晴)
final Weather? todayWeather;
const HomeLoaded({
this.recentJournals = const [],
this.hasTodayEntry = false,
@@ -61,6 +64,7 @@ final class HomeLoaded extends HomeState {
this.streakDays = 0,
this.monthCount = 0,
this.totalCount = 0,
this.todayWeather,
});
}
@@ -111,10 +115,21 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
// 推算连续天数
final streakDays = _calculateStreak(journals);
// 本月日记数 & 总数spec §3.4 quick-stats
// 本月日记数spec §3.4 quick-stats
final monthCount = journals.where((j) =>
j.date.year == today.year && j.date.month == today.month).length;
final totalCount = journals.length;
// 总日记数 — 使用仓库计数方法(不受分页限制)
final totalCount = await _journalRepo.getJournalCount();
// 今日天气 — 从今日日记中提取
final todayJournal = journals.firstWhere(
(j) => j.date.year == today.year &&
j.date.month == today.month &&
j.date.day == today.day,
orElse: () => journals.first, // fallback for type safety
);
final todayWeather = hasTodayEntry ? todayJournal.weather : null;
emit(HomeLoaded(
recentJournals: journals,
@@ -123,6 +138,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
streakDays: streakDays,
monthCount: monthCount,
totalCount: totalCount,
todayWeather: todayWeather,
));
} catch (e) {
emit(const HomeLoaded()); // 空状态而非错误,离线友好

View File

@@ -100,7 +100,7 @@ class _HomeView extends StatelessWidget {
_MoodSelectorCard(
topMood: state.topMood,
weather: const _Weather(icon: '', label: '晴 26°'),
todayWeather: state.todayWeather,
onMoodTap: (_) => context.push('/editor'),
),
const SizedBox(height: DesignTokens.spacing20),
@@ -261,12 +261,12 @@ class _StreakBadge extends StatelessWidget {
class _MoodSelectorCard extends StatelessWidget {
const _MoodSelectorCard({
required this.topMood,
required this.weather,
required this.todayWeather,
required this.onMoodTap,
});
final Mood? topMood;
final _Weather weather;
final Weather? todayWeather;
final ValueChanged<Mood> onMoodTap;
static const _moods = [
@@ -277,6 +277,14 @@ class _MoodSelectorCard extends StatelessWidget {
('🤔', '思考', Mood.thinking),
];
static const _weatherMap = {
Weather.sunny: ('', ''),
Weather.cloudy: ('', '多云'),
Weather.rainy: ('🌧', ''),
Weather.snowy: ('', ''),
Weather.windy: ('💨', ''),
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -306,10 +314,13 @@ class _MoodSelectorCard extends StatelessWidget {
),
),
const Spacer(),
Text(
'${weather.icon} ${weather.label}',
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant),
),
Builder(builder: (context) {
final w = _weatherMap[todayWeather] ?? _weatherMap[Weather.sunny]!;
return Text(
'${w.$1} ${w.$2}',
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant),
);
}),
],
),
const SizedBox(height: DesignTokens.spacing12),
@@ -376,12 +387,6 @@ class _MoodOption extends StatelessWidget {
}
}
class _Weather {
const _Weather({required this.icon, required this.label});
final String icon;
final String label;
}
/// 4. "今天的日记" 渐变卡片 + 浮动写按钮
class _TodayCard extends StatelessWidget {
const _TodayCard({required this.hasTodayEntry, required this.onTap});
@@ -621,7 +626,9 @@ class _JournalCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final moodColor = AppColors.moodColors[journal.mood.value] ?? AppColors.accent;
final excerpt = journal.tags.isEmpty ? '点击查看详情' : journal.tags.take(3).map((t) => '#$t').join(' ');
final excerpt = journal.contentExcerpt?.isNotEmpty == true
? journal.contentExcerpt!
: (journal.tags.isEmpty ? '点击查看详情' : journal.tags.take(3).map((t) => '#$t').join(' '));
return Material(
color: theme.colorScheme.surface,

View File

@@ -922,7 +922,8 @@ class _JournalCard extends StatelessWidget {
try {
final dt = DateTime.parse(isoStr);
return DateFormat('MM-dd').format(dt);
} catch (_) {
} catch (e) {
debugPrint('ParentPage._formatDate 失败: $e');
return '';
}
}

View File

@@ -408,7 +408,8 @@ class _LiveStatsBarState extends State<_LiveStatsBar> {
_streakDays = streak;
_monthCount = monthCount;
});
} catch (_) {
} catch (e) {
debugPrint('ProfilePage._loadStats 失败: $e');
// 保持默认 0 值
}
}

View File

@@ -1,7 +1,7 @@
// 搜索 BLoC — 标签+心情筛选日记
// 搜索 BLoC — 关键词+标签+心情筛选日记
//
// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError
// Phase 1 使用简单的标签+心情筛选,后续可扩展全文搜索
// 支持关键词搜索、标签筛选、心情筛选、结果分类 tab
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -11,16 +11,21 @@ import '../../../data/repositories/journal_repository.dart';
part 'search_event.dart';
part 'search_state.dart';
/// 搜索 BLoC — 处理标签和心情筛选日记的状态转换
/// 搜索 BLoC
class SearchBloc extends Bloc<SearchEvent, SearchState> {
final JournalRepository _journalRepo;
/// 内存搜索历史(最多 10 条)
final List<String> _searchHistory = [];
SearchBloc({required JournalRepository journalRepository})
: _journalRepo = journalRepository,
super(const SearchInitial()) {
on<SearchByMood>(_onSearchByMood);
on<SearchByTag>(_onSearchByTag);
on<SearchByKeyword>(_onSearchByKeyword);
on<SearchClear>(_onSearchClear);
on<SearchTabChanged>(_onSearchTabChanged);
}
/// 按心情筛选日记
@@ -31,7 +36,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
emit(const SearchLoading());
try {
if (event.mood == null) {
emit(const SearchLoaded());
emit(SearchLoaded(searchHistory: List.unmodifiable(_searchHistory)));
return;
}
final results = await _journalRepo.getJournals(
@@ -42,6 +47,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
emit(SearchLoaded(
results: results,
activeMood: event.mood!.value,
searchHistory: List.unmodifiable(_searchHistory),
));
} catch (e) {
emit(const SearchError('搜索失败,请重试'));
@@ -55,6 +61,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
) async {
emit(const SearchLoading());
try {
_addToHistory(event.tag);
final results = await _journalRepo.getJournals(
tag: event.tag,
page: 1,
@@ -63,6 +70,47 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
emit(SearchLoaded(
results: results,
activeTag: event.tag,
searchHistory: List.unmodifiable(_searchHistory),
));
} catch (e) {
emit(const SearchError('搜索失败,请重试'));
}
}
/// 关键词搜索 — 在标题中匹配关键词
Future<void> _onSearchByKeyword(
SearchByKeyword event,
Emitter<SearchState> emit,
) async {
final keyword = event.keyword.trim();
if (keyword.isEmpty) {
emit(SearchLoaded(searchHistory: List.unmodifiable(_searchHistory)));
return;
}
emit(const SearchLoading());
try {
_addToHistory(keyword);
// 获取所有日记并在客户端按关键词过滤
final allJournals = await _journalRepo.getJournals(
page: 1,
pageSize: 200,
);
final lowerKeyword = keyword.toLowerCase();
final results = allJournals.where((j) {
final titleMatch = j.title.toLowerCase().contains(lowerKeyword);
final excerptMatch = (j.contentExcerpt ?? '')
.toLowerCase()
.contains(lowerKeyword);
final tagMatch =
j.tags.any((t) => t.toLowerCase().contains(lowerKeyword));
return titleMatch || excerptMatch || tagMatch;
}).toList();
emit(SearchLoaded(
results: results,
activeKeyword: keyword,
searchHistory: List.unmodifiable(_searchHistory),
));
} catch (e) {
emit(const SearchError('搜索失败,请重试'));
@@ -74,6 +122,26 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchClear event,
Emitter<SearchState> emit,
) {
emit(const SearchLoaded());
emit(SearchLoaded(searchHistory: List.unmodifiable(_searchHistory)));
}
/// 切换结果分类 tab
void _onSearchTabChanged(
SearchTabChanged event,
Emitter<SearchState> emit,
) {
final current = state;
if (current is SearchLoaded) {
emit(current.copyWith(activeTab: event.tab));
}
}
/// 添加到搜索历史(去重,最多 10 条)
void _addToHistory(String keyword) {
_searchHistory.remove(keyword);
_searchHistory.insert(0, keyword);
if (_searchHistory.length > 10) {
_searchHistory.removeLast();
}
}
}

View File

@@ -19,7 +19,19 @@ final class SearchByTag extends SearchEvent {
const SearchByTag(this.tag);
}
/// 关键词搜索
final class SearchByKeyword extends SearchEvent {
final String keyword;
const SearchByKeyword(this.keyword);
}
/// 清除搜索结果
final class SearchClear extends SearchEvent {
const SearchClear();
}
/// 切换搜索结果分类 tab
final class SearchTabChanged extends SearchEvent {
final SearchResultTab tab;
const SearchTabChanged(this.tab);
}

View File

@@ -2,6 +2,17 @@
part of 'search_bloc.dart';
/// 搜索结果分类 tab
enum SearchResultTab {
all('全部'),
journal('日记'),
template('模板'),
tag('标签');
const SearchResultTab(this.label);
final String label;
}
/// 搜索状态基类
sealed class SearchState {
const SearchState();
@@ -19,7 +30,7 @@ final class SearchLoading extends SearchState {
/// 搜索结果已加载
final class SearchLoaded extends SearchState {
/// 搜索结果列表(空列表表示无匹配)
/// 日记搜索结果列表
final List<JournalEntry> results;
/// 当前活跃的心情筛选条件
@@ -28,24 +39,47 @@ final class SearchLoaded extends SearchState {
/// 当前活跃的标签筛选条件
final String? activeTag;
/// 当前活跃的关键词
final String? activeKeyword;
/// 当前选中的结果分类 tab
final SearchResultTab activeTab;
/// 搜索历史(内存中保存,最多 10 条)
final List<String> searchHistory;
const SearchLoaded({
this.results = const [],
this.activeMood,
this.activeTag,
this.activeKeyword,
this.activeTab = SearchResultTab.all,
this.searchHistory = const [],
});
/// 是否有活跃的筛选条件
bool get hasActiveFilter => activeMood != null || activeTag != null;
bool get hasActiveFilter =>
activeMood != null || activeTag != null || activeKeyword != null;
SearchLoaded copyWith({
List<JournalEntry>? results,
String? activeMood,
bool clearActiveMood = false,
String? activeTag,
bool clearActiveTag = false,
String? activeKeyword,
bool clearActiveKeyword = false,
SearchResultTab? activeTab,
List<String>? searchHistory,
}) =>
SearchLoaded(
results: results ?? this.results,
activeMood: activeMood ?? this.activeMood,
activeTag: activeTag ?? this.activeTag,
activeMood: clearActiveMood ? null : (activeMood ?? this.activeMood),
activeTag: clearActiveTag ? null : (activeTag ?? this.activeTag),
activeKeyword:
clearActiveKeyword ? null : (activeKeyword ?? this.activeKeyword),
activeTab: activeTab ?? this.activeTab,
searchHistory: searchHistory ?? this.searchHistory,
);
}

View File

@@ -8,6 +8,9 @@ import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
import '../bloc/template_bloc.dart';
/// 视图模式
enum _ViewMode { daily, weekly, monthly }
/// 模板画廊页面 — 浏览和选择日记模板
class TemplateGalleryPage extends StatefulWidget {
const TemplateGalleryPage({super.key});
@@ -18,6 +21,7 @@ class TemplateGalleryPage extends StatefulWidget {
class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
late final TemplateBloc _bloc;
_ViewMode _viewMode = _ViewMode.daily;
@override
void initState() {
@@ -36,91 +40,189 @@ class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final surfaceWarm = isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight;
return Scaffold(
appBar: AppBar(title: const Text('模板画廊')),
body: ListenableBuilder(
listenable: _bloc,
builder: (context, _) {
final state = _bloc.state;
body: SafeArea(
child: ListenableBuilder(
listenable: _bloc,
builder: (context, _) {
final state = _bloc.state;
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state.errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _bloc.load,
child: const Text('重试'),
),
],
),
);
}
final categories = state.categories;
return Column(
children: [
// 分类选择器
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: categories.map((cat) {
final isSelected = cat == state.selectedCategory;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
selected: isSelected,
label: Text(cat),
onSelected: (_) => _bloc.selectCategory(cat),
selectedColor: colorScheme.primaryContainer,
checkmarkColor: colorScheme.primary,
),
);
}).toList(),
if (state.errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _bloc.load,
child: const Text('重试'),
),
],
),
),
const SizedBox(height: 8),
);
}
// 模板网格
Expanded(
child: state.filteredTemplates.isEmpty
? const Center(child: Text('暂无模板'))
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.78,
),
itemCount: state.filteredTemplates.length,
itemBuilder: (context, index) {
return _TemplateCard(
template: state.filteredTemplates[index],
);
},
return Column(
children: [
// ---- 自定义顶栏 ----
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
),
],
);
},
Text('模板画廊', style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
)),
],
),
),
const SizedBox(height: 12),
// ---- 视图选择器 (日/周/月) ----
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_ViewModeButton(
emoji: '📅', label: '日视图',
selected: _viewMode == _ViewMode.daily,
surfaceWarm: surfaceWarm,
onTap: () => setState(() => _viewMode = _ViewMode.daily),
),
const SizedBox(width: 8),
_ViewModeButton(
emoji: '📊', label: '周视图',
selected: _viewMode == _ViewMode.weekly,
surfaceWarm: surfaceWarm,
onTap: () => setState(() => _viewMode = _ViewMode.weekly),
),
const SizedBox(width: 8),
_ViewModeButton(
emoji: '📈', label: '月视图',
selected: _viewMode == _ViewMode.monthly,
surfaceWarm: surfaceWarm,
onTap: () => setState(() => _viewMode = _ViewMode.monthly),
),
],
),
),
const SizedBox(height: 12),
// ---- 分类选择器 ----
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: state.categories.map((cat) {
final isSelected = cat == state.selectedCategory;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
selected: isSelected,
label: Text(cat),
onSelected: (_) => _bloc.selectCategory(cat),
selectedColor: AppColors.accent.withValues(alpha: 0.15),
checkmarkColor: AppColors.accent,
labelStyle: TextStyle(
color: isSelected ? AppColors.accent : colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
);
}).toList(),
),
),
const SizedBox(height: 8),
// ---- 模板网格 (200px 高预览) ----
Expanded(
child: state.filteredTemplates.isEmpty
? const Center(child: Text('暂无模板'))
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.52,
),
itemCount: state.filteredTemplates.length,
itemBuilder: (context, index) {
return _TemplateCard(
template: state.filteredTemplates[index],
);
},
),
),
],
);
},
),
),
);
}
}
/// 模板卡片
/// 视图模式按钮
class _ViewModeButton extends StatelessWidget {
const _ViewModeButton({
required this.emoji,
required this.label,
required this.selected,
required this.surfaceWarm,
required this.onTap,
});
final String emoji;
final String label;
final bool selected;
final Color surfaceWarm;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selected ? surfaceWarm : Colors.transparent,
border: Border.all(
color: selected ? AppColors.accent : Theme.of(context).colorScheme.outlineVariant,
width: selected ? 1.5 : 1,
),
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 14)),
const SizedBox(width: 4),
Text(label, style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
color: selected ? AppColors.accent : Theme.of(context).colorScheme.onSurface,
)),
],
),
),
);
}
}
/// 模板卡片 — 200px 预览 + 使用按钮 + 标签
class _TemplateCard extends StatelessWidget {
const _TemplateCard({required this.template});
@@ -130,6 +232,9 @@ class _TemplateCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final secondarySoft = isDark ? AppColors.secondarySoftDark : AppColors.secondarySoftLight;
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
return Card(
elevation: 0,
@@ -137,21 +242,14 @@ class _TemplateCard extends StatelessWidget {
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () {
// 使用模板创建日记
context.push('/editor?template=${template.id}');
},
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 模板预览区
Container(
width: 72,
height: 72,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 模板预览区 — 200px 高
Expanded(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
@@ -166,33 +264,88 @@ class _TemplateCard extends StatelessWidget {
alignment: Alignment.center,
child: Text(
template.emoji,
style: const TextStyle(fontSize: 36),
style: const TextStyle(fontSize: 48),
),
),
const SizedBox(height: 12),
// 模板名称
),
const SizedBox(height: 10),
// 模板名称
Text(
template.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// 描述
if (template.description != null)
Text(
template.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
template.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 描述
if (template.description != null)
Text(
template.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
const SizedBox(height: 8),
// 标签
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_TagPill(label: '学生专属', bgColor: secondarySoft, textColor: AppColors.secondary),
_TagPill(label: '简约', bgColor: tertiarySoft, textColor: AppColors.tertiary),
],
),
const SizedBox(height: 8),
// 使用按钮
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
context.push('/editor?template=${template.id}');
},
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
shape: RoundedRectangleBorder(borderRadius: AppRadius.pillBorder),
),
],
),
child: const Text('使用', style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white,
)),
),
),
],
),
),
);
}
}
/// 标签胶囊
class _TagPill extends StatelessWidget {
const _TagPill({required this.label, required this.bgColor, required this.textColor});
final String label;
final Color bgColor;
final Color textColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bgColor,
borderRadius: AppRadius.pillBorder,
),
child: Text(label, style: TextStyle(
fontSize: 10, fontWeight: FontWeight.w500, color: textColor,
)),
);
}
}

View File

@@ -44,8 +44,8 @@ void main() {
// ===== 初始状态 =====
test('初始状态:pen 工具、空笔画、空元素、非 dirty', () {
expect(bloc.state.activeTool, EditorTool.pen);
test('初始状态:brush 工具、空笔画、空元素、非 dirty', () {
expect(bloc.state.activeTool, EditorTool.brush);
expect(bloc.state.strokes, isEmpty);
expect(bloc.state.elements, isEmpty);
expect(bloc.state.isDirty, isFalse);