From ed3b06bfae10bc3f62b37c89025af29a5b94f018 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 10 Jun 2026 13:59:25 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B2=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/attachment/attachment_sheet.dart | 2 +- .../attachment/media_preview_screen.dart | 4 +- .../attachment/photo_adjust_editor.dart | 1111 ------ .../widgets/attachment/photo_crop_editor.dart | 642 ---- .../widgets/attachment/photo_draw_editor.dart | 1207 ------- .../widgets/attachment/photo_editor.dart | 2988 +++++++++++++++++ 6 files changed, 2990 insertions(+), 2964 deletions(-) delete mode 100644 lib/frontend/widgets/attachment/photo_adjust_editor.dart delete mode 100644 lib/frontend/widgets/attachment/photo_crop_editor.dart delete mode 100644 lib/frontend/widgets/attachment/photo_draw_editor.dart create mode 100644 lib/frontend/widgets/attachment/photo_editor.dart diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 6177bf0..24e3e89 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -7,7 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/widgets/attachment/media_preview_screen.dart'; -import 'package:komet/frontend/widgets/attachment/photo_crop_editor.dart'; +import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/sheet_helpers.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index fdc0bef..d9c949a 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -6,9 +6,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/media/gallery_source.dart'; -import 'package:komet/frontend/widgets/attachment/photo_adjust_editor.dart'; -import 'package:komet/frontend/widgets/attachment/photo_crop_editor.dart'; -import 'package:komet/frontend/widgets/attachment/photo_draw_editor.dart'; +import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; const Color _kAccent = Color(0xFF2F8FFF); diff --git a/lib/frontend/widgets/attachment/photo_adjust_editor.dart b/lib/frontend/widgets/attachment/photo_adjust_editor.dart deleted file mode 100644 index 5919f00..0000000 --- a/lib/frontend/widgets/attachment/photo_adjust_editor.dart +++ /dev/null @@ -1,1111 +0,0 @@ -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:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import 'package:komet/core/utils/image_utils.dart'; -import 'package:komet/frontend/widgets/custom_notification.dart'; - -const Color _kAccent = Color(0xFF2F8FFF); -const Color _kPanel = Color(0xFF0A0A0A); - -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 createState() => _PhotoAdjustEditorState(); -} - -class _PhotoAdjustEditorState extends State { - ui.Image? _image; - final ValueNotifier _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> _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 => - _enhance == 0 && - _exposure == 0 && - _contrast == 0 && - _saturation == 0 && - _warmth == 0 && - _vignette == 0 && - _blur == BlurMode.off && - _curvesIdentity; - - List _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 _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; - } - } - } - - 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 _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(); - final rendered = await picture.toImage(outW, outH); - picture.dispose(); - if (curved != img) curved.dispose(); - final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); - rendered.dispose(); - if (bd == null) return null; - - final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), outW, outH); - if (jpeg == null) return null; - final dir = await getTemporaryDirectory(); - final out = File( - p.join( - dir.path, - 'komet_adj_${DateTime.now().microsecondsSinceEpoch}.jpg', - ), - ); - await out.writeAsBytes(jpeg); - return out; - } 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, 'Не удалось применить'); - 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 Positioned.fill( - child: ColoredBox( - color: Colors.black54, - child: Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildPreview() { - final img = _image; - if (img == null) { - return const Center( - child: CircularProgressIndicator(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(_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() { - const labels = ['Все', 'Красный', 'Зелёный', 'Синий']; - 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, _, _) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _slider('Улучшение', _enhance, 0, 1, (v) => _enhance = v), - _slider('Экспозиция', _exposure, -1, 1, (v) => _exposure = v), - _slider('Контраст', _contrast, -1, 1, (v) => _contrast = v), - _slider( - 'Насыщенность', - _saturation, - -1, - 1, - (v) => _saturation = v, - ), - _slider('Тёплость', _warmth, -1, 1, (v) => _warmth = v), - _slider('Виньетка', _vignette, 0, 1, (v) => _vignette = v), - ], - ), - ); - }, - ); - } - - Widget _slider( - String label, - double value, - double min, - double max, - ValueChanged 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() { - return SizedBox( - height: 110, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _blurOption('Откл.', Symbols.block, BlurMode.off), - _blurOption('Радиальное', Symbols.blur_circular, BlurMode.radial), - _blurOption('Линейное', 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 ? _kAccent : Colors.white, size: 30), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - color: selected ? _kAccent : Colors.white70, - fontSize: 12, - ), - ), - ], - ), - ); - } - - Widget _buildBottomBar() { - return Container( - color: _kPanel, - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text( - 'ОТМЕНА', - style: 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( - 'ГОТОВО', - style: TextStyle( - color: _baking ? Colors.white38 : _kAccent, - 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 ? _kAccent : Colors.white, - disabledColor: Colors.white24, - ); - } -} - -List _identity() => [ - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, -]; - -List _brightness(double f) => [ - f, - 0, - 0, - 0, - 0, - 0, - f, - 0, - 0, - 0, - 0, - 0, - f, - 0, - 0, - 0, - 0, - 0, - 1, - 0, -]; - -List _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 _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 _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, 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; -} - -List _mulMatrix(List a, List b) { - double at(List m, int r, int c) => - r < 4 ? m[r * 5 + c] : (c == 4 ? 1.0 : 0.0); - final out = List.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 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; -} diff --git a/lib/frontend/widgets/attachment/photo_crop_editor.dart b/lib/frontend/widgets/attachment/photo_crop_editor.dart deleted file mode 100644 index e004533..0000000 --- a/lib/frontend/widgets/attachment/photo_crop_editor.dart +++ /dev/null @@ -1,642 +0,0 @@ -import 'dart:io'; -import 'dart:math' as math; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import 'package:komet/core/utils/image_utils.dart'; -import 'package:komet/frontend/widgets/custom_notification.dart'; - -const Color _kPanel = Color(0xFF0A0A0A); - -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 createState() => _PhotoCropEditorState(); -} - -class _PhotoCropEditorState extends State { - 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 _rev = ValueNotifier(0); - - @override - void initState() { - super.initState(); - _load(); - } - - void _setCrop(Rect r) { - _crop = r; - _rev.value++; - } - - 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(); - _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 _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, 'Не удалось применить'); - 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 _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(); - final rendered = await picture.toImage(pxW, pxH); - picture.dispose(); - final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); - rendered.dispose(); - if (bd == null) return null; - - final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), pxW, pxH); - if (jpeg == null) return null; - final dir = await getTemporaryDirectory(); - final out = File( - p.join(dir.path, 'komet_crop_${DateTime.now().microsecondsSinceEpoch}.jpg'), - ); - await out.writeAsBytes(jpeg); - return out; - } 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: CircularProgressIndicator(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( - valueListenable: _rev, - builder: (context, _, _) { - final crop = _crop!; - return CustomPaint( - size: vp, - painter: _CropPainter( - image: img, - matrix: _matrix(vp, crop), - crop: crop, - ), - ); - }, - ), - ); - }, - ); - } - - Widget _buildTools() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - children: [ - IconButton( - onPressed: _flip, - icon: Icon( - Symbols.flip, - color: _flipH ? const Color(0xFF2F8FFF) : Colors.white, - ), - tooltip: 'Отразить', - ), - Expanded( - child: ValueListenableBuilder( - 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: 'Повернуть', - ), - ], - ), - ); - } - - Widget _buildActions() { - return Container( - color: _kPanel, - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text( - 'ОТМЕНА', - style: TextStyle(color: Colors.white, fontSize: 15), - ), - ), - TextButton( - onPressed: _reset, - child: const Text( - 'СБРОС', - style: TextStyle(color: Colors.white, fontSize: 15), - ), - ), - TextButton( - onPressed: _baking ? null : _done, - child: Text( - 'ГОТОВО', - style: TextStyle( - color: _baking ? Colors.white38 : const Color(0xFF2F8FFF), - 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 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)), - ), - ); - } -} - -class _RulerPainter extends CustomPainter { - final double value; - - _RulerPainter(this.value); - - @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 = const Color(0xFF2F8FFF) - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round, - ); - } - - @override - bool shouldRepaint(covariant _RulerPainter old) => old.value != value; -} diff --git a/lib/frontend/widgets/attachment/photo_draw_editor.dart b/lib/frontend/widgets/attachment/photo_draw_editor.dart deleted file mode 100644 index 9dd8c61..0000000 --- a/lib/frontend/widgets/attachment/photo_draw_editor.dart +++ /dev/null @@ -1,1207 +0,0 @@ -import 'dart:io'; -import 'dart:math' as math; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import 'package:komet/core/utils/image_utils.dart'; -import 'package:komet/frontend/widgets/custom_notification.dart'; - -const Color _kPanel = 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 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 createState() => _PhotoDrawEditorState(); -} - -class _PhotoDrawEditorState extends State { - final GlobalKey _boundaryKey = GlobalKey(); - final ValueNotifier _canvasRev = ValueNotifier(0); - final List _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 _addText() async { - final controller = TextEditingController(); - final String? text; - try { - text = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), - title: const Text('Текст', style: TextStyle(color: Colors.white)), - content: TextField( - controller: controller, - autofocus: true, - style: const TextStyle(color: Colors.white), - cursorColor: Colors.white, - decoration: const InputDecoration( - hintText: 'Введите текст', - hintStyle: TextStyle(color: Colors.white38), - ), - onSubmitted: (v) => Navigator.pop(ctx, v), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Отмена'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: const Text('ОК'), - ), - ], - ), - ); - } 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 _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, 'Не удалось применить изменения'); - return; - } - Navigator.of(context).pop(file); - } - - Future _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(); - - final rendered = await picture.toImage(outW, outH); - picture.dispose(); - final byteData = await rendered.toByteData( - format: ui.ImageByteFormat.rawRgba, - ); - rendered.dispose(); - if (byteData == null) return null; - - final jpeg = await encodeRgbaToJpeg( - byteData.buffer.asUint8List(), - outW, - outH, - ); - if (jpeg == null) return null; - final dir = await getTemporaryDirectory(); - final out = File( - p.join(dir.path, 'komet_edit_${DateTime.now().microsecondsSinceEpoch}.jpg'), - ); - await out.writeAsBytes(jpeg); - return out; - } 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 Positioned.fill( - child: ColoredBox( - color: Colors.black54, - child: Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - ), - ], - ), - ); - } - - 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( - 'Очистить всё', - 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( - 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)), - ), - ), - ], - ); - }, - ), - ), - ); - } - - 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: _kPanel, - 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: const Text( - 'Добавить текст', - style: 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: [ - Color(0xFFFF3B30), - Color(0xFFFFCC00), - Color(0xFF34C759), - Color(0xFF00C7BE), - Color(0xFF2F8FFF), - Color(0xFFAF52DE), - Color(0xFFFF3B30), - ], - ), - ), - 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() { - 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('РИСУНОК', _EditTab.draw), - _buildTab('СТИКЕРЫ', _EditTab.stickers, disabled: true), - _buildTab('ТЕКСТ', _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 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; - - _SelectionPainter(this.text); - - @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 = const Color(0xFF2F8FFF) - ..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 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: _kPanel, - 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)], - ), - ); - } -} diff --git a/lib/frontend/widgets/attachment/photo_editor.dart b/lib/frontend/widgets/attachment/photo_editor.dart new file mode 100644 index 0000000..5c9bb01 --- /dev/null +++ b/lib/frontend/widgets/attachment/photo_editor.dart @@ -0,0 +1,2988 @@ +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:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'package:komet/core/utils/image_utils.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; + +const Color _kPanel = Color(0xFF0A0A0A); + +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 createState() => _PhotoCropEditorState(); +} + +class _PhotoCropEditorState extends State { + 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 _rev = ValueNotifier(0); + + @override + void initState() { + super.initState(); + _load(); + } + + void _setCrop(Rect r) { + _crop = r; + _rev.value++; + } + + 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(); + _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 _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, 'Не удалось применить'); + 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 _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(); + final rendered = await picture.toImage(pxW, pxH); + picture.dispose(); + final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); + rendered.dispose(); + if (bd == null) return null; + + final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), pxW, pxH); + if (jpeg == null) return null; + final dir = await getTemporaryDirectory(); + final out = File( + p.join( + dir.path, + 'komet_crop_${DateTime.now().microsecondsSinceEpoch}.jpg', + ), + ); + await out.writeAsBytes(jpeg); + return out; + } 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: CircularProgressIndicator(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( + valueListenable: _rev, + builder: (context, _, _) { + final crop = _crop!; + return CustomPaint( + size: vp, + painter: _CropPainter( + image: img, + matrix: _matrix(vp, crop), + crop: crop, + ), + ); + }, + ), + ); + }, + ); + } + + Widget _buildTools() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + IconButton( + onPressed: _flip, + icon: Icon( + Symbols.flip, + color: _flipH ? const Color(0xFF2F8FFF) : Colors.white, + ), + tooltip: 'Отразить', + ), + Expanded( + child: ValueListenableBuilder( + 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: 'Повернуть', + ), + ], + ), + ); + } + + Widget _buildActions() { + return Container( + color: _kPanel, + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + 'ОТМЕНА', + style: TextStyle(color: Colors.white, fontSize: 15), + ), + ), + TextButton( + onPressed: _reset, + child: const Text( + 'СБРОС', + style: TextStyle(color: Colors.white, fontSize: 15), + ), + ), + TextButton( + onPressed: _baking ? null : _done, + child: Text( + 'ГОТОВО', + style: TextStyle( + color: _baking ? Colors.white38 : const Color(0xFF2F8FFF), + 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 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)), + ), + ); + } +} + +class _RulerPainter extends CustomPainter { + final double value; + + _RulerPainter(this.value); + + @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 = const Color(0xFF2F8FFF) + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(covariant _RulerPainter old) => old.value != value; +} + +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 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 createState() => _PhotoDrawEditorState(); +} + +class _PhotoDrawEditorState extends State { + final GlobalKey _boundaryKey = GlobalKey(); + final ValueNotifier _canvasRev = ValueNotifier(0); + final List _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 _addText() async { + final controller = TextEditingController(); + final String? text; + try { + text = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: const Text('Текст', style: TextStyle(color: Colors.white)), + content: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(color: Colors.white), + cursorColor: Colors.white, + decoration: const InputDecoration( + hintText: 'Введите текст', + hintStyle: TextStyle(color: Colors.white38), + ), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: const Text('ОК'), + ), + ], + ), + ); + } 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 _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, 'Не удалось применить изменения'); + return; + } + Navigator.of(context).pop(file); + } + + Future _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(); + + final rendered = await picture.toImage(outW, outH); + picture.dispose(); + final byteData = await rendered.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + rendered.dispose(); + if (byteData == null) return null; + + final jpeg = await encodeRgbaToJpeg( + byteData.buffer.asUint8List(), + outW, + outH, + ); + if (jpeg == null) return null; + final dir = await getTemporaryDirectory(); + final out = File( + p.join( + dir.path, + 'komet_edit_${DateTime.now().microsecondsSinceEpoch}.jpg', + ), + ); + await out.writeAsBytes(jpeg); + return out; + } 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 Positioned.fill( + child: ColoredBox( + color: Colors.black54, + child: Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + ], + ), + ); + } + + 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( + 'Очистить всё', + 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( + 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)), + ), + ), + ], + ); + }, + ), + ), + ); + } + + 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: const Text( + 'Добавить текст', + style: 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: [ + Color(0xFFFF3B30), + Color(0xFFFFCC00), + Color(0xFF34C759), + Color(0xFF00C7BE), + Color(0xFF2F8FFF), + Color(0xFFAF52DE), + Color(0xFFFF3B30), + ], + ), + ), + 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() { + 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('РИСУНОК', _EditTab.draw), + _buildTab('СТИКЕРЫ', _EditTab.stickers, disabled: true), + _buildTab('ТЕКСТ', _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 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; + + _SelectionPainter(this.text); + + @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 = const Color(0xFF2F8FFF) + ..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 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)], + ), + ); + } +} + +const Color _kAccent = Color(0xFF2F8FFF); + +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 createState() => _PhotoAdjustEditorState(); +} + +class _PhotoAdjustEditorState extends State { + ui.Image? _image; + final ValueNotifier _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> _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 => + _enhance == 0 && + _exposure == 0 && + _contrast == 0 && + _saturation == 0 && + _warmth == 0 && + _vignette == 0 && + _blur == BlurMode.off && + _curvesIdentity; + + List _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 _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; + } + } + } + + 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 _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(); + final rendered = await picture.toImage(outW, outH); + picture.dispose(); + if (curved != img) curved.dispose(); + final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); + rendered.dispose(); + if (bd == null) return null; + + final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), outW, outH); + if (jpeg == null) return null; + final dir = await getTemporaryDirectory(); + final out = File( + p.join( + dir.path, + 'komet_adj_${DateTime.now().microsecondsSinceEpoch}.jpg', + ), + ); + await out.writeAsBytes(jpeg); + return out; + } 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, 'Не удалось применить'); + 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 Positioned.fill( + child: ColoredBox( + color: Colors.black54, + child: Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPreview() { + final img = _image; + if (img == null) { + return const Center( + child: CircularProgressIndicator(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(_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() { + const labels = ['Все', 'Красный', 'Зелёный', 'Синий']; + 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, _, _) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _slider('Улучшение', _enhance, 0, 1, (v) => _enhance = v), + _slider('Экспозиция', _exposure, -1, 1, (v) => _exposure = v), + _slider('Контраст', _contrast, -1, 1, (v) => _contrast = v), + _slider( + 'Насыщенность', + _saturation, + -1, + 1, + (v) => _saturation = v, + ), + _slider('Тёплость', _warmth, -1, 1, (v) => _warmth = v), + _slider('Виньетка', _vignette, 0, 1, (v) => _vignette = v), + ], + ), + ); + }, + ); + } + + Widget _slider( + String label, + double value, + double min, + double max, + ValueChanged 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() { + return SizedBox( + height: 110, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _blurOption('Откл.', Symbols.block, BlurMode.off), + _blurOption('Радиальное', Symbols.blur_circular, BlurMode.radial), + _blurOption('Линейное', 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 ? _kAccent : Colors.white, size: 30), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + color: selected ? _kAccent : Colors.white70, + fontSize: 12, + ), + ), + ], + ), + ); + } + + Widget _buildBottomBar() { + return Container( + color: _kPanel, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + 'ОТМЕНА', + style: 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( + 'ГОТОВО', + style: TextStyle( + color: _baking ? Colors.white38 : _kAccent, + 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 ? _kAccent : Colors.white, + disabledColor: Colors.white24, + ); + } +} + +List _identity() => [ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]; + +List _brightness(double f) => [ + f, + 0, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]; + +List _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 _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 _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, 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; +} + +List _mulMatrix(List a, List b) { + double at(List m, int r, int c) => + r < 4 ? m[r * 5 + c] : (c == 4 ? 1.0 : 0.0); + final out = List.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 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; +}