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

643 lines
17 KiB
Dart

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<PhotoCropEditor> createState() => _PhotoCropEditorState();
}
class _PhotoCropEditorState extends State<PhotoCropEditor> {
ui.Image? _image;
int _quarterTurns = 0;
bool _flipH = false;
double _straightenDeg = 0;
Rect? _crop;
Size _viewport = Size.zero;
bool _baking = false;
bool _stateApplied = false;
int _handle = -1;
final ValueNotifier<int> _rev = ValueNotifier(0);
@override
void initState() {
super.initState();
_load();
}
void _setCrop(Rect r) {
_crop = r;
_rev.value++;
}
Future<void> _load() async {
try {
final bytes = await widget.source.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
codec.dispose();
if (!mounted) {
frame.image.dispose();
return;
}
setState(() => _image = frame.image);
} catch (_) {
if (mounted) Navigator.of(context).pop();
}
}
@override
void dispose() {
_image?.dispose();
_rev.dispose();
super.dispose();
}
double get _imgW => _image!.width.toDouble();
double get _imgH => _image!.height.toDouble();
double get _phi =>
_straightenDeg * math.pi / 180 - _quarterTurns * math.pi / 2;
Size _orientedSize() {
final swap = _quarterTurns.isOdd;
return swap ? Size(_imgH, _imgW) : Size(_imgW, _imgH);
}
double _baseScale(Size vp) {
final o = _orientedSize();
const margin = 0.9;
return math.min(vp.width / o.width, vp.height / o.height) * margin;
}
Rect _fittedRect(Size vp) {
final o = _orientedSize();
final base = _baseScale(vp);
return Rect.fromCenter(
center: Offset(vp.width / 2, vp.height / 2),
width: o.width * base,
height: o.height * base,
);
}
double _scaleFor(Size vp, Rect crop) {
final base = _baseScale(vp);
final center = Offset(vp.width / 2, vp.height / 2);
final c = math.cos(-_phi);
final s = math.sin(-_phi);
var maxS = 0.0;
for (final corner in [
crop.topLeft,
crop.topRight,
crop.bottomLeft,
crop.bottomRight,
]) {
final rx = corner.dx - center.dx;
final ry = corner.dy - center.dy;
final lx = rx * c - ry * s;
final ly = rx * s + ry * c;
maxS = math.max(maxS, math.max(lx.abs() / (_imgW / 2), ly.abs() / (_imgH / 2)));
}
return math.max(base, maxS);
}
Matrix4 _matrix(Size vp, Rect crop) {
final scale = _scaleFor(vp, crop);
return Matrix4.identity()
..translateByDouble(vp.width / 2, vp.height / 2, 0, 1)
..multiply(
_flipH ? Matrix4.diagonal3Values(-1, 1, 1) : Matrix4.identity(),
)
..rotateZ(_phi)
..scaleByDouble(scale, scale, 1, 1)
..translateByDouble(-_imgW / 2, -_imgH / 2, 0, 1);
}
void _ensureCrop(Size vp) {
if (_crop != null && _viewport == vp) return;
_viewport = vp;
final init = widget.initialState;
if (init != null && !_stateApplied) {
_stateApplied = true;
_quarterTurns = init.quarterTurns;
_flipH = init.flipH;
_straightenDeg = init.straightenDeg;
_crop = Rect.fromLTRB(
init.cropNorm.left * vp.width,
init.cropNorm.top * vp.height,
init.cropNorm.right * vp.width,
init.cropNorm.bottom * vp.height,
);
} else {
_crop = _fittedRect(vp);
}
}
CropState _currentState(Size vp, Rect crop) => CropState(
quarterTurns: _quarterTurns,
flipH: _flipH,
straightenDeg: _straightenDeg,
cropNorm: Rect.fromLTRB(
crop.left / vp.width,
crop.top / vp.height,
crop.right / vp.width,
crop.bottom / vp.height,
),
);
void _reset() {
setState(() {
_quarterTurns = 0;
_flipH = false;
_straightenDeg = 0;
_crop = _fittedRect(_viewport);
});
}
void _rotate90() {
setState(() {
_quarterTurns = (_quarterTurns + 1) % 4;
_straightenDeg = 0;
_crop = _fittedRect(_viewport);
});
}
void _flip() => setState(() => _flipH = !_flipH);
int _hitHandle(Offset pt, Rect c) {
const r = 34.0;
final corners = [c.topLeft, c.topRight, c.bottomRight, c.bottomLeft];
for (var i = 0; i < 4; i++) {
if ((pt - corners[i]).distance < r) return i;
}
final insideV = pt.dy > c.top - r && pt.dy < c.bottom + r;
final insideH = pt.dx > c.left - r && pt.dx < c.right + r;
if ((pt.dx - c.left).abs() < r && insideV) return 4;
if ((pt.dx - c.right).abs() < r && insideV) return 5;
if ((pt.dy - c.top).abs() < r && insideH) return 6;
if ((pt.dy - c.bottom).abs() < r && insideH) return 7;
if (c.contains(pt)) return 8;
return -1;
}
void _onPanStart(Offset pt) {
final c = _crop;
if (c == null) return;
_handle = _hitHandle(pt, c);
}
void _onPanUpdate(Offset delta) {
final c = _crop;
if (c == null || _handle < 0) return;
final b = _fittedRect(_viewport);
const minSize = 64.0;
if (_handle == 8) {
var nl = c.left + delta.dx;
var nt = c.top + delta.dy;
var nr = c.right + delta.dx;
var nb = c.bottom + delta.dy;
if (nl < b.left) {
nr += b.left - nl;
nl = b.left;
}
if (nt < b.top) {
nb += b.top - nt;
nt = b.top;
}
if (nr > b.right) {
nl -= nr - b.right;
nr = b.right;
}
if (nb > b.bottom) {
nt -= nb - b.bottom;
nb = b.bottom;
}
_setCrop(Rect.fromLTRB(nl, nt, nr, nb));
return;
}
var l = c.left;
var t = c.top;
var r = c.right;
var bo = c.bottom;
switch (_handle) {
case 0:
l += delta.dx;
t += delta.dy;
case 1:
r += delta.dx;
t += delta.dy;
case 2:
r += delta.dx;
bo += delta.dy;
case 3:
l += delta.dx;
bo += delta.dy;
case 4:
l += delta.dx;
case 5:
r += delta.dx;
case 6:
t += delta.dy;
case 7:
bo += delta.dy;
}
l = l.clamp(b.left, math.max(b.left, r - minSize));
t = t.clamp(b.top, math.max(b.top, bo - minSize));
r = r.clamp(math.min(b.right, l + minSize), b.right);
bo = bo.clamp(math.min(b.bottom, t + minSize), b.bottom);
_setCrop(Rect.fromLTRB(l, t, r, bo));
}
Future<void> _done() async {
if (_baking) return;
final crop = _crop;
final vp = _viewport;
if (crop == null || vp == Size.zero) {
Navigator.of(context).pop();
return;
}
final state = _currentState(vp, crop);
final init = widget.initialState;
final noChange = init != null
? state.sameAs(init)
: (_quarterTurns == 0 && !_flipH && _straightenDeg == 0 && _isFullCrop());
if (noChange) {
Navigator.of(context).pop();
return;
}
setState(() => _baking = true);
final file = await _bake();
if (!mounted) return;
if (file == null) {
setState(() => _baking = false);
showCustomNotification(context, 'Не удалось применить');
return;
}
Navigator.of(context).pop(CropResult(file, state));
}
bool _isFullCrop() {
final c = _crop;
if (c == null) return true;
final f = _fittedRect(_viewport);
return (c.left - f.left).abs() < 1 &&
(c.top - f.top).abs() < 1 &&
(c.right - f.right).abs() < 1 &&
(c.bottom - f.bottom).abs() < 1;
}
Future<File?> _bake() async {
final img = _image;
final crop = _crop;
final vp = _viewport;
if (img == null || crop == null || vp == Size.zero) return null;
try {
final m = _matrix(vp, crop);
final upscale = 1 / _baseScale(vp);
var outW = crop.width * upscale;
var outH = crop.height * upscale;
const maxDim = 4096;
final mx = math.max(outW, outH);
final cap = mx > maxDim ? maxDim / mx : 1.0;
final eff = upscale * cap;
final pxW = (crop.width * eff).round();
final pxH = (crop.height * eff).round();
if (pxW <= 0 || pxH <= 0) return null;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(eff);
canvas.translate(-crop.left, -crop.top);
canvas.transform(m.storage);
canvas.drawImage(
img,
Offset.zero,
Paint()..filterQuality = FilterQuality.high,
);
final picture = recorder.endRecording();
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<int>(
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<int>(
valueListenable: _rev,
builder: (context, _, _) => _StraightenRuler(
value: _straightenDeg,
onChanged: (v) {
_straightenDeg = v;
_rev.value++;
},
),
),
),
IconButton(
onPressed: _rotate90,
icon: const Icon(Symbols.rotate_90_degrees_ccw, color: Colors.white),
tooltip: 'Повернуть',
),
],
),
);
}
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<double> onChanged;
const _StraightenRuler({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) {
onChanged((value - d.delta.dx * 0.22).clamp(-45.0, 45.0));
},
onDoubleTap: () => onChanged(0),
child: SizedBox(
height: 56,
child: CustomPaint(painter: _RulerPainter(value)),
),
);
}
}
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;
}