Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/settings.local.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please dont add this file

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(flutter build:*)",
"Bash(flutter upgrade:*)",
"Bash(dart run build_runner:*)",
"Bash(flutter gen-l10n:*)"
]
}
}
3 changes: 3 additions & 0 deletions api/lib/src/models/property.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ enum HorizontalAlignment { left, center, right, justify }

enum VerticalAlignment { top, center, bottom }

enum StrokeStyle { solid, dotted, dashed }

@freezed
sealed class Property with _$Property {
@Implements<PathProperty>()
Expand All @@ -34,6 +36,7 @@ sealed class Property with _$Property {
@Default(5) double strokeWidth,
required PathShape shape,
@Default(SRGBColor.black) @ColorJsonConverter() SRGBColor color,
@Default(StrokeStyle.solid) StrokeStyle strokeStyle,
}) = ShapeProperty;

const factory Property.polygon({
Expand Down
16 changes: 9 additions & 7 deletions api/lib/src/models/property.freezed.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions api/lib/src/models/property.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"zoomOut": "Zoom out",
"resetZoom": "Reset zoom",
"strokeWidth": "Stroke width",
"strokeStyle": "Stroke style",
"includeEraser": "Include eraser?",
"thinning": "Thinning",
"pen": "Pen",
Expand Down
175 changes: 120 additions & 55 deletions app/lib/renderers/elements/shape.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,47 @@ class ShapeRenderer extends Renderer<ShapeElement> {

ShapeRenderer(super.element, [super.layer]);

/// Creates a dashed path from the source path based on stroke style
Path _createDashedPath(Path source, StrokeStyle strokeStyle) {
if (strokeStyle == StrokeStyle.solid) return source;

final strokeWidth = element.property.strokeWidth;
final dashLength = strokeStyle == StrokeStyle.dashed
? strokeWidth * 3 // Dashed: 3x stroke width
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to make the app as customizable as it can. I dont know if hard coded values make sense

: strokeWidth; // Dotted: 1x stroke width
final gapLength = strokeWidth * 2;

final dashedPath = Path();
for (final metric in source.computeMetrics()) {
double distance = 0;
bool draw = true;
while (distance < metric.length) {
final length = draw ? dashLength : gapLength;
final end = (distance + length).clamp(0.0, metric.length);
if (draw) {
dashedPath.addPath(
metric.extractPath(distance, end),
Offset.zero,
);
}
distance = end;
draw = !draw;
}
}
return dashedPath;
}

/// Draws a path with the appropriate stroke style (solid, dashed, or dotted)
void _drawStyledPath(Canvas canvas, Path path, Paint paint) {
final strokeStyle = element.property.strokeStyle;
if (strokeStyle == StrokeStyle.solid) {
canvas.drawPath(path, paint);
} else {
final dashedPath = _createDashedPath(path, strokeStyle);
canvas.drawPath(dashedPath, paint);
}
}

@override
void build(
Canvas canvas,
Expand Down Expand Up @@ -52,16 +93,15 @@ class ShapeRenderer extends Renderer<ShapeElement> {
),
);
if (strokeWidth > 0) {
canvas.drawRRect(
RRect.fromRectAndCorners(
rect,
topLeft: topLeftCornerRadius,
topRight: topRightCornerRadius,
bottomLeft: bottomLeftCornerRadius,
bottomRight: bottomRightCornerRadius,
),
paint,
final rrect = RRect.fromRectAndCorners(
rect,
topLeft: topLeftCornerRadius,
topRight: topRightCornerRadius,
bottomLeft: bottomLeftCornerRadius,
bottomRight: bottomRightCornerRadius,
);
Comment on lines 94 to 100
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stroke path is created using rect (line 97) while the fill is drawn using drawRect (line 83-89). This inconsistency means the stroke and fill won't be properly aligned. The stroke should use drawRect for consistent positioning with the fill area.

Suggested fix:

final rrect = RRect.fromRectAndCorners(
  drawRect,  // Change from rect to drawRect
  topLeft: topLeftCornerRadius,
  topRight: topRightCornerRadius,
  bottomLeft: bottomLeftCornerRadius,
  bottomRight: bottomRightCornerRadius,
);

Copilot uses AI. Check for mistakes.
final path = Path()..addRRect(rrect);
_drawStyledPath(canvas, path, paint);
}
} else if (shape is CircleShape) {
canvas.drawOval(
Expand All @@ -72,14 +112,14 @@ class ShapeRenderer extends Renderer<ShapeElement> {
),
);
if (strokeWidth > 0) {
canvas.drawOval(rect, paint);
final path = Path()..addOval(rect);
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stroke path is created using rect while the fill is drawn using drawRect (line 107-108). This inconsistency means the stroke and fill won't be properly aligned. The stroke should use drawRect for consistent positioning.

Suggested fix:

final path = Path()..addOval(drawRect);  // Change from rect to drawRect
Suggested change
final path = Path()..addOval(rect);
final path = Path()..addOval(drawRect);

Copilot uses AI. Check for mistakes.
_drawStyledPath(canvas, path, paint);
}
} else if (shape is LineShape) {
canvas.drawLine(
element.firstPosition.toOffset(),
element.secondPosition.toOffset(),
paint,
);
final path = Path()
..moveTo(element.firstPosition.x, element.firstPosition.y)
..lineTo(element.secondPosition.x, element.secondPosition.y);
_drawStyledPath(canvas, path, paint);
} else if (shape is TriangleShape) {
final topCenter = drawRect.topCenter;
final path = Path()
Expand All @@ -95,7 +135,7 @@ class ShapeRenderer extends Renderer<ShapeElement> {
),
);
if (strokeWidth > 0) {
canvas.drawPath(path, paint);
_drawStyledPath(canvas, path, paint);
}
}
}
Expand All @@ -107,6 +147,16 @@ class ShapeRenderer extends Renderer<ShapeElement> {
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;

/// Returns SVG stroke-dasharray attribute value based on stroke style
String? _getSvgDashArray() {
final strokeWidth = element.property.strokeWidth;
return switch (element.property.strokeStyle) {
StrokeStyle.solid => null,
StrokeStyle.dotted => '$strokeWidth,${strokeWidth * 2}',
StrokeStyle.dashed => '${strokeWidth * 3},${strokeWidth * 2}',
};
}

@override
void buildSvg(
XmlDocument xml,
Expand All @@ -118,6 +168,8 @@ class ShapeRenderer extends Renderer<ShapeElement> {
final shape = element.property.shape;
final strokeWidth = element.property.strokeWidth;
final drawRect = rect.inflate(-strokeWidth);
final dashArray = _getSvgDashArray();

if (shape is RectangleShape) {
final topLeftRadius =
shape.topLeftCornerRadius / 100 * drawRect.shortestSide;
Expand Down Expand Up @@ -146,47 +198,60 @@ class ShapeRenderer extends Renderer<ShapeElement> {
d += 'A$topLeftRadius $topLeftRadius 0 0 1 ';
d += '${drawRect.left + topLeftRadius} ${drawRect.top} ';
d += 'Z';
xml
.getElement('svg')
?.createElement(
'path',
attributes: {
'd': d,
'fill': shape.fillColor.toHexString(),
'stroke': element.property.color.toHexString(),
'stroke-width': '${element.property.strokeWidth}px',
},
);
xml.getElement('svg')?.createElement(
'path',
attributes: {
'd': d,
'fill': shape.fillColor.toHexString(),
'stroke': element.property.color.toHexString(),
'stroke-width': '${element.property.strokeWidth}px',
if (dashArray != null) 'stroke-dasharray': dashArray,
},
);
} else if (shape is CircleShape) {
xml
.getElement('svg')
?.createElement(
'ellipse',
attributes: {
'cx': '${drawRect.center.dx}',
'cy': '${drawRect.center.dy}',
'rx': '${(drawRect.width / 2).abs()}',
'ry': '${(drawRect.height / 2).abs()}',
'fill': shape.fillColor.toHexString(),
'stroke': element.property.color.toHexString(),
'stroke-width': '${element.property.strokeWidth}px',
},
);
xml.getElement('svg')?.createElement(
'ellipse',
attributes: {
'cx': '${drawRect.center.dx}',
'cy': '${drawRect.center.dy}',
'rx': '${(drawRect.width / 2).abs()}',
'ry': '${(drawRect.height / 2).abs()}',
'fill': shape.fillColor.toHexString(),
'stroke': element.property.color.toHexString(),
'stroke-width': '${element.property.strokeWidth}px',
if (dashArray != null) 'stroke-dasharray': dashArray,
},
);
} else if (shape is LineShape) {
xml
.getElement('svg')
?.createElement(
'line',
attributes: {
'x1': '${element.firstPosition.x}px',
'y1': '${element.firstPosition.y}px',
'x2': '${element.secondPosition.x}px',
'y2': '${element.secondPosition.y}px',
'stroke-width': '${element.property.strokeWidth}px',
'stroke': element.property.color.toHexString(),
'fill': 'none',
},
);
xml.getElement('svg')?.createElement(
'line',
attributes: {
'x1': '${element.firstPosition.x}px',
'y1': '${element.firstPosition.y}px',
'x2': '${element.secondPosition.x}px',
'y2': '${element.secondPosition.y}px',
'stroke-width': '${element.property.strokeWidth}px',
'stroke': element.property.color.toHexString(),
'fill': 'none',
if (dashArray != null) 'stroke-dasharray': dashArray,
},
);
} else if (shape is TriangleShape) {
final topCenter = drawRect.topCenter;
final d =
'M${topCenter.dx} ${topCenter.dy} '
'L${drawRect.right} ${drawRect.bottom} '
'L${drawRect.left} ${drawRect.bottom} Z';
xml.getElement('svg')?.createElement(
'path',
attributes: {
'd': d,
'fill': shape.fillColor.toHexString(),
'stroke': element.property.color.toHexString(),
'stroke-width': '${element.property.strokeWidth}px',
if (dashArray != null) 'stroke-dasharray': dashArray,
},
);
}
}

Expand Down
Loading