fix(app): 修复 4 个 Flutter 交互问题
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

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 手势,支持双指缩放和旋转
This commit is contained in:
iven
2026-06-04 00:05:22 +08:00
parent 988ee7335a
commit 9fce34f4ef
14 changed files with 164 additions and 15 deletions

View File

@@ -7,6 +7,7 @@
// - JournalEntry ↔ JournalEntryCollection通过 toCollection/fromCollection // - JournalEntry ↔ JournalEntryCollection通过 toCollection/fromCollection
// - JournalElement ↔ JournalElementCollection通过 toCollection/fromCollection // - JournalElement ↔ JournalElementCollection通过 toCollection/fromCollection
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
@@ -22,6 +23,11 @@ import 'journal_repository.dart';
class IsarJournalRepository implements JournalRepository { class IsarJournalRepository implements JournalRepository {
Isar get _isar => IsarDatabase.instance; Isar get _isar => IsarDatabase.instance;
final StreamController<void> _changeController = StreamController<void>.broadcast();
@override
Stream<void> get onJournalChanged => _changeController.stream;
// ============================================================ // ============================================================
// 日记 CRUD // 日记 CRUD
// ============================================================ // ============================================================
@@ -107,6 +113,7 @@ class IsarJournalRepository implements JournalRepository {
await _isar.writeTxn(() async { await _isar.writeTxn(() async {
await _isar.journalEntryCollections.put(col); await _isar.journalEntryCollections.put(col);
}); });
_changeController.add(null);
return entry; return entry;
} }
@@ -143,6 +150,7 @@ class IsarJournalRepository implements JournalRepository {
await _isar.journalEntryCollections.put(col); await _isar.journalEntryCollections.put(col);
}); });
_changeController.add(null);
return updated; return updated;
} }
@@ -176,6 +184,7 @@ class IsarJournalRepository implements JournalRepository {
await _isar.journalElementCollections.put(el); await _isar.journalElementCollections.put(el);
} }
}); });
_changeController.add(null);
} }
// ============================================================ // ============================================================

View File

@@ -7,6 +7,9 @@ import '../models/journal_entry.dart';
import '../models/journal_element.dart'; import '../models/journal_element.dart';
import 'journal_repository.dart'; import 'journal_repository.dart';
/// 空的变更通知流 — Web 平台 stub
const _emptyStream = Stream<void>.empty();
/// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError /// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError
class IsarJournalRepository implements JournalRepository { class IsarJournalRepository implements JournalRepository {
@override @override
@@ -56,4 +59,7 @@ class IsarJournalRepository implements JournalRepository {
@override @override
Future<void> removeElement(String elementId) => Future<void> removeElement(String elementId) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台'); throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Stream<void> get onJournalChanged => _emptyStream;
} }

View File

@@ -6,6 +6,8 @@
// - SyncEngine 负责协调本地和远程仓库之间的数据同步 // - SyncEngine 负责协调本地和远程仓库之间的数据同步
// - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代 // - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代
import 'dart:async';
import '../models/journal_entry.dart'; import '../models/journal_entry.dart';
import '../models/journal_element.dart'; import '../models/journal_element.dart';
@@ -52,6 +54,9 @@ abstract class JournalRepository {
/// 从日记中移除元素 /// 从日记中移除元素
Future<void> removeElement(String elementId); Future<void> removeElement(String elementId);
/// 日记变更通知流 — create/update/delete 时发出信号
Stream<void> get onJournalChanged;
} }
/// 内存实现 — 用于开发阶段快速迭代和单元测试 /// 内存实现 — 用于开发阶段快速迭代和单元测试
@@ -61,6 +66,10 @@ abstract class JournalRepository {
class InMemoryJournalRepository implements JournalRepository { class InMemoryJournalRepository implements JournalRepository {
final Map<String, JournalEntry> _journals = {}; final Map<String, JournalEntry> _journals = {};
final Map<String, JournalElement> _elements = {}; final Map<String, JournalElement> _elements = {};
final StreamController<void> _changeController = StreamController<void>.broadcast();
@override
Stream<void> get onJournalChanged => _changeController.stream;
@override @override
Future<List<JournalEntry>> getJournals({ Future<List<JournalEntry>> getJournals({
@@ -122,6 +131,7 @@ class InMemoryJournalRepository implements JournalRepository {
@override @override
Future<JournalEntry> createJournal(JournalEntry entry) async { Future<JournalEntry> createJournal(JournalEntry entry) async {
_journals[entry.id] = entry; _journals[entry.id] = entry;
_changeController.add(null);
return entry; return entry;
} }
@@ -145,6 +155,7 @@ class InMemoryJournalRepository implements JournalRepository {
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
); );
_journals[entry.id] = updated; _journals[entry.id] = updated;
_changeController.add(null);
return updated; return updated;
} }
@@ -154,6 +165,7 @@ class InMemoryJournalRepository implements JournalRepository {
_journals.remove(id); _journals.remove(id);
// 同时移除关联元素 // 同时移除关联元素
_elements.removeWhere((_, e) => e.journalId == id); _elements.removeWhere((_, e) => e.journalId == id);
_changeController.add(null);
} }
@override @override

View File

@@ -131,6 +131,10 @@ class RemoteJournalRepository implements JournalRepository {
Future<void> removeElement(String elementId) async { Future<void> removeElement(String elementId) async {
await _api.delete('/diary/elements/$elementId'); await _api.delete('/diary/elements/$elementId');
} }
/// 远程仓库不提供本地变更通知,返回空流
@override
Stream<void> get onJournalChanged => const Stream<void>.empty();
} }
/// API 异常封装 — 后端返回非 2xx 状态码时抛出 /// API 异常封装 — 后端返回非 2xx 状态码时抛出

View File

@@ -107,6 +107,12 @@ class ToolChanged extends EditorEvent {
ToolChanged(this.tool); ToolChanged(this.tool);
} }
/// 再次点击已激活的工具 — 重新弹出设置面板
class ToolReactivated extends EditorEvent {
final EditorTool tool;
ToolReactivated(this.tool);
}
/// 加载已有元素 /// 加载已有元素
class ElementsLoaded extends EditorEvent { class ElementsLoaded extends EditorEvent {
final List<JournalElement> elements; final List<JournalElement> elements;
@@ -227,6 +233,9 @@ class EditorState {
final bool isDirty; final bool isDirty;
final DateTime? lastSavedAt; final DateTime? lastSavedAt;
// 工具重新激活时间戳(用于驱动面板重新弹出)
final int toolReactivatedAt;
const EditorState({ const EditorState({
this.strokes = const [], this.strokes = const [],
this.redoStack = const [], this.redoStack = const [],
@@ -243,6 +252,7 @@ class EditorState {
this.title = '', this.title = '',
this.isDirty = false, this.isDirty = false,
this.lastSavedAt, this.lastSavedAt,
this.toolReactivatedAt = 0,
}); });
EditorState copyWith({ EditorState copyWith({
@@ -261,6 +271,7 @@ class EditorState {
String? title, String? title,
bool? isDirty, bool? isDirty,
DateTime? lastSavedAt, DateTime? lastSavedAt,
int? toolReactivatedAt,
}) => }) =>
EditorState( EditorState(
strokes: strokes ?? this.strokes, strokes: strokes ?? this.strokes,
@@ -279,6 +290,7 @@ class EditorState {
title: title ?? this.title, title: title ?? this.title,
isDirty: isDirty ?? this.isDirty, isDirty: isDirty ?? this.isDirty,
lastSavedAt: lastSavedAt ?? this.lastSavedAt, lastSavedAt: lastSavedAt ?? this.lastSavedAt,
toolReactivatedAt: toolReactivatedAt ?? this.toolReactivatedAt,
); );
/// 是否处于手写模式 /// 是否处于手写模式
@@ -330,6 +342,7 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
// 工具栏事件 // 工具栏事件
on<ToolChanged>(_onToolChanged); on<ToolChanged>(_onToolChanged);
on<ToolReactivated>(_onToolReactivated);
// 标签/心情/标题事件 // 标签/心情/标题事件
on<TagAdded>(_onTagAdded); on<TagAdded>(_onTagAdded);
@@ -514,6 +527,13 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
)); ));
} }
void _onToolReactivated(ToolReactivated event, Emitter<EditorState> emit) {
// 不改变 activeTool仅递增时间戳驱动 UI 层重新弹出面板
emit(state.copyWith(
toolReactivatedAt: DateTime.now().millisecondsSinceEpoch,
));
}
// ============================================================ // ============================================================
// 标签/心情/标题事件处理 // 标签/心情/标题事件处理
// ============================================================ // ============================================================

View File

@@ -625,6 +625,7 @@ class _EditorStack extends StatefulWidget {
class _EditorStackState extends State<_EditorStack> { class _EditorStackState extends State<_EditorStack> {
EditorTool? _lastTool; EditorTool? _lastTool;
int _lastReactivatedAt = 0;
late final TextEditingController _titleController; late final TextEditingController _titleController;
@override @override
@@ -675,6 +676,26 @@ class _EditorStackState extends State<_EditorStack> {
}); });
} }
_lastTool = currentTool; _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, positionY: y,
)); ));
}, },
onResized: (id, w, h) {
context.read<EditorBloc>().add(ElementResized(
elementId: id,
width: w,
height: h,
));
},
onRotated: (id, r) {
context.read<EditorBloc>().add(ElementRotated(
elementId: id,
rotation: r,
));
},
onDeleted: (id) { onDeleted: (id) {
context.read<EditorBloc>().add(ElementRemoved(id)); context.read<EditorBloc>().add(ElementRemoved(id));
}, },

View File

@@ -53,6 +53,16 @@ class ActiveStrokePainter extends CustomPainter {
final path = buildStrokePath(outlinePoints); 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 // 构造临时 Stroke 用于获取 Paint
final stroke = Stroke( final stroke = Stroke(
id: '__active__', id: '__active__',

View File

@@ -19,6 +19,8 @@ class DraggableElement extends StatefulWidget {
final bool isSelected; final bool isSelected;
final ValueChanged<String> onTap; final ValueChanged<String> onTap;
final void Function(String id, double x, double y) onMoved; 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<String> onDeleted; final ValueChanged<String> onDeleted;
const DraggableElement({ const DraggableElement({
@@ -27,6 +29,8 @@ class DraggableElement extends StatefulWidget {
this.isSelected = false, this.isSelected = false,
required this.onTap, required this.onTap,
required this.onMoved, required this.onMoved,
this.onResized,
this.onRotated,
required this.onDeleted, required this.onDeleted,
}); });
@@ -41,6 +45,11 @@ class _DraggableElementState extends State<DraggableElement> {
late double _height; late double _height;
late double _rotation; late double _rotation;
// Scale 手势状态
double _baseWidth = 0;
double _baseHeight = 0;
double _baseRotation = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -76,15 +85,35 @@ class _DraggableElementState extends State<DraggableElement> {
child: Transform.rotate( child: Transform.rotate(
angle: _rotation, angle: _rotation,
child: GestureDetector( child: GestureDetector(
// 拖拽移动 // 缩放开始 — 记录基准值
onPanUpdate: (details) { onScaleStart: (details) {
_baseWidth = _width;
_baseHeight = _height;
_baseRotation = _rotation;
},
// 缩放更新 — 支持单指拖拽 + 双指缩放/旋转
onScaleUpdate: (details) {
setState(() { 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); 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); widget.onMoved(widget.element.id, _x, _y);
}, },

View File

@@ -68,7 +68,7 @@ class EditorToolbar extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return GestureDetector( return GestureDetector(
onTap: () => onEvent(ToolChanged(tool)), onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Container( child: Container(
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 36, minHeight: 36),

View File

@@ -235,7 +235,10 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
widget.onStrokeCompleted?.call(stroke); widget.onStrokeCompleted?.call(stroke);
// 光栅化新笔画到缓存(异步,不阻塞 UI // 光栅化新笔画到缓存(异步,不阻塞 UI
_cache.addStroke(stroke); // 完成后 setState 确保 Layer 1 (CachedStrokesPainter) 用新合成图重绘
_cache.addStroke(stroke).then((_) {
if (mounted) setState(() {});
});
} }
/// 指针取消(如来电打断):丢弃当前笔画。 /// 指针取消(如来电打断):丢弃当前笔画。

View File

@@ -39,19 +39,21 @@ class _BrushConfig {
/// 各画笔的渲染参数。 /// 各画笔的渲染参数。
const Map<BrushType, _BrushConfig> _brushConfigs = { const Map<BrushType, _BrushConfig> _brushConfigs = {
/// 钢笔:中等粗细,强压感变化,模拟笔效果 /// 钢笔:粗壮平滑,模拟签字笔效果
BrushType.pen: _BrushConfig( BrushType.pen: _BrushConfig(
size: 8, size: 10,
thinning: 0.7, thinning: 0.65,
smoothing: 0.5, smoothing: 0.65,
streamline: 0.6,
simulatePressure: true, simulatePressure: true,
), ),
/// 铅笔:细线,轻微压感,高平滑度产生自然线条 /// 铅笔:纤细有质感,保留书写抖动
BrushType.pencil: _BrushConfig( BrushType.pencil: _BrushConfig(
size: 4, size: 3,
thinning: 0.3, thinning: 0.4,
smoothing: 0.7, smoothing: 0.35,
streamline: 0.3,
simulatePressure: true, simulatePressure: true,
), ),

View File

@@ -1,5 +1,7 @@
// 首页 BLoC — 加载最近日记和心情概览 // 首页 BLoC — 加载最近日记和心情概览
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
@@ -78,12 +80,24 @@ final class HomeError extends HomeState {
class HomeBloc extends Bloc<HomeEvent, HomeState> { class HomeBloc extends Bloc<HomeEvent, HomeState> {
final JournalRepository _journalRepo; final JournalRepository _journalRepo;
StreamSubscription<void>? _changeSubscription;
HomeBloc({required JournalRepository journalRepository}) HomeBloc({required JournalRepository journalRepository})
: _journalRepo = journalRepository, : _journalRepo = journalRepository,
super(const HomeInitial()) { super(const HomeInitial()) {
on<HomeLoadData>(_onLoadData); on<HomeLoadData>(_onLoadData);
on<HomeRefresh>(_onRefresh); on<HomeRefresh>(_onRefresh);
// 监听日记变更,自动刷新首页数据
_changeSubscription = _journalRepo.onJournalChanged.listen((_) {
add(const HomeRefresh());
});
}
@override
Future<void> close() {
_changeSubscription?.cancel();
return super.close();
} }
Future<void> _onLoadData( Future<void> _onLoadData(

View File

@@ -233,4 +233,7 @@ class _FailingJournalRepository implements JournalRepository {
@override @override
Future<void> removeElement(String elementId) async {} Future<void> removeElement(String elementId) async {}
@override
Stream<void> get onJournalChanged => const Stream<void>.empty();
} }

View File

@@ -271,6 +271,9 @@ class _FailingJournalRepository implements JournalRepository {
Future<void> removeElement(String elementId) async { Future<void> removeElement(String elementId) async {
throw UnimplementedError(); throw UnimplementedError();
} }
@override
Stream<void> get onJournalChanged => const Stream<void>.empty();
} }
/// 在指定 bloc 上触发事件并等待处理完毕 /// 在指定 bloc 上触发事件并等待处理完毕