diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 8efb8ba..124caf7 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -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. ⤵️", diff --git a/lib/l10n/intl_pt_BR.arb b/lib/l10n/intl_pt_BR.arb index 42ef332..15695ab 100644 --- a/lib/l10n/intl_pt_BR.arb +++ b/lib/l10n/intl_pt_BR.arb @@ -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...", diff --git a/lib/l10n/intl_pt_PT.arb b/lib/l10n/intl_pt_PT.arb index 02d7830..eb5f09f 100644 --- a/lib/l10n/intl_pt_PT.arb +++ b/lib/l10n/intl_pt_PT.arb @@ -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", diff --git a/lib/pages/chat/events/image_bubble.dart b/lib/pages/chat/events/image_bubble.dart index 36fb2cf..42b7b24 100644 --- a/lib/pages/chat/events/image_bubble.dart +++ b/lib/pages/chat/events/image_bubble.dart @@ -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, diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 7dd3d65..4750775 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -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, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 3eb2ef2..fb6898c 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -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) { diff --git a/lib/pages/chat/events/video_player.dart b/lib/pages/chat/events/video_player.dart index 0f9f048..16aaeda 100644 --- a/lib/pages/chat/events/video_player.dart +++ b/lib/pages/chat/events/video_player.dart @@ -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, diff --git a/lib/pages/chat/send_file_dialog.dart b/lib/pages/chat/send_file_dialog.dart index 516567f..9d62204 100644 --- a/lib/pages/chat/send_file_dialog.dart +++ b/lib/pages/chat/send_file_dialog.dart @@ -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 { 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 _files = List.of(widget.files); - Future _editImage(int index) async { - final bytes = await _files[index].readAsBytes(); - if (!mounted) return; - final result = await showScaffoldDialog( - 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 _send(String? uniqueFileType) async { @@ -151,12 +129,24 @@ class SendFileDialogState extends State { final label = _labelTextController.text.trim(); + final extraContent = label.isEmpty + ? null + : () { + final extra = {'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 { file, thumbnail: thumbnail, shrinkImageMaxDimension: compress ? 1600 : null, - extraContent: label.isEmpty ? null : {'body': label}, + extraContent: extraContent, ); } } @@ -197,6 +187,7 @@ class SendFileDialogState extends State { @override void dispose() { + _labelFocusNode.dispose(); _labelTextController.dispose(); super.dispose(); } @@ -204,6 +195,7 @@ class SendFileDialogState extends State { @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 { 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 { 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 { 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 { } } -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 _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 _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 _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 _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 _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 _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 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; -} diff --git a/lib/pages/mural/mural_chat_page.dart b/lib/pages/mural/mural_chat_page.dart index 8ebe257..6d09018 100644 --- a/lib/pages/mural/mural_chat_page.dart +++ b/lib/pages/mural/mural_chat_page.dart @@ -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 { 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, + ), ), ), ); diff --git a/lib/widgets/adaptive_dialogs/dialog_text_field.dart b/lib/widgets/adaptive_dialogs/dialog_text_field.dart index a339a49..fdc4f5d 100644 --- a/lib/widgets/adaptive_dialogs/dialog_text_field.dart +++ b/lib/widgets/adaptive_dialogs/dialog_text_field.dart @@ -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, diff --git a/pubspec.lock b/pubspec.lock index e041cc7..e2dc559 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1253,7 +1253,7 @@ packages: source: hosted version: "1.2.0" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 diff --git a/pubspec.yaml b/pubspec.yaml index 04d0156..94716d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: local_auth: ^3.0.2 lottie: ^3.5.1 managed_configurations: ^1.2.0 + markdown: ^7.1.1 material_ui: 1.1.0 matrix: #^11.0.0 git: