1136 lines
31 KiB
Dart
1136 lines
31 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import 'package:komet/core/media/raster.dart';
|
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
|
|
|
import '../../../core/config/app_colors.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
import '../small_spinner.dart';
|
|
import 'editor_common.dart';
|
|
import 'photo_hero.dart';
|
|
|
|
export 'editor_common.dart' show CropState;
|
|
|
|
class CropResult {
|
|
final File file;
|
|
final CropState state;
|
|
|
|
const CropResult(this.file, this.state);
|
|
}
|
|
|
|
class PhotoEditState {
|
|
final File? working;
|
|
final File? cropSource;
|
|
final CropState? cropState;
|
|
|
|
const PhotoEditState({this.working, this.cropSource, this.cropState});
|
|
}
|
|
|
|
class PhotoCropEditor extends StatefulWidget {
|
|
final File source;
|
|
final CropState? initialState;
|
|
final Future<void> Function(CropResult result)? onPreview;
|
|
|
|
const PhotoCropEditor({
|
|
super.key,
|
|
required this.source,
|
|
this.initialState,
|
|
this.onPreview,
|
|
});
|
|
|
|
@override
|
|
State<PhotoCropEditor> createState() => _PhotoCropEditorState();
|
|
}
|
|
|
|
class _PhotoCropEditorState extends State<PhotoCropEditor> {
|
|
ui.Image? _image;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
try {
|
|
final bytes = await widget.source.readAsBytes();
|
|
final codec = await ui.instantiateImageCodec(bytes);
|
|
final frame = await codec.getNextFrame();
|
|
codec.dispose();
|
|
if (!mounted) {
|
|
frame.image.dispose();
|
|
return;
|
|
}
|
|
setState(() => _image = frame.image);
|
|
} catch (_) {
|
|
if (mounted) Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_image?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<Object?> _apply(
|
|
CropState state,
|
|
Size vp,
|
|
bool changed,
|
|
bool identity,
|
|
) async {
|
|
if (!changed) return null;
|
|
final file = await _bakeCrop(_image!, state, vp);
|
|
if (file == null) return null;
|
|
final result = CropResult(file, state);
|
|
await widget.onPreview?.call(result);
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final image = _image;
|
|
if (image == null) {
|
|
return const Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Center(child: SmallSpinner(size: 36, color: Colors.white)),
|
|
);
|
|
}
|
|
return CropWorkspace(
|
|
imageSize: Size(image.width.toDouble(), image.height.toDouble()),
|
|
initialState: widget.initialState,
|
|
onApply: _apply,
|
|
imageBuilder: (context, matrix) =>
|
|
CustomPaint(painter: MatrixImagePainter(image, matrix)),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<File?> _bakeCrop(ui.Image img, CropState state, Size vp) async {
|
|
if (vp == Size.zero) return null;
|
|
try {
|
|
final geometry = CropGeometry(
|
|
source: Size(img.width.toDouble(), img.height.toDouble()),
|
|
quarterTurns: state.quarterTurns,
|
|
flipH: state.flipH,
|
|
straightenDeg: state.straightenDeg,
|
|
);
|
|
final crop = Rect.fromLTRB(
|
|
state.cropNorm.left * vp.width,
|
|
state.cropNorm.top * vp.height,
|
|
state.cropNorm.right * vp.width,
|
|
state.cropNorm.bottom * vp.height,
|
|
);
|
|
final m = geometry.viewportMatrix(vp, crop);
|
|
final upscale = 1 / geometry.baseScale(vp);
|
|
const maxDim = 4096;
|
|
final mx = math.max(crop.width * upscale, crop.height * upscale);
|
|
final cap = mx > maxDim ? maxDim / mx : 1.0;
|
|
final eff = upscale * cap;
|
|
final pxW = (crop.width * eff).round();
|
|
final pxH = (crop.height * eff).round();
|
|
if (pxW <= 0 || pxH <= 0) return null;
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
final canvas = Canvas(recorder);
|
|
canvas.scale(eff);
|
|
canvas.translate(-crop.left, -crop.top);
|
|
canvas.transform(m.storage);
|
|
canvas.drawImage(
|
|
img,
|
|
Offset.zero,
|
|
Paint()..filterQuality = FilterQuality.high,
|
|
);
|
|
final picture = recorder.endRecording();
|
|
return await rasterPictureToJpegFile(picture, pxW, pxH, prefix: 'crop');
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class PhotoDrawEditor extends StatelessWidget {
|
|
final File source;
|
|
final int imageWidth;
|
|
final int imageHeight;
|
|
final Future<void> Function(File result)? onPreview;
|
|
|
|
const PhotoDrawEditor({
|
|
super.key,
|
|
required this.source,
|
|
required this.imageWidth,
|
|
required this.imageHeight,
|
|
this.onPreview,
|
|
});
|
|
|
|
Future<Object?> _apply(List<EditMark> marks, Size canvas) async {
|
|
if (marks.isEmpty) return null;
|
|
final file = await _bakeMarks(source, marks, canvas);
|
|
if (file == null) return null;
|
|
await onPreview?.call(file);
|
|
return file;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MarkupEditor(
|
|
aspectRatio: imageHeight > 0 ? imageWidth / imageHeight : 1.0,
|
|
background: Image.file(source, fit: BoxFit.cover, gaplessPlayback: true),
|
|
onApply: _apply,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<File?> _bakeMarks(File source, List<EditMark> marks, Size box) async {
|
|
if (box.isEmpty) return null;
|
|
try {
|
|
final bytes = await source.readAsBytes();
|
|
final codec = await ui.instantiateImageCodec(bytes);
|
|
final frame = await codec.getNextFrame();
|
|
final image = frame.image;
|
|
|
|
const maxDim = 4096;
|
|
final srcMax = math.max(image.width, image.height);
|
|
final cap = srcMax > maxDim ? maxDim / srcMax : 1.0;
|
|
final outW = (image.width * cap).round();
|
|
final outH = (image.height * cap).round();
|
|
final scale = outW / box.width;
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
final canvas = Canvas(recorder);
|
|
canvas.scale(scale);
|
|
canvas.drawImageRect(
|
|
image,
|
|
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
|
Rect.fromLTWH(0, 0, box.width, box.height),
|
|
Paint(),
|
|
);
|
|
DrawingPainter(marks: marks).paintMarks(canvas, box);
|
|
final picture = recorder.endRecording();
|
|
image.dispose();
|
|
codec.dispose();
|
|
|
|
return await rasterPictureToJpegFile(picture, outW, outH, prefix: 'edit');
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
enum BlurMode { off, radial, linear }
|
|
|
|
enum _Tab { adjust, blur, curves }
|
|
|
|
class PhotoAdjustEditor extends StatefulWidget {
|
|
final File source;
|
|
final Future<void> Function(File result)? onPreview;
|
|
|
|
const PhotoAdjustEditor({super.key, required this.source, this.onPreview});
|
|
|
|
@override
|
|
State<PhotoAdjustEditor> createState() => _PhotoAdjustEditorState();
|
|
}
|
|
|
|
class _PhotoAdjustEditorState extends State<PhotoAdjustEditor> {
|
|
ui.Image? _image;
|
|
final ValueNotifier<int> _rev = ValueNotifier(0);
|
|
|
|
final ColorAdjust _adjust = ColorAdjust();
|
|
BlurMode _blur = BlurMode.off;
|
|
Offset _blurCenter = const Offset(0.5, 0.5);
|
|
double _blurInner = 0.18;
|
|
double _blurOuter = 0.34;
|
|
static const double _blurAngle = 0;
|
|
int _blurHandle = 0;
|
|
|
|
final List<List<Offset>> _curves = List.generate(
|
|
4,
|
|
(_) => [const Offset(0, 0), const Offset(1, 1)],
|
|
);
|
|
int _channel = 0;
|
|
int _curveDrag = -1;
|
|
Uint8List? _smallRgba;
|
|
int _smallW = 0;
|
|
int _smallH = 0;
|
|
ui.Image? _curvedImage;
|
|
Uint8List? _curveOut;
|
|
bool _curveBusy = false;
|
|
bool _curveDirty = false;
|
|
|
|
_Tab _tab = _Tab.adjust;
|
|
bool _baking = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
try {
|
|
final bytes = await widget.source.readAsBytes();
|
|
final codec = await ui.instantiateImageCodec(bytes);
|
|
final frame = await codec.getNextFrame();
|
|
codec.dispose();
|
|
if (!mounted) {
|
|
frame.image.dispose();
|
|
return;
|
|
}
|
|
final smallCodec = await ui.instantiateImageCodec(
|
|
bytes,
|
|
targetWidth: 480,
|
|
);
|
|
final smallFrame = await smallCodec.getNextFrame();
|
|
smallCodec.dispose();
|
|
final small = smallFrame.image;
|
|
final sbd = await small.toByteData(format: ui.ImageByteFormat.rawRgba);
|
|
_smallW = small.width;
|
|
_smallH = small.height;
|
|
_smallRgba = sbd?.buffer.asUint8List();
|
|
small.dispose();
|
|
if (!mounted) {
|
|
frame.image.dispose();
|
|
return;
|
|
}
|
|
setState(() => _image = frame.image);
|
|
} catch (_) {
|
|
if (mounted) Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_image?.dispose();
|
|
_curvedImage?.dispose();
|
|
_rev.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _curveIdentity(List<Offset> pts) =>
|
|
pts.length == 2 &&
|
|
pts.first == const Offset(0, 0) &&
|
|
pts.last == const Offset(1, 1);
|
|
|
|
bool get _curvesIdentity => _curves.every(_curveIdentity);
|
|
|
|
bool get _pristine =>
|
|
_adjust.pristine && _blur == BlurMode.off && _curvesIdentity;
|
|
|
|
Gradient _maskGradient() {
|
|
if (_blur == BlurMode.linear) {
|
|
return LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: const [
|
|
Colors.transparent,
|
|
Colors.white,
|
|
Colors.white,
|
|
Colors.transparent,
|
|
],
|
|
stops: _linearStops(),
|
|
transform: _RotateAround(_blurAngle, _blurCenter),
|
|
);
|
|
}
|
|
final innerStop = _blurOuter > 0
|
|
? (_blurInner / _blurOuter).clamp(0.0, 1.0)
|
|
: 1.0;
|
|
return RadialGradient(
|
|
center: Alignment(_blurCenter.dx * 2 - 1, _blurCenter.dy * 2 - 1),
|
|
radius: _blurOuter,
|
|
colors: const [Colors.white, Colors.white, Colors.transparent],
|
|
stops: [0.0, innerStop, 1.0],
|
|
);
|
|
}
|
|
|
|
List<double> _linearStops() {
|
|
final c = _blurCenter.dy;
|
|
var s0 = (c - _blurOuter).clamp(0.0, 1.0);
|
|
var s1 = (c - _blurInner).clamp(0.0, 1.0);
|
|
var s2 = (c + _blurInner).clamp(0.0, 1.0);
|
|
var s3 = (c + _blurOuter).clamp(0.0, 1.0);
|
|
s1 = math.max(s1, s0);
|
|
s2 = math.max(s2, s1);
|
|
s3 = math.max(s3, s2);
|
|
return [s0, s1, s2, s3];
|
|
}
|
|
|
|
Rect _imageRect(Size box, ui.Image img) {
|
|
final iw = img.width.toDouble();
|
|
final ih = img.height.toDouble();
|
|
if (iw <= 0 || ih <= 0) return Offset.zero & box;
|
|
final scale = math.min(box.width / iw, box.height / ih);
|
|
final w = iw * scale;
|
|
final h = ih * scale;
|
|
return Rect.fromLTWH((box.width - w) / 2, (box.height - h) / 2, w, h);
|
|
}
|
|
|
|
double _blurAlong(Offset pos, Size imgSize) {
|
|
final c = Offset(
|
|
_blurCenter.dx * imgSize.width,
|
|
_blurCenter.dy * imgSize.height,
|
|
);
|
|
if (_blur == BlurMode.radial) return (pos - c).distance;
|
|
final axis = Offset(-math.sin(_blurAngle), math.cos(_blurAngle));
|
|
return ((pos - c).dx * axis.dx + (pos - c).dy * axis.dy).abs();
|
|
}
|
|
|
|
double _blurDenom(Size imgSize) =>
|
|
_blur == BlurMode.radial ? imgSize.shortestSide : imgSize.height;
|
|
|
|
void _onBlurPanStart(Offset pos, Size imgSize) {
|
|
final denom = _blurDenom(imgSize);
|
|
final along = _blurAlong(pos, imgSize);
|
|
final di = (along - _blurInner * denom).abs();
|
|
final doo = (along - _blurOuter * denom).abs();
|
|
if (di < doo && di < 44) {
|
|
_blurHandle = 1;
|
|
} else if (doo < 44) {
|
|
_blurHandle = 2;
|
|
} else {
|
|
_blurHandle = 0;
|
|
}
|
|
}
|
|
|
|
void _onBlurPanUpdate(Offset pos, Offset delta, Size imgSize) {
|
|
final denom = _blurDenom(imgSize);
|
|
if (_blurHandle == 1) {
|
|
_blurInner = (_blurAlong(pos, imgSize) / denom).clamp(0.02, _blurOuter);
|
|
} else if (_blurHandle == 2) {
|
|
_blurOuter = (_blurAlong(pos, imgSize) / denom).clamp(_blurInner, 1.6);
|
|
} else {
|
|
_blurCenter = Offset(
|
|
(_blurCenter.dx + delta.dx / imgSize.width).clamp(0.0, 1.0),
|
|
(_blurCenter.dy + delta.dy / imgSize.height).clamp(0.0, 1.0),
|
|
);
|
|
}
|
|
_rev.value++;
|
|
}
|
|
|
|
double _curveY(List<Offset> pts, double x) {
|
|
if (x <= pts.first.dx) return pts.first.dy;
|
|
if (x >= pts.last.dx) return pts.last.dy;
|
|
for (var i = 0; i < pts.length - 1; i++) {
|
|
final a = pts[i];
|
|
final b = pts[i + 1];
|
|
if (x >= a.dx && x <= b.dx) {
|
|
final span = b.dx - a.dx;
|
|
final t = span < 1e-6 ? 0.0 : (x - a.dx) / span;
|
|
return a.dy + (b.dy - a.dy) * t;
|
|
}
|
|
}
|
|
return pts.last.dy;
|
|
}
|
|
|
|
List<int> _lut(List<Offset> pts) => List<int>.generate(
|
|
256,
|
|
(i) => (_curveY(pts, i / 255.0) * 255).round().clamp(0, 255),
|
|
);
|
|
|
|
(List<int>, List<int>, List<int>) _combinedLuts() {
|
|
final m = _lut(_curves[0]);
|
|
final r = _lut(_curves[1]);
|
|
final g = _lut(_curves[2]);
|
|
final b = _lut(_curves[3]);
|
|
return (
|
|
List<int>.generate(256, (i) => m[r[i]]),
|
|
List<int>.generate(256, (i) => m[g[i]]),
|
|
List<int>.generate(256, (i) => m[b[i]]),
|
|
);
|
|
}
|
|
|
|
void _scheduleCurvePreview() {
|
|
_rev.value++;
|
|
if (_curvesIdentity) {
|
|
_curvedImage?.dispose();
|
|
_curvedImage = null;
|
|
return;
|
|
}
|
|
if (_curveBusy) {
|
|
_curveDirty = true;
|
|
return;
|
|
}
|
|
_runCurvePreview();
|
|
}
|
|
|
|
Future<void> _runCurvePreview() async {
|
|
final base = _smallRgba;
|
|
if (base == null) return;
|
|
_curveBusy = true;
|
|
final (rl, gl, bl) = _combinedLuts();
|
|
final out = _curveOut ??= Uint8List(base.length);
|
|
out.setAll(0, base);
|
|
_applyLutsToBytes((out, rl, gl, bl));
|
|
final completer = Completer<ui.Image>();
|
|
ui.decodeImageFromPixels(
|
|
out,
|
|
_smallW,
|
|
_smallH,
|
|
ui.PixelFormat.rgba8888,
|
|
completer.complete,
|
|
);
|
|
final img = await completer.future;
|
|
_curveBusy = false;
|
|
if (!mounted) {
|
|
img.dispose();
|
|
return;
|
|
}
|
|
_curvedImage?.dispose();
|
|
_curvedImage = img;
|
|
_rev.value++;
|
|
if (_curveDirty) {
|
|
_curveDirty = false;
|
|
_runCurvePreview();
|
|
}
|
|
}
|
|
|
|
Future<ui.Image> _curvedFull(ui.Image img) async {
|
|
if (_curvesIdentity) return img;
|
|
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
|
if (bd == null) return img;
|
|
final (rl, gl, bl) = _combinedLuts();
|
|
final out = await compute(_applyLutsToBytes, (
|
|
bd.buffer.asUint8List(),
|
|
rl,
|
|
gl,
|
|
bl,
|
|
));
|
|
final completer = Completer<ui.Image>();
|
|
ui.decodeImageFromPixels(
|
|
out,
|
|
img.width,
|
|
img.height,
|
|
ui.PixelFormat.rgba8888,
|
|
completer.complete,
|
|
);
|
|
return completer.future;
|
|
}
|
|
|
|
void _onCurvePanStart(Offset pos, Size size) {
|
|
final pts = _curves[_channel];
|
|
var hit = -1;
|
|
for (var i = 0; i < pts.length; i++) {
|
|
final sp = Offset(pts[i].dx * size.width, (1 - pts[i].dy) * size.height);
|
|
if ((pos - sp).distance < 28) {
|
|
hit = i;
|
|
break;
|
|
}
|
|
}
|
|
if (hit == -1 && pts.length < 10) {
|
|
final x = (pos.dx / size.width).clamp(0.0, 1.0);
|
|
final y = (1 - pos.dy / size.height).clamp(0.0, 1.0);
|
|
var idx = pts.indexWhere((pt) => pt.dx > x);
|
|
if (idx == -1) idx = pts.length;
|
|
pts.insert(idx, Offset(x, y));
|
|
hit = idx;
|
|
}
|
|
_curveDrag = hit;
|
|
}
|
|
|
|
void _onCurvePanUpdate(Offset pos, Size size) {
|
|
if (_curveDrag < 0) return;
|
|
final pts = _curves[_channel];
|
|
final y = (1 - pos.dy / size.height).clamp(0.0, 1.0);
|
|
double x;
|
|
if (_curveDrag == 0) {
|
|
x = 0;
|
|
} else if (_curveDrag == pts.length - 1) {
|
|
x = 1;
|
|
} else {
|
|
final lo = pts[_curveDrag - 1].dx + 0.01;
|
|
final hi = pts[_curveDrag + 1].dx - 0.01;
|
|
x = (pos.dx / size.width).clamp(lo, math.max(lo, hi));
|
|
}
|
|
pts[_curveDrag] = Offset(x, y);
|
|
_scheduleCurvePreview();
|
|
}
|
|
|
|
void _onCurveRemove(Offset pos, Size size) {
|
|
final pts = _curves[_channel];
|
|
for (var i = 1; i < pts.length - 1; i++) {
|
|
final sp = Offset(pts[i].dx * size.width, (1 - pts[i].dy) * size.height);
|
|
if ((pos - sp).distance < 28) {
|
|
pts.removeAt(i);
|
|
_scheduleCurvePreview();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<File?> _bake() async {
|
|
final img = _image;
|
|
if (img == null) return null;
|
|
try {
|
|
const maxDim = 4096;
|
|
final srcMax = img.width > img.height ? img.width : img.height;
|
|
final cap = srcMax > maxDim ? maxDim / srcMax : 1.0;
|
|
final outW = (img.width * cap).round();
|
|
final outH = (img.height * cap).round();
|
|
if (outW <= 0 || outH <= 0) return null;
|
|
final rect = Rect.fromLTWH(0, 0, outW.toDouble(), outH.toDouble());
|
|
final src = Rect.fromLTWH(
|
|
0,
|
|
0,
|
|
img.width.toDouble(),
|
|
img.height.toDouble(),
|
|
);
|
|
final curved = await _curvedFull(img);
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
final canvas = Canvas(recorder);
|
|
|
|
canvas.saveLayer(
|
|
rect,
|
|
Paint()..colorFilter = ColorFilter.matrix(_adjust.matrix()),
|
|
);
|
|
if (_blur == BlurMode.off) {
|
|
canvas.drawImageRect(curved, src, rect, Paint());
|
|
} else {
|
|
final sigma = outW * 0.02;
|
|
canvas.drawImageRect(
|
|
curved,
|
|
src,
|
|
rect,
|
|
Paint()
|
|
..imageFilter = ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
|
);
|
|
canvas.saveLayer(rect, Paint());
|
|
canvas.drawImageRect(curved, src, rect, Paint());
|
|
canvas.drawRect(
|
|
rect,
|
|
Paint()
|
|
..blendMode = BlendMode.dstIn
|
|
..shader = _maskGradient().createShader(rect),
|
|
);
|
|
canvas.restore();
|
|
}
|
|
canvas.restore();
|
|
|
|
if (_adjust.vignette > 0) {
|
|
canvas.drawRect(
|
|
rect,
|
|
Paint()..shader = _adjust.vignetteGradient().createShader(rect),
|
|
);
|
|
}
|
|
|
|
final picture = recorder.endRecording();
|
|
return await rasterPictureToJpegFile(
|
|
picture,
|
|
outW,
|
|
outH,
|
|
prefix: 'adj',
|
|
onPictureDisposed: () {
|
|
if (curved != img) curved.dispose();
|
|
},
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _done() async {
|
|
if (_baking) return;
|
|
if (_pristine) {
|
|
Navigator.of(context).pop();
|
|
return;
|
|
}
|
|
setState(() => _baking = true);
|
|
final file = await _bake();
|
|
if (!mounted) return;
|
|
if (file == null) {
|
|
setState(() => _baking = false);
|
|
showCustomNotification(
|
|
context,
|
|
AppLocalizations.of(context)!.photoEditorApplyFailed,
|
|
);
|
|
return;
|
|
}
|
|
await widget.onPreview?.call(file);
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop(file);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: SafeArea(
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Expanded(
|
|
child: PhotoHeroTarget(
|
|
child: ClipRect(child: _buildPreview()),
|
|
),
|
|
),
|
|
_buildTabContent(),
|
|
_buildBottomBar(),
|
|
],
|
|
),
|
|
if (_baking) const BusyOverlay(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPreview() {
|
|
final img = _image;
|
|
if (img == null) {
|
|
return const Center(child: SmallSpinner(size: 36, color: Colors.white));
|
|
}
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final rect = _imageRect(constraints.biggest, img);
|
|
return ValueListenableBuilder<int>(
|
|
valueListenable: _rev,
|
|
builder: (context, _, _) {
|
|
final blurTab = _tab == _Tab.blur && _blur != BlurMode.off;
|
|
final curvesTab = _tab == _Tab.curves;
|
|
final shown = _curvedImage ?? img;
|
|
final content = Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ColorFiltered(
|
|
colorFilter: ColorFilter.matrix(_adjust.matrix()),
|
|
child: _buildBlurLayer(shown),
|
|
),
|
|
if (_adjust.vignette > 0)
|
|
IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: _adjust.vignetteGradient(),
|
|
),
|
|
),
|
|
),
|
|
if (blurTab)
|
|
IgnorePointer(
|
|
child: CustomPaint(
|
|
painter: _BlurGuidePainter(
|
|
mode: _blur,
|
|
center: _blurCenter,
|
|
inner: _blurInner,
|
|
outer: _blurOuter,
|
|
angle: _blurAngle,
|
|
),
|
|
),
|
|
),
|
|
if (curvesTab)
|
|
IgnorePointer(
|
|
child: CustomPaint(
|
|
painter: _CurvePainter(
|
|
points: _curves[_channel],
|
|
color: _channelColor(_channel),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
Widget child = content;
|
|
if (blurTab) {
|
|
child = GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPanStart: (d) => _onBlurPanStart(d.localPosition, rect.size),
|
|
onPanUpdate: (d) =>
|
|
_onBlurPanUpdate(d.localPosition, d.delta, rect.size),
|
|
child: content,
|
|
);
|
|
} else if (curvesTab) {
|
|
child = GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPanStart: (d) => _onCurvePanStart(d.localPosition, rect.size),
|
|
onPanUpdate: (d) =>
|
|
_onCurvePanUpdate(d.localPosition, rect.size),
|
|
onPanEnd: (_) => _curveDrag = -1,
|
|
onDoubleTapDown: (d) =>
|
|
_onCurveRemove(d.localPosition, rect.size),
|
|
child: content,
|
|
);
|
|
}
|
|
return Stack(
|
|
children: [Positioned.fromRect(rect: rect, child: child)],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildBlurLayer(ui.Image img) {
|
|
if (_blur == BlurMode.off) {
|
|
return RawImage(image: img, fit: BoxFit.contain);
|
|
}
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ImageFiltered(
|
|
imageFilter: ui.ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
|
child: RawImage(image: img, fit: BoxFit.contain),
|
|
),
|
|
ShaderMask(
|
|
shaderCallback: (r) => _maskGradient().createShader(r),
|
|
blendMode: BlendMode.dstIn,
|
|
child: RawImage(image: img, fit: BoxFit.contain),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTabContent() {
|
|
switch (_tab) {
|
|
case _Tab.adjust:
|
|
return _buildSliders();
|
|
case _Tab.blur:
|
|
return _buildBlurOptions();
|
|
case _Tab.curves:
|
|
return _buildCurves();
|
|
}
|
|
}
|
|
|
|
Color _channelColor(int ch) {
|
|
switch (ch) {
|
|
case 1:
|
|
return const Color(0xFFFF4D4D);
|
|
case 2:
|
|
return const Color(0xFF45D964);
|
|
case 3:
|
|
return const Color(0xFF4D9DFF);
|
|
default:
|
|
return Colors.white;
|
|
}
|
|
}
|
|
|
|
Widget _buildCurves() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final labels = [
|
|
l10n.photoEditorChannelAll,
|
|
l10n.photoEditorChannelRed,
|
|
l10n.photoEditorChannelGreen,
|
|
l10n.photoEditorChannelBlue,
|
|
];
|
|
return SizedBox(
|
|
height: 110,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
for (var ch = 0; ch < 4; ch++) _channelOption(labels[ch], ch),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _channelOption(String label, int ch) {
|
|
final selected = _channel == ch;
|
|
final color = _channelColor(ch);
|
|
return GestureDetector(
|
|
onTap: () => setState(() => _channel = ch),
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: color, width: 2),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: selected
|
|
? Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: color,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: selected ? color : Colors.white70,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSliders() {
|
|
return ValueListenableBuilder<int>(
|
|
valueListenable: _rev,
|
|
builder: (context, _, _) =>
|
|
AdjustSliders(adjust: _adjust, onChanged: () => _rev.value++),
|
|
);
|
|
}
|
|
|
|
Widget _buildBlurOptions() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return SizedBox(
|
|
height: 110,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_blurOption(l10n.photoEditorBlurOff, Symbols.block, BlurMode.off),
|
|
_blurOption(
|
|
l10n.photoEditorBlurRadial,
|
|
Symbols.blur_circular,
|
|
BlurMode.radial,
|
|
),
|
|
_blurOption(
|
|
l10n.photoEditorBlurLinear,
|
|
Symbols.blur_linear,
|
|
BlurMode.linear,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _blurOption(String label, IconData icon, BlurMode mode) {
|
|
final selected = _blur == mode;
|
|
return GestureDetector(
|
|
onTap: () => setState(() => _blur = mode),
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: selected ? MediaAccent.of(context) : Colors.white,
|
|
size: 30,
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: selected ? MediaAccent.of(context) : Colors.white70,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomBar() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Container(
|
|
color: kEditorPanel,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(
|
|
l10n.photoEditorCancel,
|
|
style: const TextStyle(color: Colors.white, fontSize: 15),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
_tabIcon(Symbols.tune, _Tab.adjust),
|
|
const SizedBox(width: 26),
|
|
_tabIcon(Symbols.water_drop, _Tab.blur),
|
|
const SizedBox(width: 26),
|
|
_tabIcon(Symbols.show_chart, _Tab.curves),
|
|
const Spacer(),
|
|
TextButton(
|
|
onPressed: _baking ? null : _done,
|
|
child: Text(
|
|
l10n.photoEditorDone,
|
|
style: TextStyle(
|
|
color: _baking ? Colors.white38 : MediaAccent.of(context),
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _tabIcon(IconData icon, _Tab tab, {bool disabled = false}) {
|
|
final selected = _tab == tab;
|
|
return IconButton(
|
|
onPressed: disabled ? null : () => setState(() => _tab = tab),
|
|
icon: Icon(icon),
|
|
color: selected ? MediaAccent.of(context) : Colors.white,
|
|
disabledColor: Colors.white24,
|
|
);
|
|
}
|
|
}
|
|
|
|
Uint8List _applyLutsToBytes((Uint8List, List<int>, List<int>, List<int>) args) {
|
|
final (rgba, rl, gl, bl) = args;
|
|
for (var i = 0; i < rgba.length; i += 4) {
|
|
rgba[i] = rl[rgba[i]];
|
|
rgba[i + 1] = gl[rgba[i + 1]];
|
|
rgba[i + 2] = bl[rgba[i + 2]];
|
|
}
|
|
return rgba;
|
|
}
|
|
|
|
class _RotateAround extends GradientTransform {
|
|
final double radians;
|
|
final Offset center;
|
|
|
|
const _RotateAround(this.radians, this.center);
|
|
|
|
@override
|
|
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
|
|
final cx = bounds.left + center.dx * bounds.width;
|
|
final cy = bounds.top + center.dy * bounds.height;
|
|
return Matrix4.identity()
|
|
..translateByDouble(cx, cy, 0, 1)
|
|
..rotateZ(radians)
|
|
..translateByDouble(-cx, -cy, 0, 1);
|
|
}
|
|
}
|
|
|
|
class _BlurGuidePainter extends CustomPainter {
|
|
final BlurMode mode;
|
|
final Offset center;
|
|
final double inner;
|
|
final double outer;
|
|
final double angle;
|
|
|
|
_BlurGuidePainter({
|
|
required this.mode,
|
|
required this.center,
|
|
required this.inner,
|
|
required this.outer,
|
|
required this.angle,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size sz) {
|
|
canvas.clipRect(Offset.zero & sz);
|
|
final c = Offset(center.dx * sz.width, center.dy * sz.height);
|
|
final line = Paint()
|
|
..color = Colors.white
|
|
..strokeWidth = 1.5
|
|
..style = PaintingStyle.stroke;
|
|
|
|
if (mode == BlurMode.radial) {
|
|
final ss = sz.shortestSide;
|
|
_dashedCircle(canvas, c, inner * ss, line);
|
|
_dashedCircle(canvas, c, outer * ss, line);
|
|
} else {
|
|
final axis = Offset(-math.sin(angle), math.cos(angle));
|
|
final perp = Offset(math.cos(angle), math.sin(angle));
|
|
final innerPx = inner * sz.height;
|
|
final outerPx = outer * sz.height;
|
|
for (final o in [-outerPx, -innerPx, innerPx, outerPx]) {
|
|
final mid = c + axis * o;
|
|
_dashedLine(canvas, mid - perp * 4000, mid + perp * 4000, line);
|
|
}
|
|
}
|
|
|
|
canvas.drawCircle(c, 9, Paint()..color = Colors.white);
|
|
canvas.drawCircle(
|
|
c,
|
|
9,
|
|
Paint()
|
|
..color = Colors.black26
|
|
..strokeWidth = 1
|
|
..style = PaintingStyle.stroke,
|
|
);
|
|
}
|
|
|
|
void _dashedCircle(Canvas canvas, Offset c, double r, Paint paint) {
|
|
if (r <= 1) return;
|
|
const seg = 48;
|
|
const sweep = 2 * math.pi / seg;
|
|
final rect = Rect.fromCircle(center: c, radius: r);
|
|
for (var i = 0; i < seg; i += 2) {
|
|
canvas.drawArc(rect, i * sweep, sweep, false, paint);
|
|
}
|
|
}
|
|
|
|
void _dashedLine(Canvas canvas, Offset a, Offset b, Paint paint) {
|
|
const dash = 9.0;
|
|
const gap = 7.0;
|
|
final total = (b - a).distance;
|
|
if (total <= 0) return;
|
|
final dir = (b - a) / total;
|
|
var d = 0.0;
|
|
while (d < total) {
|
|
final start = a + dir * d;
|
|
final end = a + dir * math.min(d + dash, total);
|
|
canvas.drawLine(start, end, paint);
|
|
d += dash + gap;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _BlurGuidePainter old) =>
|
|
old.mode != mode ||
|
|
old.center != center ||
|
|
old.inner != inner ||
|
|
old.outer != outer ||
|
|
old.angle != angle;
|
|
}
|
|
|
|
class _CurvePainter extends CustomPainter {
|
|
final List<Offset> points;
|
|
final Color color;
|
|
|
|
_CurvePainter({required this.points, required this.color});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size sz) {
|
|
canvas.clipRect(Offset.zero & sz);
|
|
final grid = Paint()
|
|
..color = Colors.white.withValues(alpha: 0.22)
|
|
..strokeWidth = 0.7;
|
|
for (var i = 1; i < 3; i++) {
|
|
final x = sz.width * i / 3;
|
|
final y = sz.height * i / 3;
|
|
canvas.drawLine(Offset(x, 0), Offset(x, sz.height), grid);
|
|
canvas.drawLine(Offset(0, y), Offset(sz.width, y), grid);
|
|
}
|
|
|
|
Offset sp(Offset pt) => Offset(pt.dx * sz.width, (1 - pt.dy) * sz.height);
|
|
final path = Path();
|
|
for (var i = 0; i < points.length; i++) {
|
|
final s = sp(points[i]);
|
|
if (i == 0) {
|
|
path.moveTo(s.dx, s.dy);
|
|
} else {
|
|
path.lineTo(s.dx, s.dy);
|
|
}
|
|
}
|
|
canvas.drawPath(
|
|
path,
|
|
Paint()
|
|
..color = color
|
|
..strokeWidth = 2
|
|
..style = PaintingStyle.stroke,
|
|
);
|
|
|
|
final fill = Paint()..color = color;
|
|
final ring = Paint()
|
|
..color = Colors.white
|
|
..strokeWidth = 2
|
|
..style = PaintingStyle.stroke;
|
|
for (final pt in points) {
|
|
final s = sp(pt);
|
|
canvas.drawCircle(s, 6, fill);
|
|
canvas.drawCircle(s, 6, ring);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _CurvePainter old) => true;
|
|
}
|