feat: cartões de publicação em canais e corrreções de salas arquivadas
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled

- Canais: publicações viram cartões de largura total mantendo todas as
  funções do balão (reações, resposta, editar, excluir, comentários em
  thread). Composer de publicação fica restrito a admins no feed; membros
  comentam via tela de thread. Canais novos usam events_default 0 para
  permitir comentários e reações.
- Ao sair/excluir uma sala, a cópia local é removida do banco imediatamente;
  salas já deixadas no servidor não apresentam mais erro ao excluir de novo.
- Classificação mural/canal carregada direto do sync (room_type em
  importantStateEvents + postLoad) sem precisar abrir a sala.
- Diálogos com raio e padding reduzidos (dialogTheme, text input e ok/cancel).
- Ícones do app regenerados (logo_background) e dependência linkify.
This commit is contained in:
2026-09-14 08:30:25 -03:00
parent 41a6a52393
commit ebafa1dc39
29 changed files with 10543 additions and 9965 deletions

View File

@@ -3,20 +3,27 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:go_router/go_router.dart';
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/l10n/l10n.dart';
import 'package:tatchat/pages/chat/send_file_dialog.dart';
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
import 'package:tatchat/pages/image_viewer/video_player.dart';
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/stream_extension.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:tatchat/widgets/adaptive_dialogs/user_dialog.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
const _muralMediaTypes = {
MessageTypes.Image,
@@ -68,7 +75,10 @@ class _MuralChatViewState extends State<MuralChatView> {
? const []
: timeline!.events
.where(
(e) => !e.redacted && _muralMediaTypes.contains(e.messageType),
(e) =>
!e.redacted &&
e.relationshipType == null &&
_muralMediaTypes.contains(e.messageType),
)
.toList();
@@ -340,7 +350,6 @@ class _Header extends StatelessWidget {
final creatorAvatar = Uri.tryParse(
creatorState?.content.tryGet<String>('avatar_url') ?? '',
);
final topic = room.topic.trim();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -406,51 +415,52 @@ class _Header extends StatelessWidget {
left: 16 + avatarSize + 12,
right: 8,
bottom: -avatarSize / 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
displayName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: const [
Shadow(
color: Colors.black54,
blurRadius: 6,
offset: Offset(0, 1),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
displayName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: const [
Shadow(
color: Colors.black54,
blurRadius: 6,
offset: Offset(0, 1),
),
),
],
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
),
child: IconButton(
tooltip: l10n.settings,
icon: const Icon(Icons.more_vert),
color: Colors.white,
onPressed: onEditInfo,
),
),
],
),
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 48, 16, 4),
child: _ExpandableDescription(
text: topic,
padding: const EdgeInsets.fromLTRB(16, 4, 8, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: IconButton(
tooltip: l10n.settings,
icon: const Icon(Icons.more_vert),
onPressed: onEditInfo,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: _MuralDescription(
room: room,
text: room.topic,
expanded: descriptionExpanded,
onToggle: onToggleDescription,
),
@@ -460,12 +470,14 @@ class _Header extends StatelessWidget {
}
}
class _ExpandableDescription extends StatelessWidget {
class _MuralDescription extends StatelessWidget {
final Room room;
final String? text;
final bool expanded;
final VoidCallback onToggle;
const _ExpandableDescription({
const _MuralDescription({
required this.room,
required this.text,
required this.expanded,
required this.onToggle,
@@ -478,10 +490,54 @@ class _ExpandableDescription extends StatelessWidget {
final text = this.text?.trim();
if (text == null || text.isEmpty) return const SizedBox.shrink();
final style = theme.textTheme.bodyMedium?.copyWith(height: 1.4);
final linkStyle = TextStyle(
color: theme.colorScheme.primary,
decorationColor: theme.colorScheme.primary,
decoration: TextDecoration.underline,
);
const linkifiers = [UrlLinkifier(), EmailLinkifier(), UserTagLinkifier()];
final elements = linkify(text, linkifiers: linkifiers);
Future<void> openLink(LinkableElement rawLink) async {
final url = rawLink.url;
if (rawLink is UserTagElement) {
final userTag = rawLink.userTag;
var mentionId = userTag.startsWith('@')
? userTag.substring(1)
: userTag;
if (!mentionId.contains(':')) {
mentionId = '$mentionId:${room.client.homeserver?.host ?? ''}';
}
final memberState = room.getState(EventTypes.RoomMember, mentionId);
final profile = Profile(
userId: mentionId,
displayName:
memberState?.content.tryGet<String>('displayname') ?? userTag,
avatarUrl: Uri.tryParse(
memberState?.content.tryGet<String>('avatar_url') ?? '',
),
);
await UserDialog.show(context: context, profile: profile);
return;
}
UrlLauncher(context, url).launchUrl();
}
Widget richText({bool measure = false}) => Text.rich(
buildTextSpan(
elements,
style: style,
linkStyle: linkStyle,
onOpen: openLink,
),
maxLines: expanded ? null : 4,
overflow: expanded ? null : TextOverflow.ellipsis,
);
return LayoutBuilder(
builder: (context, constraints) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
text: buildTextSpan(elements, style: style, linkStyle: linkStyle),
maxLines: 4,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
@@ -489,12 +545,7 @@ class _ExpandableDescription extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
text,
style: style,
maxLines: expanded ? null : 4,
overflow: expanded ? null : TextOverflow.ellipsis,
),
richText(),
if (exceeds)
TextButton(
onPressed: onToggle,
@@ -671,6 +722,50 @@ class _PostViewerState extends State<_PostViewer> {
await widget.room.sendTextEvent(result.trim(), editEventId: event.eventId);
}
Future<void> _deletePost() async {
final l10n = L10n.of(context);
final delete = await showOkCancelAlertDialog(
context: context,
title: l10n.areYouSure,
message: l10n.areYouSureDeletePost,
okLabel: l10n.delete,
cancelLabel: l10n.cancel,
);
if (delete != OkCancelResult.ok || !mounted) return;
try {
await event.redactEvent();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).oopsSomethingWentWrong)),
);
return;
}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _reportPost() async {
final l10n = L10n.of(context);
final reason = await showTextInputDialog(
context: context,
title: l10n.whyDoYouWantToReportThis,
okLabel: l10n.report,
cancelLabel: l10n.cancel,
hintText: l10n.reason,
);
if (reason == null || reason.isEmpty || !mounted) return;
await widget.room.client.reportEvent(
widget.room.id,
event.eventId,
reason: reason,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.contentHasBeenReported)));
}
Future<void> _sendComment() async {
final text = _commentController.text.trim();
if (text.isEmpty || _sending) return;
@@ -683,6 +778,86 @@ class _PostViewerState extends State<_PostViewer> {
}
}
void _typeEmoji(Emoji? emoji) {
if (emoji == null) return;
final emojiText = emoji.emoji;
final value = _commentController.value;
if (value.selection.start != -1 && value.selection.isValid) {
final newText = value.text.replaceRange(
value.selection.start,
value.selection.end,
emojiText,
);
_commentController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: value.selection.start + emojiText.characters.length,
),
);
} else {
_commentController.text += emojiText;
}
}
Future<void> _showEmojiPicker() async {
final theme = Theme.of(context);
await showModalBottomSheet<void>(
context: context,
useSafeArea: true,
backgroundColor: theme.colorScheme.surface,
builder: (context) => SizedBox(
height: MediaQuery.sizeOf(context).height * 0.5,
child: EmojiPicker(
onEmojiSelected: (_, emoji) {
_typeEmoji(emoji);
},
config: Config(
locale: Localizations.localeOf(context),
emojiViewConfig: EmojiViewConfig(
backgroundColor: theme.colorScheme.onInverseSurface,
),
bottomActionBarConfig: const BottomActionBarConfig(enabled: false),
categoryViewConfig: CategoryViewConfig(
backspaceColor: theme.colorScheme.primary,
iconColor: theme.colorScheme.primary.withAlpha(128),
iconColorSelected: theme.colorScheme.primary,
indicatorColor: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.surface,
),
skinToneConfig: const SkinToneConfig(indicatorColor: Colors.grey),
),
),
),
);
}
Future<void> _sendSticker() async {
final theme = Theme.of(context);
await showModalBottomSheet<void>(
context: context,
useSafeArea: true,
backgroundColor: theme.colorScheme.surface,
builder: (context) => SizedBox(
height: MediaQuery.sizeOf(context).height * 0.6,
child: StickerPickerDialog(
room: widget.room,
onSelected: (sticker) {
Navigator.of(context, rootNavigator: true).pop();
widget.room.sendEvent(
{
'body': sticker.body,
'info': sticker.info ?? {},
'url': sticker.url.toString(),
},
type: EventTypes.Sticker,
threadRootEventId: event.eventId,
);
},
),
),
);
}
List<Event> _comments() {
final comments = widget.timeline.events
.where(
@@ -696,6 +871,18 @@ class _PostViewerState extends State<_PostViewer> {
return comments;
}
void _openProfile(Event comment) {
final sender = comment.senderFromMemoryOrFallback;
UserDialog.show(
context: context,
profile: Profile(
userId: sender.id,
displayName: sender.calcDisplayname(),
avatarUrl: sender.avatarUrl,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -772,11 +959,13 @@ class _PostViewerState extends State<_PostViewer> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
tooltip: l10n.editCaption,
icon: const Icon(Icons.edit_outlined),
onPressed: _editCaption,
tooltip: liked ? l10n.unlike : l10n.like,
icon: Icon(
liked ? Icons.favorite : Icons.favorite_border,
color: liked ? Colors.redAccent : null,
),
onPressed: _toggleLike,
),
const Spacer(),
if (_likeCount > 0)
Padding(
padding: const EdgeInsets.only(bottom: 10),
@@ -787,13 +976,46 @@ class _PostViewerState extends State<_PostViewer> {
),
),
),
IconButton(
tooltip: liked ? l10n.unlike : l10n.like,
icon: Icon(
liked ? Icons.favorite : Icons.favorite_border,
color: liked ? Colors.redAccent : null,
),
onPressed: _toggleLike,
const Spacer(),
PopupMenuButton<String>(
tooltip: l10n.more,
onSelected: (action) async {
switch (action) {
case 'edit':
await _editCaption();
case 'delete':
await _deletePost();
case 'report':
await _reportPost();
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(l10n.editCaption),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'delete',
child: ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(l10n.delete),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'report',
child: ListTile(
leading: const Icon(Icons.flag_outlined),
title: Text(l10n.report),
contentPadding: EdgeInsets.zero,
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
@@ -827,6 +1049,9 @@ class _PostViewerState extends State<_PostViewer> {
itemCount: comments.length,
itemBuilder: (context, i) {
final comment = comments[i];
final isSticker =
comment.messageType == MessageTypes.Sticker;
final sender = comment.senderFromMemoryOrFallback;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -835,14 +1060,14 @@ class _PostViewerState extends State<_PostViewer> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
mxContent: comment
.senderFromMemoryOrFallback
.avatarUrl,
name: comment.senderFromMemoryOrFallback
.calcDisplayname(),
size: 32,
client: widget.room.client,
GestureDetector(
onTap: () => _openProfile(comment),
child: Avatar(
mxContent: sender.avatarUrl,
name: sender.calcDisplayname(),
size: 32,
client: widget.room.client,
),
),
const SizedBox(width: 8),
Expanded(
@@ -850,25 +1075,36 @@ class _PostViewerState extends State<_PostViewer> {
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
comment.senderFromMemoryOrFallback
.calcDisplayname(),
style: theme.textTheme.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
GestureDetector(
onTap: () => _openProfile(comment),
child: Text(
sender.calcDisplayname(),
style: theme.textTheme.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
Text(
comment
.getDisplayEvent(widget.timeline)
.calcLocalizedBodyFallback(
MatrixLocals(l10n),
withSenderNamePrefix: false,
hideReply: true,
),
style: theme.textTheme.bodyMedium,
),
if (isSticker)
MxcImage(
key: ValueKey(comment.eventId),
event: comment,
fit: BoxFit.contain,
isThumbnail: false,
width: double.infinity,
)
else
Text(
comment
.getDisplayEvent(widget.timeline)
.calcLocalizedBodyFallback(
MatrixLocals(l10n),
withSenderNamePrefix: false,
hideReply: true,
),
style: theme.textTheme.bodyMedium,
),
],
),
),
@@ -879,9 +1115,19 @@ class _PostViewerState extends State<_PostViewer> {
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8),
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Row(
children: [
IconButton(
tooltip: l10n.emojis,
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: _showEmojiPicker,
),
IconButton(
tooltip: l10n.stickers,
icon: const Icon(Icons.emoji_emotions),
onPressed: _sendSticker,
),
Expanded(
child: TextField(
controller: _commentController,