feat: menu de contexto em bottom sheet, painel de anexos, perfil e feed do mural
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

- Salas (chat, canais, murais e salas de espacos) abrem bottom sheet com grid de acoes
- Botao + da barra de digitacao abre painel de anexos em grid de 3 colunas que empurra a caixa de texto
- Modal do perfil permite editar nome/status e ler QR code (mobile)
- Alterar status atualiza localmente via extensao do SDK matrix
- Mural pre-carrega historico para exibir todas as midias em dispositivos sem cache
This commit is contained in:
2026-09-21 14:30:47 -03:00
parent 762178d9f9
commit 5a72c71cfc
14 changed files with 861 additions and 584 deletions

View File

@@ -204,6 +204,8 @@ class ChatController extends State<ChatPageWithRoom>
bool showEmojiPicker = false;
bool showAttachmentPicker = false;
String? get threadLastEventId {
final threadId = activeThreadId;
if (threadId == null) return null;
@@ -978,18 +980,40 @@ class ChatController extends State<ChatPageWithRoom>
setState(() => showEmojiPicker = false);
}
void hideAttachmentPicker() {
setState(() => showAttachmentPicker = false);
}
void emojiPickerAction() {
if (showEmojiPicker) {
inputFocus.requestFocus();
} else {
inputFocus.unfocus();
}
setState(() => showEmojiPicker = !showEmojiPicker);
setState(() {
showEmojiPicker = !showEmojiPicker;
showAttachmentPicker = false;
});
}
void attachmentPickerAction() {
if (showAttachmentPicker) {
inputFocus.requestFocus();
} else {
inputFocus.unfocus();
}
setState(() {
showAttachmentPicker = !showAttachmentPicker;
showEmojiPicker = false;
});
}
void _inputFocusListener() {
if (showEmojiPicker && inputFocus.hasFocus) {
setState(() => showEmojiPicker = false);
if ((showEmojiPicker || showAttachmentPicker) && inputFocus.hasFocus) {
setState(() {
showEmojiPicker = false;
showAttachmentPicker = false;
});
}
}
@@ -1016,6 +1040,7 @@ class ChatController extends State<ChatPageWithRoom>
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
setState(() {
showEmojiPicker = false;
showAttachmentPicker = false;
selectedEvents.clear();
});
}
@@ -1044,6 +1069,7 @@ class ChatController extends State<ChatPageWithRoom>
if (!mounted) return;
setState(() {
showEmojiPicker = false;
showAttachmentPicker = false;
selectedEvents.clear();
});
scaffoldMessenger.showSnackBar(
@@ -1120,6 +1146,7 @@ class ChatController extends State<ChatPageWithRoom>
);
setState(() {
showEmojiPicker = false;
showAttachmentPicker = false;
selectedEvents.clear();
});
}
@@ -1305,6 +1332,7 @@ class ChatController extends State<ChatPageWithRoom>
void clearSelectedEvents() => setState(() {
selectedEvents.clear();
showEmojiPicker = false;
showAttachmentPicker = false;
});
void clearSingleSelectedEvent() {

View File

@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat/chat.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:material_ui/material_ui.dart';
class ChatAttachmentPicker extends StatelessWidget {
final ChatController controller;
const ChatAttachmentPicker(this.controller, {super.key});
static const double _itemSpacing = 8;
static const double _handleSize = 4;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = L10n.of(context);
final mobile = PlatformInfos.isMobile;
final items = <(IconData, String, AddPopupMenuActions)>[
if (mobile)
(
Icons.gps_fixed_outlined,
l10n.shareLocation,
AddPopupMenuActions.location,
),
(Icons.poll_outlined, l10n.startPoll, AddPopupMenuActions.poll),
if (mobile)
(
Icons.videocam_outlined,
l10n.recordAVideo,
AddPopupMenuActions.videoCamera,
),
if (mobile)
(
Icons.camera_alt_outlined,
l10n.takeAPhoto,
AddPopupMenuActions.photoCamera,
),
(Icons.image_outlined, l10n.openGallery, AddPopupMenuActions.media),
(Icons.attachment_outlined, l10n.sendFile, AddPopupMenuActions.file),
];
if (items.isEmpty) return const SizedBox.shrink();
final rows = <List<(IconData, String, AddPopupMenuActions)>>[];
for (var i = 0; i < items.length; i += 3) {
rows.add(items.sublist(i, i + 3 > items.length ? items.length : i + 3));
}
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
alignment: Alignment.bottomCenter,
child: controller.showAttachmentPicker
? Material(
color: theme.colorScheme.surfaceContainer,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
child: Column(
mainAxisSize: .min,
children: [
Container(
width: 32,
height: _handleSize,
decoration: BoxDecoration(
color: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(_handleSize / 2),
),
),
const SizedBox(height: 12),
Column(
mainAxisSize: .min,
children: [
for (final (i, row) in rows.indexed) ...[
if (i > 0) const SizedBox(height: _itemSpacing),
Row(
children: [
for (final item in row) ...[
if (row.indexOf(item) > 0)
const SizedBox(width: _itemSpacing),
Expanded(
child: AspectRatio(
aspectRatio: 1,
child: _AttachmentItem(
icon: item.$1,
label: item.$2,
onTap: () {
controller.hideAttachmentPicker();
controller
.onAddPopupMenuButtonSelected(
item.$3,
);
},
),
),
),
],
if (row.length < 3)
for (var i = 0; i < 3 - row.length; i++) ...[
const SizedBox(width: _itemSpacing),
const Expanded(child: SizedBox()),
],
],
),
],
],
),
],
),
),
)
: const SizedBox.shrink(),
),
);
}
}
class _AttachmentItem extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _AttachmentItem({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 30, color: theme.colorScheme.onSecondaryContainer),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
),
),
),
],
),
),
);
}
}

View File

@@ -141,102 +141,10 @@ class ChatInputRow extends StatelessWidget {
alignment: Alignment.center,
decoration: const BoxDecoration(),
clipBehavior: Clip.hardEdge,
child: PopupMenuButton<AddPopupMenuActions>(
useRootNavigator: true,
child: IconButton(
icon: const Icon(Icons.add_circle_outline),
iconColor: theme.colorScheme.onPrimaryContainer,
onSelected: controller.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) => [
if (PlatformInfos.isMobile)
PopupMenuItem(
value: AddPopupMenuActions.location,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.gps_fixed_outlined),
),
title: Text(L10n.of(context).shareLocation),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem(
value: AddPopupMenuActions.poll,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.poll_outlined),
),
title: Text(L10n.of(context).startPoll),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuDivider(),
if (PlatformInfos.isMobile) ...[
PopupMenuItem(
value: AddPopupMenuActions.videoCamera,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.videocam_outlined),
),
title: Text(L10n.of(context).recordAVideo),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem(
value: AddPopupMenuActions.photoCamera,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.camera_alt_outlined),
),
title: Text(L10n.of(context).takeAPhoto),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuDivider(),
],
PopupMenuItem(
value: AddPopupMenuActions.media,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.image_outlined),
),
title: Text(L10n.of(context).openGallery),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem(
value: AddPopupMenuActions.file,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.attachment_outlined),
),
title: Text(L10n.of(context).sendFile),
contentPadding: const EdgeInsets.all(0),
),
),
],
color: theme.colorScheme.onPrimaryContainer,
onPressed: controller.attachmentPickerAction,
),
),
Container(

View File

@@ -27,6 +27,7 @@ import 'package:matrix/matrix.dart';
import '../../utils/matrix_sdk_extensions/room_extension.dart';
import '../../utils/stream_extension.dart';
import 'chat_attachment_picker.dart';
import 'chat_emoji_picker.dart';
import 'chat_input_row.dart';
@@ -57,6 +58,7 @@ class ChatView extends StatelessWidget {
canPop:
controller.selectedEvents.isEmpty &&
!controller.showEmojiPicker &&
!controller.showAttachmentPicker &&
controller.activeThreadId == null,
onPopInvokedWithResult: (pop, _) async {
if (pop) return;
@@ -64,6 +66,8 @@ class ChatView extends StatelessWidget {
controller.clearSelectedEvents();
} else if (controller.showEmojiPicker) {
controller.emojiPickerAction();
} else if (controller.showAttachmentPicker) {
controller.attachmentPickerAction();
} else if (controller.activeThreadId != null) {
controller.closeThread();
}
@@ -473,6 +477,9 @@ class ChatView extends StatelessWidget {
children: [
ReplyDisplay(controller),
ChatInputRow(controller),
ChatAttachmentPicker(
controller,
),
ChatEmojiPicker(controller),
],
),

View File

@@ -31,6 +31,14 @@ import 'package:tatchat/widgets/adaptive_dialogs/dialog_text_field.dart';
import '../../utils/resize_video.dart';
/// Opens the crop/draw/rotate image editor with [bytes] and returns the edited
/// PNG bytes, or null if the user cancelled or made no changes.
Future<Uint8List?> editImageBytes(BuildContext context, Uint8List bytes) =>
showScaffoldDialog<Uint8List?>(
context: context,
builder: (_) => _ImageEditPage(bytes: bytes),
);
class SendFileDialog extends StatefulWidget {
final Room room;
final List<XFile> files;