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",
|
"sendTypingNotifications": "Send typing notifications",
|
||||||
"swipeRightToLeftToReply": "Swipe right to left to reply",
|
"swipeRightToLeftToReply": "Swipe left to right to reply",
|
||||||
"sendOnEnter": "Send on enter",
|
"sendOnEnter": "Send on enter",
|
||||||
"noMoreChatsFound": "No more chats found...",
|
"noMoreChatsFound": "No more chats found...",
|
||||||
"noChatsFoundHere": "No chats found here yet. Start a new chat with someone by using the button below. ⤵️",
|
"noChatsFoundHere": "No chats found here yet. Start a new chat with someone by using the button below. ⤵️",
|
||||||
|
|||||||
@@ -1875,9 +1875,9 @@
|
|||||||
"tryAgain": "Tentar novamente",
|
"tryAgain": "Tentar novamente",
|
||||||
"unbanUserDescription": "O usuário poderá entrar novamente na conversa, caso tente.",
|
"unbanUserDescription": "O usuário poderá entrar novamente na conversa, caso tente.",
|
||||||
"messagesStyle": "Mensagens:",
|
"messagesStyle": "Mensagens:",
|
||||||
"newSpaceDescription": "Os espaços permitem que você consolide suas conversas e construa comunidades públicas ou privadas.",
|
"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.",
|
"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.",
|
"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",
|
"chatDescription": "Descrição da conversa",
|
||||||
"encryptThisChat": "Criptografar esta conversa",
|
"encryptThisChat": "Criptografar esta conversa",
|
||||||
"reopenChat": "Reabrir conversa",
|
"reopenChat": "Reabrir conversa",
|
||||||
@@ -1898,7 +1898,7 @@
|
|||||||
},
|
},
|
||||||
"fileIsTooBigForServer": "Não foi possível enviar! O servidor suporta anexos somente até {max}.",
|
"fileIsTooBigForServer": "Não foi possível enviar! O servidor suporta anexos somente até {max}.",
|
||||||
"readUpToHere": "Lido até aqui",
|
"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.",
|
"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": "Apagado por {username}, pois: \"{reason}\"",
|
||||||
"@redactedByBecause": {
|
"@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!",
|
"invalidInput": "Entrada inválida!",
|
||||||
"report": "relatar",
|
"report": "relatar",
|
||||||
"hasKnocked": "🚪 {user} bateu na porta",
|
"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",
|
"verifiedDevicesOnly": "Somente dispositivos verificados",
|
||||||
"boldText": "Texto em negrito",
|
"boldText": "Texto em negrito",
|
||||||
"recordAVideo": "Gravar um vídeo",
|
"recordAVideo": "Gravar um vídeo",
|
||||||
"optionalMessage": "(Opcional) mensagem...",
|
"optionalMessage": "Comentar...",
|
||||||
"notSupportedOnThisDevice": "Não há suporte neste dispositivo",
|
"notSupportedOnThisDevice": "Não há suporte neste dispositivo",
|
||||||
"commandHint_roomupgrade": "Atualizar esta sala para a versão de sala especificada",
|
"commandHint_roomupgrade": "Atualizar esta sala para a versão de sala especificada",
|
||||||
"noMoreChatsFound": "Mais nenhuma conversa foi encontrada...",
|
"noMoreChatsFound": "Mais nenhuma conversa foi encontrada...",
|
||||||
|
|||||||
@@ -1819,7 +1819,7 @@
|
|||||||
"type": "String",
|
"type": "String",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"swipeRightToLeftToReply": "Deslize da direita para a esquerda para responder",
|
"swipeRightToLeftToReply": "Deslize da esquerda para a direita para responder",
|
||||||
"doNotShowAgain": "Não mostrar novamente",
|
"doNotShowAgain": "Não mostrar novamente",
|
||||||
"report": "reportar",
|
"report": "reportar",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import 'package:tatchat/config/setting_keys.dart';
|
|||||||
import 'package:tatchat/utils/file_description.dart';
|
import 'package:tatchat/utils/file_description.dart';
|
||||||
import 'package:tatchat/utils/url_launcher.dart';
|
import 'package:tatchat/utils/url_launcher.dart';
|
||||||
import 'package:tatchat/widgets/mxc_image.dart';
|
import 'package:tatchat/widgets/mxc_image.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
import '../../../widgets/blur_hash.dart';
|
import '../../../widgets/blur_hash.dart';
|
||||||
|
import 'html_message.dart';
|
||||||
|
|
||||||
class ImageBubble extends StatelessWidget {
|
class ImageBubble extends StatelessWidget {
|
||||||
final Event event;
|
final Event event;
|
||||||
@@ -121,14 +121,13 @@ class ImageBubble extends StatelessWidget {
|
|||||||
width: width,
|
width: width,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Linkify(
|
child: HtmlMessage(
|
||||||
text: fileDescription,
|
html: event.isRichMessage
|
||||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
? event.formattedText
|
||||||
style: TextStyle(
|
: fileDescription,
|
||||||
color: textColor,
|
room: event.room,
|
||||||
|
textColor: textColor,
|
||||||
fontSize: AppConfig.messageFontSize,
|
fontSize: AppConfig.messageFontSize,
|
||||||
),
|
|
||||||
options: const LinkifyOptions(humanize: false),
|
|
||||||
linkStyle: TextStyle(
|
linkStyle: TextStyle(
|
||||||
color: linkColor,
|
color: linkColor,
|
||||||
fontSize: AppConfig.messageFontSize,
|
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/date_time_extension.dart';
|
||||||
import 'package:tatchat/utils/file_description.dart';
|
import 'package:tatchat/utils/file_description.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.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/utils/string_color.dart';
|
||||||
import 'package:tatchat/widgets/avatar.dart';
|
import 'package:tatchat/widgets/avatar.dart';
|
||||||
import 'package:tatchat/widgets/matrix.dart';
|
import 'package:tatchat/widgets/matrix.dart';
|
||||||
import 'package:tatchat/widgets/member_actions_popup_menu_button.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:flutter/services.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
@@ -247,8 +249,8 @@ class Message extends StatelessWidget {
|
|||||||
child: Center(child: Icon(Icons.check_outlined)),
|
child: Center(child: Icon(Icons.check_outlined)),
|
||||||
),
|
),
|
||||||
direction: AppSettings.swipeRightToLeftToReply.value
|
direction: AppSettings.swipeRightToLeftToReply.value
|
||||||
? SwipeDirection.endToStart
|
? SwipeDirection.startToEnd
|
||||||
: SwipeDirection.startToEnd,
|
: SwipeDirection.endToStart,
|
||||||
onSwipe: (_) => onSwipe(),
|
onSwipe: (_) => onSwipe(),
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: card
|
constraints: card
|
||||||
@@ -683,7 +685,7 @@ class Message extends StatelessWidget {
|
|||||||
duration: FluffyThemes.animationDuration,
|
duration: FluffyThemes.animationDuration,
|
||||||
curve: FluffyThemes.animationCurve,
|
curve: FluffyThemes.animationCurve,
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: !hasReactions
|
child: card || !hasReactions
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Container(
|
: Container(
|
||||||
alignment: card
|
alignment: card
|
||||||
@@ -699,6 +701,17 @@ class Message extends StatelessWidget {
|
|||||||
child: MessageReactions(event, timeline),
|
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(
|
Row(
|
||||||
mainAxisAlignment: card
|
mainAxisAlignment: card
|
||||||
? .start
|
? .start
|
||||||
@@ -990,7 +1003,7 @@ class Message extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (enterThread != null)
|
if (enterThread != null && !card)
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: FluffyThemes.animationDuration,
|
duration: FluffyThemes.animationDuration,
|
||||||
curve: FluffyThemes.animationCurve,
|
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,17 +78,19 @@ class MessageContent extends StatelessWidget {
|
|||||||
: BoxFit.cover;
|
: BoxFit.cover;
|
||||||
if (w != null && h != null) {
|
if (w != null && h != null) {
|
||||||
fit = BoxFit.contain;
|
fit = BoxFit.contain;
|
||||||
if (fullWidthCard &&
|
if (fullWidthCard && event.messageType == MessageTypes.Image) {
|
||||||
event.messageType == MessageTypes.Image &&
|
return LayoutBuilder(
|
||||||
w > 0 &&
|
builder: (context, constraints) {
|
||||||
h > 0) {
|
final maxWidth = constraints.maxWidth;
|
||||||
return AspectRatio(
|
final cardWidth = maxWidth.isFinite && maxWidth > 0
|
||||||
aspectRatio: w / h,
|
? maxWidth
|
||||||
child: ImageBubble(
|
: maxSize;
|
||||||
|
final hasKnownRatio = w > 0 && h > 0;
|
||||||
|
return ImageBubble(
|
||||||
event,
|
event,
|
||||||
width: double.infinity,
|
width: cardWidth,
|
||||||
height: double.infinity,
|
height: hasKnownRatio ? cardWidth * h / w : maxSize,
|
||||||
fit: BoxFit.cover,
|
fit: hasKnownRatio ? BoxFit.cover : BoxFit.contain,
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
timeline: timeline,
|
timeline: timeline,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
@@ -100,7 +102,8 @@ class MessageContent extends StatelessWidget {
|
|||||||
outerContext: context,
|
outerContext: context,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (w > h) {
|
if (w > h) {
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import 'package:tatchat/utils/platform_infos.dart';
|
|||||||
import 'package:tatchat/utils/url_launcher.dart';
|
import 'package:tatchat/utils/url_launcher.dart';
|
||||||
import 'package:tatchat/widgets/blur_hash.dart';
|
import 'package:tatchat/widgets/blur_hash.dart';
|
||||||
import 'package:tatchat/widgets/mxc_image.dart';
|
import 'package:tatchat/widgets/mxc_image.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
import '../../image_viewer/image_viewer.dart';
|
import '../../image_viewer/image_viewer.dart';
|
||||||
|
import 'html_message.dart';
|
||||||
|
|
||||||
class EventVideoPlayer extends StatelessWidget {
|
class EventVideoPlayer extends StatelessWidget {
|
||||||
final Event event;
|
final Event event;
|
||||||
@@ -151,14 +151,13 @@ class EventVideoPlayer extends StatelessWidget {
|
|||||||
width: width,
|
width: width,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Linkify(
|
child: HtmlMessage(
|
||||||
text: fileDescription,
|
html: event.isRichMessage
|
||||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
? event.formattedText
|
||||||
style: TextStyle(
|
: fileDescription,
|
||||||
color: textColor,
|
room: event.room,
|
||||||
|
textColor: textColor!,
|
||||||
fontSize: AppConfig.messageFontSize,
|
fontSize: AppConfig.messageFontSize,
|
||||||
),
|
|
||||||
options: const LinkifyOptions(humanize: false),
|
|
||||||
linkStyle: TextStyle(
|
linkStyle: TextStyle(
|
||||||
color: linkColor,
|
color: linkColor,
|
||||||
fontSize: AppConfig.messageFontSize,
|
fontSize: AppConfig.messageFontSize,
|
||||||
|
|||||||
@@ -3,21 +3,18 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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:async/async.dart' show Result;
|
||||||
import 'package:crop_image/crop_image.dart';
|
|
||||||
import 'package:cross_file/cross_file.dart';
|
import 'package:cross_file/cross_file.dart';
|
||||||
import 'package:cupertino_ui/cupertino_ui.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/app_config.dart';
|
||||||
|
import 'package:tatchat/config/setting_keys.dart';
|
||||||
import 'package:tatchat/l10n/l10n.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/pages/chat/trust_user_key_dialog.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_file_extension.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/other_party_can_receive.dart';
|
||||||
import 'package:tatchat/utils/platform_infos.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/size_string.dart';
|
||||||
import 'package:tatchat/utils/start_push_foreground_service.dart';
|
import 'package:tatchat/utils/start_push_foreground_service.dart';
|
||||||
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.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;
|
static const int minSizeToCompress = 20 * 1000;
|
||||||
|
|
||||||
final TextEditingController _labelTextController = TextEditingController();
|
final TextEditingController _labelTextController = TextEditingController();
|
||||||
|
final FocusNode _labelFocusNode = FocusNode();
|
||||||
|
|
||||||
/// Mutable working copy of [SendFileDialog.files]. Editing an image replaces
|
/// Mutable working copy of [SendFileDialog.files].
|
||||||
/// 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.
|
|
||||||
late final List<XFile> _files = List<XFile>.of(widget.files);
|
late final List<XFile> _files = List<XFile>.of(widget.files);
|
||||||
|
|
||||||
Future<void> _editImage(int index) async {
|
void _editLabel() {
|
||||||
final bytes = await _files[index].readAsBytes();
|
_labelFocusNode.requestFocus();
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _send(String? uniqueFileType) async {
|
Future<void> _send(String? uniqueFileType) async {
|
||||||
@@ -151,12 +129,24 @@ class SendFileDialogState extends State<SendFileDialog> {
|
|||||||
|
|
||||||
final label = _labelTextController.text.trim();
|
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 {
|
try {
|
||||||
await widget.room.sendFileEvent(
|
await widget.room.sendFileEvent(
|
||||||
file,
|
file,
|
||||||
thumbnail: thumbnail,
|
thumbnail: thumbnail,
|
||||||
shrinkImageMaxDimension: compress ? 1600 : null,
|
shrinkImageMaxDimension: compress ? 1600 : null,
|
||||||
extraContent: label.isEmpty ? null : {'body': label},
|
extraContent: extraContent,
|
||||||
threadRootEventId: widget.threadRootEventId,
|
threadRootEventId: widget.threadRootEventId,
|
||||||
threadLastEventId: widget.threadLastEventId,
|
threadLastEventId: widget.threadLastEventId,
|
||||||
);
|
);
|
||||||
@@ -175,7 +165,7 @@ class SendFileDialogState extends State<SendFileDialog> {
|
|||||||
file,
|
file,
|
||||||
thumbnail: thumbnail,
|
thumbnail: thumbnail,
|
||||||
shrinkImageMaxDimension: compress ? 1600 : null,
|
shrinkImageMaxDimension: compress ? 1600 : null,
|
||||||
extraContent: label.isEmpty ? null : {'body': label},
|
extraContent: extraContent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +187,7 @@ class SendFileDialogState extends State<SendFileDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_labelFocusNode.dispose();
|
||||||
_labelTextController.dispose();
|
_labelTextController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -204,6 +195,7 @@ class SendFileDialogState extends State<SendFileDialog> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final isMobile = PlatformInfos.isMobile;
|
||||||
|
|
||||||
var sendStr = L10n.of(context).sendFile;
|
var sendStr = L10n.of(context).sendFile;
|
||||||
final uniqueFileType = _files
|
final uniqueFileType = _files
|
||||||
@@ -244,8 +236,18 @@ class SendFileDialogState extends State<SendFileDialog> {
|
|||||||
|
|
||||||
return AlertDialog.adaptive(
|
return AlertDialog.adaptive(
|
||||||
title: Text(sendStr),
|
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(
|
content: SizedBox(
|
||||||
width: 256,
|
width: 512,
|
||||||
|
height: isMobile ? MediaQuery.sizeOf(context).height * 0.85 : null,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
@@ -333,13 +335,15 @@ class SendFileDialogState extends State<SendFileDialog> {
|
|||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
shape: const CircleBorder(),
|
shape: const CircleBorder(),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
tooltip: L10n.of(context).editImage,
|
tooltip: L10n.of(
|
||||||
|
context,
|
||||||
|
).optionalMessage,
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.edit_outlined,
|
Icons.edit_outlined,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => _editImage(i),
|
onPressed: _editLabel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -393,10 +397,12 @@ class SendFileDialogState extends State<SendFileDialog> {
|
|||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
child: DialogTextField(
|
child: DialogTextField(
|
||||||
controller: _labelTextController,
|
controller: _labelTextController,
|
||||||
|
focusNode: _labelFocusNode,
|
||||||
labelText: L10n.of(context).optionalMessage,
|
labelText: L10n.of(context).optionalMessage,
|
||||||
minLines: 1,
|
keyboardType: TextInputType.multiline,
|
||||||
maxLines: 1,
|
minLines: isMobile ? 12 : 3,
|
||||||
maxLength: 255,
|
maxLines: isMobile ? null : 8,
|
||||||
|
maxLength: AppSettings.textMessageMaxLength.value,
|
||||||
counterText: '',
|
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 {
|
extension on ScaffoldMessengerState {
|
||||||
void showLoadingSnackBar(String title) => showSnackBar(
|
void showLoadingSnackBar(String title) => showSnackBar(
|
||||||
SnackBar(
|
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:linkify/linkify.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:tatchat/config/themes.dart';
|
||||||
import 'package:tatchat/l10n/l10n.dart';
|
import 'package:tatchat/l10n/l10n.dart';
|
||||||
import 'package:tatchat/pages/chat/send_file_dialog.dart';
|
import 'package:tatchat/pages/chat/send_file_dialog.dart';
|
||||||
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
|
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
|
||||||
@@ -259,11 +260,15 @@ class _MuralChatViewState extends State<MuralChatView> {
|
|||||||
useSafeArea: true,
|
useSafeArea: true,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: theme.colorScheme.surface,
|
backgroundColor: theme.colorScheme.surface,
|
||||||
builder: (context) => ConstrainedBox(
|
builder: (context) => AnimatedPadding(
|
||||||
|
duration: FluffyThemes.animationDuration,
|
||||||
|
curve: FluffyThemes.animationCurve,
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight:
|
maxHeight: MediaQuery.sizeOf(context).height * 0.92,
|
||||||
MediaQuery.viewInsetsOf(context).bottom +
|
|
||||||
(MediaQuery.sizeOf(context).height * 0.92),
|
|
||||||
),
|
),
|
||||||
child: _PostViewer(
|
child: _PostViewer(
|
||||||
key: Key('post_viewer_${event.eventId}'),
|
key: Key('post_viewer_${event.eventId}'),
|
||||||
@@ -272,6 +277,7 @@ class _MuralChatViewState extends State<MuralChatView> {
|
|||||||
timeline: timeline,
|
timeline: timeline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:material_ui/material_ui.dart';
|
|||||||
|
|
||||||
class DialogTextField extends StatelessWidget {
|
class DialogTextField extends StatelessWidget {
|
||||||
final TextEditingController? controller;
|
final TextEditingController? controller;
|
||||||
|
final FocusNode? focusNode;
|
||||||
final String? hintText;
|
final String? hintText;
|
||||||
final String? labelText;
|
final String? labelText;
|
||||||
final String? initialText;
|
final String? initialText;
|
||||||
@@ -38,6 +39,7 @@ class DialogTextField extends StatelessWidget {
|
|||||||
this.keyboardType,
|
this.keyboardType,
|
||||||
this.maxLength,
|
this.maxLength,
|
||||||
this.controller,
|
this.controller,
|
||||||
|
this.focusNode,
|
||||||
this.counterText,
|
this.counterText,
|
||||||
this.errorText,
|
this.errorText,
|
||||||
this.obscureText = false,
|
this.obscureText = false,
|
||||||
@@ -59,6 +61,7 @@ class DialogTextField extends StatelessWidget {
|
|||||||
return TextField(
|
return TextField(
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
obscureText: obscureText,
|
obscureText: obscureText,
|
||||||
minLines: minLines,
|
minLines: minLines,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
@@ -94,6 +97,7 @@ class DialogTextField extends StatelessWidget {
|
|||||||
height: placeholder == null ? null : ((maxLines ?? 1) + 1) * 20,
|
height: placeholder == null ? null : ((maxLines ?? 1) + 1) * 20,
|
||||||
child: CupertinoTextField(
|
child: CupertinoTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
obscureText: obscureText,
|
obscureText: obscureText,
|
||||||
minLines: minLines,
|
minLines: minLines,
|
||||||
|
|||||||
@@ -1253,7 +1253,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.2.0"
|
||||||
markdown:
|
markdown:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: markdown
|
name: markdown
|
||||||
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
|
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ dependencies:
|
|||||||
local_auth: ^3.0.2
|
local_auth: ^3.0.2
|
||||||
lottie: ^3.5.1
|
lottie: ^3.5.1
|
||||||
managed_configurations: ^1.2.0
|
managed_configurations: ^1.2.0
|
||||||
|
markdown: ^7.1.1
|
||||||
material_ui: 1.1.0
|
material_ui: 1.1.0
|
||||||
matrix: #^11.0.0
|
matrix: #^11.0.0
|
||||||
git:
|
git:
|
||||||
|
|||||||
Reference in New Issue
Block a user