feat: доделал работу с медиа.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user