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:qlyra/core/media/raster.dart'; import 'package:qlyra/frontend/widgets/custom_notification.dart'; import '../../../core/config/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../small_spinner.dart'; import 'editor_common.dart'; import 'photo_hero.dart'; export 'editor_common.dart' show CropState; 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; final Future Function(CropResult result)? onPreview; const PhotoCropEditor({ super.key, required this.source, this.initialState, this.onPreview, }); @override State createState() => _PhotoCropEditorState(); } class _PhotoCropEditorState extends State { ui.Image? _image; @override void initState() { super.initState(); _load(); } Future _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(); super.dispose(); } Future _apply( CropState state, Size vp, bool changed, bool identity, ) async { if (!changed) return null; final file = await _bakeCrop(_image!, state, vp); if (file == null) return null; final result = CropResult(file, state); await widget.onPreview?.call(result); return result; } @override Widget build(BuildContext context) { final image = _image; if (image == null) { return const Scaffold( backgroundColor: Colors.black, body: Center(child: SmallSpinner(size: 36, color: Colors.white)), ); } return CropWorkspace( imageSize: Size(image.width.toDouble(), image.height.toDouble()), initialState: widget.initialState, onApply: _apply, imageBuilder: (context, matrix) => CustomPaint(painter: MatrixImagePainter(image, matrix)), ); } } Future _bakeCrop(ui.Image img, CropState state, Size vp) async { if (vp == Size.zero) return null; try { final geometry = CropGeometry( source: Size(img.width.toDouble(), img.height.toDouble()), quarterTurns: state.quarterTurns, flipH: state.flipH, straightenDeg: state.straightenDeg, ); final crop = Rect.fromLTRB( state.cropNorm.left * vp.width, state.cropNorm.top * vp.height, state.cropNorm.right * vp.width, state.cropNorm.bottom * vp.height, ); final m = geometry.viewportMatrix(vp, crop); final upscale = 1 / geometry.baseScale(vp); const maxDim = 4096; final mx = math.max(crop.width * upscale, crop.height * upscale); 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; } } class PhotoDrawEditor extends StatelessWidget { final File source; final int imageWidth; final int imageHeight; final Future Function(File result)? onPreview; const PhotoDrawEditor({ super.key, required this.source, required this.imageWidth, required this.imageHeight, this.onPreview, }); Future _apply(List marks, Size canvas) async { if (marks.isEmpty) return null; final file = await _bakeMarks(source, marks, canvas); if (file == null) return null; await onPreview?.call(file); return file; } @override Widget build(BuildContext context) { return MarkupEditor( aspectRatio: imageHeight > 0 ? imageWidth / imageHeight : 1.0, background: Image.file(source, fit: BoxFit.cover, gaplessPlayback: true), onApply: _apply, ); } } Future _bakeMarks(File source, List marks, Size box) async { if (box.isEmpty) return null; try { final bytes = await 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; } } enum BlurMode { off, radial, linear } enum _Tab { adjust, blur, curves } class PhotoAdjustEditor extends StatefulWidget { final File source; final Future Function(File result)? onPreview; const PhotoAdjustEditor({super.key, required this.source, this.onPreview}); @override State createState() => _PhotoAdjustEditorState(); } class _PhotoAdjustEditorState extends State { ui.Image? _image; final ValueNotifier _rev = ValueNotifier(0); final ColorAdjust _adjust = ColorAdjust(); 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> _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 _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 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 => _adjust.pristine && _blur == BlurMode.off && _curvesIdentity; 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 _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 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 _lut(List pts) => List.generate( 256, (i) => (_curveY(pts, i / 255.0) * 255).round().clamp(0, 255), ); (List, List, List) _combinedLuts() { final m = _lut(_curves[0]); final r = _lut(_curves[1]); final g = _lut(_curves[2]); final b = _lut(_curves[3]); return ( List.generate(256, (i) => m[r[i]]), List.generate(256, (i) => m[g[i]]), List.generate(256, (i) => m[b[i]]), ); } void _scheduleCurvePreview() { _rev.value++; if (_curvesIdentity) { _curvedImage?.dispose(); _curvedImage = null; return; } if (_curveBusy) { _curveDirty = true; return; } _runCurvePreview(); } Future _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.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 _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.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; } } } Future _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(_adjust.matrix()), ); 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 (_adjust.vignette > 0) { canvas.drawRect( rect, Paint()..shader = _adjust.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 _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; } await widget.onPreview?.call(file); if (!mounted) 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: PhotoHeroTarget( 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( 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(_adjust.matrix()), child: _buildBlurLayer(shown), ), if (_adjust.vignette > 0) IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( gradient: _adjust.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( valueListenable: _rev, builder: (context, _, _) => AdjustSliders(adjust: _adjust, onChanged: () => _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: kEditorPanel, 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, ); } } Uint8List _applyLutsToBytes((Uint8List, List, List, List) 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; } 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 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; }