diff --git a/lib/ui/views/main_page/chat/widgets/message.dart b/lib/ui/views/main_page/chat/widgets/message.dart index 4894760f..fceee48c 100644 --- a/lib/ui/views/main_page/chat/widgets/message.dart +++ b/lib/ui/views/main_page/chat/widgets/message.dart @@ -2,7 +2,6 @@ // Copyright 2025 BitCodersNN import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bbcode/flutter_bbcode.dart'; import 'package:unn_mobile/core/constants/date_pattern.dart'; import 'package:unn_mobile/core/misc/custom_bb_tags.dart'; @@ -22,6 +21,8 @@ import 'package:unn_mobile/core/viewmodels/main_page/feed/attached_file_view_mod import 'package:unn_mobile/ui/views/base_view.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/attached_file.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/reaction_bubble.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_factory.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_helper.dart'; const systemMessageSeparator = '------------------------------------------------------\n'; @@ -231,7 +232,17 @@ class _MessageWidgetState extends State { return BaseView( model: MessageReactionViewModel.cached(widget.message.messageId), builder: (context, model, _) => GestureDetector( - onLongPress: () => _showContextMenu(model), + onLongPress: () => ContextMenuHelper.showContextMenu( + context: context, + model: model, + actionsBuilder: () => createMessageActions( + context: context, + model: model, + widget: widget, + ), + onOpen: () => setState(() => _isHighlighted = true), + onClose: () => setState(() => _isHighlighted = false), + ), child: _buildMessageContent(context, model, theme), ), onModelReady: (model) => model.init( @@ -241,93 +252,6 @@ class _MessageWidgetState extends State { ); } - void _showContextMenu(MessageReactionViewModel model) { - setState(() => _isHighlighted = true); - triggerHaptic(HapticIntensity.medium); - - final renderBox = context.findRenderObject()! as RenderBox; - final offset = renderBox.localToGlobal(Offset.zero); - - showMenu( - context: context, - position: RelativeRect.fromLTRB( - offset.dx, - offset.dy + renderBox.size.height, - offset.dx + renderBox.size.width, - offset.dy + renderBox.size.height + 1, - ), - items: _buildMenuItems(model), - ).then((value) { - setState(() => _isHighlighted = false); - _handleMenuSelection(value); - }); - } - - List> _buildMenuItems( - MessageReactionViewModel model, - ) => - [ - PopupMenuItem( - enabled: false, - child: Scrollbar( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: ReactionType.values - .map( - (reaction) => GestureDetector( - onTap: () => _handleReactionTap(reaction, model), - child: Padding( - padding: const EdgeInsets.all(4.0), - child: CircleAvatar( - radius: 16, - backgroundImage: AssetImage(reaction.assetName), - ), - ), - ), - ) - .toList(), - ), - ), - ), - ), - const PopupMenuItem( - value: 'copy', - child: Text('Скопировать текст'), - ), - const PopupMenuItem( - value: 'reply', - child: Text('Ответить'), - ), - ]; - - void _handleReactionTap( - ReactionType reaction, - MessageReactionViewModel model, - ) { - triggerHaptic(HapticIntensity.selection); - if (model.currentReaction != reaction) { - model.toggleReaction(reaction); - } - Navigator.pop(context); - } - - void _handleMenuSelection(String? value) async { - if (value == null) { - return; - } - - switch (value) { - case 'copy': - await Clipboard.setData(ClipboardData(text: widget.message.text)); - break; - case 'reply': - widget.chatModel.replyMessage = widget.message; - break; - } - } - Widget _buildMessageContent( BuildContext context, MessageReactionViewModel model, diff --git a/lib/ui/views/main_page/feed/widgets/comments_page.dart b/lib/ui/views/main_page/feed/widgets/comments_page.dart index ea088a48..8ae1660f 100644 --- a/lib/ui/views/main_page/feed/widgets/comments_page.dart +++ b/lib/ui/views/main_page/feed/widgets/comments_page.dart @@ -51,25 +51,23 @@ class CommentsPage extends StatelessWidget { ), ), ), - for (final comment in model.comments) - Column( - children: [ - const Padding( - padding: EdgeInsets.only( - left: 18, - bottom: 10, - right: 18, - ), - child: Divider( - thickness: 0.3, - color: Color(0xFF989EA9), - ), + Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, ), - FeedCommentView( - viewModel: comment, + child: Divider( + height: 1, + thickness: 0.3, + color: Color(0xFF989EA9), ), - ], - ), + ), + for (final comment in model.comments) + FeedCommentView(viewModel: comment), + ], + ), ], ), ), diff --git a/lib/ui/views/main_page/feed/widgets/feed_comment.dart b/lib/ui/views/main_page/feed/widgets/feed_comment.dart index 07af561d..22d2102e 100644 --- a/lib/ui/views/main_page/feed/widgets/feed_comment.dart +++ b/lib/ui/views/main_page/feed/widgets/feed_comment.dart @@ -13,6 +13,8 @@ import 'package:unn_mobile/ui/views/main_page/feed/widgets/attached_file.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/packed_post_images.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/post_html_widget.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/reaction_bubble.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_factory.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_helper.dart'; import 'package:unn_mobile/ui/widgets/shimmer.dart'; import 'package:unn_mobile/ui/widgets/shimmer_loading.dart'; @@ -26,38 +28,50 @@ class FeedCommentView extends StatelessWidget { @override Widget build(BuildContext context) => BaseView( model: viewModel, - builder: (context, model, child) => Shimmer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CommentHeader( - dateTime: model.comment.dateTime, - viewModel: model.profileViewModel, - hide: model.isBusy, - ), - Padding( - padding: const EdgeInsets.only( - left: 16, - bottom: 10, - right: 10, - top: 8, + builder: (context, model, child) => GestureDetector( + onLongPress: () => ContextMenuHelper.showContextMenu( + context: context, + model: model, + actionsBuilder: () => createCommentActions( + context: context, + model: model, + ), + ), + child: Shimmer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CommentHeader( + dateTime: model.comment.dateTime, + viewModel: model.profileViewModel, + hide: model.isBusy, + ), + Padding( + padding: const EdgeInsets.only( + left: 16, + bottom: 10, + right: 10, + top: 8, + ), + child: model.renderMessage + ? PostHtmlWidget(text: model.message) + : const SizedBox(), ), - child: model.renderMessage - ? PostHtmlWidget(text: model.message) - : const SizedBox(), - ), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0), - child: PackedPostImages(attachedImages: model.attachedImages), - ), - for (final file in model.attachedFileViewModels) Padding( - padding: const EdgeInsets.only(left: 16), - child: AttachedFile(viewModel: file), + padding: const EdgeInsets.symmetric( + horizontal: 20.0, + vertical: 0.0, + ), + child: PackedPostImages(attachedImages: model.attachedImages), ), - _ReactionView(model: model.reactionViewModel, context: context), - ], + for (final file in model.attachedFileViewModels) + Padding( + padding: const EdgeInsets.only(left: 16), + child: AttachedFile(viewModel: file), + ), + _ReactionView(model: model.reactionViewModel, context: context), + ], + ), ), ), ); diff --git a/lib/ui/views/main_page/feed/widgets/feed_post.dart b/lib/ui/views/main_page/feed/widgets/feed_post.dart index 9871a6a0..a3d997fe 100644 --- a/lib/ui/views/main_page/feed/widgets/feed_post.dart +++ b/lib/ui/views/main_page/feed/widgets/feed_post.dart @@ -26,6 +26,8 @@ import 'package:unn_mobile/ui/views/main_page/feed/widgets/attached_file.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/packed_post_images.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/post_html_widget.dart'; import 'package:unn_mobile/ui/views/main_page/main_page_routing.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_factory.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_helper.dart'; import 'package:unn_mobile/ui/widgets/height_limiter.dart'; import 'package:unn_mobile/ui/widgets/shimmer.dart'; import 'package:unn_mobile/ui/widgets/shimmer_loading.dart'; @@ -71,6 +73,17 @@ class _FeedPostState extends State { .putInCache(model.blogData.id, model); _openPostCommentsPage(context, model); }, + onLongPress: () => ContextMenuHelper.showContextMenu( + context: context, + model: model, + actionsBuilder: () => createPostActions( + context: context, + model: model, + onShare: _sharePost, + ), + onOpen: () => setState(() {}), + onClose: () => setState(() {}), + ), child: Shimmer( child: AnimatedContainer( duration: const Duration(milliseconds: 100), diff --git a/lib/ui/widgets/context_menu/context_menu_action.dart b/lib/ui/widgets/context_menu/context_menu_action.dart new file mode 100644 index 00000000..adb0cc69 --- /dev/null +++ b/lib/ui/widgets/context_menu/context_menu_action.dart @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 BitCodersNN + +import 'package:flutter/material.dart'; +import 'package:unn_mobile/core/misc/haptic_utils.dart'; +import 'package:unn_mobile/core/models/feed/rating_list.dart'; +import 'package:unn_mobile/core/viewmodels/main_page/common/reaction_view_model_base.dart'; + +class ContextMenuAction { + final PopupMenuEntry entry; + final VoidCallback? onTap; + + const ContextMenuAction({ + required this.entry, + this.onTap, + }); + + factory ContextMenuAction.text({ + required String label, + VoidCallback? onTap, + Widget? leadingIcon, + }) => + ContextMenuAction( + entry: PopupMenuItem( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leadingIcon != null) ...[ + leadingIcon, + const SizedBox(width: 12), + ], + Text(label), + ], + ), + ), + onTap: onTap, + ); + + factory ContextMenuAction.reaction({ + required BuildContext context, + required ReactionViewModelBase reactionViewModel, + }) => + ContextMenuAction( + entry: PopupMenuItem( + enabled: false, + child: SizedBox( + width: 280, + child: Scrollbar( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final reaction in ReactionType.values) + GestureDetector( + onTap: () { + triggerHaptic(HapticIntensity.selection); + if (reactionViewModel.currentReaction != reaction) { + reactionViewModel.toggleReaction(reaction); + } + Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: CircleAvatar( + radius: 16, + backgroundImage: AssetImage(reaction.assetName), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + factory ContextMenuAction.custom({ + required Widget child, + VoidCallback? onTap, + }) => + ContextMenuAction( + entry: PopupMenuItem( + enabled: false, + child: child, + ), + onTap: onTap, + ); +} diff --git a/lib/ui/widgets/context_menu/context_menu_factory.dart b/lib/ui/widgets/context_menu/context_menu_factory.dart new file mode 100644 index 00000000..77e3a2e4 --- /dev/null +++ b/lib/ui/widgets/context_menu/context_menu_factory.dart @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 BitCodersNN + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:unn_mobile/core/viewmodels/main_page/chat/message_reaction_view_model.dart'; +import 'package:unn_mobile/core/viewmodels/main_page/common/reaction_view_model_base.dart'; +import 'package:unn_mobile/core/viewmodels/main_page/feed/feed_comment_view_model.dart'; +import 'package:unn_mobile/core/viewmodels/main_page/feed/feed_post_view_model.dart'; +import 'package:unn_mobile/ui/views/main_page/chat/widgets/message.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_action.dart'; + +List createMessageActions({ + required BuildContext context, + required MessageReactionViewModel model, + required MessageWidget widget, +}) => + _createActions( + context: context, + reactionViewModel: model, + textToCopy: widget.message.text, + onReply: () => widget.chatModel.replyMessage = widget.message, + ); + +List createPostActions({ + required BuildContext context, + required FeedPostViewModel model, + required Function(FeedPostViewModel) onShare, +}) => + _createActions( + context: context, + reactionViewModel: model.reactionViewModel, + textToCopy: model.postText, + onTogglePin: model.togglePin, + isPinned: model.isPinned, + onShare: () => onShare(model), + ); + +List createCommentActions({ + required BuildContext context, + required FeedCommentViewModel model, +}) => + _createActions( + context: context, + reactionViewModel: model.reactionViewModel, + textToCopy: model.message, + ); + +List _createActions({ + required BuildContext context, + ReactionViewModelBase? reactionViewModel, + String? textToCopy, + VoidCallback? onReply, + VoidCallback? onTogglePin, + bool? isPinned, + VoidCallback? onShare, +}) { + final actions = []; + + if (reactionViewModel != null) { + actions.add( + ContextMenuAction.reaction( + context: context, + reactionViewModel: reactionViewModel, + ), + ); + } + + if (textToCopy != null) { + actions.add( + ContextMenuAction.text( + label: 'Скопировать текст', + onTap: () => Clipboard.setData(ClipboardData(text: textToCopy)), + leadingIcon: const Icon(Icons.content_copy, size: 18), + ), + ); + } + + if (onReply != null) { + actions.add( + ContextMenuAction.text( + label: 'Ответить', + onTap: onReply, + leadingIcon: const Icon(Icons.reply, size: 18), + ), + ); + } + + if (onTogglePin != null && isPinned != null) { + actions.add( + ContextMenuAction.text( + label: isPinned ? 'Открепить' : 'Закрепить', + onTap: onTogglePin, + leadingIcon: Icon( + isPinned ? Icons.push_pin : Icons.push_pin_outlined, + size: 18, + ), + ), + ); + } + + if (onShare != null) { + actions.add( + ContextMenuAction.text( + label: 'Поделиться', + onTap: onShare, + leadingIcon: const Icon(Icons.share, size: 18), + ), + ); + } + + return actions; +} diff --git a/lib/ui/widgets/context_menu/context_menu_helper.dart b/lib/ui/widgets/context_menu/context_menu_helper.dart new file mode 100644 index 00000000..577751e7 --- /dev/null +++ b/lib/ui/widgets/context_menu/context_menu_helper.dart @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 BitCodersNN + +// context_menu_action.dart + +import 'package:flutter/material.dart'; +import 'package:unn_mobile/core/misc/haptic_utils.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_action.dart'; + +class ContextMenuHelper { + static void showContextMenu({ + required BuildContext context, + required dynamic model, + required List Function() actionsBuilder, + VoidCallback? onOpen, + VoidCallback? onClose, + }) { + triggerHaptic(HapticIntensity.medium); + final renderBox = context.findRenderObject()! as RenderBox; + + final actions = actionsBuilder(); + + ContextMenuHelper.show( + context: context, + renderBox: renderBox, + actions: actions, + onOpen: onOpen, + onClose: onClose, + ); + } + + static Future show({ + required BuildContext context, + required RenderBox renderBox, + required List actions, + VoidCallback? onOpen, + VoidCallback? onClose, + }) async { + triggerHaptic(HapticIntensity.medium); + onOpen?.call(); + + final wrappedEntries = >[ + for (final action in actions) _wrapAction(context, action), + ]; + + final offset = renderBox.localToGlobal(Offset.zero); + final position = RelativeRect.fromLTRB( + offset.dx, + offset.dy + renderBox.size.height, + offset.dx + renderBox.size.width, + offset.dy + renderBox.size.height + 1, + ); + + await showMenu( + context: context, + position: position, + items: wrappedEntries, + ); + + onClose?.call(); + } + + static PopupMenuEntry _wrapAction( + BuildContext context, + ContextMenuAction action, + ) { + final entry = action.entry; + + if (entry is PopupMenuItem && entry.enabled) { + return PopupMenuItem( + child: entry.child, + onTap: () { + action.onTap?.call(); + }, + ); + } + return entry; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 210af5a3..ff17972c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: unn_mobile description: A mobile application for UNN Portal website publish_to: 'none' -version: 0.6.0+363 +version: 0.6.0+364 environment: sdk: '>=3.1.2 <4.0.0'