feat: доделал работу с медиа.

This commit is contained in:
Jganenok
2026-06-10 12:57:30 +07:00
parent 32aa869b89
commit d89c1366e2
8 changed files with 1919 additions and 288 deletions
+17 -13
View File
@@ -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<GalleryPermission> ensurePermission();
Future<List<GalleryItem>> load({int limit});
@@ -178,17 +194,5 @@ class _FileGalleryItem implements GalleryItem {
Future<File?> 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);
}
+1 -15
View File
@@ -1804,7 +1804,7 @@ class _ChatScreenState extends State<ChatScreen> 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<ChatScreen> 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<void> _pickAndUploadFile() async {
final result = await FilePicker.platform.pickFiles();
if (result == null || result.files.isEmpty) return;
@@ -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<AttachmentSheet> {
final GallerySource _source = GallerySource.create();
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
final Map<String, File> _edited = {};
final Map<String, PhotoEditState> _edits = {};
final TextEditingController _captionCtrl = TextEditingController();
final PageController _pageController = PageController();
@@ -122,9 +123,9 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
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<AttachmentSheet> {
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<AttachmentSheet> {
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<AttachmentSheet> {
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<AttachmentSheet> {
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<AttachmentSheet> {
}
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),
);
},
),
),
);
}
@@ -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<Set<String>> selectedIds;
final VoidCallback onToggleSelection;
final VoidCallback onSend;
final File? editedFile;
final void Function(File edited)? onEdited;
final PhotoEditState? editState;
final ValueChanged<PhotoEditState>? onEditChanged;
final String initialCaption;
final ValueChanged<String>? 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<MediaPreviewScreen> createState() => _MediaPreviewScreenState();
}
class _MediaPreviewScreenState extends State<MediaPreviewScreen>
with SingleTickerProviderStateMixin {
class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
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<void> _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<void> _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<MediaPreviewScreen>
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<void> _rotate() async {
if (_workingFile == null || _rotationOriginal == null) return;
_queuedTurns++;
if (_rotating) return;
_rotating = true;
while (_queuedTurns > 0 && mounted) {
_queuedTurns--;
await _rotateOneStep();
}
_rotating = false;
Future<T?> _pushEditor<T>(Widget editor) {
return Navigator.of(context).push<T>(
PageRouteBuilder<T>(
opaque: true,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => editor,
),
);
}
Future<void> _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<File?> _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<void> _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<CropResult>(
PhotoCropEditor(source: source, initialState: _cropState),
);
if (result != null && mounted) {
_cropState = result.state;
setState(() => _workingFile = result.file);
_reportEdit();
}
}
Future<void> _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<File>(
PageRouteBuilder<File>(
opaque: true,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => PhotoDrawEditor(
source: file,
imageWidth: dims.$1,
imageHeight: dims.$2,
),
final result = await _pushEditor<File>(
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<void> _openAdjust() async {
final file = _workingFile;
if (file == null) return;
final result = await _pushEditor<File>(PhotoAdjustEditor(source: file));
if (result != null && mounted) {
_cropSource = result;
_cropState = null;
setState(() => _workingFile = result);
_reportEdit();
}
}
@@ -268,31 +187,11 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen>
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<MediaPreviewScreen>
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),
],
),
),
File diff suppressed because it is too large Load Diff
@@ -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<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;
}
@@ -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<PhotoDrawEditor> {
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<PhotoDrawEditor> {
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<int>(
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 &&
+45 -30
View File
@@ -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,
),
),
],
),
),
],
),
),
],
),
),
],
),
),
),
);
}