fix(app): Phase 1.1 紧急修复 — SyncEngine 接入 + authorId + catch 异常处理
- 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:
@@ -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();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自动保存
|
||||
// ============================================================
|
||||
|
||||
@@ -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 树获取 JournalRepository(IsarJournalRepository)
|
||||
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,
|
||||
|
||||
215
app/lib/features/editor/widgets/brush_panel.dart
Normal file
215
app/lib/features/editor/widgets/brush_panel.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
28
app/lib/features/editor/widgets/dot_grid_painter.dart
Normal file
28
app/lib/features/editor/widgets/dot_grid_painter.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
157
app/lib/features/editor/widgets/tag_panel.dart
Normal file
157
app/lib/features/editor/widgets/tag_panel.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
143
app/lib/features/editor/widgets/text_format_bar.dart
Normal file
143
app/lib/features/editor/widgets/text_format_bar.dart
Normal 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],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user