feat: marcar legenda com markdown, barra de ações nos posts do canal e limites de mensagem na modal
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-14 19:18:09 -03:00
parent 9d69e833bd
commit 43e0dc177c
12 changed files with 244 additions and 810 deletions

View File

@@ -8,11 +8,11 @@ import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/utils/file_description.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../../widgets/blur_hash.dart';
import 'html_message.dart';
class ImageBubble extends StatelessWidget {
final Event event;
@@ -121,14 +121,13 @@ class ImageBubble extends StatelessWidget {
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Linkify(
text: fileDescription,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize,
),
options: const LinkifyOptions(humanize: false),
child: HtmlMessage(
html: event.isRichMessage
? event.formattedText
: fileDescription,
room: event.room,
textColor: textColor,
fontSize: AppConfig.messageFontSize,
linkStyle: TextStyle(
color: linkColor,
fontSize: AppConfig.messageFontSize,

View File

@@ -14,10 +14,12 @@ import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
import 'package:tatchat/utils/date_time_extension.dart';
import 'package:tatchat/utils/file_description.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/show_scaffold_dialog.dart';
import 'package:tatchat/utils/string_color.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/member_actions_popup_menu_button.dart';
import 'package:tatchat/widgets/share_scaffold_dialog.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
@@ -247,8 +249,8 @@ class Message extends StatelessWidget {
child: Center(child: Icon(Icons.check_outlined)),
),
direction: AppSettings.swipeRightToLeftToReply.value
? SwipeDirection.endToStart
: SwipeDirection.startToEnd,
? SwipeDirection.startToEnd
: SwipeDirection.endToStart,
onSwipe: (_) => onSwipe(),
child: Container(
constraints: card
@@ -683,7 +685,7 @@ class Message extends StatelessWidget {
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
alignment: Alignment.bottomCenter,
child: !hasReactions
child: card || !hasReactions
? const SizedBox.shrink()
: Container(
alignment: card
@@ -699,6 +701,17 @@ class Message extends StatelessWidget {
child: MessageReactions(event, timeline),
),
),
if (card)
_PostActionBar(
event: event,
timeline: timeline,
hasReactions: hasReactions,
threadCount: threadChildren.length,
onAddReaction: () => onSelect(event),
onOpenThread: enterThread == null
? null
: () => enterThread(event.eventId),
),
Row(
mainAxisAlignment: card
? .start
@@ -990,7 +1003,7 @@ class Message extends StatelessWidget {
),
],
),
if (enterThread != null)
if (enterThread != null && !card)
AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
@@ -1177,3 +1190,115 @@ class __AnimateInState extends State<_AnimateIn> {
);
}
}
class _PostActionBar extends StatelessWidget {
final Event event;
final Timeline timeline;
final bool hasReactions;
final int threadCount;
final VoidCallback? onAddReaction;
final VoidCallback? onOpenThread;
const _PostActionBar({
required this.event,
required this.timeline,
required this.hasReactions,
required this.threadCount,
required this.onAddReaction,
required this.onOpenThread,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withAlpha(150),
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant.withAlpha(120),
),
),
),
child: Row(
children: [
_BarAction(
icon: Icons.visibility_outlined,
label: '${event.receipts.length}',
),
Expanded(
child: Row(
children: [
if (hasReactions)
Flexible(
child: Align(
alignment: Alignment.centerLeft,
child: MessageReactions(event, timeline),
),
),
_BarAction(
icon: Icons.add_reaction_outlined,
onPressed: onAddReaction,
),
],
),
),
_BarAction(
icon: Icons.chat_bubble_outline,
label: '$threadCount',
onPressed: onOpenThread,
),
_BarAction(
icon: Icons.share_outlined,
onPressed: () {
showScaffoldDialog(
context: context,
builder: (context) => ShareScaffoldDialog(
items: [ContentShareItem(event.content.copy())],
),
);
},
),
],
),
);
}
}
class _BarAction extends StatelessWidget {
final IconData icon;
final String? label;
final VoidCallback? onPressed;
const _BarAction({required this.icon, this.label, this.onPressed});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant),
if (label != null) ...[
const SizedBox(width: 4),
Text(
label!,
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
),
);
}
}

View File

@@ -78,29 +78,32 @@ class MessageContent extends StatelessWidget {
: BoxFit.cover;
if (w != null && h != null) {
fit = BoxFit.contain;
if (fullWidthCard &&
event.messageType == MessageTypes.Image &&
w > 0 &&
h > 0) {
return AspectRatio(
aspectRatio: w / h,
child: ImageBubble(
event,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
borderRadius: borderRadius,
timeline: timeline,
textColor: textColor,
onTap: () => showDialog(
context: context,
builder: (_) => ImageViewer(
event,
timeline: timeline,
outerContext: context,
if (fullWidthCard && event.messageType == MessageTypes.Image) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cardWidth = maxWidth.isFinite && maxWidth > 0
? maxWidth
: maxSize;
final hasKnownRatio = w > 0 && h > 0;
return ImageBubble(
event,
width: cardWidth,
height: hasKnownRatio ? cardWidth * h / w : maxSize,
fit: hasKnownRatio ? BoxFit.cover : BoxFit.contain,
borderRadius: borderRadius,
timeline: timeline,
textColor: textColor,
onTap: () => showDialog(
context: context,
builder: (_) => ImageViewer(
event,
timeline: timeline,
outerContext: context,
),
),
),
),
);
},
);
}
if (w > h) {

View File

@@ -13,11 +13,11 @@ import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/blur_hash.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../image_viewer/image_viewer.dart';
import 'html_message.dart';
class EventVideoPlayer extends StatelessWidget {
final Event event;
@@ -151,14 +151,13 @@ class EventVideoPlayer extends StatelessWidget {
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Linkify(
text: fileDescription,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize,
),
options: const LinkifyOptions(humanize: false),
child: HtmlMessage(
html: event.isRichMessage
? event.formattedText
: fileDescription,
room: event.room,
textColor: textColor!,
fontSize: AppConfig.messageFontSize,
linkStyle: TextStyle(
color: linkColor,
fontSize: AppConfig.messageFontSize,

View File

@@ -3,21 +3,18 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:async/async.dart' show Result;
import 'package:crop_image/crop_image.dart';
import 'package:cross_file/cross_file.dart';
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:html/parser.dart' show parseFragment;
import 'package:markdown/markdown.dart' show markdownToHtml;
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat/image_edit_geometry.dart';
import 'package:tatchat/pages/chat/trust_user_key_dialog.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_file_extension.dart';
import 'package:tatchat/utils/other_party_can_receive.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/show_scaffold_dialog.dart';
import 'package:tatchat/utils/size_string.dart';
import 'package:tatchat/utils/start_push_foreground_service.dart';
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
@@ -54,32 +51,13 @@ class SendFileDialogState extends State<SendFileDialog> {
static const int minSizeToCompress = 20 * 1000;
final TextEditingController _labelTextController = TextEditingController();
final FocusNode _labelFocusNode = FocusNode();
/// Mutable working copy of [SendFileDialog.files]. Editing an image replaces
/// its entry with a new [XFile] whose bytes, mimetype and filename extension
/// all reflect the edit result (always PNG). Keeping bytes + mimetype in one
/// object avoids drift between the preview, the upload and the extension.
/// Mutable working copy of [SendFileDialog.files].
late final List<XFile> _files = List<XFile>.of(widget.files);
Future<void> _editImage(int index) async {
final bytes = await _files[index].readAsBytes();
if (!mounted) return;
final result = await showScaffoldDialog<Uint8List?>(
context: context,
builder: (_) => _ImageEditPage(bytes: bytes),
);
if (!mounted || result == null) return;
final original = _files[index];
final dot = original.name.lastIndexOf('.');
final baseName = dot > 0 ? original.name.substring(0, dot) : original.name;
setState(() {
_files[index] = XFile.fromData(
result,
mimeType: 'image/png',
name: '$baseName.png',
length: result.length,
);
});
void _editLabel() {
_labelFocusNode.requestFocus();
}
Future<void> _send(String? uniqueFileType) async {
@@ -151,12 +129,24 @@ class SendFileDialogState extends State<SendFileDialog> {
final label = _labelTextController.text.trim();
final extraContent = label.isEmpty
? null
: () {
final extra = <String, Object>{'body': label};
final html = markdownToHtml(label);
if (parseFragment(html).text != label) {
extra['format'] = 'org.matrix.custom.html';
extra['formatted_body'] = html;
}
return extra;
}();
try {
await widget.room.sendFileEvent(
file,
thumbnail: thumbnail,
shrinkImageMaxDimension: compress ? 1600 : null,
extraContent: label.isEmpty ? null : {'body': label},
extraContent: extraContent,
threadRootEventId: widget.threadRootEventId,
threadLastEventId: widget.threadLastEventId,
);
@@ -175,7 +165,7 @@ class SendFileDialogState extends State<SendFileDialog> {
file,
thumbnail: thumbnail,
shrinkImageMaxDimension: compress ? 1600 : null,
extraContent: label.isEmpty ? null : {'body': label},
extraContent: extraContent,
);
}
}
@@ -197,6 +187,7 @@ class SendFileDialogState extends State<SendFileDialog> {
@override
void dispose() {
_labelFocusNode.dispose();
_labelTextController.dispose();
super.dispose();
}
@@ -204,6 +195,7 @@ class SendFileDialogState extends State<SendFileDialog> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isMobile = PlatformInfos.isMobile;
var sendStr = L10n.of(context).sendFile;
final uniqueFileType = _files
@@ -244,8 +236,18 @@ class SendFileDialogState extends State<SendFileDialog> {
return AlertDialog.adaptive(
title: Text(sendStr),
insetPadding: isMobile
? const EdgeInsets.all(8.0)
: const EdgeInsets.symmetric(horizontal: 40, vertical: 24),
constraints: isMobile
? const BoxConstraints(
maxWidth: double.infinity,
maxHeight: double.infinity,
)
: null,
content: SizedBox(
width: 256,
width: 512,
height: isMobile ? MediaQuery.sizeOf(context).height * 0.85 : null,
child: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
@@ -333,13 +335,15 @@ class SendFileDialogState extends State<SendFileDialog> {
color: Colors.black54,
shape: const CircleBorder(),
child: IconButton(
tooltip: L10n.of(context).editImage,
tooltip: L10n.of(
context,
).optionalMessage,
icon: const Icon(
Icons.edit_outlined,
color: Colors.white,
size: 20,
),
onPressed: () => _editImage(i),
onPressed: _editLabel,
),
),
),
@@ -393,10 +397,12 @@ class SendFileDialogState extends State<SendFileDialog> {
padding: const EdgeInsets.only(bottom: 8.0),
child: DialogTextField(
controller: _labelTextController,
focusNode: _labelFocusNode,
labelText: L10n.of(context).optionalMessage,
minLines: 1,
maxLines: 1,
maxLength: 255,
keyboardType: TextInputType.multiline,
minLines: isMobile ? 12 : 3,
maxLines: isMobile ? null : 8,
maxLength: AppSettings.textMessageMaxLength.value,
counterText: '',
),
),
@@ -474,465 +480,6 @@ class SendFileDialogState extends State<SendFileDialog> {
}
}
enum _EditMode { crop, draw }
/// The crop (a normalized rectangle held by [CropController]), the rotation
/// (quarter turns) and the drawn strokes are only composited into a single
/// full-resolution bitmap when the user saves.
///
/// Returns the edited image bytes (PNG) via [Navigator.pop], or null if the user
/// cancelled or made no changes (in which case the original file is kept).
class _ImageEditPage extends StatefulWidget {
final Uint8List bytes;
const _ImageEditPage({required this.bytes});
@override
State<_ImageEditPage> createState() => _ImageEditPageState();
}
class _ImageEditPageState extends State<_ImageEditPage> {
static const List<Color> _palette = [
Colors.red,
Colors.orange,
Colors.yellow,
Colors.green,
Colors.blue,
Colors.purple,
Colors.black,
Colors.white,
];
static const Rect _fullCrop = Rect.fromLTWH(0, 0, 1, 1);
final CropController _cropController = CropController();
/// Source image, decoded once at full resolution. Never mutated.
ui.Image? _image;
Object? _decodeError;
_EditMode _mode = _EditMode.crop;
/// Clockwise quarter turns applied on top of the crop. Kept separate from the
/// crop controller so the crop rectangle always stays in the un-rotated image
/// coordinate space, which keeps all stroke math rotation-free.
int _quarterTurns = 0;
/// Strokes, stored in source-image pixel coordinates (crop- and
/// rotation-independent) so they stay anchored to the image content.
final List<_Stroke> _strokes = [];
Color _color = Colors.red;
double _strokeWidth = 4.0;
bool _eraser = false;
bool _saving = false;
/// View transform for the draw surface (two-finger pinch to zoom). Drawing
/// happens with one finger; two fingers zoom via [InteractiveViewer].
final TransformationController _drawTransform = TransformationController();
/// Horizontal mirror (flip), applied together with rotation at save time.
bool _mirror = false;
@override
void initState() {
super.initState();
_decode();
}
@override
void dispose() {
_cropController.dispose();
_drawTransform.dispose();
// Note: [_image] is intentionally not disposed here. A save may still be
// rasterizing from it asynchronously when this page is popped; the engine's
// finalizer reclaims the decoded image once it is no longer referenced.
super.dispose();
}
Future<void> _decode() async {
try {
final codec = await ui.instantiateImageCodec(widget.bytes);
final frame = await codec.getNextFrame();
if (!mounted) {
frame.image.dispose();
return;
}
setState(() => _image = frame.image);
} catch (e, s) {
Logs().w('Unable to decode image for editing', e, s);
if (mounted) setState(() => _decodeError = e);
}
}
Rect _cropRectPx(ui.Image image) {
final c = _cropController.crop;
return Rect.fromLTRB(
c.left * image.width,
c.top * image.height,
c.right * image.width,
c.bottom * image.height,
);
}
bool get _hasEdits =>
_strokes.isNotEmpty ||
_quarterTurns % 4 != 0 ||
_mirror ||
_cropController.crop != _fullCrop;
void _rotate(int direction) {
if (_saving) return;
setState(() => _quarterTurns += direction);
}
void _toggleMirror() {
if (_saving) return;
setState(() => _mirror = !_mirror);
}
/// Returns a horizontally mirrored copy of [image] (lossless, axis-aligned).
Future<ui.Image> _flippedHorizontally(ui.Image image) {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.translate(image.width.toDouble(), 0);
canvas.scale(-1, 1);
canvas.drawImage(image, Offset.zero, Paint());
return recorder.endRecording().toImage(image.width, image.height);
}
/// Composites the current edits into a single full-resolution PNG.
Future<Uint8List?> _compose(ui.Image image) async {
// 1. Bake strokes onto the full-resolution image (only if there are any).
var painted = image;
if (_strokes.isNotEmpty) {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final size = Size(image.width.toDouble(), image.height.toDouble());
canvas.drawImage(image, Offset.zero, Paint());
_StrokePainter(
strokes: _strokes,
sourceRect: Offset.zero & size,
).paint(canvas, size);
painted = await recorder.endRecording().toImage(
image.width,
image.height,
);
}
// 2. Crop (without rotation — the crop rect lives in the un-rotated frame).
final cropped = await CropController.getCroppedBitmap(
crop: _cropController.crop,
rotation: CropRotation.up,
image: painted,
);
if (!identical(painted, image)) painted.dispose();
// 3. Mirror, then rotation — both lossless and in the same order as the
// on-screen preview (flip inside, rotation outside).
var staged = cropped;
if (_mirror) {
staged = await _flippedHorizontally(cropped);
cropped.dispose();
}
final rotation = imageEditorRotationForQuarterTurns(_quarterTurns);
var output = staged;
if (rotation != CropRotation.up) {
output = await CropController.getCroppedBitmap(
crop: _fullCrop,
rotation: rotation,
image: staged,
);
staged.dispose();
}
// 4. Encode to PNG.
final data = await output.toByteData(format: ui.ImageByteFormat.png);
output.dispose();
return data?.buffer.asUint8List();
}
Future<void> _save() async {
if (_saving) return;
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
final image = _image;
// Nothing was changed: keep the original file untouched (pop with null).
if (image == null || !_hasEdits) {
navigator.pop();
return;
}
setState(() => _saving = true);
try {
final result = await _compose(image);
if (!mounted) return;
navigator.pop(result);
} catch (e, s) {
Logs().w('Unable to save edited image', e, s);
if (!mounted) return;
setState(() => _saving = false);
messenger.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
final theme = Theme.of(context);
final image = _image;
return Scaffold(
appBar: AppBar(
title: Text(l10n.editImage),
actions: [
if (_saving)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
),
),
)
else
IconButton(
tooltip: l10n.save,
icon: const Icon(Icons.check),
onPressed: image == null ? null : _save,
),
],
bottom: image == null
? null
: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: SegmentedButton<_EditMode>(
segments: [
ButtonSegment(
value: _EditMode.crop,
icon: const Icon(Icons.crop),
label: Text(l10n.crop),
),
ButtonSegment(
value: _EditMode.draw,
icon: const Icon(Icons.brush_outlined),
label: Text(l10n.draw),
),
],
selected: {_mode},
onSelectionChanged: _saving
? null
: (s) => setState(() {
_mode = s.first;
// Start each draw session at the un-zoomed fit.
// Gesture-state (active stroke, active pointers,
// multi-touch lock) lives inside [_DrawSurface] and
// is reset automatically because the widget is
// recreated whenever the user switches back.
_drawTransform.value = Matrix4.identity();
}),
),
),
),
),
body: _decodeError != null
? Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.broken_image_outlined, size: 64),
const SizedBox(height: 16),
Text(l10n.oopsSomethingWentWrong),
],
),
),
)
: image == null
? const Center(child: CircularProgressIndicator.adaptive())
: Column(
children: [
Expanded(
// Inset from the screen edges so dragging crop handles or
// drawing near the border doesn't trigger the system back-
// swipe gesture.
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20.0,
vertical: 12.0,
),
child: RotatedBox(
quarterTurns: _quarterTurns,
child: Transform.flip(
flipX: _mirror,
child: _mode == _EditMode.crop
? CropImage(
controller: _cropController,
// Decode a separate image from the original
// bytes for the crop widget. Sharing our
// [_image] via UiImageProvider is unsafe: when
// CropImage is unmounted (switching to draw),
// its Image widget disposes the underlying
// ui.Image — which would be our [_image],
// breaking draw mode and save.
image: Image.memory(widget.bytes),
overlayPainter: _strokes.isEmpty
? null
: _StrokePainter(
strokes: _strokes,
sourceRect:
Offset.zero &
Size(
image.width.toDouble(),
image.height.toDouble(),
),
),
)
: _DrawSurface(
image: image,
cropPx: _cropRectPx(image),
strokes: _strokes,
color: _color,
strokeWidth: _strokeWidth,
eraser: _eraser,
transformController: _drawTransform,
onStrokesChanged: () => setState(() {}),
),
),
),
),
),
SafeArea(
top: false,
child: _mode == _EditMode.crop
? Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
tooltip: l10n.rotateLeft,
icon: const Icon(Icons.rotate_left),
onPressed: () => _rotate(-1),
),
IconButton(
tooltip: l10n.rotateRight,
icon: const Icon(Icons.rotate_right),
onPressed: () => _rotate(1),
),
IconButton(
tooltip: l10n.mirror,
isSelected: _mirror,
icon: const Icon(Icons.flip),
onPressed: _toggleMirror,
),
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
IconButton(
tooltip: l10n.draw,
isSelected: !_eraser,
icon: const Icon(Icons.brush_outlined),
selectedIcon: const Icon(Icons.brush),
onPressed: () =>
setState(() => _eraser = false),
),
IconButton(
tooltip: l10n.eraser,
isSelected: _eraser,
icon: const Icon(
Icons.cleaning_services_outlined,
),
selectedIcon: const Icon(
Icons.cleaning_services,
),
onPressed: () =>
setState(() => _eraser = true),
),
Expanded(
child: Slider(
min: 1,
max: 30,
value: _strokeWidth,
onChanged: (v) =>
setState(() => _strokeWidth = v),
),
),
IconButton(
tooltip: l10n.undo,
icon: const Icon(Icons.undo),
onPressed: _strokes.isEmpty
? null
: () => setState(_strokes.removeLast),
),
IconButton(
tooltip: l10n.clear,
icon: const Icon(Icons.delete_outline),
onPressed: _strokes.isEmpty
? null
: () => setState(_strokes.clear),
),
],
),
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
),
children: [
for (final color in _palette)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
),
child: GestureDetector(
onTap: () => setState(() {
_color = color;
_eraser = false;
}),
child: Container(
width: 32,
height: 32,
margin: const EdgeInsets.symmetric(
vertical: 8.0,
),
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: !_eraser && _color == color
? theme.colorScheme.primary
: Colors.grey,
width: !_eraser && _color == color
? 3
: 1,
),
),
),
),
),
],
),
),
],
),
),
],
),
);
}
}
extension on ScaffoldMessengerState {
void showLoadingSnackBar(String title) => showSnackBar(
SnackBar(
@@ -953,253 +500,3 @@ extension on ScaffoldMessengerState {
),
);
}
/// Freehand drawing surface with pinch-to-zoom.
///
/// This is a widget class (not a helper method on the parent state) so it gets
/// its own [BuildContext] and its own rebuild boundary — a pointer move only
/// invalidates this subtree, not the whole editor page.
///
/// Persistent editor state (the stroke list, current colour/width/eraser flag,
/// zoom transform) lives on the parent and is passed in as props; only the
/// pure gesture state (active stroke, currently-down pointers, multi-touch
/// lock) lives inside this widget. [onStrokesChanged] is called whenever a
/// stroke is added or discarded so the parent can rebuild consumers like the
/// undo/clear button enable state and the crop overlay preview.
class _DrawSurface extends StatefulWidget {
final ui.Image image;
final Rect cropPx;
final List<_Stroke> strokes;
final Color color;
final double strokeWidth;
final bool eraser;
final TransformationController transformController;
final VoidCallback onStrokesChanged;
const _DrawSurface({
required this.image,
required this.cropPx,
required this.strokes,
required this.color,
required this.strokeWidth,
required this.eraser,
required this.transformController,
required this.onStrokesChanged,
});
@override
State<_DrawSurface> createState() => _DrawSurfaceState();
}
class _DrawSurfaceState extends State<_DrawSurface> {
/// The stroke the current one-finger gesture is drawing, or null when no
/// stroke is active (e.g. during a two-finger zoom). Held by reference so a
/// stroke can be fully discarded if the gesture turns into a pinch.
_Stroke? _activeStroke;
/// Currently active (touching) pointer ids on the draw surface.
final Set<int> _pointers = {};
/// True once a second finger has touched, until all fingers lift again. While
/// set, drawing is suppressed so a pinch never draws — not at its start and
/// not when lifting back down to one finger on release.
bool _multiTouch = false;
/// Removes a lifted pointer. Once all fingers are up, the multi-touch lock is
/// released so a fresh single touch can draw again.
void _endPointer(int pointer) {
_pointers.remove(pointer);
if (_pointers.isEmpty) {
setState(() {
_multiTouch = false;
_activeStroke = null;
});
}
}
@override
Widget build(BuildContext context) {
final cropPx = widget.cropPx;
return Center(
child: AspectRatio(
aspectRatio: cropPx.width / cropPx.height,
child: LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, constraints.maxHeight);
// Map a pointer position (viewport pixels) to source-image pixels,
// going through the current zoom transform first.
Offset toImagePx(Offset viewportPoint) => imageEditorLocalToImagePx(
widget.transformController.toScene(viewportPoint),
size,
cropPx,
);
// Drawing is driven from raw pointer events (not InteractiveViewer's
// gesture phases) so the real finger count decides draw vs. zoom.
// The Listener is passive: it never enters the gesture arena, so
// InteractiveViewer below still handles the two-finger pinch.
return Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
_pointers.add(e.pointer);
if (_pointers.length >= 2) {
// A pinch: this gesture is a zoom. Drop any stroke it started
// and block drawing until every finger has lifted again.
_multiTouch = true;
final active = _activeStroke;
if (active != null) {
widget.strokes.remove(active);
setState(() => _activeStroke = null);
widget.onStrokesChanged();
}
return;
}
if (_multiTouch) return;
final stroke = _Stroke(
color: widget.color,
width: widget.strokeWidth * (cropPx.width / size.width),
eraser: widget.eraser,
points: [toImagePx(e.localPosition)],
);
widget.strokes.add(stroke);
setState(() => _activeStroke = stroke);
widget.onStrokesChanged();
},
onPointerMove: (e) {
final active = _activeStroke;
if (active == null || _multiTouch || _pointers.length != 1) {
return;
}
setState(() => active.points.add(toImagePx(e.localPosition)));
},
onPointerUp: (e) => _endPointer(e.pointer),
onPointerCancel: (e) => _endPointer(e.pointer),
child: InteractiveViewer(
transformationController: widget.transformController,
// One finger draws (via the Listener); two fingers pinch-zoom.
panEnabled: false,
minScale: 1,
maxScale: 8,
child: Stack(
fit: StackFit.expand,
children: [
CustomPaint(
painter: _CroppedImagePainter(
image: widget.image,
sourceRect: cropPx,
),
),
CustomPaint(
painter: _StrokePainter(
strokes: widget.strokes,
sourceRect: cropPx,
),
),
],
),
),
);
},
),
),
);
}
}
class _Stroke {
final Color color;
/// Stroke width, in source-image pixels.
final double width;
final bool eraser;
/// Points, in source-image pixel coordinates.
final List<Offset> points;
_Stroke({
required this.color,
required this.width,
required this.eraser,
required this.points,
});
}
/// Paints the [sourceRect] region (in image pixels) of [image] to fill the
/// canvas. Only repaints when the image or the crop changes, so freehand
/// drawing doesn't continuously re-rasterize the background.
class _CroppedImagePainter extends CustomPainter {
final ui.Image image;
final Rect sourceRect;
const _CroppedImagePainter({required this.image, required this.sourceRect});
@override
void paint(Canvas canvas, Size size) {
canvas.drawImageRect(
image,
sourceRect,
Offset.zero & size,
Paint()..filterQuality = FilterQuality.high,
);
}
@override
bool shouldRepaint(covariant _CroppedImagePainter oldDelegate) =>
oldDelegate.image != image || oldDelegate.sourceRect != sourceRect;
}
/// Paints freehand [strokes] (stored in source-image pixel coordinates) onto a
/// canvas that shows the image region [sourceRect] scaled to fill the canvas.
class _StrokePainter extends CustomPainter {
final List<_Stroke> strokes;
final Rect sourceRect;
const _StrokePainter({required this.strokes, required this.sourceRect});
@override
void paint(Canvas canvas, Size size) {
if (strokes.isEmpty) return;
final scale = size.width / sourceRect.width;
// saveLayer so eraser strokes (BlendMode.clear) only erase prior strokes
// within this layer, not whatever is painted below it.
canvas.saveLayer(Offset.zero & size, Paint());
canvas.translate(-sourceRect.left * scale, -sourceRect.top * scale);
canvas.scale(scale);
for (final stroke in strokes) {
if (stroke.points.isEmpty) continue;
final paint = Paint()
..strokeWidth = stroke.width
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
if (stroke.eraser) {
paint
..color = const Color(0xFF000000)
..blendMode = BlendMode.clear;
} else {
paint.color = stroke.color;
}
// A single point (a tap) renders as a dot.
if (stroke.points.length == 1) {
canvas.drawCircle(
stroke.points.first,
stroke.width / 2,
Paint()
..color = paint.color
..blendMode = paint.blendMode
..style = PaintingStyle.fill,
);
continue;
}
final path = Path()
..moveTo(stroke.points.first.dx, stroke.points.first.dy);
for (final p in stroke.points.skip(1)) {
path.lineTo(p.dx, p.dy);
}
canvas.drawPath(path, paint);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant _StrokePainter oldDelegate) => true;
}