diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 3e0413230..69f37136c 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -3,7 +3,6 @@ import 'dart:developer'; import 'dart:io'; import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:collection/collection.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:file_picker/file_picker.dart'; @@ -16,6 +15,7 @@ import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; @@ -23,9 +23,12 @@ import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/firebase_analytics.dart'; +import 'package:fluffychat/pangea/utils/overlay.dart'; import 'package:fluffychat/pangea/utils/report_message.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart'; import 'package:fluffychat/pangea/widgets/igc/pangea_text_controller.dart'; +import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart'; import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart'; @@ -838,10 +841,23 @@ class ChatController extends State }); } - void hideEmojiPicker() { + // #Pangea + // void hideEmojiPicker() { + void hideEmojiPicker({bool closeOverlay = false}) { + if (closeOverlay) { + MatrixState.pAnyState.closeOverlay(); + } + // Pangea# setState(() => showEmojiPicker = false); } + // #Pangea + void hideOverlayEmojiPicker() { + MatrixState.pAnyState.closeOverlay(); + setState(() => showEmojiPicker = false); + } + // Pangea + void emojiPickerAction() { if (showEmojiPicker) { inputFocus.requestFocus(); @@ -887,12 +903,18 @@ class ChatController extends State Clipboard.setData(ClipboardData(text: _getSelectedEventString())); setState(() { showEmojiPicker = false; - selectedEvents.clear(); + // #Pangea + // selectedEvents.clear(); + clearSelectedEvents(); + // Pangea# }); } void reportEventAction() async { final event = selectedEvents.single; + // #Pangea + clearSelectedEvents(); + // Pangea# final score = await showConfirmationDialog( context: context, title: L10n.of(context)!.reportMessage, @@ -997,7 +1019,12 @@ class ChatController extends State cancelLabel: L10n.of(context)!.cancel, ) : []; - if (reasonInput == null) return; + if (reasonInput == null) { + // #Pangea + clearSelectedEvents(); + // Pangea# + return; + } final reason = reasonInput.single.isEmpty ? null : reasonInput.single; for (final event in selectedEvents) { await showFutureLoadingDialog( @@ -1025,6 +1052,9 @@ class ChatController extends State }, ); } + // #Pangea + clearSelectedEvents(); + // Pangea# setState(() { showEmojiPicker = false; selectedEvents.clear(); @@ -1104,6 +1134,9 @@ class ChatController extends State replyEvent = replyTo ?? selectedEvents.first; selectedEvents.clear(); }); + // #Pangea + clearSelectedEvents(); + // Pangea inputFocus.requestFocus(); } @@ -1216,6 +1249,9 @@ class ChatController extends State } void pickEmojiReactionAction(Iterable allReactionEvents) async { + // #Pangea + MatrixState.pAnyState.closeAllOverlays(); + // Pangea# _allReactionEvents = allReactionEvents; emojiPickerType = EmojiPickerType.reaction; setState(() => showEmojiPicker = true); @@ -1230,9 +1266,15 @@ class ChatController extends State emoji!, ); } + // #Pangea + clearSelectedEvents(); + // Pangea# } void clearSelectedEvents() => setState(() { + // #Pangea + MatrixState.pAnyState.closeAllOverlays(); + // Pangea# selectedEvents.clear(); showEmojiPicker = false; }); @@ -1469,8 +1511,12 @@ class ChatController extends State bool get isArchived => {Membership.leave, Membership.ban}.contains(room.membership); - void showEventInfo([Event? event]) => - (event ?? selectedEvents.single).showInfoDialog(context); + void showEventInfo([Event? event]) { + (event ?? selectedEvents.single).showInfoDialog(context); + // #Pangea + clearSelectedEvents(); + // Pangea# + } void onPhoneButtonTap() async { // VoIP required Android SDK 21 @@ -1526,80 +1572,55 @@ class ChatController extends State editEvent = null; }); - // #Pangea - final Map _pangeaMessageEvents = {}; - final Map _toolbarDisplayControllers = {}; +// #Pangea + MessageTextSelection textSelection = MessageTextSelection(); - void setPangeaMessageEvent(String eventId) { - final Event? event = timeline!.events.firstWhereOrNull( - (e) => e.eventId == eventId, - ); - if (event == null || timeline == null) return; - _pangeaMessageEvents[eventId] = PangeaMessageEvent( - event: event, - timeline: timeline!, - ownMessage: event.senderId == room.client.userID, - ); - } - - void setToolbarDisplayController( - String eventId, { - Event? nextEvent, - Event? previousEvent, + void showToolbar( + PangeaMessageEvent pangeaMessageEvent, { + MessageMode? mode, }) { - final Event? event = timeline!.events.firstWhereOrNull( - (e) => e.eventId == eventId, - ); - if (event == null || timeline == null) return; - if (_pangeaMessageEvents[eventId] == null) { - setPangeaMessageEvent(eventId); - if (_pangeaMessageEvents[eventId] == null) return; + // select the message + onSelectMessage(pangeaMessageEvent.event); + HapticFeedback.mediumImpact(); + + // Close keyboard, if open + if (inputFocus.hasFocus && PlatformInfos.isMobile) { + inputFocus.unfocus(); + return; + } + // Close emoji picker, if open + showEmojiPicker = false; + + // Check if the user has set their languages. If not, prompt them to do so. + if (!MatrixState.pangeaController.languageController.languagesSet) { + pLanguageDialog(context, () {}); + return; } + Widget? overlayEntry; try { - _toolbarDisplayControllers[eventId] = ToolbarDisplayController( - targetId: event.eventId, - pangeaMessageEvent: _pangeaMessageEvents[eventId]!, - immersionMode: choreographer.immersionMode, + overlayEntry = MessageSelectionOverlay( controller: this, - nextEvent: nextEvent, - previousEvent: previousEvent, - ); - _toolbarDisplayControllers[eventId]!.setToolbar(); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - m: "Failed to set toolbar display controller", - data: { - "eventId": eventId, - "event": event.toJson(), - "pangeaMessageEvent": _pangeaMessageEvents[eventId]?.toString(), - }, + event: pangeaMessageEvent.event, + pangeaMessageEvent: pangeaMessageEvent, + textSelection: textSelection, ); + } catch (err) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: err, s: StackTrace.current); + return; } - } - PangeaMessageEvent? getPangeaMessageEvent(String eventId) { - if (_pangeaMessageEvents[eventId] == null) { - setPangeaMessageEvent(eventId); - } - return _pangeaMessageEvents[eventId]; - } - - ToolbarDisplayController? getToolbarDisplayController( - String eventId, { - Event? nextEvent, - Event? previousEvent, - }) { - if (_toolbarDisplayControllers[eventId] == null) { - setToolbarDisplayController( - eventId, - nextEvent: nextEvent, - previousEvent: previousEvent, - ); - } - return _toolbarDisplayControllers[eventId]; + OverlayUtil.showOverlay( + context: context, + child: overlayEntry, + transformTargetId: "", + backgroundColor: const Color.fromRGBO(0, 0, 0, 1).withAlpha(200), + closePrevOverlay: + MatrixState.pangeaController.subscriptionController.isSubscribed, + position: OverlayPositionEnum.centered, + onDismiss: clearSelectedEvents, + ); } // Pangea# diff --git a/lib/pages/chat/chat_emoji_picker.dart b/lib/pages/chat/chat_emoji_picker.dart index f38243b84..e7bf9c744 100644 --- a/lib/pages/chat/chat_emoji_picker.dart +++ b/lib/pages/chat/chat_emoji_picker.dart @@ -14,90 +14,95 @@ class ChatEmojiPicker extends StatelessWidget { @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - return AnimatedContainer( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - height: controller.showEmojiPicker - ? MediaQuery.of(context).size.height / 2 - : 0, - child: controller.showEmojiPicker - ? DefaultTabController( - length: 2, - child: Column( - children: [ - TabBar( - tabs: [ - Tab(text: L10n.of(context)!.emojis), - Tab(text: L10n.of(context)!.stickers), - ], - ), - Expanded( - child: TabBarView( - children: [ - EmojiPicker( - onEmojiSelected: controller.onEmojiSelected, - onBackspacePressed: controller.emojiPickerBackspace, - config: Config( - emojiViewConfig: EmojiViewConfig( - noRecents: const NoRecent(), - backgroundColor: Theme.of(context) - .colorScheme - .onInverseSurface, - ), - bottomActionBarConfig: const BottomActionBarConfig( - enabled: false, - ), - categoryViewConfig: CategoryViewConfig( - backspaceColor: theme.colorScheme.primary, - iconColor: - theme.colorScheme.primary.withOpacity(0.5), - iconColorSelected: theme.colorScheme.primary, - indicatorColor: theme.colorScheme.primary, - ), - skinToneConfig: SkinToneConfig( - dialogBackgroundColor: Color.lerp( - theme.colorScheme.surface, - theme.colorScheme.primaryContainer, - 0.75, - )!, - indicatorColor: theme.colorScheme.onSurface, - ), - ), - ), - StickerPickerDialog( - room: controller.room, - onSelected: (sticker) { - controller.room.sendEvent( - { - 'body': sticker.body, - 'info': sticker.info ?? {}, - 'url': sticker.url.toString(), - }, - type: EventTypes.Sticker, - ); - controller.hideEmojiPicker(); - }, - ), + // #Pangea + return Material( + // Pangea# + child: AnimatedContainer( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + height: controller.showEmojiPicker + ? MediaQuery.of(context).size.height / 2 + : 0, + child: controller.showEmojiPicker + ? DefaultTabController( + length: 2, + child: Column( + children: [ + TabBar( + tabs: [ + Tab(text: L10n.of(context)!.emojis), + Tab(text: L10n.of(context)!.stickers), ], ), - ), - // #Pangea - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: FloatingActionButton( - onPressed: controller.hideEmojiPicker, - shape: const CircleBorder(), - mini: true, - child: const Icon(Icons.close), + Expanded( + child: TabBarView( + children: [ + EmojiPicker( + onEmojiSelected: controller.onEmojiSelected, + onBackspacePressed: controller.emojiPickerBackspace, + config: Config( + emojiViewConfig: EmojiViewConfig( + noRecents: const NoRecent(), + backgroundColor: Theme.of(context) + .colorScheme + .onInverseSurface, + ), + bottomActionBarConfig: + const BottomActionBarConfig( + enabled: false, + ), + categoryViewConfig: CategoryViewConfig( + backspaceColor: theme.colorScheme.primary, + iconColor: + theme.colorScheme.primary.withOpacity(0.5), + iconColorSelected: theme.colorScheme.primary, + indicatorColor: theme.colorScheme.primary, + ), + skinToneConfig: SkinToneConfig( + dialogBackgroundColor: Color.lerp( + theme.colorScheme.surface, + theme.colorScheme.primaryContainer, + 0.75, + )!, + indicatorColor: theme.colorScheme.onSurface, + ), + ), + ), + StickerPickerDialog( + room: controller.room, + onSelected: (sticker) { + controller.room.sendEvent( + { + 'body': sticker.body, + 'info': sticker.info ?? {}, + 'url': sticker.url.toString(), + }, + type: EventTypes.Sticker, + ); + controller.hideEmojiPicker(); + }, + ), + ], + ), ), - ), - // Pangea# - ], - ), - ) - : null, + // #Pangea + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: FloatingActionButton( + onPressed: controller.hideEmojiPicker, + shape: const CircleBorder(), + mini: true, + child: const Icon(Icons.close), + ), + ), + // Pangea# + ], + ), + ) + : null, + ), ); } } diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 9bca32169..003d0d47d 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -165,13 +165,15 @@ class ChatEventList extends StatelessWidget { ), highlightMarker: controller.scrollToEventIdMarker == event.eventId, - onSelect: controller.onSelectMessage, + // #Pangea + // onSelect: controller.onSelectMessage, + onSelect: (_) {}, + // Pangea# scrollToEventId: (String eventId) => controller.scrollToEventId(eventId), longPressSelect: controller.selectedEvents.isNotEmpty, // #Pangea immersionMode: controller.choreographer.immersionMode, - definitions: controller.choreographer.definitionsEnabled, controller: controller, // Pangea# selected: controller.selectedEvents diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 4d04d3051..83ca6ca27 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -6,7 +6,6 @@ import 'package:fluffychat/pages/chat/chat_app_bar_title.dart'; import 'package:fluffychat/pages/chat/chat_emoji_picker.dart'; import 'package:fluffychat/pages/chat/chat_event_list.dart'; import 'package:fluffychat/pages/chat/pinned_events.dart'; -import 'package:fluffychat/pages/chat/reactions_picker.dart'; import 'package:fluffychat/pages/chat/reply_display.dart'; import 'package:fluffychat/pangea/choreographer/widgets/it_bar.dart'; import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart'; @@ -496,7 +495,6 @@ class ChatView extends StatelessWidget { ITBar( choreographer: controller.choreographer, ), - ReactionsPicker(controller), ReplyDisplay(controller), ChatInputRowWrapper( controller: controller, diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 3a6b7030c..0ac258f18 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -2,14 +2,13 @@ import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/enum/use_type.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/widgets/chat/message_buttons.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; import 'package:swipe_to_action/swipe_to_action.dart'; @@ -39,8 +38,8 @@ class Message extends StatelessWidget { final void Function()? resetAnimateIn; // #Pangea final bool immersionMode; - final bool definitions; final ChatController controller; + final bool isOverlay; // Pangea# final Color? avatarPresenceBackgroundColor; @@ -63,21 +62,32 @@ class Message extends StatelessWidget { this.avatarPresenceBackgroundColor, // #Pangea required this.immersionMode, - required this.definitions, required this.controller, + this.isOverlay = false, // Pangea# super.key, }); // #Pangea - PangeaMessageEvent? get pangeaMessageEvent => - controller.getPangeaMessageEvent(event.eventId); + void showToolbar(PangeaMessageEvent? pangeaMessageEvent) { + if (pangeaMessageEvent != null && !isOverlay) { + controller.showToolbar(pangeaMessageEvent); + } + } // Pangea# @override Widget build(BuildContext context) { // #Pangea debugPrint('Message.build()'); + PangeaMessageEvent? pangeaMessageEvent; + if (event.type == EventTypes.Message) { + pangeaMessageEvent = PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: event.senderId == Matrix.of(context).client.userID, + ); + } WidgetsBinding.instance.addPostFrameCallback((_) { if (controller.pangeaEditingEvent?.eventId == event.eventId) { pangeaMessageEvent?.updateLatestEdit(); @@ -162,21 +172,6 @@ class Message extends StatelessWidget { : Theme.of(context).colorScheme.primary; } - // #Pangea - ToolbarDisplayController? toolbarController; - if (event.type == EventTypes.Message && - !event.redacted && - (event.messageType == MessageTypes.Text || - event.messageType == MessageTypes.Notice || - event.messageType == MessageTypes.Audio)) { - toolbarController = controller.getToolbarDisplayController( - event.eventId, - nextEvent: nextEvent, - previousEvent: previousEvent, - ); - } - // Pangea# - final resetAnimateIn = this.resetAnimateIn; var animateIn = this.animateIn; @@ -203,8 +198,11 @@ class Message extends StatelessWidget { left: 0, right: 0, child: InkWell( - onTap: () => onSelect(event), - onLongPress: () => onSelect(event), + // #Pangea + onTap: controller.clearSelectedEvents, + // onTap: () => onSelect(event), + // onLongPress: () => onSelect(event), + // Pangea# borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), child: Material( @@ -228,17 +226,20 @@ class Message extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: rowMainAxisAlignment, children: [ - if (longPressSelect) - SizedBox( - height: 32, - width: Avatar.defaultSize, - child: Checkbox.adaptive( - value: selected, - shape: const CircleBorder(), - onChanged: (_) => onSelect(event), - ), - ) - else if (nextEventSameSender || ownMessage) + // #Pangea + // if (longPressSelect) + // SizedBox( + // height: 32, + // width: Avatar.defaultSize, + // child: Checkbox.adaptive( + // value: selected, + // shape: const CircleBorder(), + // onChanged: (_) => onSelect(event), + // ), + // ) + // else if (nextEventSameSender || ownMessage) + if (nextEventSameSender || ownMessage || isOverlay) + // Pangea# SizedBox( width: Avatar.defaultSize, child: Center( @@ -277,7 +278,10 @@ class Message extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (!nextEventSameSender) + // #Pangea + // if (!nextEventSameSender) + if (!nextEventSameSender && !isOverlay) + // Pangea# Padding( padding: const EdgeInsets.only( left: 8.0, @@ -314,18 +318,18 @@ class Message extends StatelessWidget { padding: const EdgeInsets.only(left: 8), child: GestureDetector( // #Pangea - onTap: () => toolbarController?.showToolbar( - context, - ), + onTap: () => showToolbar(pangeaMessageEvent), onDoubleTap: () => - toolbarController?.showToolbar(context), + showToolbar(pangeaMessageEvent), + onLongPress: () => + showToolbar(pangeaMessageEvent), + // onLongPress: longPressSelect + // ? null + // : () { + // HapticFeedback.heavyImpact(); + // onSelect(event); + // }, // Pangea# - onLongPress: longPressSelect - ? null - : () { - HapticFeedback.heavyImpact(); - onSelect(event); - }, child: AnimatedOpacity( opacity: animateIn ? 0 @@ -346,13 +350,21 @@ class Message extends StatelessWidget { ), // #Pangea child: CompositedTransformTarget( - link: MatrixState.pAnyState - .layerLinkAndKey(event.eventId) - .link, + link: isOverlay + ? LayerLinkAndKey('overlay_msg') + .link + : MatrixState.pAnyState + .layerLinkAndKey(event.eventId) + .link, child: Container( - key: MatrixState.pAnyState - .layerLinkAndKey(event.eventId) - .key, + key: isOverlay + ? LayerLinkAndKey('overlay_msg') + .key + : MatrixState.pAnyState + .layerLinkAndKey( + event.eventId, + ) + .key, // Pangea# decoration: BoxDecoration( borderRadius: BorderRadius.circular( @@ -439,8 +451,8 @@ class Message extends StatelessWidget { pangeaMessageEvent: pangeaMessageEvent, immersionMode: immersionMode, - toolbarController: - toolbarController, + isOverlay: isOverlay, + controller: controller, // Pangea# ), if (event.hasAggregatedEvents( @@ -536,7 +548,10 @@ class Message extends StatelessWidget { crossAxisAlignment: ownMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - if (displayTime || selected) + // #Pangea + // if (displayTime || selected) + if ((displayTime || selected) && !isOverlay) + // Pangea# Padding( padding: displayTime ? const EdgeInsets.symmetric(vertical: 8.0) @@ -587,7 +602,8 @@ class Message extends StatelessWidget { children: [ if (pangeaMessageEvent?.showMessageButtons ?? false) MessageButtons( - toolbarController: toolbarController, + controller: controller, + pangeaMessageEvent: pangeaMessageEvent!, ), MessageReactions(event, timeline), ], @@ -631,6 +647,10 @@ class Message extends StatelessWidget { container = row; } + // #Pangea + container = Material(type: MaterialType.transparency, child: container); + // Pangea# + return Center( child: Swipeable( key: ValueKey(event.eventId), diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 5468b59e8..ae20d7a20 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -1,16 +1,15 @@ import 'dart:math'; +import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/video_player.dart'; -import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_context_menu.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:matrix/matrix.dart'; @@ -37,8 +36,8 @@ class MessageContent extends StatelessWidget { //here rather than passing the choreographer? pangea rich text, a widget //further down in the chain is also using pangeaController so its not constant final bool immersionMode; - final ToolbarDisplayController? toolbarController; final bool isOverlay; + final ChatController controller; // Pangea# const MessageContent( @@ -50,8 +49,8 @@ class MessageContent extends StatelessWidget { required this.selected, this.pangeaMessageEvent, required this.immersionMode, - required this.toolbarController, this.isOverlay = false, + required this.controller, // Pangea# required this.borderRadius, }); @@ -301,48 +300,34 @@ class MessageContent extends StatelessWidget { style: messageTextStyle, pangeaMessageEvent: pangeaMessageEvent!, immersionMode: immersionMode, - toolbarController: toolbarController, + isOverlay: isOverlay, + controller: controller, ), ); - } else if (pangeaMessageEvent != null) { - toolbarController?.toolbar?.textSelection.setMessageText( - (event.getDisplayEvent(pangeaMessageEvent!.timeline).body), + } + + if (isOverlay) { + controller.textSelection.setMessageText( + event.calcLocalizedBodyFallback( + MatrixLocals(L10n.of(context)!), + hideReply: true, + ), ); } return SelectableLinkify( onSelectionChanged: (selection, cause) { - if (cause == SelectionChangedCause.longPress && - toolbarController != null && - pangeaMessageEvent != null && - !(toolbarController!.highlighted) && - !selected) { - toolbarController!.controller.onSelectMessage( - pangeaMessageEvent!.event, - ); - return; + if (isOverlay) { + controller.textSelection.onTextSelection(selection); } - toolbarController?.toolbar?.textSelection - .onTextSelection(selection); }, - onTap: () => toolbarController?.showToolbar(context), - contextMenuBuilder: (context, state) => - (toolbarController?.highlighted ?? false) - ? const SizedBox.shrink() - : MessageContextMenu.contextMenuOverride( - context: context, - textSelection: state, - onDefine: () => toolbarController?.showToolbar( - context, - mode: MessageMode.definition, - ), - onListen: () => toolbarController?.showToolbar( - context, - mode: MessageMode.textToSpeech, - ), - ), - enableInteractiveSelection: - toolbarController?.highlighted ?? false, + onTap: () { + if (pangeaMessageEvent != null && !isOverlay) { + HapticFeedback.mediumImpact(); + controller.showToolbar(pangeaMessageEvent!); + } + }, + enableInteractiveSelection: isOverlay, // Pangea# text: event.calcLocalizedBodyFallback( MatrixLocals(L10n.of(context)!), diff --git a/lib/pages/chat/reactions_picker.dart b/lib/pages/chat/reactions_picker.dart index 7256e00bc..c61dc4f48 100644 --- a/lib/pages/chat/reactions_picker.dart +++ b/lib/pages/chat/reactions_picker.dart @@ -1,11 +1,10 @@ -import 'package:flutter/material.dart'; - import 'package:emoji_proposal/emoji_proposal.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_emojis.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + import '../../config/themes.dart'; class ReactionsPicker extends StatelessWidget { diff --git a/lib/pages/image_viewer/image_viewer.dart b/lib/pages/image_viewer/image_viewer.dart index 94ab19dd4..f7d3ea544 100644 --- a/lib/pages/image_viewer/image_viewer.dart +++ b/lib/pages/image_viewer/image_viewer.dart @@ -1,11 +1,10 @@ -import 'package:flutter/material.dart'; - -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/pages/image_viewer/image_viewer_view.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + import '../../utils/matrix_sdk_extensions/event_extension.dart'; class ImageViewer extends StatefulWidget { diff --git a/lib/pangea/utils/any_state_holder.dart b/lib/pangea/utils/any_state_holder.dart index 9705a9ca1..00800a74b 100644 --- a/lib/pangea/utils/any_state_holder.dart +++ b/lib/pangea/utils/any_state_holder.dart @@ -61,11 +61,31 @@ class PangeaAnyState { } } + void closeAllOverlays() { + for (int i = 0; i < entries.length; i++) { + try { + entries.last.remove(); + } catch (err, s) { + ErrorHandler.logError( + e: err, + s: s, + data: { + "overlay": entries.last, + }, + ); + } + entries.removeLast(); + } + } + LayerLinkAndKey messageLinkAndKey(String eventId) => layerLinkAndKey(eventId); // String chatViewTargetKey(String? roomId) => "chatViewKey$roomId"; // LayerLinkAndKey chatViewLinkAndKey(String? roomId) => // layerLinkAndKey(chatViewTargetKey(roomId)); + + RenderBox? getRenderBox(String key) => + layerLinkAndKey(key).key.currentContext?.findRenderObject() as RenderBox?; } class LayerLinkAndKey { diff --git a/lib/pangea/utils/overlay.dart b/lib/pangea/utils/overlay.dart index ce8c63d99..c83c39af4 100644 --- a/lib/pangea/utils/overlay.dart +++ b/lib/pangea/utils/overlay.dart @@ -11,6 +11,11 @@ import '../../config/themes.dart'; import '../../widgets/matrix.dart'; import 'error_handler.dart'; +enum OverlayPositionEnum { + transform, + centered, +} + class OverlayUtil { static showOverlay({ required BuildContext context, @@ -26,13 +31,13 @@ class OverlayUtil { Alignment? targetAnchor, Alignment? followerAnchor, bool closePrevOverlay = true, + Function? onDismiss, + OverlayPositionEnum position = OverlayPositionEnum.transform, }) { try { if (closePrevOverlay) { MatrixState.pAnyState.closeOverlay(); } - final LayerLinkAndKey layerLinkAndKey = - MatrixState.pAnyState.layerLinkAndKey(transformTargetId); final OverlayEntry entry = OverlayEntry( builder: (context) => AnimatedContainer( @@ -43,18 +48,27 @@ class OverlayUtil { if (backDropToDismiss) TransparentBackdrop( backgroundColor: backgroundColor, + onDismiss: onDismiss, ), Positioned( + top: (position == OverlayPositionEnum.centered) ? 0 : null, + right: (position == OverlayPositionEnum.centered) ? 0 : null, + left: (position == OverlayPositionEnum.centered) ? 0 : null, + bottom: (position == OverlayPositionEnum.centered) ? 0 : null, width: width, height: height, - child: CompositedTransformFollower( - targetAnchor: targetAnchor ?? Alignment.topLeft, - followerAnchor: followerAnchor ?? Alignment.topLeft, - link: layerLinkAndKey.link, - showWhenUnlinked: false, - offset: offset ?? Offset.zero, - child: child, - ), + child: (position != OverlayPositionEnum.transform) + ? child + : CompositedTransformFollower( + targetAnchor: targetAnchor ?? Alignment.topLeft, + followerAnchor: followerAnchor ?? Alignment.topLeft, + link: MatrixState.pAnyState + .layerLinkAndKey(transformTargetId) + .link, + showWhenUnlinked: false, + offset: offset ?? Offset.zero, + child: child, + ), ), ], ), @@ -191,8 +205,10 @@ class OverlayUtil { class TransparentBackdrop extends StatelessWidget { final Color? backgroundColor; + final Function? onDismiss; const TransparentBackdrop({ super.key, + this.onDismiss, this.backgroundColor, }); @@ -208,6 +224,9 @@ class TransparentBackdrop extends StatelessWidget { focusColor: Colors.transparent, highlightColor: Colors.transparent, onTap: () { + if (onDismiss != null) { + onDismiss!(); + } MatrixState.pAnyState.closeOverlay(); }, child: Container( diff --git a/lib/pangea/widgets/chat/message_buttons.dart b/lib/pangea/widgets/chat/message_buttons.dart index f7748675f..43dbfc95a 100644 --- a/lib/pangea/widgets/chat/message_buttons.dart +++ b/lib/pangea/widgets/chat/message_buttons.dart @@ -1,27 +1,27 @@ +import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:flutter/material.dart'; class MessageButtons extends StatelessWidget { - final ToolbarDisplayController? toolbarController; + final ChatController controller; + final PangeaMessageEvent pangeaMessageEvent; const MessageButtons({ super.key, - this.toolbarController, + required this.controller, + required this.pangeaMessageEvent, }); void showActivity(BuildContext context) { - toolbarController?.showToolbar( - context, + controller.showToolbar( + pangeaMessageEvent, mode: MessageMode.practiceActivity, ); } @override Widget build(BuildContext context) { - if (toolbarController == null) { - return const SizedBox.shrink(); - } return Padding( padding: const EdgeInsets.only(right: 8.0), child: Row( diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart new file mode 100644 index 000000000..a62b8e930 --- /dev/null +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pages/chat/events/message.dart'; +import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart'; +import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +class MessageSelectionOverlay extends StatefulWidget { + final ChatController controller; + final Event event; + final PangeaMessageEvent pangeaMessageEvent; + final MessageMode? initialMode; + final MessageTextSelection textSelection; + + const MessageSelectionOverlay({ + required this.controller, + required this.event, + required this.pangeaMessageEvent, + required this.textSelection, + this.initialMode, + super.key, + }); + + @override + MessageSelectionOverlayState createState() => MessageSelectionOverlayState(); +} + +class MessageSelectionOverlayState extends State { + double overlayBottomOffset = -1; + double adjustedOverlayBottomOffset = -1; + Size? messageSize; + Offset? messageOffset; + + final StreamController _completeAnimationStream = + StreamController.broadcast(); + + @override + void initState() { + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // position the overlay directly over the underlying message + setOverlayBottomOffset(); + + // wait for the toolbar to animate to full height + _completeAnimationStream.stream.first.then((_) { + if (toolbarHeight == null || + messageSize == null || + messageOffset == null) { + return; + } + + // Once the toolbar has fully expanded, adjust + // the overlay's position if there's an overflow + final overlayTopOffset = messageOffset!.dy - toolbarHeight!; + + final bool hasHeaderOverflow = overlayTopOffset < headerHeight; + final bool hasFooterOverflow = overlayBottomOffset < footerHeight; + + if (hasHeaderOverflow) { + final overlayHeight = toolbarHeight! + messageSize!.height; + adjustedOverlayBottomOffset = screenHeight - + overlayHeight - + footerHeight - + MediaQuery.of(context).padding.bottom; + } else if (hasFooterOverflow) { + adjustedOverlayBottomOffset = footerHeight; + } + + setState(() {}); + }); + } + + @override + void dispose() { + _completeAnimationStream.close(); + super.dispose(); + } + + void setOverlayBottomOffset() { + // Try to get the offset and size of the original message bubble. + // If it fails, return an empty SizedBox. For instance, this can fail if + // you change the screen size while the overlay is open. + try { + final messageRenderBox = MatrixState.pAnyState.getRenderBox( + widget.event.eventId, + ); + if (messageRenderBox != null && messageRenderBox.hasSize) { + messageSize = messageRenderBox.size; + messageOffset = messageRenderBox.localToGlobal(Offset.zero); + final messageTopOffset = messageOffset!.dy; + overlayBottomOffset = + screenHeight - messageTopOffset - messageSize!.height; + } + } catch (err) { + overlayBottomOffset = adjustedOverlayBottomOffset = -1; + } finally { + setState(() {}); + } + } + + // height of the reply/forward bar + the reaction picker + contextual padding + double get footerHeight => + 48 + 56 + (FluffyThemes.isColumnMode(context) ? 16.0 : 8.0); + + double get headerHeight => + (Theme.of(context).appBarTheme.toolbarHeight ?? 56) + + MediaQuery.of(context).padding.top; + + double get screenHeight => MediaQuery.of(context).size.height; + + double? get toolbarHeight { + try { + final toolbarRenderBox = MatrixState.pAnyState.getRenderBox( + '${widget.pangeaMessageEvent.eventId}-toolbar', + ); + + return toolbarRenderBox?.size.height; + } catch (e) { + return null; + } + } + + @override + Widget build(BuildContext context) { + if (overlayBottomOffset == -1) { + return const SizedBox.shrink(); + } + + final overlayMessage = ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 2.5, + ), + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: widget.pangeaMessageEvent.ownMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only( + left: widget.pangeaMessageEvent.ownMessage + ? 0 + : Avatar.defaultSize + 16, + right: widget.pangeaMessageEvent.ownMessage ? 8 : 0, + ), + child: MessageToolbar( + pangeaMessageEvent: widget.pangeaMessageEvent, + controller: widget.controller, + textSelection: widget.textSelection, + completeAnimationStream: _completeAnimationStream, + initialMode: widget.initialMode, + ), + ), + ], + ), + Message( + widget.event, + onSwipe: () => {}, + onInfoTab: (_) => {}, + onAvatarTab: (_) => {}, + scrollToEventId: (_) => {}, + onSelect: (_) => {}, + immersionMode: widget.controller.choreographer.immersionMode, + controller: widget.controller, + timeline: widget.controller.timeline!, + isOverlay: true, + animateIn: false, + ), + ], + ), + ), + ); + + return Expanded( + child: Stack( + children: [ + AnimatedPositioned( + duration: FluffyThemes.animationDuration, + left: 0, + right: 0, + bottom: adjustedOverlayBottomOffset == -1 + ? overlayBottomOffset + : adjustedOverlayBottomOffset, + child: Align( + alignment: Alignment.center, + child: overlayMessage, + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + OverlayFooter(controller: widget.controller), + ], + ), + ), + Material( + child: OverlayHeader(controller: widget.controller), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 7c5682933..943269bdb 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -1,233 +1,35 @@ import 'dart:async'; -import 'dart:developer'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:fluffychat/pangea/utils/overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; import 'package:fluffychat/pangea/widgets/chat/message_speech_to_text_card.dart'; import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart'; import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart'; import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart'; -import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart'; import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; -import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - -class ToolbarDisplayController { - final PangeaMessageEvent pangeaMessageEvent; - final String targetId; - final bool immersionMode; - final ChatController controller; - final FocusNode focusNode = FocusNode(); - Event? nextEvent; - Event? previousEvent; - - MessageToolbar? toolbar; - String? overlayId; - double? messageWidth; - - final toolbarModeStream = StreamController.broadcast(); - - ToolbarDisplayController({ - required this.pangeaMessageEvent, - required this.targetId, - required this.immersionMode, - required this.controller, - this.nextEvent, - this.previousEvent, - }); - - void setToolbar() { - toolbar ??= MessageToolbar( - textSelection: MessageTextSelection(), - room: pangeaMessageEvent.room, - toolbarModeStream: toolbarModeStream, - pangeaMessageEvent: pangeaMessageEvent, - immersionMode: immersionMode, - controller: controller, - ); - } - - void showToolbar(BuildContext context, {MessageMode? mode}) { - // Don't show toolbar if keyboard open - if (controller.inputFocus.hasFocus) { - FocusManager.instance.primaryFocus?.unfocus(); - return; - } - - bool toolbarUp = true; - if (highlighted) return; - if (controller.selectMode) { - controller.clearSelectedEvents(); - } - if (!MatrixState.pangeaController.languageController.languagesSet) { - pLanguageDialog(context, () {}); - return; - } - focusNode.requestFocus(); - - final LayerLinkAndKey layerLinkAndKey = - MatrixState.pAnyState.layerLinkAndKey(targetId); - final targetRenderBox = - layerLinkAndKey.key.currentContext?.findRenderObject(); - if (targetRenderBox != null) { - final Size transformTargetSize = (targetRenderBox as RenderBox).size; - messageWidth = transformTargetSize.width; - final Offset targetOffset = (targetRenderBox).localToGlobal(Offset.zero); - - // If there is enough space above, procede as normal - // Else if there is enough space below, show toolbar underneath - if (targetOffset.dy < 320) { - final spaceBeneath = MediaQuery.of(context).size.height - - (targetOffset.dy + transformTargetSize.height); - if (spaceBeneath >= 320) { - toolbarUp = false; - } - - // See if it's possible to scroll up to make space - else if (controller.scrollController.offset - targetOffset.dy + 320 >= - controller.scrollController.position.minScrollExtent && - controller.scrollController.offset - targetOffset.dy + 320 <= - controller.scrollController.position.maxScrollExtent) { - controller.scrollController.animateTo( - controller.scrollController.offset - targetOffset.dy + 320, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - ); - } - - // See if it's possible to scroll down to make space - else if (controller.scrollController.offset + spaceBeneath - 320 >= - controller.scrollController.position.minScrollExtent && - controller.scrollController.offset + spaceBeneath - 320 <= - controller.scrollController.position.maxScrollExtent) { - controller.scrollController.animateTo( - controller.scrollController.offset + spaceBeneath - 320, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - ); - toolbarUp = false; - } - - // If message is too big and can't scroll either way - // Scroll up as much as possible, and show toolbar above - else { - controller.scrollController.animateTo( - controller.scrollController.position.minScrollExtent, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - ); - } - } - } - - final Widget overlayMessage = OverlayMessage( - pangeaMessageEvent.event, - timeline: pangeaMessageEvent.timeline, - immersionMode: immersionMode, - ownMessage: pangeaMessageEvent.ownMessage, - toolbarController: this, - width: messageWidth, - nextEvent: nextEvent, - previousEvent: previousEvent, - ); - - // I'm not sure why I put this here, but it causes the toolbar - // not to open immediately after clicking (user has to scroll or move their cursor) - // so I'm commenting it out for now - // WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Widget overlayEntry; - if (toolbar == null) return; - try { - overlayEntry = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: pangeaMessageEvent.ownMessage - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - toolbarUp ? toolbar! : overlayMessage, - const SizedBox(height: 6), - toolbarUp ? overlayMessage : toolbar!, - ], - ); - } catch (err) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: StackTrace.current); - return; - } - - OverlayUtil.showOverlay( - context: context, - child: overlayEntry, - transformTargetId: targetId, - targetAnchor: pangeaMessageEvent.ownMessage - ? toolbarUp - ? Alignment.bottomRight - : Alignment.topRight - : toolbarUp - ? Alignment.bottomLeft - : Alignment.topLeft, - followerAnchor: pangeaMessageEvent.ownMessage - ? toolbarUp - ? Alignment.bottomRight - : Alignment.topRight - : toolbarUp - ? Alignment.bottomLeft - : Alignment.topLeft, - backgroundColor: const Color.fromRGBO(0, 0, 0, 1).withAlpha(100), - closePrevOverlay: - MatrixState.pangeaController.subscriptionController.isSubscribed, - ); - - if (MatrixState.pAnyState.entries.isNotEmpty) { - overlayId = MatrixState.pAnyState.entries.last.hashCode.toString(); - } - - if (mode != null) { - Future.delayed( - const Duration(milliseconds: 100), - () => toolbarModeStream.add(mode), - ); - } - // }); - } - - bool get highlighted { - if (overlayId == null) return false; - if (MatrixState.pAnyState.entries.isEmpty) { - overlayId = null; - return false; - } - return MatrixState.pAnyState.entries.last.hashCode.toString() == overlayId; - } -} class MessageToolbar extends StatefulWidget { final MessageTextSelection textSelection; - final Room room; final PangeaMessageEvent pangeaMessageEvent; - final StreamController toolbarModeStream; - final bool immersionMode; final ChatController controller; + final MessageMode? initialMode; + + final StreamController completeAnimationStream; const MessageToolbar({ super.key, required this.textSelection, - required this.room, required this.pangeaMessageEvent, - required this.toolbarModeStream, - required this.immersionMode, required this.controller, + required this.completeAnimationStream, + this.initialMode, }); @override @@ -239,7 +41,6 @@ class MessageToolbarState extends State { MessageMode? currentMode; bool updatingMode = false; late StreamSubscription selectionStream; - late StreamSubscription toolbarModeStream; void updateMode(MessageMode newMode) { //Early exit from the function if the widget has been unmounted to prevent updates on an inactive widget. @@ -278,7 +79,7 @@ class MessageToolbarState extends State { toolbarContent = MessageUnsubscribedCard( languageTool: newMode.title(context), mode: newMode, - toolbarModeStream: widget.toolbarModeStream, + controller: this, ); } else { switch (currentMode) { @@ -317,7 +118,7 @@ class MessageToolbarState extends State { debugPrint("show translation"); toolbarContent = MessageTranslationCard( messageEvent: widget.pangeaMessageEvent, - immersionMode: widget.immersionMode, + immersionMode: widget.controller.choreographer.immersionMode, selection: widget.textSelection, ); } @@ -350,7 +151,7 @@ class MessageToolbarState extends State { fullText: widget.textSelection.messageText, fullTextLang: widget.pangeaMessageEvent.messageDisplayLangCode, hasInfo: true, - room: widget.room, + room: widget.controller.room, ); } @@ -364,30 +165,25 @@ class MessageToolbarState extends State { void spellCheck() {} - void showMore() { - MatrixState.pAnyState.closeOverlay(); - widget.controller.onSelectMessage(widget.pangeaMessageEvent.event); - } - @override void initState() { super.initState(); widget.textSelection.selectedText = null; - toolbarModeStream = widget.toolbarModeStream.stream.listen((mode) { - updateMode(mode); - }); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { if (widget.pangeaMessageEvent.isAudioMessage) { updateMode(MessageMode.speechToText); return; } - MatrixState.pangeaController.userController.profile.userSettings - .autoPlayMessages - ? updateMode(MessageMode.textToSpeech) - : updateMode(MessageMode.translation); + if (widget.initialMode != null) { + updateMode(widget.initialMode!); + } else { + MatrixState.pangeaController.userController.profile.userSettings + .autoPlayMessages + ? updateMode(MessageMode.textToSpeech) + : updateMode(MessageMode.translation); + } }); Timer? timer; @@ -410,13 +206,37 @@ class MessageToolbarState extends State { @override void dispose() { selectionStream.cancel(); - toolbarModeStream.cancel(); super.dispose(); } @override Widget build(BuildContext context) { + final buttonRow = Row( + mainAxisSize: MainAxisSize.min, + children: MessageMode.values + .map( + (mode) => mode.isValidMode(widget.pangeaMessageEvent.event) + ? Tooltip( + message: mode.tooltip(context), + child: IconButton( + icon: Icon(mode.icon), + color: mode.iconColor( + widget.pangeaMessageEvent, + currentMode, + context, + ), + onPressed: () => updateMode(mode), + ), + ) + : const SizedBox.shrink(), + ) + .toList(), + ); + return Material( + key: MatrixState.pAnyState + .layerLinkAndKey('${widget.pangeaMessageEvent.eventId}-toolbar') + .key, type: MaterialType.transparency, child: Container( padding: const EdgeInsets.all(10), @@ -430,72 +250,26 @@ class MessageToolbarState extends State { Radius.circular(25), ), ), - constraints: const BoxConstraints( - maxWidth: 300, - minWidth: 300, - maxHeight: 300, - ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - constraints: const BoxConstraints( - minWidth: 300, - maxHeight: 228, - ), - child: SingleChildScrollView( - child: AnimatedSize( - duration: FluffyThemes.animationDuration, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: toolbarContent ?? const SizedBox(), - ), - SizedBox(height: toolbarContent == null ? 0 : 20), - ], + if (toolbarContent != null) + Container( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), + constraints: const BoxConstraints( + maxWidth: 275, + minWidth: 275, + maxHeight: 250, + ), + child: SingleChildScrollView( + child: AnimatedSize( + duration: FluffyThemes.animationDuration, + child: toolbarContent, + onEnd: () => widget.completeAnimationStream.add(null), ), ), ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: MessageMode.values.map((mode) { - if ([ - MessageMode.definition, - MessageMode.textToSpeech, - MessageMode.translation, - ].contains(mode) && - widget.pangeaMessageEvent.isAudioMessage) { - return const SizedBox.shrink(); - } - if (mode == MessageMode.speechToText && - !widget.pangeaMessageEvent.isAudioMessage) { - return const SizedBox.shrink(); - } - return Tooltip( - message: mode.tooltip(context), - child: IconButton( - icon: Icon(mode.icon), - color: mode.iconColor( - widget.pangeaMessageEvent, - currentMode, - context, - ), - onPressed: () => updateMode(mode), - ), - ); - }).toList() + - [ - Tooltip( - message: L10n.of(context)!.more, - child: IconButton( - icon: const Icon(Icons.add_reaction_outlined), - onPressed: showMore, - ), - ), - ], - ), + buttonRow, ], ), ), diff --git a/lib/pangea/widgets/chat/message_unsubscribed_card.dart b/lib/pangea/widgets/chat/message_unsubscribed_card.dart index 720b25748..d1ff5c343 100644 --- a/lib/pangea/widgets/chat/message_unsubscribed_card.dart +++ b/lib/pangea/widgets/chat/message_unsubscribed_card.dart @@ -1,7 +1,6 @@ -import 'dart:async'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -11,13 +10,13 @@ import '../../enum/message_mode_enum.dart'; class MessageUnsubscribedCard extends StatelessWidget { final String languageTool; final MessageMode mode; - final StreamController toolbarModeStream; + final MessageToolbarState controller; const MessageUnsubscribedCard({ super.key, required this.languageTool, required this.mode, - required this.toolbarModeStream, + required this.controller, }); @override @@ -29,7 +28,7 @@ class MessageUnsubscribedCard extends StatelessWidget { if (inTrialWindow) { MatrixState.pangeaController.subscriptionController .activateNewUserTrial(); - toolbarModeStream.add(mode); + controller.updateMode(mode); } else { MatrixState.pangeaController.subscriptionController .showPaywall(context); @@ -49,7 +48,7 @@ class MessageUnsubscribedCard extends StatelessWidget { child: TextButton( onPressed: onButtonPress, style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( + backgroundColor: WidgetStateProperty.all( (AppConfig.primaryColor).withOpacity(0.1), ), ), diff --git a/lib/pangea/widgets/chat/overlay_footer.dart b/lib/pangea/widgets/chat/overlay_footer.dart new file mode 100644 index 000000000..b4c51d07c --- /dev/null +++ b/lib/pangea/widgets/chat/overlay_footer.dart @@ -0,0 +1,48 @@ +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pages/chat/chat_input_row.dart'; +import 'package:fluffychat/pages/chat/reactions_picker.dart'; +import 'package:flutter/material.dart'; + +class OverlayFooter extends StatelessWidget { + final ChatController controller; + + const OverlayFooter({ + required this.controller, + super.key, + }); + + @override + Widget build(BuildContext context) { + final bottomSheetPadding = FluffyThemes.isColumnMode(context) ? 16.0 : 8.0; + + return Container( + margin: EdgeInsets.only( + bottom: bottomSheetPadding, + left: bottomSheetPadding, + right: bottomSheetPadding, + ), + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 2.5, + ), + alignment: Alignment.center, + child: Column( + children: [ + Material( + clipBehavior: Clip.hardEdge, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all( + Radius.circular(24), + ), + child: Column( + children: [ + ReactionsPicker(controller), + ChatInputRow(controller), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/widgets/chat/overlay_header.dart b/lib/pangea/widgets/chat/overlay_header.dart new file mode 100644 index 000000000..cce47adcc --- /dev/null +++ b/lib/pangea/widgets/chat/overlay_header.dart @@ -0,0 +1,88 @@ +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pages/chat/chat_app_bar_title.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +class OverlayHeader extends StatelessWidget { + final ChatController controller; + + const OverlayHeader({ + required this.controller, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppBar( + actionsIconTheme: IconThemeData( + color: Theme.of(context).colorScheme.primary, + ), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: controller.clearSelectedEvents, + tooltip: L10n.of(context)!.close, + color: Theme.of(context).colorScheme.primary, + ), + titleSpacing: 0, + title: ChatAppBarTitle(controller), + actions: [ + if (controller.canEditSelectedEvents) + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: L10n.of(context)!.edit, + onPressed: controller.editSelectedEventAction, + ), + if (controller.selectedEvents.length == 1 && + controller.selectedEvents.single.messageType == + MessageTypes.Text) + IconButton( + icon: const Icon(Icons.copy_outlined), + tooltip: L10n.of(context)!.copy, + onPressed: controller.copyEventsAction, + ), + if (controller.canSaveSelectedEvent) + // Use builder context to correctly position the share dialog on iPad + Builder( + builder: (context) => IconButton( + icon: Icon(Icons.adaptive.share), + tooltip: L10n.of(context)!.share, + onPressed: () => controller.saveSelectedEvent(context), + ), + ), + if (controller.canPinSelectedEvents) + IconButton( + icon: const Icon(Icons.push_pin_outlined), + onPressed: controller.pinEvent, + tooltip: L10n.of(context)!.pinMessage, + ), + if (controller.canRedactSelectedEvents) + IconButton( + icon: const Icon(Icons.delete_outlined), + tooltip: L10n.of(context)!.redactMessage, + onPressed: controller.redactEventsAction, + ), + if (controller.selectedEvents.length == 1) + IconButton( + icon: const Icon(Icons.info_outlined), + tooltip: L10n.of(context)!.messageInfo, + onPressed: () { + controller.showEventInfo(); + controller.clearSelectedEvents(); + }, + ), + if (controller.selectedEvents.length == 1) + IconButton( + icon: const Icon(Icons.shield_outlined), + tooltip: L10n.of(context)!.reportMessage, + onPressed: controller.reportEventAction, + ), + ], + ), + ], + ); + } +} diff --git a/lib/pangea/widgets/chat/overlay_message.dart b/lib/pangea/widgets/chat/overlay_message.dart deleted file mode 100644 index 31e2b10b1..000000000 --- a/lib/pangea/widgets/chat/overlay_message.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pages/chat/events/message_content.dart'; -import 'package:fluffychat/pangea/enum/use_type.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; -import 'package:fluffychat/utils/date_time_extension.dart'; -import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; - -import '../../../config/app_config.dart'; - -class OverlayMessage extends StatelessWidget { - final Event event; - final Event? nextEvent; - final Event? previousEvent; - final bool selected; - final Timeline timeline; - // final LanguageModel? selectedDisplayLang; - final bool immersionMode; - // final bool definitions; - final bool ownMessage; - final ToolbarDisplayController toolbarController; - final double? width; - - const OverlayMessage( - this.event, { - this.nextEvent, - this.previousEvent, - this.selected = false, - required this.timeline, - required this.immersionMode, - required this.ownMessage, - required this.toolbarController, - this.width, - super.key, - }); - - @override - Widget build(BuildContext context) { - if (event.type != EventTypes.Message || - event.messageType == EventTypes.KeyVerificationRequest) { - return const SizedBox.shrink(); - } - - var color = Theme.of(context).colorScheme.surfaceContainerHighest; - final isLight = Theme.of(context).brightness == Brightness.light; - var lightness = isLight ? .05 : .85; - final textColor = ownMessage - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface; - - const hardCorner = Radius.circular(4); - - final displayTime = event.type == EventTypes.RoomCreate || - nextEvent == null || - !event.originServerTs.sameEnvironment(nextEvent!.originServerTs); - - final nextEventSameSender = nextEvent != null && - { - EventTypes.Message, - EventTypes.Sticker, - EventTypes.Encrypted, - }.contains(nextEvent!.type) && - nextEvent!.senderId == event.senderId && - !displayTime; - - final previousEventSameSender = previousEvent != null && - { - EventTypes.Message, - EventTypes.Sticker, - EventTypes.Encrypted, - }.contains(previousEvent!.type) && - previousEvent!.senderId == event.senderId && - previousEvent!.originServerTs.sameEnvironment(event.originServerTs); - - const roundedCorner = Radius.circular(AppConfig.borderRadius); - final borderRadius = BorderRadius.only( - topLeft: !ownMessage && nextEventSameSender ? hardCorner : roundedCorner, - topRight: ownMessage && nextEventSameSender ? hardCorner : roundedCorner, - bottomLeft: - !ownMessage && previousEventSameSender ? hardCorner : roundedCorner, - bottomRight: - ownMessage && previousEventSameSender ? hardCorner : roundedCorner, - ); - - final noBubble = { - MessageTypes.Video, - MessageTypes.Image, - MessageTypes.Sticker, - }.contains(event.messageType) && - !event.redacted; - final noPadding = { - MessageTypes.File, - MessageTypes.Audio, - }.contains(event.messageType); - - if (ownMessage) { - color = Theme.of(context).colorScheme.primary; - lightness = isLight ? .15 : .85; - } - // Make overlay a little darker/lighter than the message - color = Color.fromARGB( - color.alpha, - isLight - ? (color.red + lightness * (255 - color.red)).round() - : (color.red * lightness).round(), - isLight - ? (color.green + lightness * (255 - color.green)).round() - : (color.green * lightness).round(), - isLight - ? (color.blue + lightness * (255 - color.blue)).round() - : (color.blue * lightness).round(), - ); - - final pangeaMessageEvent = PangeaMessageEvent( - event: event, - timeline: timeline, - ownMessage: ownMessage, - ); - - return Flexible( - child: Material( - color: noBubble ? Colors.transparent : color, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( - borderRadius: borderRadius, - ), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - ), - padding: noBubble || noPadding - ? EdgeInsets.zero - : const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - constraints: BoxConstraints( - maxWidth: width ?? FluffyThemes.columnWidth * 1.25, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: MessageContent( - event.getDisplayEvent(timeline), - textColor: textColor, - borderRadius: borderRadius, - selected: selected, - pangeaMessageEvent: pangeaMessageEvent, - immersionMode: immersionMode, - toolbarController: toolbarController, - isOverlay: true, - ), - ), - if (event.hasAggregatedEvents( - timeline, - RelationshipTypes.edit, - ) || - (pangeaMessageEvent.showUseType)) - Padding( - padding: const EdgeInsets.only( - top: 4.0, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (pangeaMessageEvent.showUseType) ...[ - pangeaMessageEvent.msgUseType.iconView( - context, - textColor.withAlpha(164), - ), - const SizedBox(width: 4), - ], - if (event.hasAggregatedEvents( - timeline, - RelationshipTypes.edit, - )) ...[ - Icon( - Icons.edit_outlined, - color: textColor.withAlpha(164), - size: 14, - ), - Text( - ' - ${event.getDisplayEvent(timeline).originServerTs.localizedTimeShort(context)}', - style: TextStyle( - color: textColor.withAlpha(164), - fontSize: 12, - ), - ), - ], - ], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart b/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart index 021f4ee44..9edc9971d 100644 --- a/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart +++ b/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart @@ -7,13 +7,15 @@ class ToolbarContentLoadingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( + return Center( + child: SizedBox( height: 14, width: 14, child: CircularProgressIndicator( strokeWidth: 2.0, color: Theme.of(context).colorScheme.primary, ), - ); + ), + ); } -} \ No newline at end of file +} diff --git a/lib/pangea/widgets/igc/pangea_rich_text.dart b/lib/pangea/widgets/igc/pangea_rich_text.dart index abf583b3b..3ff10d4c7 100644 --- a/lib/pangea/widgets/igc/pangea_rich_text.dart +++ b/lib/pangea/widgets/igc/pangea_rich_text.dart @@ -2,31 +2,31 @@ import 'dart:developer'; import 'dart:ui'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/instructions_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_context_menu.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../../enum/message_mode_enum.dart'; import '../../models/pangea_match_model.dart'; class PangeaRichText extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; final bool immersionMode; - final ToolbarDisplayController? toolbarController; final TextStyle? style; + final bool isOverlay; + final ChatController controller; const PangeaRichText({ super.key, required this.pangeaMessageEvent, required this.immersionMode, - required this.toolbarController, + required this.isOverlay, + required this.controller, this.style, }); @@ -59,12 +59,11 @@ class PangeaRichTextState extends State { void _setTextSpan(String newTextSpan) { try { if (!mounted) return; // Early exit if the widget is no longer in the tree - - widget.toolbarController?.toolbar?.textSelection.setMessageText( - newTextSpan, - ); setState(() { textSpan = newTextSpan; + if (widget.isOverlay) { + widget.controller.textSelection.setMessageText(textSpan); + } }); } catch (error, stackTrace) { ErrorHandler.logError( @@ -137,38 +136,16 @@ class PangeaRichTextState extends State { //TODO - take out of build function of every message final Widget richText = SelectableText.rich( onSelectionChanged: (selection, cause) { - if (cause == SelectionChangedCause.longPress && - !(widget.toolbarController?.highlighted ?? false) && - !(widget.toolbarController?.controller.selectedEvents.any( - (e) => e.eventId == widget.pangeaMessageEvent.eventId, - ) ?? - false)) { - widget.toolbarController?.controller.onSelectMessage( - widget.pangeaMessageEvent.event, - ); - return; + if (widget.isOverlay) { + widget.controller.textSelection.onTextSelection(selection); } - widget.toolbarController?.toolbar?.textSelection - .onTextSelection(selection); }, - onTap: () => widget.toolbarController?.showToolbar(context), - enableInteractiveSelection: - widget.toolbarController?.highlighted ?? false, - contextMenuBuilder: (context, state) => - widget.toolbarController?.highlighted ?? true - ? const SizedBox.shrink() - : MessageContextMenu.contextMenuOverride( - context: context, - textSelection: state, - onDefine: () => widget.toolbarController?.showToolbar( - context, - mode: MessageMode.definition, - ), - onListen: () => widget.toolbarController?.showToolbar( - context, - mode: MessageMode.textToSpeech, - ), - ), + onTap: () { + if (!widget.isOverlay) { + widget.controller.showToolbar(widget.pangeaMessageEvent); + } + }, + enableInteractiveSelection: widget.isOverlay, TextSpan( text: textSpan, style: widget.style,