From d89c1366e25fd1fe6911ece6305e9f439380348f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 10 Jun 2026 12:57:30 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D0=BB=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=20=D1=81=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/media/gallery_source.dart | 30 +- lib/frontend/screens/chats/chat_screen.dart | 16 +- .../widgets/attachment/attachment_sheet.dart | 70 +- .../attachment/media_preview_screen.dart | 241 ++-- .../attachment/photo_adjust_editor.dart | 1111 +++++++++++++++++ .../widgets/attachment/photo_crop_editor.dart | 642 ++++++++++ .../widgets/attachment/photo_draw_editor.dart | 22 - lib/frontend/widgets/sliding_pill_nav.dart | 75 +- 8 files changed, 1919 insertions(+), 288 deletions(-) create mode 100644 lib/frontend/widgets/attachment/photo_adjust_editor.dart create mode 100644 lib/frontend/widgets/attachment/photo_crop_editor.dart diff --git a/lib/core/media/gallery_source.dart b/lib/core/media/gallery_source.dart index 1fc7ea8..3b8ede6 100644 --- a/lib/core/media/gallery_source.dart +++ b/lib/core/media/gallery_source.dart @@ -23,6 +23,22 @@ class PickedPhoto { const PickedPhoto({required this.item, this.editedFile}); } +Future<(int, int)?> imageFileDimensions(File file) async { + ui.ImmutableBuffer? buffer; + ui.ImageDescriptor? descriptor; + try { + final bytes = await file.readAsBytes(); + buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + descriptor = await ui.ImageDescriptor.encoded(buffer); + return (descriptor.width, descriptor.height); + } catch (_) { + return null; + } finally { + descriptor?.dispose(); + buffer?.dispose(); + } +} + abstract class GallerySource { Future ensurePermission(); Future> load({int limit}); @@ -178,17 +194,5 @@ class _FileGalleryItem implements GalleryItem { Future originFile() async => file; @override - Future<(int, int)?> dimensions() async { - try { - final bytes = await file.readAsBytes(); - final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - final result = (frame.image.width, frame.image.height); - frame.image.dispose(); - codec.dispose(); - return result; - } catch (_) { - return null; - } - } + Future<(int, int)?> dimensions() => imageFileDimensions(file); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 70843b0..1ae054c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1804,7 +1804,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { edited ?? photo.item.localFile ?? await photo.item.originFile(); if (file == null) continue; final dim = edited != null - ? await _decodeImageDimensions(edited) + ? await imageFileDimensions(edited) : await photo.item.dimensions(); files.add(file); attachments.add( @@ -1942,20 +1942,6 @@ class _ChatScreenState extends State with TickerProviderStateMixin { _photoUploadProgress.remove(tempId)?.dispose(); } - Future<(int, int)?> _decodeImageDimensions(File file) async { - try { - final bytes = await file.readAsBytes(); - final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - final result = (frame.image.width, frame.image.height); - frame.image.dispose(); - codec.dispose(); - return result; - } catch (_) { - return null; - } - } - Future _pickAndUploadFile() async { final result = await FilePicker.platform.pickFiles(); if (result == null || result.files.isEmpty) return; diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 5d78374..6177bf0 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -7,6 +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/custom_notification.dart'; import 'package:komet/frontend/widgets/sheet_helpers.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; @@ -49,7 +50,7 @@ class _AttachmentSheetState extends State { final GallerySource _source = GallerySource.create(); final ValueNotifier> _selected = ValueNotifier({}); - final Map _edited = {}; + final Map _edits = {}; final TextEditingController _captionCtrl = TextEditingController(); final PageController _pageController = PageController(); @@ -122,9 +123,9 @@ class _AttachmentSheetState extends State { selectedIds: _selected, onToggleSelection: () => _toggleSelection(item), onSend: () => _sendSelection(fallback: item), - editedFile: _edited[item.id], - onEdited: (file) { - if (mounted) setState(() => _edited[item.id] = file); + editState: _edits[item.id], + onEditChanged: (state) { + if (mounted) setState(() => _edits[item.id] = state); }, initialCaption: _captionCtrl.text, onCaptionChanged: (text) => _captionCtrl.text = text, @@ -151,7 +152,7 @@ class _AttachmentSheetState extends State { if (chosen.isEmpty && fallback != null) chosen = [fallback]; if (chosen.isEmpty) return; final picked = chosen - .map((it) => PickedPhoto(item: it, editedFile: _edited[it.id])) + .map((it) => PickedPhoto(item: it, editedFile: _edits[it.id]?.working)) .toList(); final callback = widget.onSend; final caption = _captionCtrl.text.trim(); @@ -233,7 +234,6 @@ class _AttachmentSheetState extends State { static const double _barHeight = SlidingPillNav.height + _pillMargin; static const Duration _navAnim = Duration(milliseconds: 300); - // Matches the chat composer (message input field) surface. Color _composerColor(ColorScheme cs) => Color.alphaBlend( cs.surfaceContainerHighest.withValues(alpha: 0.92), cs.surface, @@ -331,7 +331,7 @@ class _AttachmentSheetState extends State { selectedIds: _selected, onOpen: () => _openPreview(item), onToggle: () => _toggleSelection(item), - editedFile: _edited[item.id], + editedFile: _edits[item.id]?.working, cs: cs, ); }, childCount: gridPhotos.length), @@ -354,7 +354,7 @@ class _AttachmentSheetState extends State { selectedIds: _selected, onOpen: () => _openPreview(item), onToggle: () => _toggleSelection(item), - editedFile: _edited[item.id], + editedFile: _edits[item.id]?.working, cs: cs, ); } @@ -587,36 +587,32 @@ class _AttachmentSheetState extends State { } Widget _buildPillNav() { - return LayoutBuilder( + final geometry = PillNavGeometry.equal(54, _navItems.length); + return Align( key: const ValueKey('nav'), - builder: (context, constraints) { - final geometry = PillNavGeometry.fromInnerWidth( - constraints.maxWidth - 4, - _navItems.length, - ); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (_) => _onPillDragStart(), - onHorizontalDragUpdate: (d) => - _onPillDragUpdate(d.delta.dx, geometry.inactiveWidth), - onHorizontalDragEnd: (_) => _onPillDragEnd(), - onHorizontalDragCancel: _onPillDragEnd, - child: AnimatedBuilder( - animation: _pageController, - builder: (context, _) { - final cs = Theme.of(context).colorScheme; - return SlidingPillNav( - items: _navItems, - position: _currentPageT(), - geometry: geometry, - onTap: _onSectionTap, - backgroundColor: _composerColor(cs), - borderColor: _composerBorderColor(cs), - ); - }, - ), - ); - }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) => _onPillDragStart(), + onHorizontalDragUpdate: (d) => + _onPillDragUpdate(d.delta.dx, geometry.inactiveWidth), + onHorizontalDragEnd: (_) => _onPillDragEnd(), + onHorizontalDragCancel: _onPillDragEnd, + child: AnimatedBuilder( + animation: _pageController, + builder: (context, _) { + final cs = Theme.of(context).colorScheme; + return SlidingPillNav( + items: _navItems, + position: _currentPageT(), + geometry: geometry, + onTap: _onSectionTap, + iconsOnly: true, + backgroundColor: _composerColor(cs), + borderColor: _composerBorderColor(cs), + ); + }, + ), + ), ); } diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index eaefd53..fdc0bef 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -1,15 +1,13 @@ 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/media/gallery_source.dart'; -import 'package:komet/core/utils/image_utils.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/custom_notification.dart'; @@ -22,8 +20,8 @@ class MediaPreviewScreen extends StatefulWidget { final ValueListenable> selectedIds; final VoidCallback onToggleSelection; final VoidCallback onSend; - final File? editedFile; - final void Function(File edited)? onEdited; + final PhotoEditState? editState; + final ValueChanged? onEditChanged; final String initialCaption; final ValueChanged? onCaptionChanged; @@ -34,8 +32,8 @@ class MediaPreviewScreen extends StatefulWidget { required this.onToggleSelection, required this.onSend, this.title, - this.editedFile, - this.onEdited, + this.editState, + this.onEditChanged, this.initialCaption = '', this.onCaptionChanged, }); @@ -44,61 +42,39 @@ class MediaPreviewScreen extends StatefulWidget { State createState() => _MediaPreviewScreenState(); } -class _MediaPreviewScreenState extends State - with SingleTickerProviderStateMixin { +class _MediaPreviewScreenState extends State { late final TextEditingController _caption = TextEditingController( text: widget.initialCaption, ); File? _workingFile; - File? _rotationOriginal; - int _appliedTurns = 0; - int _queuedTurns = 0; - bool _rotating = false; - late final AnimationController _rotCtrl; - Size? _boxSize; - double _aspect = 1; - double _rotFitScale = 1; + File? _cropSource; + CropState? _cropState; @override void initState() { super.initState(); - _rotCtrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 260), - ); _caption.addListener( () => widget.onCaptionChanged?.call(_caption.text), ); + _cropState = widget.editState?.cropState; + _cropSource = widget.editState?.cropSource; _resolveWorkingFile(); } Future _resolveWorkingFile() async { - final initial = widget.editedFile ?? widget.item.localFile; + final initial = widget.editState?.working ?? widget.item.localFile; if (initial != null) { _workingFile = initial; - _rotationOriginal = initial; - _updateAspect(); return; } final file = await widget.item.originFile(); if (!mounted) return; setState(() => _workingFile = file); - _rotationOriginal = file; - _updateAspect(); - } - - Future _updateAspect() async { - final file = _workingFile; - if (file == null) return; - final dims = await decodeImageFileDimensions(file); - if (!mounted || dims == null || dims.$2 == 0) return; - _aspect = dims.$1 / dims.$2; } @override void dispose() { _caption.dispose(); - _rotCtrl.dispose(); super.dispose(); } @@ -107,132 +83,75 @@ class _MediaPreviewScreenState extends State widget.onSend(); } - // Each tap queues one more 90° step; taps are never dropped. Every step is - // baked from the pristine original at the net angle, so repeated rotation - // never stacks JPEG generations. - Future _rotate() async { - if (_workingFile == null || _rotationOriginal == null) return; - _queuedTurns++; - if (_rotating) return; - _rotating = true; - while (_queuedTurns > 0 && mounted) { - _queuedTurns--; - await _rotateOneStep(); - } - _rotating = false; + Future _pushEditor(Widget editor) { + return Navigator.of(context).push( + PageRouteBuilder( + opaque: true, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => editor, + ), + ); } - Future _rotateOneStep() async { - final original = _rotationOriginal; - if (original == null) return; - final box = _boxSize; - _rotFitScale = box != null ? _rotatedFitScale(_aspect, box) : 1.0; - final target = (_appliedTurns + 1) % 4; - _rotCtrl.value = 0; - final bakeFut = _rotateImageFile(original, target); - await _rotCtrl.forward(); - final baked = await bakeFut; - if (!mounted) return; - if (baked != null) { - try { - await precacheImage(FileImage(baked), context); - } catch (_) {} - if (!mounted) return; - _appliedTurns = target; - _aspect = _aspect > 0 ? 1 / _aspect : 1; - setState(() { - _workingFile = baked; - _rotCtrl.value = 0; - }); - widget.onEdited?.call(baked); - } else { - setState(() => _rotCtrl.value = 0); - } + void _reportEdit() { + widget.onEditChanged?.call( + PhotoEditState( + working: _workingFile, + cropSource: _cropSource, + cropState: _cropState, + ), + ); } - double _rotatedFitScale(double a, Size box) { - final bw = box.width; - final bh = box.height; - if (bw <= 0 || bh <= 0 || a <= 0) return 1; - double dw; - double dh; - if (bw / bh > a) { - dh = bh; - dw = bh * a; - } else { - dw = bw; - dh = bw / a; - } - final s = math.min(bw / dh, bh / dw); - return s.isFinite && s > 0 ? s : 1; - } - - Future _rotateImageFile(File src, int quarterTurnsCCW) async { - final turns = ((quarterTurnsCCW % 4) + 4) % 4; - if (turns == 0) return src; - try { - final bytes = await src.readAsBytes(); - final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - final image = frame.image; - final w = image.width; - final h = image.height; - final swap = turns.isOdd; - final outW = swap ? h : w; - final outH = swap ? w : h; - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - canvas.translate(outW / 2, outH / 2); - canvas.rotate(-math.pi / 2 * turns); - canvas.drawImage(image, Offset(-w / 2, -h / 2), Paint()); - final picture = recorder.endRecording(); - final rotated = await picture.toImage(outW, outH); - picture.dispose(); - image.dispose(); - codec.dispose(); - final bd = await rotated.toByteData(format: ui.ImageByteFormat.rawRgba); - rotated.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_rot_${DateTime.now().microsecondsSinceEpoch}.jpg'), - ); - await out.writeAsBytes(jpeg); - return out; - } catch (_) { - return null; + Future _openCrop() async { + if (_workingFile == null) return; + final source = + _cropSource ??= widget.item.localFile ?? await widget.item.originFile(); + if (source == null || !mounted) return; + final result = await _pushEditor( + PhotoCropEditor(source: source, initialState: _cropState), + ); + if (result != null && mounted) { + _cropState = result.state; + setState(() => _workingFile = result.file); + _reportEdit(); } } Future _openDraw() async { final file = _workingFile; - if (file == null || _rotating) return; - final dims = await decodeImageFileDimensions(file); + if (file == null) return; + final dims = await imageFileDimensions(file); if (!mounted) return; if (dims == null) { showCustomNotification(context, 'Не удалось открыть редактор'); return; } - final result = await Navigator.of(context).push( - PageRouteBuilder( - opaque: true, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (_, _, _) => PhotoDrawEditor( - source: file, - imageWidth: dims.$1, - imageHeight: dims.$2, - ), + final result = await _pushEditor( + PhotoDrawEditor( + source: file, + imageWidth: dims.$1, + imageHeight: dims.$2, ), ); if (result != null && mounted) { - _rotationOriginal = result; - _appliedTurns = 0; + _cropSource = result; + _cropState = null; setState(() => _workingFile = result); - _updateAspect(); - widget.onEdited?.call(result); + _reportEdit(); + } + } + + Future _openAdjust() async { + final file = _workingFile; + if (file == null) return; + final result = await _pushEditor(PhotoAdjustEditor(source: file)); + if (result != null && mounted) { + _cropSource = result; + _cropState = null; + setState(() => _workingFile = result); + _reportEdit(); } } @@ -268,31 +187,11 @@ class _MediaPreviewScreenState extends State body: Column( children: [ Expanded( - child: ClipRect( - child: LayoutBuilder( - builder: (context, constraints) { - _boxSize = constraints.biggest; - return Center( - child: AnimatedBuilder( - animation: _rotCtrl, - builder: (context, child) { - final t = _rotCtrl.value; - return Transform.rotate( - angle: -math.pi / 2 * t, - child: Transform.scale( - scale: 1 + (_rotFitScale - 1) * t, - child: child, - ), - ); - }, - child: InteractiveViewer( - minScale: 1, - maxScale: 4, - child: _buildImage(), - ), - ), - ); - }, + child: Center( + child: InteractiveViewer( + minScale: 1, + maxScale: 4, + child: _buildImage(), ), ), ), @@ -379,10 +278,10 @@ class _MediaPreviewScreenState extends State child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _ToolIcon(icon: Symbols.crop_rotate, onTap: _rotate), + _ToolIcon(icon: Symbols.crop_rotate, onTap: _openCrop), _ToolIcon(icon: Symbols.brush, onTap: _openDraw), const _FileToggle(), - _ToolIcon(icon: Symbols.tune, onTap: () {}), + _ToolIcon(icon: Symbols.tune, onTap: _openAdjust), ], ), ), diff --git a/lib/frontend/widgets/attachment/photo_adjust_editor.dart b/lib/frontend/widgets/attachment/photo_adjust_editor.dart new file mode 100644 index 0000000..5919f00 --- /dev/null +++ b/lib/frontend/widgets/attachment/photo_adjust_editor.dart @@ -0,0 +1,1111 @@ +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 new file mode 100644 index 0000000..e004533 --- /dev/null +++ b/lib/frontend/widgets/attachment/photo_crop_editor.dart @@ -0,0 +1,642 @@ +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 index 4df357e..9dd8c61 100644 --- a/lib/frontend/widgets/attachment/photo_draw_editor.dart +++ b/lib/frontend/widgets/attachment/photo_draw_editor.dart @@ -66,20 +66,6 @@ class TextMark extends EditMark { }); } -Future<(int, int)?> decodeImageFileDimensions(File file) async { - try { - final bytes = await file.readAsBytes(); - final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - final result = (frame.image.width, frame.image.height); - frame.image.dispose(); - codec.dispose(); - return result; - } catch (_) { - return null; - } -} - class PhotoDrawEditor extends StatefulWidget { final File source; final int imageWidth; @@ -336,8 +322,6 @@ class _PhotoDrawEditorState extends State { if (ro is! RenderBox || ro.size.isEmpty) return null; final box = ro.size; try { - // Composite at the image's native resolution rather than capturing the - // on-screen widget, so the photo keeps its full quality. final bytes = await widget.source.readAsBytes(); final codec = await ui.instantiateImageCodec(bytes); final frame = await codec.getNextFrame(); @@ -454,8 +438,6 @@ class _PhotoDrawEditorState extends State { return Center( child: AspectRatio( aspectRatio: aspect <= 0 ? 1.0 : aspect, - // Only this subtree repaints while drawing (driven by _canvasRev), - // so the toolbar/tabs/slider don't rebuild on every pointer move. child: ValueListenableBuilder( valueListenable: _canvasRev, child: Image.file( @@ -787,8 +769,6 @@ class _DrawingPainter extends CustomPainter { void paint(Canvas canvas, Size size) => paintMarks(canvas, size); void paintMarks(Canvas canvas, Size size) { - // An isolated layer is only needed so the eraser can punch through the - // strokes to reveal the photo beneath — skip it otherwise (it's costly). final needsLayer = _hasEraser(); if (needsLayer) canvas.saveLayer(Offset.zero & size, Paint()); for (final m in marks) { @@ -973,8 +953,6 @@ class _TextLayout { _TextLayout(this.text, this.fontSize, this.color, this.painter); } -/// Laid-out [TextPainter] for a [TextMark], memoized per mark so a static text -/// isn't re-laid-out every frame, and the size/paint passes share one layout. TextPainter layoutText(TextMark t) { final cached = _textLayoutCache[t]; if (cached != null && diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart index 45ddf99..f642e6f 100644 --- a/lib/frontend/widgets/sliding_pill_nav.dart +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -25,6 +25,9 @@ class PillNavGeometry { return PillNavGeometry(navInnerW, unit * _activeWeight, unit); } + factory PillNavGeometry.equal(double itemWidth, int itemCount) => + PillNavGeometry(itemWidth * itemCount, itemWidth, itemWidth); + static const double _activeWeight = 2.2; } @@ -39,6 +42,7 @@ class SlidingPillNav extends StatelessWidget { final double labelGap; final Color? backgroundColor; final Color? borderColor; + final bool iconsOnly; const SlidingPillNav({ super.key, @@ -52,6 +56,7 @@ class SlidingPillNav extends StatelessWidget { this.labelGap = 6, this.backgroundColor, this.borderColor, + this.iconsOnly = false, }); static const double height = 68; @@ -122,6 +127,7 @@ class SlidingPillNav extends StatelessWidget { animationDuration: animationDuration, iconSize: iconSize, labelGap: labelGap, + iconsOnly: iconsOnly, onTap: () => onTap(i), onLongPress: (onItemLongPress == null || !items[i].longPressable) @@ -146,6 +152,7 @@ class _PillNavCell extends StatelessWidget { final Duration animationDuration; final double iconSize; final double labelGap; + final bool iconsOnly; final VoidCallback onTap; final void Function(Offset globalPosition)? onLongPress; @@ -156,6 +163,7 @@ class _PillNavCell extends StatelessWidget { required this.animationDuration, required this.iconSize, required this.labelGap, + required this.iconsOnly, required this.onTap, required this.onLongPress, }); @@ -172,44 +180,51 @@ class _PillNavCell extends StatelessWidget { : (d) => onLongPress!(d.globalPosition), behavior: HitTestBehavior.opaque, child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( + child: iconsOnly + ? Icon( item.icon, color: selected ? cs.onPrimary : cs.onSurface, size: iconSize, fill: 1, - ), - AnimatedContainer( - duration: animationDuration, - curve: Curves.easeOutCubic, - width: selected ? null : 0, - child: AnimatedOpacity( - duration: opacityDuration, - opacity: selected ? 1.0 : 0.0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: labelGap), - Text( - item.label, - style: TextStyle( - color: cs.onPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, + ) + : FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + item.icon, + color: selected ? cs.onPrimary : cs.onSurface, + size: iconSize, + fill: 1, + ), + AnimatedContainer( + duration: animationDuration, + curve: Curves.easeOutCubic, + width: selected ? null : 0, + child: AnimatedOpacity( + duration: opacityDuration, + opacity: selected ? 1.0 : 0.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: labelGap), + Text( + item.label, + style: TextStyle( + color: cs.onPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), - ], - ), + ), + ], ), ), - ], - ), - ), ), ); }