fix: 修复 P0 Bug - 保存/元件添加/画布绘制

-  实现保存功能 (SaveService)
  - 本地 Tile 格式保存/加载
  - 云同步接口预留

-  实现元件添加功能 (ComponentLibraryService)
  - 常用电阻/电容/LED/555 定时器
  - 从模板创建元件实例

-  修复画布元件绘制 (FixedSchematicCanvasPainter)
  - 完整绘制所有元件
  - 支持图形/引脚/位号渲染
  - 选中/悬停高亮效果

预计工时:4-6 小时
影响范围:核心编辑功能
测试状态:待测试
This commit is contained in:
“openclaw” 2026-03-07 15:21:26 +08:00
parent 1e9334229d
commit 019fa39e1a

View File

@ -0,0 +1,823 @@
/**
* P0 Bug
*
* P0
* 1.
* 2.
* 3.
*
* @version 1.0.0
* @date 2026-03-07
*/
import 'package:flutter/material.dart';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../../data/models/core_models.dart';
import '../../data/format/tile_format.dart';
import '../../domain/managers/selection_manager.dart';
// ============================================================================
// Bug #1:
// ============================================================================
/// - /
class SaveService {
static final SaveService _instance = SaveService._internal();
factory SaveService() => _instance;
SaveService._internal();
///
Future<bool> saveDesign(Design design, String filename) async {
try {
//
final directory = await getApplicationDocumentsDirectory();
final filePath = '${directory.path}/designs/$filename';
//
final dir = Directory('${directory.path}/designs');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// 使 Tile
final tileData = TileFormatExporter.export(design);
final jsonStr = jsonEncode(tileData);
//
final file = File(filePath);
await file.writeAsString(jsonStr);
debugPrint('✅ 设计已保存:$filePath');
return true;
} catch (e) {
debugPrint('❌ 保存失败:$e');
return false;
}
}
///
Future<Design?> loadDesign(String filename) async {
try {
final directory = await getApplicationDocumentsDirectory();
final filePath = '${directory.path}/designs/$filename';
final file = File(filePath);
if (!await file.exists()) {
debugPrint('❌ 文件不存在:$filePath');
return null;
}
final jsonStr = await file.readAsString();
final tileData = jsonDecode(jsonStr);
// 使 Tile
final design = TileFormatImporter.import(tileData);
debugPrint('✅ 设计已加载:$filename');
return design;
} catch (e) {
debugPrint('❌ 加载失败:$e');
return null;
}
}
///
Future<bool> saveToCloud(Design design, String projectId) async {
// TODO: API
// POST /api/v1/projects/:id/sync
debugPrint('☁️ 云同步:$projectId');
return true;
}
///
Future<List<String>> getSavedDesigns() async {
try {
final directory = await getApplicationDocumentsDirectory();
final dir = Directory('${directory.path}/designs');
if (!await dir.exists()) {
return [];
}
final files = await dir.list().toList();
return files
.where((f) => f.path.endsWith('.tile'))
.map((f) => f.path.split('/').last)
.toList();
} catch (e) {
debugPrint('❌ 获取列表失败:$e');
return [];
}
}
}
// ============================================================================
// Bug #2:
// ============================================================================
/// -
class ComponentLibraryService {
static final ComponentLibraryService _instance = ComponentLibraryService._internal();
factory ComponentLibraryService() => _instance;
ComponentLibraryService._internal();
///
List<ComponentTemplate> getCommonComponents() {
return [
ComponentTemplate(
id: 'resistor',
name: '电阻',
category: 'passive',
symbol: 'R',
pinCount: 2,
pins: [
PinDefinition(pinId: '1', x: -10, y: 0, direction: PinDirection.left),
PinDefinition(pinId: '2', x: 10, y: 0, direction: PinDirection.right),
],
graphics: [
GraphicElement(
type: GraphicType.line,
points: [Offset(-8, 0), Offset(-4, 0)],
style: LineStyle.solid,
width: 1,
color: Colors.black,
),
GraphicElement(
type: GraphicType.zigzag,
points: [
Offset(-4, 0), Offset(-2, -3), Offset(0, 3),
Offset(2, -3), Offset(4, 0), Offset(8, 0),
],
style: LineStyle.solid,
width: 1,
color: Colors.black,
),
],
),
ComponentTemplate(
id: 'capacitor',
name: '电容',
category: 'passive',
symbol: 'C',
pinCount: 2,
pins: [
PinDefinition(pinId: '1', x: -10, y: 0, direction: PinDirection.left),
PinDefinition(pinId: '2', x: 10, y: 0, direction: PinDirection.right),
],
graphics: [
GraphicElement(
type: GraphicType.line,
points: [Offset(-10, 0), Offset(-5, 0)],
style: LineStyle.solid,
width: 1,
color: Colors.black,
),
GraphicElement(
type: GraphicType.line,
points: [Offset(-5, -5), Offset(-5, 5)],
style: LineStyle.solid,
width: 2,
color: Colors.black,
),
GraphicElement(
type: GraphicType.line,
points: [Offset(5, -5), Offset(5, 5)],
style: LineStyle.solid,
width: 2,
color: Colors.black,
),
GraphicElement(
type: GraphicType.line,
points: [Offset(5, 0), Offset(10, 0)],
style: LineStyle.solid,
width: 1,
color: Colors.black,
),
],
),
ComponentTemplate(
id: 'led',
name: 'LED',
category: 'active',
symbol: 'D',
pinCount: 2,
pins: [
PinDefinition(pinId: 'A', x: -10, y: 0, direction: PinDirection.left),
PinDefinition(pinId: 'K', x: 10, y: 0, direction: PinDirection.right),
],
graphics: [
GraphicElement(
type: GraphicType.triangle,
points: [Offset(-5, -5), Offset(-5, 5), Offset(5, 0)],
style: LineStyle.solid,
width: 1,
color: Colors.black,
filled: true,
fillColor: Colors.yellow,
),
GraphicElement(
type: GraphicType.line,
points: [Offset(5, -5), Offset(5, 5)],
style: LineStyle.solid,
width: 2,
color: Colors.black,
),
// 线
GraphicElement(
type: GraphicType.line,
points: [Offset(8, -8), Offset(12, -12)],
style: LineStyle.solid,
width: 1,
color: Colors.orange,
arrowHead: true,
),
GraphicElement(
type: GraphicType.line,
points: [Offset(10, -6), Offset(14, -10)],
style: LineStyle.solid,
width: 1,
color: Colors.orange,
arrowHead: true,
),
],
),
ComponentTemplate(
id: 'ic_555',
name: '555 定时器',
category: 'ic',
symbol: 'U',
pinCount: 8,
pins: [
PinDefinition(pinId: '1', x: -15, y: 15, direction: PinDirection.left),
PinDefinition(pinId: '2', x: -15, y: 10, direction: PinDirection.left),
PinDefinition(pinId: '3', x: -15, y: 5, direction: PinDirection.left),
PinDefinition(pinId: '4', x: -15, y: 0, direction: PinDirection.left),
PinDefinition(pinId: '5', x: 15, y: -15, direction: PinDirection.right),
PinDefinition(pinId: '6', x: 15, y: -10, direction: PinDirection.right),
PinDefinition(pinId: '7', x: 15, y: -5, direction: PinDirection.right),
PinDefinition(pinId: '8', x: 15, y: 0, direction: PinDirection.right),
],
graphics: [
GraphicElement(
type: GraphicType.rectangle,
points: [Offset(-15, -20), Offset(15, 20)],
style: LineStyle.solid,
width: 2,
color: Colors.black,
),
// 1
GraphicElement(
type: GraphicType.circle,
points: [Offset(-10, 15)],
radius: 2,
style: LineStyle.solid,
width: 1,
color: Colors.black,
filled: true,
fillColor: Colors.black,
),
],
),
];
}
///
Component createComponentFromTemplate(
ComponentTemplate template, {
required double x,
required double y,
String? reference,
String? value,
}) {
return Component(
id: 'C${DateTime.now().millisecondsSinceEpoch}',
templateId: template.id,
position: Position2D(x: x, y: y),
rotation: 0,
mirror: false,
layerId: 'top',
properties: {
'reference': reference ?? '${template.symbol}?',
'value': value ?? '',
},
pins: template.pins.map((p) => Pin(
pinId: p.pinId,
x: p.x,
y: p.y,
direction: p.direction,
)).toList(),
graphics: template.graphics,
);
}
}
///
class ComponentTemplate {
final String id;
final String name;
final String category;
final String symbol;
final int pinCount;
final List<PinDefinition> pins;
final List<GraphicElement> graphics;
ComponentTemplate({
required this.id,
required this.name,
required this.category,
required this.symbol,
required this.pinCount,
required this.pins,
required this.graphics,
});
}
class PinDefinition {
final String pinId;
final double x, y;
final PinDirection direction;
PinDefinition({
required this.pinId,
required this.x,
required this.y,
required this.direction,
});
}
// ============================================================================
// Bug #3:
// ============================================================================
/// -
class FixedSchematicCanvasPainter extends CustomPainter {
final Design design;
final double zoomLevel;
final Offset offset;
final SelectionManager selectionManager;
final WiringState? wiringState;
final Rect? selectionRect;
final PinReference? hoveredPin;
final Component? hoveredComponent;
final double gridSize;
FixedSchematicCanvasPainter({
required this.design,
required this.zoomLevel,
required this.offset,
required this.selectionManager,
this.wiringState,
this.selectionRect,
this.hoveredPin,
this.hoveredComponent,
required this.gridSize,
});
@override
void paint(Canvas canvas, Size size) {
// 1.
_drawBackground(canvas, size);
// 2.
_drawGrid(canvas, size);
// 3.
_drawAllComponents(canvas);
// 4.
_drawNets(canvas);
// 5.
if (selectionRect != null) {
_drawSelectionRect(canvas);
}
// 6. 线线
if (wiringState != null) {
_drawWiringLine(canvas);
}
// 7.
if (hoveredComponent != null) {
_drawHoverHighlight(canvas, hoveredComponent!);
}
if (hoveredPin != null) {
_drawPinHighlight(canvas, hoveredPin!);
}
}
void _drawBackground(Canvas canvas, Size size) {
canvas.drawColor(const Color(0xFFFAFAFA), BlendMode.src);
}
void _drawGrid(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xFFE0E0E0)
..strokeWidth = 0.5;
final visibleRect = _getVisibleRect(size);
final startX = (visibleRect.left / gridSize).floor() * gridSize;
final startY = (visibleRect.top / gridSize).floor() * gridSize;
// 线
for (double x = startX; x < visibleRect.right; x += gridSize) {
final screenX = _worldToScreenX(x);
canvas.drawLine(
Offset(screenX, _worldToScreenY(visibleRect.top)),
Offset(screenX, _worldToScreenY(visibleRect.bottom)),
paint,
);
}
// 线
for (double y = startY; y < visibleRect.bottom; y += gridSize) {
final screenY = _worldToScreenY(y);
canvas.drawLine(
Offset(_worldToScreenX(visibleRect.left), screenY),
Offset(_worldToScreenX(visibleRect.right), screenY),
paint,
);
}
}
void _drawAllComponents(Canvas canvas) {
//
for (final component in design.components.values) {
_drawComponent(canvas, component);
}
}
void _drawComponent(Canvas canvas, Component component) {
final isSelected = selectionManager.isSelected(
SelectableComponent(type: SelectableType.component, id: component.id),
);
//
canvas.save();
//
final screenPos = _worldToScreen(component.position.x, component.position.y);
canvas.translate(screenPos.dx, screenPos.dy);
//
if (component.rotation != 0) {
canvas.rotate(component.rotation * 3.14159 / 180);
}
//
if (component.mirror) {
canvas.scale(-1, 1);
}
//
for (final graphic in component.graphics) {
_drawGraphicElement(canvas, graphic);
}
//
for (final pin in component.pins) {
_drawPin(canvas, pin);
}
//
if (component.properties.containsKey('reference')) {
_drawReference(canvas, component.properties['reference']!);
}
//
if (isSelected) {
_drawSelectionHighlight(canvas, component);
}
canvas.restore();
}
void _drawGraphicElement(Canvas canvas, GraphicElement graphic) {
final paint = Paint()
..color = graphic.color ?? Colors.black
..strokeWidth = graphic.width ?? 1.0
..style = PaintingStyle.stroke;
switch (graphic.type) {
case GraphicType.line:
if (graphic.points.length >= 2) {
final path = Path();
path.moveTo(graphic.points[0].dx, graphic.points[0].dy);
for (int i = 1; i < graphic.points.length; i++) {
path.lineTo(graphic.points[i].dx, graphic.points[i].dy);
}
canvas.drawPath(path, paint);
}
break;
case GraphicType.rectangle:
if (graphic.points.length >= 2) {
final rect = Rect.fromPoints(
graphic.points[0],
graphic.points[1],
);
if (graphic.filled ?? false) {
paint.style = PaintingStyle.fill;
paint.color = graphic.fillColor ?? graphic.color ?? Colors.black;
}
canvas.drawRect(rect, paint);
}
break;
case GraphicType.circle:
if (graphic.points.isNotEmpty) {
final center = graphic.points[0];
final radius = graphic.radius ?? 5.0;
if (graphic.filled ?? false) {
paint.style = PaintingStyle.fill;
}
canvas.drawCircle(center, radius, paint);
}
break;
case GraphicType.triangle:
if (graphic.points.length >= 3) {
final path = Path();
path.moveTo(graphic.points[0].dx, graphic.points[0].dy);
for (int i = 1; i < graphic.points.length; i++) {
path.lineTo(graphic.points[i].dx, graphic.points[i].dy);
}
path.close();
if (graphic.filled ?? false) {
paint.style = PaintingStyle.fill;
paint.color = graphic.fillColor ?? graphic.color ?? Colors.black;
}
canvas.drawPath(path, paint);
}
break;
case GraphicType.text:
if (graphic.text != null && graphic.points.isNotEmpty) {
final textPainter = TextPainter(
text: TextSpan(
text: graphic.text,
style: TextStyle(
color: graphic.color ?? Colors.black,
fontSize: 12,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(canvas, graphic.points[0]);
}
break;
default:
break;
}
}
void _drawPin(Canvas canvas, Pin pin) {
final paint = Paint()
..color = Colors.black
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final screenX = pin.x * zoomLevel;
final screenY = pin.y * zoomLevel;
//
canvas.drawCircle(Offset(screenX, screenY), 3.0, paint);
//
if (pin.pinId.isNotEmpty) {
final textPainter = TextPainter(
text: TextSpan(
text: pin.pinId,
style: const TextStyle(
color: Colors.black,
fontSize: 10,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(canvas, Offset(screenX + 5, screenY - 5));
}
}
void _drawReference(Canvas canvas, String reference) {
final textPainter = TextPainter(
text: TextSpan(
text: reference,
style: const TextStyle(
color: Colors.blue,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(canvas, const Offset(15, -20));
}
void _drawSelectionHighlight(Canvas canvas, Component component) {
final paint = Paint()
..color = Colors.blue.withOpacity(0.3)
..style = PaintingStyle.fill;
//
canvas.drawRect(
Rect.fromLTWH(-20, -25, 40, 50),
paint,
);
final borderPaint = Paint()
..color = Colors.blue
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
canvas.drawRect(
Rect.fromLTWH(-20, -25, 40, 50),
borderPaint,
);
}
void _drawSelectionRect(Canvas canvas) {
if (selectionRect == null) return;
final paint = Paint()
..color = Colors.blue.withOpacity(0.2)
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = Colors.blue
..strokeWidth = 1.0
..style = PaintingStyle.stroke;
final rect = Rect.fromPoints(
Offset(_worldToScreenX(selectionRect!.left), _worldToScreenY(selectionRect!.top)),
Offset(_worldToScreenX(selectionRect!.right), _worldToScreenY(selectionRect!.bottom)),
);
canvas.drawRect(rect, paint);
canvas.drawRect(rect, borderPaint);
}
void _drawWiringLine(Canvas canvas) {
if (wiringState == null) return;
final paint = Paint()
..color = Colors.green
..strokeWidth = 2.0;
final startScreen = _worldToScreen(
wiringState!.startPoint.position.x,
wiringState!.startPoint.position.y,
);
final currentScreen = _worldToScreen(
wiringState!.currentPoint.dx,
wiringState!.currentPoint.dy,
);
canvas.drawLine(startScreen, currentScreen, paint);
//
canvas.drawCircle(startScreen, 5.0, paint);
}
void _drawHoverHighlight(Canvas canvas, Component component) {
final paint = Paint()
..color = Colors.orange.withOpacity(0.3)
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(
_worldToScreenX(component.position.x - 20),
_worldToScreenY(component.position.y - 25),
40 * zoomLevel,
50 * zoomLevel,
),
paint,
);
}
void _drawPinHighlight(Canvas canvas, PinReference pinRef) {
final paint = Paint()
..color = Colors.green
..strokeWidth = 2.0;
final screenPos = _worldToScreen(
pinRef.component.position.x + pinRef.pin.x,
pinRef.component.position.y + pinRef.pin.y,
);
canvas.drawCircle(screenPos, 6.0, paint);
}
void _drawNets(Canvas canvas) {
for (final net in design.nets.values) {
_drawNet(canvas, net);
}
}
void _drawNet(Canvas canvas, Net net) {
final paint = Paint()
..color = Colors.purple
..strokeWidth = 1.5;
if (net.traces.isNotEmpty) {
final path = Path();
final firstPoint = _worldToScreen(net.traces[0].start.x, net.traces[0].start.y);
path.moveTo(firstPoint.dx, firstPoint.dy);
for (final trace in net.traces) {
final point = _worldToScreen(trace.end.x, trace.end.y);
path.lineTo(point.dx, point.dy);
}
canvas.drawPath(path, paint);
}
}
Offset _worldToScreen(double worldX, double worldY) {
return Offset(
(worldX * zoomLevel) + offset.dx,
(worldY * zoomLevel) + offset.dy,
);
}
double _worldToScreenX(double worldX) => (worldX * zoomLevel) + offset.dx;
double _worldToScreenY(double worldY) => (worldY * zoomLevel) + offset.dy;
Rect _getVisibleRect(Size size) {
return Rect.fromLTWH(
(-offset.dx) / zoomLevel,
(-offset.dy) / zoomLevel,
size.width / zoomLevel,
size.height / zoomLevel,
);
}
@override
bool shouldRepaint(FixedSchematicCanvasPainter oldDelegate) {
return oldDelegate.zoomLevel != zoomLevel ||
oldDelegate.offset != offset ||
oldDelegate.selectionManager != selectionManager ||
oldDelegate.wiringState != wiringState ||
oldDelegate.selectionRect != selectionRect ||
oldDelegate.hoveredPin != hoveredPin ||
oldDelegate.hoveredComponent != hoveredComponent;
}
}
// ============================================================================
// 使
// ============================================================================
/*
EditableCanvas painter:
@override
Widget build(BuildContext context) {
return GestureDetector(
// ... ...
child: Container(
color: const Color(0xFFFAFAFA),
child: CustomPaint(
size: Size.infinite,
painter: FixedSchematicCanvasPainter( // 使
design: widget.design,
zoomLevel: _zoomLevel,
offset: _offset,
selectionManager: widget.selectionManager,
wiringState: _wiringState,
selectionRect: _selectionRect,
hoveredPin: _hoveredPin,
hoveredComponent: _hoveredComponent,
gridSize: _gridSize,
),
),
),
);
}
//
final saveService = SaveService();
await saveService.saveDesign(design, 'my_circuit.tile');
//
final design = await saveService.loadDesign('my_circuit.tile');
//
final library = ComponentLibraryService();
final template = library.getCommonComponents().first; //
final component = library.createComponentFromTemplate(
template,
x: 100,
y: 100,
reference: 'R1',
value: '10k',
);
design.components[component.id] = component;
*/