From 9fce34f4efc3f6464944efb4661ea85fdcd9486d Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 4 Jun 2026 00:05:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E4=BF=AE=E5=A4=8D=204=20=E4=B8=AA?= =?UTF-8?q?=20Flutter=20=E4=BA=A4=E4=BA=92=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 首页数据不刷新 — JournalRepository 添加 onJournalChanged Stream 变更通知,HomeBloc 订阅后自动刷新 2. 画笔再次点击不弹出面板 — 添加 ToolReactivated 事件, 工具栏检测已激活工具时发出重新激活信号 3. 钢笔铅笔效果一样 — 调整 perfect_freehand 参数 (pen: size 10/smooth 0.65, pencil: size 3/smooth 0.35) 4. 橡皮擦不生效 — ActiveStrokePainter 橡皮擦模式绘制 半透明灰色反馈,笔画完成后 setState 触发 Layer 1 重绘 5. 贴纸文字无法缩放 — DraggableElement 用 Scale 手势 替换 Pan 手势,支持双指缩放和旋转 --- .../isar_journal_repository_native.dart | 9 +++++ .../isar_journal_repository_web.dart | 6 +++ .../data/repositories/journal_repository.dart | 12 ++++++ .../remote_journal_repository.dart | 4 ++ app/lib/features/editor/bloc/editor_bloc.dart | 20 ++++++++++ .../features/editor/views/editor_page.dart | 34 ++++++++++++++++ .../editor/widgets/active_stroke_painter.dart | 10 +++++ .../editor/widgets/draggable_element.dart | 39 ++++++++++++++++--- .../editor/widgets/editor_toolbar.dart | 2 +- .../editor/widgets/handwriting_canvas.dart | 5 ++- .../editor/widgets/stroke_renderer.dart | 18 +++++---- app/lib/features/home/bloc/home_bloc.dart | 14 +++++++ .../calendar/bloc/calendar_bloc_test.dart | 3 ++ .../features/home/bloc/home_bloc_test.dart | 3 ++ 14 files changed, 164 insertions(+), 15 deletions(-) diff --git a/app/lib/data/repositories/isar_journal_repository_native.dart b/app/lib/data/repositories/isar_journal_repository_native.dart index d50b38e..bac9f35 100644 --- a/app/lib/data/repositories/isar_journal_repository_native.dart +++ b/app/lib/data/repositories/isar_journal_repository_native.dart @@ -7,6 +7,7 @@ // - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection) // - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection) +import 'dart:async'; import 'dart:convert'; import 'package:isar/isar.dart'; @@ -22,6 +23,11 @@ import 'journal_repository.dart'; class IsarJournalRepository implements JournalRepository { Isar get _isar => IsarDatabase.instance; + final StreamController _changeController = StreamController.broadcast(); + + @override + Stream get onJournalChanged => _changeController.stream; + // ============================================================ // 日记 CRUD // ============================================================ @@ -107,6 +113,7 @@ class IsarJournalRepository implements JournalRepository { await _isar.writeTxn(() async { await _isar.journalEntryCollections.put(col); }); + _changeController.add(null); return entry; } @@ -143,6 +150,7 @@ class IsarJournalRepository implements JournalRepository { await _isar.journalEntryCollections.put(col); }); + _changeController.add(null); return updated; } @@ -176,6 +184,7 @@ class IsarJournalRepository implements JournalRepository { await _isar.journalElementCollections.put(el); } }); + _changeController.add(null); } // ============================================================ diff --git a/app/lib/data/repositories/isar_journal_repository_web.dart b/app/lib/data/repositories/isar_journal_repository_web.dart index ea7dcd2..3ce044f 100644 --- a/app/lib/data/repositories/isar_journal_repository_web.dart +++ b/app/lib/data/repositories/isar_journal_repository_web.dart @@ -7,6 +7,9 @@ import '../models/journal_entry.dart'; import '../models/journal_element.dart'; import 'journal_repository.dart'; +/// 空的变更通知流 — Web 平台 stub +const _emptyStream = Stream.empty(); + /// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError) class IsarJournalRepository implements JournalRepository { @override @@ -56,4 +59,7 @@ class IsarJournalRepository implements JournalRepository { @override Future removeElement(String elementId) => throw UnsupportedError('IsarJournalRepository 不支持 Web 平台'); + + @override + Stream get onJournalChanged => _emptyStream; } diff --git a/app/lib/data/repositories/journal_repository.dart b/app/lib/data/repositories/journal_repository.dart index 9bf6245..7042def 100644 --- a/app/lib/data/repositories/journal_repository.dart +++ b/app/lib/data/repositories/journal_repository.dart @@ -6,6 +6,8 @@ // - SyncEngine 负责协调本地和远程仓库之间的数据同步 // - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代 +import 'dart:async'; + import '../models/journal_entry.dart'; import '../models/journal_element.dart'; @@ -52,6 +54,9 @@ abstract class JournalRepository { /// 从日记中移除元素 Future removeElement(String elementId); + + /// 日记变更通知流 — create/update/delete 时发出信号 + Stream get onJournalChanged; } /// 内存实现 — 用于开发阶段快速迭代和单元测试 @@ -61,6 +66,10 @@ abstract class JournalRepository { class InMemoryJournalRepository implements JournalRepository { final Map _journals = {}; final Map _elements = {}; + final StreamController _changeController = StreamController.broadcast(); + + @override + Stream get onJournalChanged => _changeController.stream; @override Future> getJournals({ @@ -122,6 +131,7 @@ class InMemoryJournalRepository implements JournalRepository { @override Future createJournal(JournalEntry entry) async { _journals[entry.id] = entry; + _changeController.add(null); return entry; } @@ -145,6 +155,7 @@ class InMemoryJournalRepository implements JournalRepository { updatedAt: DateTime.now(), ); _journals[entry.id] = updated; + _changeController.add(null); return updated; } @@ -154,6 +165,7 @@ class InMemoryJournalRepository implements JournalRepository { _journals.remove(id); // 同时移除关联元素 _elements.removeWhere((_, e) => e.journalId == id); + _changeController.add(null); } @override diff --git a/app/lib/data/repositories/remote_journal_repository.dart b/app/lib/data/repositories/remote_journal_repository.dart index 88e8030..30ace01 100644 --- a/app/lib/data/repositories/remote_journal_repository.dart +++ b/app/lib/data/repositories/remote_journal_repository.dart @@ -131,6 +131,10 @@ class RemoteJournalRepository implements JournalRepository { Future removeElement(String elementId) async { await _api.delete('/diary/elements/$elementId'); } + + /// 远程仓库不提供本地变更通知,返回空流 + @override + Stream get onJournalChanged => const Stream.empty(); } /// API 异常封装 — 后端返回非 2xx 状态码时抛出 diff --git a/app/lib/features/editor/bloc/editor_bloc.dart b/app/lib/features/editor/bloc/editor_bloc.dart index 0b8dfac..6531c25 100644 --- a/app/lib/features/editor/bloc/editor_bloc.dart +++ b/app/lib/features/editor/bloc/editor_bloc.dart @@ -107,6 +107,12 @@ class ToolChanged extends EditorEvent { ToolChanged(this.tool); } +/// 再次点击已激活的工具 — 重新弹出设置面板 +class ToolReactivated extends EditorEvent { + final EditorTool tool; + ToolReactivated(this.tool); +} + /// 加载已有元素 class ElementsLoaded extends EditorEvent { final List elements; @@ -227,6 +233,9 @@ class EditorState { final bool isDirty; final DateTime? lastSavedAt; + // 工具重新激活时间戳(用于驱动面板重新弹出) + final int toolReactivatedAt; + const EditorState({ this.strokes = const [], this.redoStack = const [], @@ -243,6 +252,7 @@ class EditorState { this.title = '', this.isDirty = false, this.lastSavedAt, + this.toolReactivatedAt = 0, }); EditorState copyWith({ @@ -261,6 +271,7 @@ class EditorState { String? title, bool? isDirty, DateTime? lastSavedAt, + int? toolReactivatedAt, }) => EditorState( strokes: strokes ?? this.strokes, @@ -279,6 +290,7 @@ class EditorState { title: title ?? this.title, isDirty: isDirty ?? this.isDirty, lastSavedAt: lastSavedAt ?? this.lastSavedAt, + toolReactivatedAt: toolReactivatedAt ?? this.toolReactivatedAt, ); /// 是否处于手写模式 @@ -330,6 +342,7 @@ class EditorBloc extends Bloc { // 工具栏事件 on(_onToolChanged); + on(_onToolReactivated); // 标签/心情/标题事件 on(_onTagAdded); @@ -514,6 +527,13 @@ class EditorBloc extends Bloc { )); } + void _onToolReactivated(ToolReactivated event, Emitter emit) { + // 不改变 activeTool,仅递增时间戳驱动 UI 层重新弹出面板 + emit(state.copyWith( + toolReactivatedAt: DateTime.now().millisecondsSinceEpoch, + )); + } + // ============================================================ // 标签/心情/标题事件处理 // ============================================================ diff --git a/app/lib/features/editor/views/editor_page.dart b/app/lib/features/editor/views/editor_page.dart index 6e92004..ed0e205 100644 --- a/app/lib/features/editor/views/editor_page.dart +++ b/app/lib/features/editor/views/editor_page.dart @@ -625,6 +625,7 @@ class _EditorStack extends StatefulWidget { class _EditorStackState extends State<_EditorStack> { EditorTool? _lastTool; + int _lastReactivatedAt = 0; late final TextEditingController _titleController; @override @@ -675,6 +676,26 @@ class _EditorStackState extends State<_EditorStack> { }); } _lastTool = currentTool; + + // 工具重新激活(再次点击已选中的工具)→ 重新弹出面板 + final reactivatedAt = widget.state.toolReactivatedAt; + if (reactivatedAt != _lastReactivatedAt) { + _lastReactivatedAt = reactivatedAt; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + switch (currentTool) { + case EditorTool.brush: + _showBrushPanel(); + case EditorTool.sticker: + _showStickerPicker(); + case EditorTool.more: + _showMoreSheet(); + default: + break; + } + }); + } } /// 显示贴纸选择底部面板 @@ -962,6 +983,19 @@ class _EditorStackState extends State<_EditorStack> { positionY: y, )); }, + onResized: (id, w, h) { + context.read().add(ElementResized( + elementId: id, + width: w, + height: h, + )); + }, + onRotated: (id, r) { + context.read().add(ElementRotated( + elementId: id, + rotation: r, + )); + }, onDeleted: (id) { context.read().add(ElementRemoved(id)); }, diff --git a/app/lib/features/editor/widgets/active_stroke_painter.dart b/app/lib/features/editor/widgets/active_stroke_painter.dart index 5a0d73d..49dbfc9 100644 --- a/app/lib/features/editor/widgets/active_stroke_painter.dart +++ b/app/lib/features/editor/widgets/active_stroke_painter.dart @@ -53,6 +53,16 @@ class ActiveStrokePainter extends CustomPainter { final path = buildStrokePath(outlinePoints); + // 橡皮擦实时反馈:绘制半透明灰色,让用户看到擦除范围 + // 实际擦除在笔画完成后的合成图中通过 BlendMode.dstOut 执行 + if (brushType == BrushType.eraser) { + canvas.drawPath(path, Paint() + ..color = const Color(0x40808080) // 25% 灰色 + ..style = PaintingStyle.fill + ..isAntiAlias = true); + return; + } + // 构造临时 Stroke 用于获取 Paint final stroke = Stroke( id: '__active__', diff --git a/app/lib/features/editor/widgets/draggable_element.dart b/app/lib/features/editor/widgets/draggable_element.dart index b214466..7e87d4e 100644 --- a/app/lib/features/editor/widgets/draggable_element.dart +++ b/app/lib/features/editor/widgets/draggable_element.dart @@ -19,6 +19,8 @@ class DraggableElement extends StatefulWidget { final bool isSelected; final ValueChanged onTap; final void Function(String id, double x, double y) onMoved; + final void Function(String id, double w, double h)? onResized; + final void Function(String id, double rotation)? onRotated; final ValueChanged onDeleted; const DraggableElement({ @@ -27,6 +29,8 @@ class DraggableElement extends StatefulWidget { this.isSelected = false, required this.onTap, required this.onMoved, + this.onResized, + this.onRotated, required this.onDeleted, }); @@ -41,6 +45,11 @@ class _DraggableElementState extends State { late double _height; late double _rotation; + // Scale 手势状态 + double _baseWidth = 0; + double _baseHeight = 0; + double _baseRotation = 0; + @override void initState() { super.initState(); @@ -76,15 +85,35 @@ class _DraggableElementState extends State { child: Transform.rotate( angle: _rotation, child: GestureDetector( - // 拖拽移动 - onPanUpdate: (details) { + // 缩放开始 — 记录基准值 + onScaleStart: (details) { + _baseWidth = _width; + _baseHeight = _height; + _baseRotation = _rotation; + }, + // 缩放更新 — 支持单指拖拽 + 双指缩放/旋转 + onScaleUpdate: (details) { setState(() { - _x += details.delta.dx; - _y += details.delta.dy; + // 拖拽(单指和双指都支持) + _x += details.focalPointDelta.dx; + _y += details.focalPointDelta.dy; + + // 双指缩放 + 旋转 + if (details.pointerCount >= 2) { + final newW = (_baseWidth * details.scale).clamp(40.0, 400.0); + final newH = (_baseHeight * details.scale).clamp(40.0, 400.0); + _width = newW; + _height = newH; + _rotation = _baseRotation + details.rotation; + } }); widget.onMoved(widget.element.id, _x, _y); + if (details.pointerCount >= 2) { + widget.onResized?.call(widget.element.id, _width, _height); + widget.onRotated?.call(widget.element.id, _rotation); + } }, - onPanEnd: (_) { + onScaleEnd: (_) { // 确保最终位置已通知 widget.onMoved(widget.element.id, _x, _y); }, diff --git a/app/lib/features/editor/widgets/editor_toolbar.dart b/app/lib/features/editor/widgets/editor_toolbar.dart index 32c514e..44784e3 100644 --- a/app/lib/features/editor/widgets/editor_toolbar.dart +++ b/app/lib/features/editor/widgets/editor_toolbar.dart @@ -68,7 +68,7 @@ class EditorToolbar extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; return GestureDetector( - onTap: () => onEvent(ToolChanged(tool)), + onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)), behavior: HitTestBehavior.opaque, child: Container( constraints: const BoxConstraints(minWidth: 36, minHeight: 36), diff --git a/app/lib/features/editor/widgets/handwriting_canvas.dart b/app/lib/features/editor/widgets/handwriting_canvas.dart index 5c4c614..bebdc31 100644 --- a/app/lib/features/editor/widgets/handwriting_canvas.dart +++ b/app/lib/features/editor/widgets/handwriting_canvas.dart @@ -235,7 +235,10 @@ class _HandwritingCanvasState extends State { widget.onStrokeCompleted?.call(stroke); // 光栅化新笔画到缓存(异步,不阻塞 UI) - _cache.addStroke(stroke); + // 完成后 setState 确保 Layer 1 (CachedStrokesPainter) 用新合成图重绘 + _cache.addStroke(stroke).then((_) { + if (mounted) setState(() {}); + }); } /// 指针取消(如来电打断):丢弃当前笔画。 diff --git a/app/lib/features/editor/widgets/stroke_renderer.dart b/app/lib/features/editor/widgets/stroke_renderer.dart index 8b6c1ff..39271f2 100644 --- a/app/lib/features/editor/widgets/stroke_renderer.dart +++ b/app/lib/features/editor/widgets/stroke_renderer.dart @@ -39,19 +39,21 @@ class _BrushConfig { /// 各画笔的渲染参数。 const Map _brushConfigs = { - /// 钢笔:中等粗细,强压感变化,模拟毛笔效果 + /// 钢笔:粗壮平滑,模拟签字笔效果 BrushType.pen: _BrushConfig( - size: 8, - thinning: 0.7, - smoothing: 0.5, + size: 10, + thinning: 0.65, + smoothing: 0.65, + streamline: 0.6, simulatePressure: true, ), - /// 铅笔:细线,轻微压感,高平滑度产生自然线条 + /// 铅笔:纤细有质感,保留书写抖动 BrushType.pencil: _BrushConfig( - size: 4, - thinning: 0.3, - smoothing: 0.7, + size: 3, + thinning: 0.4, + smoothing: 0.35, + streamline: 0.3, simulatePressure: true, ), diff --git a/app/lib/features/home/bloc/home_bloc.dart b/app/lib/features/home/bloc/home_bloc.dart index b4a188a..27c15d5 100644 --- a/app/lib/features/home/bloc/home_bloc.dart +++ b/app/lib/features/home/bloc/home_bloc.dart @@ -1,5 +1,7 @@ // 首页 BLoC — 加载最近日记和心情概览 +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; @@ -78,12 +80,24 @@ final class HomeError extends HomeState { class HomeBloc extends Bloc { final JournalRepository _journalRepo; + StreamSubscription? _changeSubscription; HomeBloc({required JournalRepository journalRepository}) : _journalRepo = journalRepository, super(const HomeInitial()) { on(_onLoadData); on(_onRefresh); + + // 监听日记变更,自动刷新首页数据 + _changeSubscription = _journalRepo.onJournalChanged.listen((_) { + add(const HomeRefresh()); + }); + } + + @override + Future close() { + _changeSubscription?.cancel(); + return super.close(); } Future _onLoadData( diff --git a/app/test/features/calendar/bloc/calendar_bloc_test.dart b/app/test/features/calendar/bloc/calendar_bloc_test.dart index 9ada21f..79537d3 100644 --- a/app/test/features/calendar/bloc/calendar_bloc_test.dart +++ b/app/test/features/calendar/bloc/calendar_bloc_test.dart @@ -233,4 +233,7 @@ class _FailingJournalRepository implements JournalRepository { @override Future removeElement(String elementId) async {} + + @override + Stream get onJournalChanged => const Stream.empty(); } diff --git a/app/test/features/home/bloc/home_bloc_test.dart b/app/test/features/home/bloc/home_bloc_test.dart index 930b44e..ea784f7 100644 --- a/app/test/features/home/bloc/home_bloc_test.dart +++ b/app/test/features/home/bloc/home_bloc_test.dart @@ -271,6 +271,9 @@ class _FailingJournalRepository implements JournalRepository { Future removeElement(String elementId) async { throw UnimplementedError(); } + + @override + Stream get onJournalChanged => const Stream.empty(); } /// 在指定 bloc 上触发事件并等待处理完毕