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

@@ -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],
),
),
);
}
}