feat: marcar legenda com markdown, barra de ações nos posts do canal e limites de mensagem na modal
This commit is contained in:
@@ -245,7 +245,7 @@
|
||||
}
|
||||
},
|
||||
"sendTypingNotifications": "Send typing notifications",
|
||||
"swipeRightToLeftToReply": "Swipe right to left to reply",
|
||||
"swipeRightToLeftToReply": "Swipe left to right to reply",
|
||||
"sendOnEnter": "Send on enter",
|
||||
"noMoreChatsFound": "No more chats found...",
|
||||
"noChatsFoundHere": "No chats found here yet. Start a new chat with someone by using the button below. ⤵️",
|
||||
|
||||
@@ -1875,9 +1875,9 @@
|
||||
"tryAgain": "Tentar novamente",
|
||||
"unbanUserDescription": "O usuário poderá entrar novamente na conversa, caso tente.",
|
||||
"messagesStyle": "Mensagens:",
|
||||
"newSpaceDescription": "Os espaços permitem que você consolide suas conversas e construa comunidades públicas ou privadas.",
|
||||
"newCanalDescription": "O canal é uma sala onde somente administradores podem publicar. Use-o para transmitir comunicados oficiais.",
|
||||
"newMuralDescription": "O mural é um quadro de avisos onde todos podem publicar. Use-o para deixar recados para a sua comunidade.",
|
||||
"newSpaceDescription": "Os Espaços permitem que você consolide suas conversas e construa comunidades públicas ou privadas.",
|
||||
"newCanalDescription": "O Canal é uma sala onde somente administradores podem publicar. Use-o para transmitir comunicados oficiais.",
|
||||
"newMuralDescription": "O Mural é um sala onde você pode publicar sua fotos e vídeos para a comunidade ver, ou apenas seus amigos.",
|
||||
"chatDescription": "Descrição da conversa",
|
||||
"encryptThisChat": "Criptografar esta conversa",
|
||||
"reopenChat": "Reabrir conversa",
|
||||
@@ -1898,7 +1898,7 @@
|
||||
},
|
||||
"fileIsTooBigForServer": "Não foi possível enviar! O servidor suporta anexos somente até {max}.",
|
||||
"readUpToHere": "Lido até aqui",
|
||||
"optionalRedactReason": "(Opcional) Motivo para apagar esta mensagem.",
|
||||
"optionalRedactReason": "Por que você está apagando?",
|
||||
"archiveRoomDescription": "A conversa será movida para o arquivo. Outros usuários verão que você deixou a conversa.",
|
||||
"redactedByBecause": "Apagado por {username}, pois: \"{reason}\"",
|
||||
"@redactedByBecause": {
|
||||
@@ -1921,7 +1921,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"redactMessageDescription": "A mensagem será apagada para todos os participantes desta conversa. Isto não poderá ser desfeito.",
|
||||
"redactMessageDescription": "A mensagem será apagada para todos nessa conversa. Isto não poderá ser desfeito.",
|
||||
"invalidInput": "Entrada inválida!",
|
||||
"report": "relatar",
|
||||
"hasKnocked": "🚪 {user} bateu na porta",
|
||||
@@ -2343,11 +2343,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"swipeRightToLeftToReply": "Deslizar da direita pra esquerda para responder",
|
||||
"swipeRightToLeftToReply": "Deslizar da esquerda pra direita para responder",
|
||||
"verifiedDevicesOnly": "Somente dispositivos verificados",
|
||||
"boldText": "Texto em negrito",
|
||||
"recordAVideo": "Gravar um vídeo",
|
||||
"optionalMessage": "(Opcional) mensagem...",
|
||||
"optionalMessage": "Comentar...",
|
||||
"notSupportedOnThisDevice": "Não há suporte neste dispositivo",
|
||||
"commandHint_roomupgrade": "Atualizar esta sala para a versão de sala especificada",
|
||||
"noMoreChatsFound": "Mais nenhuma conversa foi encontrada...",
|
||||
|
||||
@@ -1819,7 +1819,7 @@
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"swipeRightToLeftToReply": "Deslize da direita para a esquerda para responder",
|
||||
"swipeRightToLeftToReply": "Deslize da esquerda para a direita para responder",
|
||||
"doNotShowAgain": "Não mostrar novamente",
|
||||
"report": "reportar",
|
||||
"status": "Estado",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/send_file_dialog.dart';
|
||||
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
|
||||
@@ -259,17 +260,22 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
useSafeArea: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
builder: (context) => ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight:
|
||||
MediaQuery.viewInsetsOf(context).bottom +
|
||||
(MediaQuery.sizeOf(context).height * 0.92),
|
||||
builder: (context) => AnimatedPadding(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: _PostViewer(
|
||||
key: Key('post_viewer_${event.eventId}'),
|
||||
event: event,
|
||||
room: room,
|
||||
timeline: timeline,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.92,
|
||||
),
|
||||
child: _PostViewer(
|
||||
key: Key('post_viewer_${event.eventId}'),
|
||||
event: event,
|
||||
room: room,
|
||||
timeline: timeline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class DialogTextField extends StatelessWidget {
|
||||
final TextEditingController? controller;
|
||||
final FocusNode? focusNode;
|
||||
final String? hintText;
|
||||
final String? labelText;
|
||||
final String? initialText;
|
||||
@@ -38,6 +39,7 @@ class DialogTextField extends StatelessWidget {
|
||||
this.keyboardType,
|
||||
this.maxLength,
|
||||
this.controller,
|
||||
this.focusNode,
|
||||
this.counterText,
|
||||
this.errorText,
|
||||
this.obscureText = false,
|
||||
@@ -59,6 +61,7 @@ class DialogTextField extends StatelessWidget {
|
||||
return TextField(
|
||||
readOnly: readOnly,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
obscureText: obscureText,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
@@ -94,6 +97,7 @@ class DialogTextField extends StatelessWidget {
|
||||
height: placeholder == null ? null : ((maxLines ?? 1) + 1) * 20,
|
||||
child: CupertinoTextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
readOnly: readOnly,
|
||||
obscureText: obscureText,
|
||||
minLines: minLines,
|
||||
|
||||
Reference in New Issue
Block a user