feat: menu de contexto em bottom sheet, painel de anexos, perfil e feed do mural
- 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:
@@ -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() {
|
||||
|
||||
168
lib/pages/chat/chat_attachment_picker.dart
Normal file
168
lib/pages/chat/chat_attachment_picker.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user