Files
Qlyra/lib/frontend/widgets/attachment/photo_editor.dart
T

3005 lines
80 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/media/raster.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import '../../../core/config/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../small_spinner.dart';
import '../../../core/config/app_shape.dart';
const Color _kPanel = Color(0xFF0A0A0A);
const List<Color> _kPenWheel = [
Color(0xFFFF3B30),
Color(0xFFFFCC00),
Color(0xFF34C759),
Color(0xFF00C7BE),
Color(0xFF2F8FFF),
Color(0xFFAF52DE),
Color(0xFFFF3B30),
];
class CropState {
final int quarterTurns;
final bool flipH;
final double straightenDeg;
final Rect cropNorm;
const CropState({
required this.quarterTurns,
required this.flipH,
required this.straightenDeg,
required this.cropNorm,
});
bool sameAs(CropState o) =>
quarterTurns == o.quarterTurns &&
flipH == o.flipH &&
(straightenDeg - o.straightenDeg).abs() < 0.05 &&
cropNorm == o.cropNorm;
}
class CropResult {
final File file;
final CropState state;
const CropResult(this.file, this.state);
}
class PhotoEditState {
final File? working;
final File? cropSource;
final CropState? cropState;
const PhotoEditState({this.working, this.cropSource, this.cropState});
}
class PhotoCropEditor extends StatefulWidget {
final File source;
final CropState? initialState;
const PhotoCropEditor({super.key, required this.source, this.initialState});
@override
State<PhotoCropEditor> createState() => _PhotoCropEditorState();
}
class _PhotoCropEditorState extends State<PhotoCropEditor> {
ui.Image? _image;
int _quarterTurns = 0;
bool _flipH = false;
double _straightenDeg = 0;
Rect? _crop;
Size _viewport = Size.zero;
bool _baking = false;
bool _stateApplied = false;
int _handle = -1;
final ValueNotifier<int> _rev = ValueNotifier(0);
@override
void initState() {
super.initState();
_load();
}
void _setCrop(Rect r) {
_crop = r;
_rev.value++;
}
Future<void> _load() async {
try {
final bytes = await widget.source.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
codec.dispose();
if (!mounted) {
frame.image.dispose();
return;
}
setState(() => _image = frame.image);
} catch (_) {
if (mounted) Navigator.of(context).pop();
}
}
@override
void dispose() {
_image?.dispose();
_rev.dispose();
super.dispose();
}
double get _imgW => _image!.width.toDouble();
double get _imgH => _image!.height.toDouble();
double get _phi =>
_straightenDeg * math.pi / 180 - _quarterTurns * math.pi / 2;
Size _orientedSize() {
final swap = _quarterTurns.isOdd;
return swap ? Size(_imgH, _imgW) : Size(_imgW, _imgH);
}
double _baseScale(Size vp) {
final o = _orientedSize();
const margin = 0.9;
return math.min(vp.width / o.width, vp.height / o.height) * margin;
}
Rect _fittedRect(Size vp) {
final o = _orientedSize();
final base = _baseScale(vp);
return Rect.fromCenter(
center: Offset(vp.width / 2, vp.height / 2),
width: o.width * base,
height: o.height * base,
);
}
double _scaleFor(Size vp, Rect crop) {
final base = _baseScale(vp);
final center = Offset(vp.width / 2, vp.height / 2);
final c = math.cos(-_phi);
final s = math.sin(-_phi);
var maxS = 0.0;
for (final corner in [
crop.topLeft,
crop.topRight,
crop.bottomLeft,
crop.bottomRight,
]) {
final rx = corner.dx - center.dx;
final ry = corner.dy - center.dy;
final lx = rx * c - ry * s;
final ly = rx * s + ry * c;
maxS = math.max(
maxS,
math.max(lx.abs() / (_imgW / 2), ly.abs() / (_imgH / 2)),
);
}
return math.max(base, maxS);
}
Matrix4 _matrix(Size vp, Rect crop) {
final scale = _scaleFor(vp, crop);
return Matrix4.identity()
..translateByDouble(vp.width / 2, vp.height / 2, 0, 1)
..multiply(
_flipH ? Matrix4.diagonal3Values(-1, 1, 1) : Matrix4.identity(),
)
..rotateZ(_phi)
..scaleByDouble(scale, scale, 1, 1)
..translateByDouble(-_imgW / 2, -_imgH / 2, 0, 1);
}
void _ensureCrop(Size vp) {
if (_crop != null && _viewport == vp) return;
_viewport = vp;
final init = widget.initialState;
if (init != null && !_stateApplied) {
_stateApplied = true;
_quarterTurns = init.quarterTurns;
_flipH = init.flipH;
_straightenDeg = init.straightenDeg;
_crop = Rect.fromLTRB(
init.cropNorm.left * vp.width,
init.cropNorm.top * vp.height,
init.cropNorm.right * vp.width,
init.cropNorm.bottom * vp.height,
);
} else {
_crop = _fittedRect(vp);
}
}
CropState _currentState(Size vp, Rect crop) => CropState(
quarterTurns: _quarterTurns,
flipH: _flipH,
straightenDeg: _straightenDeg,
cropNorm: Rect.fromLTRB(
crop.left / vp.width,
crop.top / vp.height,
crop.right / vp.width,
crop.bottom / vp.height,
),
);
void _reset() {
setState(() {
_quarterTurns = 0;
_flipH = false;
_straightenDeg = 0;
_crop = _fittedRect(_viewport);
});
}
void _rotate90() {
setState(() {
_quarterTurns = (_quarterTurns + 1) % 4;
_straightenDeg = 0;
_crop = _fittedRect(_viewport);
});
}
void _flip() => setState(() => _flipH = !_flipH);
int _hitHandle(Offset pt, Rect c) {
const r = 34.0;
final corners = [c.topLeft, c.topRight, c.bottomRight, c.bottomLeft];
for (var i = 0; i < 4; i++) {
if ((pt - corners[i]).distance < r) return i;
}
final insideV = pt.dy > c.top - r && pt.dy < c.bottom + r;
final insideH = pt.dx > c.left - r && pt.dx < c.right + r;
if ((pt.dx - c.left).abs() < r && insideV) return 4;
if ((pt.dx - c.right).abs() < r && insideV) return 5;
if ((pt.dy - c.top).abs() < r && insideH) return 6;
if ((pt.dy - c.bottom).abs() < r && insideH) return 7;
if (c.contains(pt)) return 8;
return -1;
}
void _onPanStart(Offset pt) {
final c = _crop;
if (c == null) return;
_handle = _hitHandle(pt, c);
}
void _onPanUpdate(Offset delta) {
final c = _crop;
if (c == null || _handle < 0) return;
final b = _fittedRect(_viewport);
const minSize = 64.0;
if (_handle == 8) {
var nl = c.left + delta.dx;
var nt = c.top + delta.dy;
var nr = c.right + delta.dx;
var nb = c.bottom + delta.dy;
if (nl < b.left) {
nr += b.left - nl;
nl = b.left;
}
if (nt < b.top) {
nb += b.top - nt;
nt = b.top;
}
if (nr > b.right) {
nl -= nr - b.right;
nr = b.right;
}
if (nb > b.bottom) {
nt -= nb - b.bottom;
nb = b.bottom;
}
_setCrop(Rect.fromLTRB(nl, nt, nr, nb));
return;
}
var l = c.left;
var t = c.top;
var r = c.right;
var bo = c.bottom;
switch (_handle) {
case 0:
l += delta.dx;
t += delta.dy;
case 1:
r += delta.dx;
t += delta.dy;
case 2:
r += delta.dx;
bo += delta.dy;
case 3:
l += delta.dx;
bo += delta.dy;
case 4:
l += delta.dx;
case 5:
r += delta.dx;
case 6:
t += delta.dy;
case 7:
bo += delta.dy;
}
l = l.clamp(b.left, math.max(b.left, r - minSize));
t = t.clamp(b.top, math.max(b.top, bo - minSize));
r = r.clamp(math.min(b.right, l + minSize), b.right);
bo = bo.clamp(math.min(b.bottom, t + minSize), b.bottom);
_setCrop(Rect.fromLTRB(l, t, r, bo));
}
Future<void> _done() async {
if (_baking) return;
final crop = _crop;
final vp = _viewport;
if (crop == null || vp == Size.zero) {
Navigator.of(context).pop();
return;
}
final state = _currentState(vp, crop);
final init = widget.initialState;
final noChange = init != null
? state.sameAs(init)
: (_quarterTurns == 0 &&
!_flipH &&
_straightenDeg == 0 &&
_isFullCrop());
if (noChange) {
Navigator.of(context).pop();
return;
}
setState(() => _baking = true);
final file = await _bake();
if (!mounted) return;
if (file == null) {
setState(() => _baking = false);
showCustomNotification(
context,
AppLocalizations.of(context)!.photoEditorApplyFailed,
);
return;
}
Navigator.of(context).pop(CropResult(file, state));
}
bool _isFullCrop() {
final c = _crop;
if (c == null) return true;
final f = _fittedRect(_viewport);
return (c.left - f.left).abs() < 1 &&
(c.top - f.top).abs() < 1 &&
(c.right - f.right).abs() < 1 &&
(c.bottom - f.bottom).abs() < 1;
}
Future<File?> _bake() async {
final img = _image;
final crop = _crop;
final vp = _viewport;
if (img == null || crop == null || vp == Size.zero) return null;
try {
final m = _matrix(vp, crop);
final upscale = 1 / _baseScale(vp);
var outW = crop.width * upscale;
var outH = crop.height * upscale;
const maxDim = 4096;
final mx = math.max(outW, outH);
final cap = mx > maxDim ? maxDim / mx : 1.0;
final eff = upscale * cap;
final pxW = (crop.width * eff).round();
final pxH = (crop.height * eff).round();
if (pxW <= 0 || pxH <= 0) return null;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(eff);
canvas.translate(-crop.left, -crop.top);
canvas.transform(m.storage);
canvas.drawImage(
img,
Offset.zero,
Paint()..filterQuality = FilterQuality.high,
);
final picture = recorder.endRecording();
return await rasterPictureToJpegFile(picture, pxW, pxH, prefix: 'crop');
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
Expanded(child: _buildViewport()),
_buildTools(),
_buildActions(),
],
),
),
);
}
Widget _buildViewport() {
final img = _image;
if (img == null) {
return const Center(child: SmallSpinner(size: 36, color: Colors.white));
}
return LayoutBuilder(
builder: (context, constraints) {
final vp = constraints.biggest;
_ensureCrop(vp);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (d) => _onPanStart(d.localPosition),
onPanUpdate: (d) => _onPanUpdate(d.delta),
child: ValueListenableBuilder<int>(
valueListenable: _rev,
builder: (context, _, _) {
final crop = _crop!;
return CustomPaint(
size: vp,
painter: _CropPainter(
image: img,
matrix: _matrix(vp, crop),
crop: crop,
),
);
},
),
);
},
);
}
Widget _buildTools() {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
IconButton(
onPressed: _flip,
icon: Icon(
Symbols.flip,
color: _flipH ? MediaAccent.of(context) : Colors.white,
),
tooltip: l10n.photoEditorFlipTooltip,
),
Expanded(
child: ValueListenableBuilder<int>(
valueListenable: _rev,
builder: (context, _, _) => _StraightenRuler(
value: _straightenDeg,
onChanged: (v) {
_straightenDeg = v;
_rev.value++;
},
),
),
),
IconButton(
onPressed: _rotate90,
icon: const Icon(
Symbols.rotate_90_degrees_ccw,
color: Colors.white,
),
tooltip: l10n.photoEditorRotateTooltip,
),
],
),
);
}
Widget _buildActions() {
final l10n = AppLocalizations.of(context)!;
return Container(
color: _kPanel,
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
l10n.photoEditorCancel,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
TextButton(
onPressed: _reset,
child: Text(
l10n.photoEditorReset,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
TextButton(
onPressed: _baking ? null : _done,
child: Text(
l10n.photoEditorDone,
style: TextStyle(
color: _baking ? Colors.white38 : MediaAccent.of(context),
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
class _CropPainter extends CustomPainter {
final ui.Image image;
final Matrix4 matrix;
final Rect crop;
_CropPainter({required this.image, required this.matrix, required this.crop});
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.transform(matrix.storage);
canvas.drawImage(
image,
Offset.zero,
Paint()..filterQuality = FilterQuality.medium,
);
canvas.restore();
canvas.drawPath(
Path.combine(
PathOperation.difference,
Path()..addRect(Offset.zero & size),
Path()..addRect(crop),
),
Paint()..color = Colors.black.withValues(alpha: 0.55),
);
final grid = Paint()
..color = Colors.white.withValues(alpha: 0.4)
..strokeWidth = 0.7;
for (var i = 1; i < 3; i++) {
final x = crop.left + crop.width * i / 3;
final y = crop.top + crop.height * i / 3;
canvas.drawLine(Offset(x, crop.top), Offset(x, crop.bottom), grid);
canvas.drawLine(Offset(crop.left, y), Offset(crop.right, y), grid);
}
final border = Paint()
..color = Colors.white.withValues(alpha: 0.7)
..strokeWidth = 1
..style = PaintingStyle.stroke;
canvas.drawRect(crop, border);
final bracket = Paint()
..color = Colors.white
..strokeWidth = 3
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
const len = 20.0;
void corner(Offset o, double dx, double dy) {
canvas.drawLine(o, o.translate(dx, 0), bracket);
canvas.drawLine(o, o.translate(0, dy), bracket);
}
corner(crop.topLeft, len, len);
corner(crop.topRight, -len, len);
corner(crop.bottomLeft, len, -len);
corner(crop.bottomRight, -len, -len);
}
@override
bool shouldRepaint(covariant _CropPainter old) =>
old.matrix != matrix || old.crop != crop || old.image != image;
}
class _StraightenRuler extends StatelessWidget {
final double value;
final ValueChanged<double> onChanged;
const _StraightenRuler({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) {
onChanged((value - d.delta.dx * 0.22).clamp(-45.0, 45.0));
},
onDoubleTap: () => onChanged(0),
child: SizedBox(
height: 56,
child: CustomPaint(
painter: _RulerPainter(value, MediaAccent.of(context)),
),
),
);
}
}
class _RulerPainter extends CustomPainter {
final double value;
final Color accent;
_RulerPainter(this.value, this.accent);
@override
void paint(Canvas canvas, Size size) {
final cx = size.width / 2;
const pxPerDeg = 6.0;
final baseY = size.height - 6;
final tick = Paint()..strokeWidth = 1;
for (var deg = -60; deg <= 60; deg++) {
final x = cx + (deg - value) * pxPerDeg;
if (x < 0 || x > size.width) continue;
final major = deg % 5 == 0;
tick.color = Colors.white.withValues(alpha: major ? 0.85 : 0.4);
final h = major ? 14.0 : 8.0;
canvas.drawLine(Offset(x, baseY - h), Offset(x, baseY), tick);
}
final tp = TextPainter(
text: TextSpan(
text: '${value.toStringAsFixed(1).replaceAll('.', ',')}°',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(cx - tp.width / 2, 0));
canvas.drawLine(
Offset(cx, baseY - 18),
Offset(cx, baseY + 2),
Paint()
..color = accent
..strokeWidth = 2
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(covariant _RulerPainter old) =>
old.value != value || old.accent != accent;
}
const Color _kDrawPanel = Color(0xFF101010);
enum DrawTool { pen, marker, neon, eraser }
enum ShapeKind { circle, rectangle, star, cloud, arrow }
enum _EditTab { draw, stickers, text }
sealed class EditMark {}
class StrokeMark extends EditMark {
final List<Offset> points;
final Color color;
final double width;
final DrawTool tool;
StrokeMark({
required this.points,
required this.color,
required this.width,
required this.tool,
});
}
class ShapeMark extends EditMark {
final ShapeKind kind;
final Offset start;
final Offset end;
final Color color;
final double width;
ShapeMark({
required this.kind,
required this.start,
required this.end,
required this.color,
required this.width,
});
}
class TextMark extends EditMark {
String text;
Offset position;
Color color;
double fontSize;
double rotation;
TextMark({
required this.text,
required this.position,
required this.color,
required this.fontSize,
this.rotation = 0,
});
}
class PhotoDrawEditor extends StatefulWidget {
final File source;
final int imageWidth;
final int imageHeight;
const PhotoDrawEditor({
super.key,
required this.source,
required this.imageWidth,
required this.imageHeight,
});
@override
State<PhotoDrawEditor> createState() => _PhotoDrawEditorState();
}
class _PhotoDrawEditorState extends State<PhotoDrawEditor> {
final GlobalKey _boundaryKey = GlobalKey();
final ValueNotifier<int> _canvasRev = ValueNotifier(0);
final List<EditMark> _marks = [];
StrokeMark? _liveStroke;
ShapeMark? _liveShape;
TextMark? _draggingText;
DrawTool _tool = DrawTool.pen;
Color _color = Colors.white;
double _width = 8;
TextMark? _selectedText;
bool _resizingText = false;
double _resizeBaseSize = 0;
double _resizeBaseDist = 1;
double _resizeBaseRotation = 0;
double _resizeBaseAngle = 0;
ShapeKind? _shapeMode;
_EditTab _tab = _EditTab.draw;
bool _paletteOpen = false;
bool _shapesOpen = false;
bool _baking = false;
@override
void dispose() {
_canvasRev.dispose();
super.dispose();
}
void _bumpCanvas() => _canvasRev.value++;
void _undo() {
if (_marks.isEmpty) return;
if (identical(_marks.last, _selectedText)) _selectedText = null;
setState(() => _marks.removeLast());
}
void _clearAll() {
if (_marks.isEmpty) return;
_selectedText = null;
setState(_marks.clear);
}
void _onPanStart(Offset pos) {
if (_tab == _EditTab.text) {
final sel = _selectedText;
if (sel != null && _nearHandle(sel, pos)) {
final v = pos - sel.position;
_resizingText = true;
_resizeBaseSize = sel.fontSize;
_resizeBaseDist = math.max(8, v.distance);
_resizeBaseRotation = sel.rotation;
_resizeBaseAngle = math.atan2(v.dy, v.dx);
return;
}
final hit = _hitText(pos);
_draggingText = hit;
if (hit != null && !identical(hit, _selectedText)) {
_selectedText = hit;
_bumpCanvas();
}
return;
}
final shape = _shapeMode;
if (shape != null) {
_liveShape = ShapeMark(
kind: shape,
start: pos,
end: pos,
color: _color,
width: _width,
);
} else {
_liveStroke = StrokeMark(
points: [pos],
color: _color,
width: _width,
tool: _tool,
);
}
_bumpCanvas();
}
void _onPanUpdate(Offset pos) {
if (_tab == _EditTab.text) {
if (_resizingText) {
final sel = _selectedText;
if (sel != null) {
final v = pos - sel.position;
final angle = math.atan2(v.dy, v.dx);
sel.fontSize = (_resizeBaseSize * v.distance / _resizeBaseDist).clamp(
10.0,
200.0,
);
sel.rotation = _resizeBaseRotation + (angle - _resizeBaseAngle);
_bumpCanvas();
}
return;
}
final t = _draggingText;
if (t != null) {
t.position = pos;
_bumpCanvas();
}
return;
}
final shape = _liveShape;
if (shape != null) {
_liveShape = ShapeMark(
kind: shape.kind,
start: shape.start,
end: pos,
color: shape.color,
width: shape.width,
);
_bumpCanvas();
} else if (_liveStroke != null) {
final pts = _liveStroke!.points;
if (pts.isEmpty || (pos - pts.last).distance >= 2.0) {
pts.add(pos);
_bumpCanvas();
}
}
}
void _onPanEnd() {
if (_tab == _EditTab.text) {
_resizingText = false;
_draggingText = null;
return;
}
final shape = _liveShape;
if (shape != null) {
if ((shape.end - shape.start).distance > 4) _marks.add(shape);
setState(() {
_liveShape = null;
_shapeMode = null;
});
} else if (_liveStroke != null) {
if (_liveStroke!.points.isNotEmpty) _marks.add(_liveStroke!);
setState(() => _liveStroke = null);
}
}
TextMark? _hitText(Offset pos) {
for (final m in _marks.reversed) {
if (m is! TextMark) continue;
final local = _toLocal(pos, m);
final box = textMarkSize(m);
if (local.dx.abs() <= box.width / 2 && local.dy.abs() <= box.height / 2) {
return m;
}
}
return null;
}
Offset _toLocal(Offset pos, TextMark t) {
final v = pos - t.position;
final c = math.cos(-t.rotation);
final s = math.sin(-t.rotation);
return Offset(v.dx * c - v.dy * s, v.dx * s + v.dy * c);
}
bool _nearHandle(TextMark t, Offset pos) {
final (left, right) = handlePositions(t);
return (pos - left).distance < 26 || (pos - right).distance < 26;
}
Future<void> _addText() async {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
final String? text;
try {
text = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E),
shape: AppShape.dialogBorder,
title: Text(
l10n.photoEditorTextDialogTitle,
style: const TextStyle(color: Colors.white),
),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: Colors.white),
cursorColor: Colors.white,
decoration: InputDecoration(
hintText: l10n.photoEditorTextDialogHint,
hintStyle: const TextStyle(color: Colors.white38),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.spoofDialogCancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: Text(l10n.photoEditorOk),
),
],
),
);
} finally {
controller.dispose();
}
if (text == null || text.trim().isEmpty || !mounted) return;
final ro = _boundaryKey.currentContext?.findRenderObject();
final size = ro is RenderBox ? ro.size : const Size(300, 300);
final mark = TextMark(
text: text.trim(),
position: Offset(size.width / 2, size.height / 2),
color: _color,
fontSize: 34,
);
setState(() {
_marks.add(mark);
_selectedText = mark;
});
}
Future<void> _apply() async {
if (_baking) return;
if (_marks.isEmpty) {
Navigator.of(context).pop();
return;
}
setState(() => _baking = true);
final file = await _bake();
if (!mounted) return;
if (file == null) {
setState(() => _baking = false);
showCustomNotification(
context,
AppLocalizations.of(context)!.photoEditorApplyChangesFailed,
);
return;
}
Navigator.of(context).pop(file);
}
Future<File?> _bake() async {
final ro = _boundaryKey.currentContext?.findRenderObject();
if (ro is! RenderBox || ro.size.isEmpty) return null;
final box = ro.size;
try {
final bytes = await widget.source.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final image = frame.image;
const maxDim = 4096;
final srcMax = math.max(image.width, image.height);
final cap = srcMax > maxDim ? maxDim / srcMax : 1.0;
final outW = (image.width * cap).round();
final outH = (image.height * cap).round();
final scale = outW / box.width;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(scale);
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
Rect.fromLTWH(0, 0, box.width, box.height),
Paint(),
);
_DrawingPainter(marks: _marks).paintMarks(canvas, box);
final picture = recorder.endRecording();
image.dispose();
codec.dispose();
return await rasterPictureToJpegFile(picture, outW, outH, prefix: 'edit');
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Column(
children: [
_buildTopBar(),
Expanded(child: _buildCanvas()),
_buildBottomPanel(),
],
),
if (_tab == _EditTab.draw) _buildSideSlider(),
if (_baking) const BusyOverlay(),
],
),
);
}
Widget _buildTopBar() {
return SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
children: [
IconButton(
onPressed: _marks.isEmpty ? null : _undo,
icon: const Icon(Symbols.undo),
color: Colors.white,
disabledColor: Colors.white24,
),
const Spacer(),
TextButton(
onPressed: _marks.isEmpty ? null : _clearAll,
child: Text(
AppLocalizations.of(context)!.photoEditorClearAll,
style: TextStyle(
color: _marks.isEmpty ? Colors.white24 : Colors.white,
fontSize: 15,
),
),
),
],
),
),
);
}
Widget _buildCanvas() {
final aspect = widget.imageHeight > 0
? widget.imageWidth / widget.imageHeight
: 1.0;
return Center(
child: AspectRatio(
aspectRatio: aspect <= 0 ? 1.0 : aspect,
child: ValueListenableBuilder<int>(
valueListenable: _canvasRev,
child: Image.file(
widget.source,
fit: BoxFit.cover,
gaplessPlayback: true,
),
builder: (context, _, image) {
final selected = _tab == _EditTab.text ? _selectedText : null;
return Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
key: _boundaryKey,
child: Stack(
fit: StackFit.expand,
children: [
image!,
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (d) => _onPanStart(d.localPosition),
onPanUpdate: (d) => _onPanUpdate(d.localPosition),
onPanEnd: (_) => _onPanEnd(),
child: CustomPaint(
painter: _DrawingPainter(
marks: _marks,
live: _liveStroke ?? _liveShape,
),
),
),
),
],
),
),
if (selected != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _SelectionPainter(
selected,
MediaAccent.of(context),
),
),
),
),
],
);
},
),
),
);
}
Widget _buildSideSlider() {
return Positioned(
left: 2,
top: 0,
bottom: 0,
child: Center(
child: SizedBox(
height: 220,
child: RotatedBox(
quarterTurns: 3,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
thumbColor: Colors.white,
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9),
),
child: Slider(
min: 2,
max: 40,
value: _width,
onChanged: (v) => setState(() => _width = v),
),
),
),
),
),
);
}
Widget _buildBottomPanel() {
return Container(
color: _kDrawPanel,
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_paletteOpen) _buildColorPicker(),
if (_shapesOpen && _tab == _EditTab.draw) _buildShapesRow(),
_buildToolbar(),
const SizedBox(height: 2),
_buildTabs(),
],
),
),
);
}
Widget _buildToolbar() {
switch (_tab) {
case _EditTab.draw:
return _buildDrawToolbar();
case _EditTab.text:
return _buildTextToolbar();
case _EditTab.stickers:
return const SizedBox(height: 56);
}
}
Widget _buildDrawToolbar() {
return SizedBox(
height: 56,
child: Row(
children: [
const SizedBox(width: 10),
_buildColorButton(),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildToolButton(DrawTool.pen, Symbols.edit),
_buildToolButton(DrawTool.marker, Symbols.ink_highlighter),
_buildToolButton(DrawTool.neon, Symbols.auto_awesome),
_buildToolButton(DrawTool.eraser, Symbols.ink_eraser),
],
),
),
IconButton(
onPressed: () => setState(() {
_shapesOpen = !_shapesOpen;
_paletteOpen = false;
}),
icon: Icon(
Symbols.add,
color: _shapeMode != null ? _color : Colors.white,
),
),
const SizedBox(width: 8),
],
),
);
}
Widget _buildTextToolbar() {
return SizedBox(
height: 56,
child: Row(
children: [
const SizedBox(width: 10),
_buildColorButton(),
const SizedBox(width: 14),
TextButton.icon(
onPressed: _addText,
icon: const Icon(Symbols.add, color: Colors.white),
label: Text(
AppLocalizations.of(context)!.photoEditorAddText,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
const Spacer(),
],
),
);
}
Widget _buildColorButton() {
return GestureDetector(
onTap: () => setState(() {
_paletteOpen = !_paletteOpen;
_shapesOpen = false;
}),
child: Container(
width: 32,
height: 32,
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: SweepGradient(colors: _kPenWheel),
),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _color,
border: Border.all(color: Colors.white, width: 1.5),
),
),
),
);
}
Widget _buildToolButton(DrawTool tool, IconData icon) {
final selected = _shapeMode == null && _tool == tool;
return GestureDetector(
onTap: () => setState(() {
_tool = tool;
_shapeMode = null;
_shapesOpen = false;
}),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected
? Colors.white.withValues(alpha: 0.18)
: Colors.transparent,
),
child: Icon(
icon,
color: selected ? Colors.white : Colors.white60,
size: 24,
),
),
);
}
Widget _buildColorPicker() {
return _ColorPicker(
color: _color,
onChanged: (c) => setState(() {
_color = c;
if (_tab == _EditTab.text) _selectedText?.color = c;
}),
);
}
Widget _buildShapesRow() {
const shapes = <(ShapeKind, IconData)>[
(ShapeKind.circle, Symbols.circle),
(ShapeKind.rectangle, Symbols.rectangle),
(ShapeKind.star, Symbols.star),
(ShapeKind.cloud, Symbols.cloud),
(ShapeKind.arrow, Symbols.north_east),
];
return SizedBox(
height: 48,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final (kind, icon) in shapes)
IconButton(
onPressed: () => setState(() {
_shapeMode = kind;
_shapesOpen = false;
}),
icon: Icon(
icon,
color: _shapeMode == kind ? _color : Colors.white,
),
),
],
),
);
}
Widget _buildTabs() {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
height: 48,
child: Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Symbols.close, color: Colors.white),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildTab(l10n.photoEditorTabDraw, _EditTab.draw),
_buildTab(
l10n.photoEditorTabStickers,
_EditTab.stickers,
disabled: true,
),
_buildTab(l10n.photoEditorTabText, _EditTab.text),
],
),
),
IconButton(
onPressed: _baking ? null : _apply,
icon: const Icon(Symbols.check, color: Colors.white),
),
],
),
);
}
Widget _buildTab(String label, _EditTab tab, {bool disabled = false}) {
final selected = _tab == tab;
return GestureDetector(
onTap: disabled
? null
: () => setState(() {
_tab = tab;
_paletteOpen = false;
_shapesOpen = false;
if (tab != _EditTab.draw) _shapeMode = null;
}),
child: Text(
label,
style: TextStyle(
color: disabled
? Colors.white24
: (selected ? Colors.white : Colors.white60),
fontSize: 14,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: 0.5,
),
),
);
}
}
class _DrawingPainter extends CustomPainter {
final List<EditMark> marks;
final EditMark? live;
_DrawingPainter({required this.marks, this.live});
@override
void paint(Canvas canvas, Size size) => paintMarks(canvas, size);
void paintMarks(Canvas canvas, Size size) {
final needsLayer = _hasEraser();
if (needsLayer) canvas.saveLayer(Offset.zero & size, Paint());
for (final m in marks) {
_paintMark(canvas, m);
}
final l = live;
if (l != null) _paintMark(canvas, l);
if (needsLayer) canvas.restore();
}
bool _hasEraser() {
for (final m in marks) {
if (m is StrokeMark && m.tool == DrawTool.eraser) return true;
}
final l = live;
return l is StrokeMark && l.tool == DrawTool.eraser;
}
void _paintMark(Canvas canvas, EditMark m) {
switch (m) {
case StrokeMark s:
_paintStroke(canvas, s);
case ShapeMark sh:
_paintShape(canvas, sh);
case TextMark t:
_paintText(canvas, t);
}
}
void _paintStroke(Canvas canvas, StrokeMark s) {
final paint = Paint()
..color = s.color
..strokeWidth = s.width
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
switch (s.tool) {
case DrawTool.pen:
break;
case DrawTool.marker:
paint.color = s.color.withValues(alpha: 0.4);
paint.strokeWidth = s.width * 1.6;
paint.strokeCap = StrokeCap.square;
case DrawTool.neon:
final glow = Paint()
..color = s.color.withValues(alpha: 0.7)
..strokeWidth = s.width * 2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
_drawStrokeGeometry(canvas, s, glow);
paint.color = Colors.white;
case DrawTool.eraser:
paint.blendMode = BlendMode.clear;
}
_drawStrokeGeometry(canvas, s, paint);
}
void _drawStrokeGeometry(Canvas canvas, StrokeMark s, Paint paint) {
if (s.points.length < 2) {
final dot = Paint()
..color = paint.color
..blendMode = paint.blendMode
..maskFilter = paint.maskFilter
..style = PaintingStyle.fill;
canvas.drawCircle(s.points.first, paint.strokeWidth / 2, dot);
return;
}
final path = Path()..moveTo(s.points.first.dx, s.points.first.dy);
for (var i = 1; i < s.points.length; i++) {
path.lineTo(s.points[i].dx, s.points[i].dy);
}
canvas.drawPath(path, paint);
}
void _paintShape(Canvas canvas, ShapeMark sh) {
final paint = Paint()
..color = sh.color
..strokeWidth = sh.width
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final rect = Rect.fromPoints(sh.start, sh.end);
switch (sh.kind) {
case ShapeKind.circle:
canvas.drawOval(rect, paint);
case ShapeKind.rectangle:
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
paint,
);
case ShapeKind.star:
canvas.drawPath(_starPath(rect), paint);
case ShapeKind.cloud:
canvas.drawPath(_cloudPath(rect), paint);
case ShapeKind.arrow:
_paintArrow(canvas, sh.start, sh.end, paint);
}
}
Path _starPath(Rect rect) {
final cx = rect.center.dx;
final cy = rect.center.dy;
final outer = math.min(rect.width.abs(), rect.height.abs()) / 2;
final inner = outer * 0.45;
final path = Path();
for (var i = 0; i < 10; i++) {
final r = i.isEven ? outer : inner;
final angle = -math.pi / 2 + i * math.pi / 5;
final x = cx + r * math.cos(angle);
final y = cy + r * math.sin(angle);
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
path.close();
return path;
}
Path _cloudPath(Rect rect) {
final w = rect.width;
final h = rect.height;
Offset pt(double nx, double ny) =>
Offset(rect.left + nx * w, rect.top + ny * h);
final path = Path()..moveTo(pt(0.25, 0.78).dx, pt(0.25, 0.78).dy);
path
..cubicTo(
pt(0.0, 0.78).dx,
pt(0.0, 0.78).dy,
pt(0.0, 0.45).dx,
pt(0.0, 0.45).dy,
pt(0.22, 0.42).dx,
pt(0.22, 0.42).dy,
)
..cubicTo(
pt(0.2, 0.12).dx,
pt(0.2, 0.12).dy,
pt(0.56, 0.08).dx,
pt(0.56, 0.08).dy,
pt(0.62, 0.36).dx,
pt(0.62, 0.36).dy,
)
..cubicTo(
pt(0.86, 0.24).dx,
pt(0.86, 0.24).dy,
pt(1.02, 0.5).dx,
pt(1.02, 0.5).dy,
pt(0.8, 0.6).dx,
pt(0.8, 0.6).dy,
)
..cubicTo(
pt(1.02, 0.66).dx,
pt(1.02, 0.66).dy,
pt(0.96, 0.9).dx,
pt(0.96, 0.9).dy,
pt(0.74, 0.8).dx,
pt(0.74, 0.8).dy,
)
..cubicTo(
pt(0.7, 0.98).dx,
pt(0.7, 0.98).dy,
pt(0.34, 0.98).dx,
pt(0.34, 0.98).dy,
pt(0.25, 0.78).dx,
pt(0.25, 0.78).dy,
)
..close();
return path;
}
void _paintArrow(Canvas canvas, Offset start, Offset end, Paint paint) {
canvas.drawLine(start, end, paint);
final angle = math.atan2(end.dy - start.dy, end.dx - start.dx);
final headLen = math.max(paint.strokeWidth * 4, 18.0);
const headAngle = math.pi / 7;
final p1 =
end -
Offset(math.cos(angle - headAngle), math.sin(angle - headAngle)) *
headLen;
final p2 =
end -
Offset(math.cos(angle + headAngle), math.sin(angle + headAngle)) *
headLen;
canvas.drawLine(end, p1, paint);
canvas.drawLine(end, p2, paint);
}
void _paintText(Canvas canvas, TextMark t) {
final tp = layoutText(t);
canvas.save();
canvas.translate(t.position.dx, t.position.dy);
canvas.rotate(t.rotation);
tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2));
canvas.restore();
}
@override
bool shouldRepaint(covariant _DrawingPainter oldDelegate) => true;
}
final Expando<_TextLayout> _textLayoutCache = Expando<_TextLayout>();
class _TextLayout {
final String text;
final double fontSize;
final Color color;
final TextPainter painter;
_TextLayout(this.text, this.fontSize, this.color, this.painter);
}
TextPainter layoutText(TextMark t) {
final cached = _textLayoutCache[t];
if (cached != null &&
cached.text == t.text &&
cached.fontSize == t.fontSize &&
cached.color == t.color) {
return cached.painter;
}
final tp = TextPainter(
text: TextSpan(
text: t.text,
style: TextStyle(
color: t.color,
fontSize: t.fontSize,
fontWeight: FontWeight.w600,
shadows: const [Shadow(blurRadius: 4, color: Colors.black54)],
),
),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
)..layout(maxWidth: 2000);
_textLayoutCache[t] = _TextLayout(t.text, t.fontSize, t.color, tp);
return tp;
}
Size textMarkSize(TextMark t) {
final tp = layoutText(t);
return Size(tp.width + 32, tp.height + 24);
}
(Offset, Offset) handlePositions(TextMark t) {
final hw = textMarkSize(t).width / 2;
final c = math.cos(t.rotation);
final s = math.sin(t.rotation);
return (
t.position + Offset(-hw * c, -hw * s),
t.position + Offset(hw * c, hw * s),
);
}
class _SelectionPainter extends CustomPainter {
final TextMark text;
final Color accent;
_SelectionPainter(this.text, this.accent);
@override
void paint(Canvas canvas, Size size) {
final box = textMarkSize(text);
final hw = box.width / 2;
final hh = box.height / 2;
canvas.save();
canvas.translate(text.position.dx, text.position.dy);
canvas.rotate(text.rotation);
final border = Paint()
..color = Colors.white
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final tl = Offset(-hw, -hh);
final tr = Offset(hw, -hh);
final br = Offset(hw, hh);
final bl = Offset(-hw, hh);
_dashedLine(canvas, tl, tr, border);
_dashedLine(canvas, tr, br, border);
_dashedLine(canvas, br, bl, border);
_dashedLine(canvas, bl, tl, border);
final fill = Paint()
..color = accent
..style = PaintingStyle.fill;
final ring = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
for (final c in [Offset(-hw, 0), Offset(hw, 0)]) {
canvas.drawCircle(c, 7, fill);
canvas.drawCircle(c, 7, ring);
}
canvas.restore();
}
void _dashedLine(Canvas canvas, Offset a, Offset b, Paint paint) {
const dash = 7.0;
const gap = 5.0;
final total = (b - a).distance;
if (total <= 0) return;
final dir = (b - a) / total;
var d = 0.0;
while (d < total) {
final start = a + dir * d;
final end = a + dir * math.min(d + dash, total);
canvas.drawLine(start, end, paint);
d += dash + gap;
}
}
@override
bool shouldRepaint(covariant _SelectionPainter oldDelegate) => true;
}
class _ColorPicker extends StatefulWidget {
final Color color;
final ValueChanged<Color> onChanged;
const _ColorPicker({required this.color, required this.onChanged});
@override
State<_ColorPicker> createState() => _ColorPickerState();
}
class _ColorPickerState extends State<_ColorPicker> {
late HSVColor _hsv;
@override
void initState() {
super.initState();
final hsv = HSVColor.fromColor(widget.color);
_hsv = hsv.saturation == 0 ? hsv.withHue(0) : hsv;
}
void _setSV(Offset pos, Size size) {
if (size.width <= 0 || size.height <= 0) return;
final s = (pos.dx / size.width).clamp(0.0, 1.0);
final v = (1 - pos.dy / size.height).clamp(0.0, 1.0);
setState(() => _hsv = _hsv.withSaturation(s).withValue(v));
widget.onChanged(_hsv.toColor());
}
void _setHue(double dx, double width) {
if (width <= 0) return;
setState(() => _hsv = _hsv.withHue((dx / width).clamp(0.0, 1.0) * 360));
widget.onChanged(_hsv.toColor());
}
@override
Widget build(BuildContext context) {
final hueColor = HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor();
return Container(
color: _kDrawPanel,
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 132,
child: LayoutBuilder(
builder: (context, constraints) {
final size = constraints.biggest;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => _setSV(d.localPosition, size),
onPanUpdate: (d) => _setSV(d.localPosition, size),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Colors.white, hueColor],
),
),
),
),
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black],
),
),
),
),
Positioned(
left: _hsv.saturation * size.width - 9,
top: (1 - _hsv.value) * size.height - 9,
child: _thumb(_hsv.toColor()),
),
],
),
),
);
},
),
),
const SizedBox(height: 14),
SizedBox(
height: 22,
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => _setHue(d.localPosition.dx, width),
onPanUpdate: (d) => _setHue(d.localPosition.dx, width),
child: ClipRRect(
borderRadius: BorderRadius.circular(11),
child: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFFF0000),
Color(0xFFFFFF00),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0000FF),
Color(0xFFFF00FF),
Color(0xFFFF0000),
],
),
),
),
),
Positioned(
left: (_hsv.hue / 360) * width - 9,
top: 1,
bottom: 1,
child: _thumb(hueColor),
),
],
),
),
);
},
),
),
],
),
);
}
Widget _thumb(Color color) {
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)],
),
);
}
}
enum BlurMode { off, radial, linear }
enum _Tab { adjust, blur, curves }
class PhotoAdjustEditor extends StatefulWidget {
final File source;
const PhotoAdjustEditor({super.key, required this.source});
@override
State<PhotoAdjustEditor> createState() => _PhotoAdjustEditorState();
}
class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
ui.Image? _image;
final ValueNotifier<int> _rev = ValueNotifier(0);
double _enhance = 0;
double _exposure = 0;
double _contrast = 0;
double _saturation = 0;
double _warmth = 0;
double _vignette = 0;
BlurMode _blur = BlurMode.off;
Offset _blurCenter = const Offset(0.5, 0.5);
double _blurInner = 0.18;
double _blurOuter = 0.34;
static const double _blurAngle = 0;
int _blurHandle = 0;
final List<List<Offset>> _curves = List.generate(
4,
(_) => [const Offset(0, 0), const Offset(1, 1)],
);
int _channel = 0;
int _curveDrag = -1;
Uint8List? _smallRgba;
int _smallW = 0;
int _smallH = 0;
ui.Image? _curvedImage;
Uint8List? _curveOut;
bool _curveBusy = false;
bool _curveDirty = false;
_Tab _tab = _Tab.adjust;
bool _baking = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final bytes = await widget.source.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
codec.dispose();
if (!mounted) {
frame.image.dispose();
return;
}
final smallCodec = await ui.instantiateImageCodec(
bytes,
targetWidth: 480,
);
final smallFrame = await smallCodec.getNextFrame();
smallCodec.dispose();
final small = smallFrame.image;
final sbd = await small.toByteData(format: ui.ImageByteFormat.rawRgba);
_smallW = small.width;
_smallH = small.height;
_smallRgba = sbd?.buffer.asUint8List();
small.dispose();
if (!mounted) {
frame.image.dispose();
return;
}
setState(() => _image = frame.image);
} catch (_) {
if (mounted) Navigator.of(context).pop();
}
}
@override
void dispose() {
_image?.dispose();
_curvedImage?.dispose();
_rev.dispose();
super.dispose();
}
bool _curveIdentity(List<Offset> pts) =>
pts.length == 2 &&
pts.first == const Offset(0, 0) &&
pts.last == const Offset(1, 1);
bool get _curvesIdentity => _curves.every(_curveIdentity);
bool get _pristine =>
_enhance == 0 &&
_exposure == 0 &&
_contrast == 0 &&
_saturation == 0 &&
_warmth == 0 &&
_vignette == 0 &&
_blur == BlurMode.off &&
_curvesIdentity;
List<double> _colorMatrix() {
var m = _identity();
m = _mulMatrix(_brightness(1 + _exposure), m);
m = _mulMatrix(_contrastMatrix(1 + _contrast), m);
m = _mulMatrix(_saturationMatrix(1 + _saturation), m);
m = _mulMatrix(_warmthMatrix(_warmth), m);
if (_enhance > 0) {
m = _mulMatrix(_contrastMatrix(1 + _enhance * 0.35), m);
m = _mulMatrix(_saturationMatrix(1 + _enhance * 0.4), m);
m = _mulMatrix(_brightness(1 + _enhance * 0.05), m);
}
return m;
}
Gradient _maskGradient() {
if (_blur == BlurMode.linear) {
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [
Colors.transparent,
Colors.white,
Colors.white,
Colors.transparent,
],
stops: _linearStops(),
transform: _RotateAround(_blurAngle, _blurCenter),
);
}
final innerStop = _blurOuter > 0
? (_blurInner / _blurOuter).clamp(0.0, 1.0)
: 1.0;
return RadialGradient(
center: Alignment(_blurCenter.dx * 2 - 1, _blurCenter.dy * 2 - 1),
radius: _blurOuter,
colors: const [Colors.white, Colors.white, Colors.transparent],
stops: [0.0, innerStop, 1.0],
);
}
List<double> _linearStops() {
final c = _blurCenter.dy;
var s0 = (c - _blurOuter).clamp(0.0, 1.0);
var s1 = (c - _blurInner).clamp(0.0, 1.0);
var s2 = (c + _blurInner).clamp(0.0, 1.0);
var s3 = (c + _blurOuter).clamp(0.0, 1.0);
s1 = math.max(s1, s0);
s2 = math.max(s2, s1);
s3 = math.max(s3, s2);
return [s0, s1, s2, s3];
}
Rect _imageRect(Size box, ui.Image img) {
final iw = img.width.toDouble();
final ih = img.height.toDouble();
if (iw <= 0 || ih <= 0) return Offset.zero & box;
final scale = math.min(box.width / iw, box.height / ih);
final w = iw * scale;
final h = ih * scale;
return Rect.fromLTWH((box.width - w) / 2, (box.height - h) / 2, w, h);
}
double _blurAlong(Offset pos, Size imgSize) {
final c = Offset(
_blurCenter.dx * imgSize.width,
_blurCenter.dy * imgSize.height,
);
if (_blur == BlurMode.radial) return (pos - c).distance;
final axis = Offset(-math.sin(_blurAngle), math.cos(_blurAngle));
return ((pos - c).dx * axis.dx + (pos - c).dy * axis.dy).abs();
}
double _blurDenom(Size imgSize) =>
_blur == BlurMode.radial ? imgSize.shortestSide : imgSize.height;
void _onBlurPanStart(Offset pos, Size imgSize) {
final denom = _blurDenom(imgSize);
final along = _blurAlong(pos, imgSize);
final di = (along - _blurInner * denom).abs();
final doo = (along - _blurOuter * denom).abs();
if (di < doo && di < 44) {
_blurHandle = 1;
} else if (doo < 44) {
_blurHandle = 2;
} else {
_blurHandle = 0;
}
}
void _onBlurPanUpdate(Offset pos, Offset delta, Size imgSize) {
final denom = _blurDenom(imgSize);
if (_blurHandle == 1) {
_blurInner = (_blurAlong(pos, imgSize) / denom).clamp(0.02, _blurOuter);
} else if (_blurHandle == 2) {
_blurOuter = (_blurAlong(pos, imgSize) / denom).clamp(_blurInner, 1.6);
} else {
_blurCenter = Offset(
(_blurCenter.dx + delta.dx / imgSize.width).clamp(0.0, 1.0),
(_blurCenter.dy + delta.dy / imgSize.height).clamp(0.0, 1.0),
);
}
_rev.value++;
}
double _curveY(List<Offset> pts, double x) {
if (x <= pts.first.dx) return pts.first.dy;
if (x >= pts.last.dx) return pts.last.dy;
for (var i = 0; i < pts.length - 1; i++) {
final a = pts[i];
final b = pts[i + 1];
if (x >= a.dx && x <= b.dx) {
final span = b.dx - a.dx;
final t = span < 1e-6 ? 0.0 : (x - a.dx) / span;
return a.dy + (b.dy - a.dy) * t;
}
}
return pts.last.dy;
}
List<int> _lut(List<Offset> pts) => List<int>.generate(
256,
(i) => (_curveY(pts, i / 255.0) * 255).round().clamp(0, 255),
);
(List<int>, List<int>, List<int>) _combinedLuts() {
final m = _lut(_curves[0]);
final r = _lut(_curves[1]);
final g = _lut(_curves[2]);
final b = _lut(_curves[3]);
return (
List<int>.generate(256, (i) => m[r[i]]),
List<int>.generate(256, (i) => m[g[i]]),
List<int>.generate(256, (i) => m[b[i]]),
);
}
void _scheduleCurvePreview() {
_rev.value++;
if (_curvesIdentity) {
_curvedImage?.dispose();
_curvedImage = null;
return;
}
if (_curveBusy) {
_curveDirty = true;
return;
}
_runCurvePreview();
}
Future<void> _runCurvePreview() async {
final base = _smallRgba;
if (base == null) return;
_curveBusy = true;
final (rl, gl, bl) = _combinedLuts();
final out = _curveOut ??= Uint8List(base.length);
out.setAll(0, base);
_applyLutsToBytes((out, rl, gl, bl));
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
out,
_smallW,
_smallH,
ui.PixelFormat.rgba8888,
completer.complete,
);
final img = await completer.future;
_curveBusy = false;
if (!mounted) {
img.dispose();
return;
}
_curvedImage?.dispose();
_curvedImage = img;
_rev.value++;
if (_curveDirty) {
_curveDirty = false;
_runCurvePreview();
}
}
Future<ui.Image> _curvedFull(ui.Image img) async {
if (_curvesIdentity) return img;
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
if (bd == null) return img;
final (rl, gl, bl) = _combinedLuts();
final out = await compute(_applyLutsToBytes, (
bd.buffer.asUint8List(),
rl,
gl,
bl,
));
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
out,
img.width,
img.height,
ui.PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
}
void _onCurvePanStart(Offset pos, Size size) {
final pts = _curves[_channel];
var hit = -1;
for (var i = 0; i < pts.length; i++) {
final sp = Offset(pts[i].dx * size.width, (1 - pts[i].dy) * size.height);
if ((pos - sp).distance < 28) {
hit = i;
break;
}
}
if (hit == -1 && pts.length < 10) {
final x = (pos.dx / size.width).clamp(0.0, 1.0);
final y = (1 - pos.dy / size.height).clamp(0.0, 1.0);
var idx = pts.indexWhere((pt) => pt.dx > x);
if (idx == -1) idx = pts.length;
pts.insert(idx, Offset(x, y));
hit = idx;
}
_curveDrag = hit;
}
void _onCurvePanUpdate(Offset pos, Size size) {
if (_curveDrag < 0) return;
final pts = _curves[_channel];
final y = (1 - pos.dy / size.height).clamp(0.0, 1.0);
double x;
if (_curveDrag == 0) {
x = 0;
} else if (_curveDrag == pts.length - 1) {
x = 1;
} else {
final lo = pts[_curveDrag - 1].dx + 0.01;
final hi = pts[_curveDrag + 1].dx - 0.01;
x = (pos.dx / size.width).clamp(lo, math.max(lo, hi));
}
pts[_curveDrag] = Offset(x, y);
_scheduleCurvePreview();
}
void _onCurveRemove(Offset pos, Size size) {
final pts = _curves[_channel];
for (var i = 1; i < pts.length - 1; i++) {
final sp = Offset(pts[i].dx * size.width, (1 - pts[i].dy) * size.height);
if ((pos - sp).distance < 28) {
pts.removeAt(i);
_scheduleCurvePreview();
return;
}
}
}
Gradient _vignetteGradient() => RadialGradient(
radius: 0.9,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: (_vignette * 0.6).clamp(0.0, 1.0)),
],
stops: const [0.5, 1.0],
);
Future<File?> _bake() async {
final img = _image;
if (img == null) return null;
try {
const maxDim = 4096;
final srcMax = img.width > img.height ? img.width : img.height;
final cap = srcMax > maxDim ? maxDim / srcMax : 1.0;
final outW = (img.width * cap).round();
final outH = (img.height * cap).round();
if (outW <= 0 || outH <= 0) return null;
final rect = Rect.fromLTWH(0, 0, outW.toDouble(), outH.toDouble());
final src = Rect.fromLTWH(
0,
0,
img.width.toDouble(),
img.height.toDouble(),
);
final curved = await _curvedFull(img);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.saveLayer(
rect,
Paint()..colorFilter = ColorFilter.matrix(_colorMatrix()),
);
if (_blur == BlurMode.off) {
canvas.drawImageRect(curved, src, rect, Paint());
} else {
final sigma = outW * 0.02;
canvas.drawImageRect(
curved,
src,
rect,
Paint()
..imageFilter = ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
);
canvas.saveLayer(rect, Paint());
canvas.drawImageRect(curved, src, rect, Paint());
canvas.drawRect(
rect,
Paint()
..blendMode = BlendMode.dstIn
..shader = _maskGradient().createShader(rect),
);
canvas.restore();
}
canvas.restore();
if (_vignette > 0) {
canvas.drawRect(
rect,
Paint()..shader = _vignetteGradient().createShader(rect),
);
}
final picture = recorder.endRecording();
return await rasterPictureToJpegFile(
picture,
outW,
outH,
prefix: 'adj',
onPictureDisposed: () {
if (curved != img) curved.dispose();
},
);
} catch (_) {
return null;
}
}
Future<void> _done() async {
if (_baking) return;
if (_pristine) {
Navigator.of(context).pop();
return;
}
setState(() => _baking = true);
final file = await _bake();
if (!mounted) return;
if (file == null) {
setState(() => _baking = false);
showCustomNotification(
context,
AppLocalizations.of(context)!.photoEditorApplyFailed,
);
return;
}
Navigator.of(context).pop(file);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
Column(
children: [
Expanded(child: ClipRect(child: _buildPreview())),
_buildTabContent(),
_buildBottomBar(),
],
),
if (_baking) const BusyOverlay(),
],
),
),
);
}
Widget _buildPreview() {
final img = _image;
if (img == null) {
return const Center(child: SmallSpinner(size: 36, color: Colors.white));
}
return LayoutBuilder(
builder: (context, constraints) {
final rect = _imageRect(constraints.biggest, img);
return ValueListenableBuilder<int>(
valueListenable: _rev,
builder: (context, _, _) {
final blurTab = _tab == _Tab.blur && _blur != BlurMode.off;
final curvesTab = _tab == _Tab.curves;
final shown = _curvedImage ?? img;
final content = Stack(
fit: StackFit.expand,
children: [
ColorFiltered(
colorFilter: ColorFilter.matrix(_colorMatrix()),
child: _buildBlurLayer(shown),
),
if (_vignette > 0)
IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(gradient: _vignetteGradient()),
),
),
if (blurTab)
IgnorePointer(
child: CustomPaint(
painter: _BlurGuidePainter(
mode: _blur,
center: _blurCenter,
inner: _blurInner,
outer: _blurOuter,
angle: _blurAngle,
),
),
),
if (curvesTab)
IgnorePointer(
child: CustomPaint(
painter: _CurvePainter(
points: _curves[_channel],
color: _channelColor(_channel),
),
),
),
],
);
Widget child = content;
if (blurTab) {
child = GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (d) => _onBlurPanStart(d.localPosition, rect.size),
onPanUpdate: (d) =>
_onBlurPanUpdate(d.localPosition, d.delta, rect.size),
child: content,
);
} else if (curvesTab) {
child = GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (d) => _onCurvePanStart(d.localPosition, rect.size),
onPanUpdate: (d) =>
_onCurvePanUpdate(d.localPosition, rect.size),
onPanEnd: (_) => _curveDrag = -1,
onDoubleTapDown: (d) =>
_onCurveRemove(d.localPosition, rect.size),
child: content,
);
}
return Stack(
children: [Positioned.fromRect(rect: rect, child: child)],
);
},
);
},
);
}
Widget _buildBlurLayer(ui.Image img) {
if (_blur == BlurMode.off) {
return RawImage(image: img, fit: BoxFit.contain);
}
return Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ui.ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: RawImage(image: img, fit: BoxFit.contain),
),
ShaderMask(
shaderCallback: (r) => _maskGradient().createShader(r),
blendMode: BlendMode.dstIn,
child: RawImage(image: img, fit: BoxFit.contain),
),
],
);
}
Widget _buildTabContent() {
switch (_tab) {
case _Tab.adjust:
return _buildSliders();
case _Tab.blur:
return _buildBlurOptions();
case _Tab.curves:
return _buildCurves();
}
}
Color _channelColor(int ch) {
switch (ch) {
case 1:
return const Color(0xFFFF4D4D);
case 2:
return const Color(0xFF45D964);
case 3:
return const Color(0xFF4D9DFF);
default:
return Colors.white;
}
}
Widget _buildCurves() {
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.photoEditorChannelAll,
l10n.photoEditorChannelRed,
l10n.photoEditorChannelGreen,
l10n.photoEditorChannelBlue,
];
return SizedBox(
height: 110,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (var ch = 0; ch < 4; ch++) _channelOption(labels[ch], ch),
],
),
);
}
Widget _channelOption(String label, int ch) {
final selected = _channel == ch;
final color = _channelColor(ch);
return GestureDetector(
onTap: () => setState(() => _channel = ch),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: color, width: 2),
),
alignment: Alignment.center,
child: selected
? Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
)
: null,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
color: selected ? color : Colors.white70,
fontSize: 12,
),
),
],
),
);
}
Widget _buildSliders() {
return ValueListenableBuilder<int>(
valueListenable: _rev,
builder: (context, _, _) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_slider(
l10n.photoEditorEnhance,
_enhance,
0,
1,
(v) => _enhance = v,
),
_slider(
l10n.photoEditorExposure,
_exposure,
-1,
1,
(v) => _exposure = v,
),
_slider(
l10n.photoEditorContrast,
_contrast,
-1,
1,
(v) => _contrast = v,
),
_slider(
l10n.photoEditorSaturation,
_saturation,
-1,
1,
(v) => _saturation = v,
),
_slider(
l10n.photoEditorWarmth,
_warmth,
-1,
1,
(v) => _warmth = v,
),
_slider(
l10n.photoEditorVignette,
_vignette,
0,
1,
(v) => _vignette = v,
),
],
),
);
},
);
}
Widget _slider(
String label,
double value,
double min,
double max,
ValueChanged<double> onChanged,
) {
return Row(
children: [
SizedBox(
width: 104,
child: Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2,
thumbColor: Colors.white,
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
),
child: Slider(
min: min,
max: max,
value: value.clamp(min, max),
onChanged: (v) {
onChanged(v);
_rev.value++;
},
),
),
),
],
);
}
Widget _buildBlurOptions() {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
height: 110,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_blurOption(l10n.photoEditorBlurOff, Symbols.block, BlurMode.off),
_blurOption(
l10n.photoEditorBlurRadial,
Symbols.blur_circular,
BlurMode.radial,
),
_blurOption(
l10n.photoEditorBlurLinear,
Symbols.blur_linear,
BlurMode.linear,
),
],
),
);
}
Widget _blurOption(String label, IconData icon, BlurMode mode) {
final selected = _blur == mode;
return GestureDetector(
onTap: () => setState(() => _blur = mode),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: selected ? MediaAccent.of(context) : Colors.white,
size: 30,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
color: selected ? MediaAccent.of(context) : Colors.white70,
fontSize: 12,
),
),
],
),
);
}
Widget _buildBottomBar() {
final l10n = AppLocalizations.of(context)!;
return Container(
color: _kPanel,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
l10n.photoEditorCancel,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
const Spacer(),
_tabIcon(Symbols.tune, _Tab.adjust),
const SizedBox(width: 26),
_tabIcon(Symbols.water_drop, _Tab.blur),
const SizedBox(width: 26),
_tabIcon(Symbols.show_chart, _Tab.curves),
const Spacer(),
TextButton(
onPressed: _baking ? null : _done,
child: Text(
l10n.photoEditorDone,
style: TextStyle(
color: _baking ? Colors.white38 : MediaAccent.of(context),
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Widget _tabIcon(IconData icon, _Tab tab, {bool disabled = false}) {
final selected = _tab == tab;
return IconButton(
onPressed: disabled ? null : () => setState(() => _tab = tab),
icon: Icon(icon),
color: selected ? MediaAccent.of(context) : Colors.white,
disabledColor: Colors.white24,
);
}
}
List<double> _identity() => [
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
];
List<double> _brightness(double f) => [
f,
0,
0,
0,
0,
0,
f,
0,
0,
0,
0,
0,
f,
0,
0,
0,
0,
0,
1,
0,
];
List<double> _contrastMatrix(double c) {
final t = 127.5 * (1 - c);
return [c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0];
}
List<double> _saturationMatrix(double s) {
const lr = 0.2126;
const lg = 0.7152;
const lb = 0.0722;
final i = 1 - s;
return [
lr * i + s,
lg * i,
lb * i,
0,
0,
lr * i,
lg * i + s,
lb * i,
0,
0,
lr * i,
lg * i,
lb * i + s,
0,
0,
0,
0,
0,
1,
0,
];
}
List<double> _warmthMatrix(double w) {
final o = w * 25.0;
return [1, 0, 0, 0, o, 0, 1, 0, 0, 0, 0, 0, 1, 0, -o, 0, 0, 0, 1, 0];
}
Uint8List _applyLutsToBytes((Uint8List, List<int>, List<int>, List<int>) args) {
final (rgba, rl, gl, bl) = args;
for (var i = 0; i < rgba.length; i += 4) {
rgba[i] = rl[rgba[i]];
rgba[i + 1] = gl[rgba[i + 1]];
rgba[i + 2] = bl[rgba[i + 2]];
}
return rgba;
}
List<double> _mulMatrix(List<double> a, List<double> b) {
double at(List<double> m, int r, int c) =>
r < 4 ? m[r * 5 + c] : (c == 4 ? 1.0 : 0.0);
final out = List<double>.filled(20, 0);
for (var r = 0; r < 4; r++) {
for (var c = 0; c < 5; c++) {
var sum = 0.0;
for (var k = 0; k < 5; k++) {
sum += at(a, r, k) * at(b, k, c);
}
out[r * 5 + c] = sum;
}
}
return out;
}
class _RotateAround extends GradientTransform {
final double radians;
final Offset center;
const _RotateAround(this.radians, this.center);
@override
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
final cx = bounds.left + center.dx * bounds.width;
final cy = bounds.top + center.dy * bounds.height;
return Matrix4.identity()
..translateByDouble(cx, cy, 0, 1)
..rotateZ(radians)
..translateByDouble(-cx, -cy, 0, 1);
}
}
class _BlurGuidePainter extends CustomPainter {
final BlurMode mode;
final Offset center;
final double inner;
final double outer;
final double angle;
_BlurGuidePainter({
required this.mode,
required this.center,
required this.inner,
required this.outer,
required this.angle,
});
@override
void paint(Canvas canvas, Size sz) {
canvas.clipRect(Offset.zero & sz);
final c = Offset(center.dx * sz.width, center.dy * sz.height);
final line = Paint()
..color = Colors.white
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
if (mode == BlurMode.radial) {
final ss = sz.shortestSide;
_dashedCircle(canvas, c, inner * ss, line);
_dashedCircle(canvas, c, outer * ss, line);
} else {
final axis = Offset(-math.sin(angle), math.cos(angle));
final perp = Offset(math.cos(angle), math.sin(angle));
final innerPx = inner * sz.height;
final outerPx = outer * sz.height;
for (final o in [-outerPx, -innerPx, innerPx, outerPx]) {
final mid = c + axis * o;
_dashedLine(canvas, mid - perp * 4000, mid + perp * 4000, line);
}
}
canvas.drawCircle(c, 9, Paint()..color = Colors.white);
canvas.drawCircle(
c,
9,
Paint()
..color = Colors.black26
..strokeWidth = 1
..style = PaintingStyle.stroke,
);
}
void _dashedCircle(Canvas canvas, Offset c, double r, Paint paint) {
if (r <= 1) return;
const seg = 48;
const sweep = 2 * math.pi / seg;
final rect = Rect.fromCircle(center: c, radius: r);
for (var i = 0; i < seg; i += 2) {
canvas.drawArc(rect, i * sweep, sweep, false, paint);
}
}
void _dashedLine(Canvas canvas, Offset a, Offset b, Paint paint) {
const dash = 9.0;
const gap = 7.0;
final total = (b - a).distance;
if (total <= 0) return;
final dir = (b - a) / total;
var d = 0.0;
while (d < total) {
final start = a + dir * d;
final end = a + dir * math.min(d + dash, total);
canvas.drawLine(start, end, paint);
d += dash + gap;
}
}
@override
bool shouldRepaint(covariant _BlurGuidePainter old) =>
old.mode != mode ||
old.center != center ||
old.inner != inner ||
old.outer != outer ||
old.angle != angle;
}
class _CurvePainter extends CustomPainter {
final List<Offset> points;
final Color color;
_CurvePainter({required this.points, required this.color});
@override
void paint(Canvas canvas, Size sz) {
canvas.clipRect(Offset.zero & sz);
final grid = Paint()
..color = Colors.white.withValues(alpha: 0.22)
..strokeWidth = 0.7;
for (var i = 1; i < 3; i++) {
final x = sz.width * i / 3;
final y = sz.height * i / 3;
canvas.drawLine(Offset(x, 0), Offset(x, sz.height), grid);
canvas.drawLine(Offset(0, y), Offset(sz.width, y), grid);
}
Offset sp(Offset pt) => Offset(pt.dx * sz.width, (1 - pt.dy) * sz.height);
final path = Path();
for (var i = 0; i < points.length; i++) {
final s = sp(points[i]);
if (i == 0) {
path.moveTo(s.dx, s.dy);
} else {
path.lineTo(s.dx, s.dy);
}
}
canvas.drawPath(
path,
Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke,
);
final fill = Paint()..color = color;
final ring = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
for (final pt in points) {
final s = sp(pt);
canvas.drawCircle(s, 6, fill);
canvas.drawCircle(s, 6, ring);
}
}
@override
bool shouldRepaint(covariant _CurvePainter old) => true;
}