From d963af407d11d35fa1d95438a3b7b12a861652ce Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 08:22:05 +0000 Subject: [PATCH] =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BB=D1=8C=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9:=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=BA=20(=D0=BA=D0=B0=D0=BA=20=D0=B2=20teleg?= =?UTF-8?q?ram);=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B2=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 47 +++++ .../config/app_message_actions_style.dart | 36 ++++ lib/frontend/screens/chats/chat_screen.dart | 2 + .../screens/profile/customization_screen.dart | 10 + .../profile/message_actions_screen.dart | 173 +++++++++++++++++ .../widgets/message_actions_overlay.dart | 174 ++++++++++++++++-- lib/main.dart | 2 + 7 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 lib/core/config/app_message_actions_style.dart create mode 100644 lib/frontend/screens/profile/message_actions_screen.dart diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3dd856d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,47 @@ + + +## Описание + + + +**Объём:** N файлов, +X / −Y. + +## Что вошло + + + +### 💬 Чаты и сообщения +- + +### 👤 Профиль и контакты +- + +### 🔒 Безопасность + +- + +### 🛠 Прочее +- + +## Коммиты + + +- `xxxxxxx` + +## Проверка + +- [ ] `flutter analyze` +- [ ] Сборка `komet` flavor +- [ ] Ручная проверка затронутых сценариев +- [ ] Регресс по безопасности (если затронута) diff --git a/lib/core/config/app_message_actions_style.dart b/lib/core/config/app_message_actions_style.dart new file mode 100644 index 0000000..2b8c5ef --- /dev/null +++ b/lib/core/config/app_message_actions_style.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum MessageActionsStyle { radial, list } + +class AppMessageActionsStyle { + static const prefKey = 'app_message_actions_style'; + static final ValueNotifier current = ValueNotifier( + MessageActionsStyle.radial, + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return _parse(prefs.getString(prefKey)); + } + + static Future save(MessageActionsStyle style) async { + current.value = style; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, style.name); + } + + static MessageActionsStyle _parse(String? val) { + if (val == MessageActionsStyle.list.name) return MessageActionsStyle.list; + return MessageActionsStyle.radial; + } + + static String label(MessageActionsStyle style) { + switch (style) { + case MessageActionsStyle.radial: + return 'Радиальное'; + case MessageActionsStyle.list: + return 'Список'; + } + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 3fb8bdb..96884ff 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -19,6 +19,7 @@ import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; +import '../../../core/config/app_message_actions_style.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/message_actions_overlay.dart'; @@ -1745,6 +1746,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { isMe: widget.isMe, messageText: widget.message.text, controller: controller, + style: AppMessageActionsStyle.current.value, onDispose: () { if (identical(_controller, controller)) { _controller = null; diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index ff447a7..aca2378 100644 --- a/lib/frontend/screens/profile/customization_screen.dart +++ b/lib/frontend/screens/profile/customization_screen.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../core/utils/haptics.dart'; import 'appearance_screen.dart'; import 'font_settings_screen.dart'; +import 'message_actions_screen.dart'; import 'theme_settings_screen.dart'; class _CustomizationCategory { @@ -43,6 +44,12 @@ class CustomizationScreen extends StatelessWidget { subtitle: 'Шрифт приложения, свои шрифты, размер текста', builder: _buildFontSettings, ), + _CustomizationCategory( + icon: Symbols.touch_app, + title: 'Меню действий', + subtitle: 'Радиальное или список — для долгого нажатия на сообщение', + builder: _buildMessageActions, + ), ]; static Widget _buildAppearance(BuildContext context) => @@ -54,6 +61,9 @@ class CustomizationScreen extends StatelessWidget { static Widget _buildThemeSettings(BuildContext context) => const ThemeSettingsScreen(); + static Widget _buildMessageActions(BuildContext context) => + const MessageActionsScreen(); + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; diff --git a/lib/frontend/screens/profile/message_actions_screen.dart b/lib/frontend/screens/profile/message_actions_screen.dart new file mode 100644 index 0000000..c0abf82 --- /dev/null +++ b/lib/frontend/screens/profile/message_actions_screen.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_message_actions_style.dart'; +import '../../../core/utils/haptics.dart'; + +class MessageActionsScreen extends StatelessWidget { + const MessageActionsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Меню действий', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: const [ + _StyleCard(), + ], + ), + ), + ); + } +} + +class _StyleCard extends StatelessWidget { + const _StyleCard(); + + static const _items = [ + ( + style: MessageActionsStyle.radial, + icon: Symbols.bubble_chart, + label: 'Радиальное', + description: 'Дуга кнопок вокруг точки нажатия', + ), + ( + style: MessageActionsStyle.list, + icon: Symbols.menu, + label: 'Список', + description: 'Вертикальное меню рядом с сообщением', + ), + ]; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Стиль', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Как показывается меню при долгом нажатии на сообщение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: AppMessageActionsStyle.current, + builder: (context, current, _) { + return Column( + children: [ + for (final item in _items) + _StyleTile( + icon: item.icon, + label: item.label, + description: item.description, + selected: current == item.style, + onTap: () { + if (current == item.style) return; + Haptics.selection(); + AppMessageActionsStyle.save(item.style); + }, + ), + ], + ); + }, + ), + ], + ), + ), + ); + } +} + +class _StyleTile extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final bool selected; + final VoidCallback onTap; + + const _StyleTile({ + required this.icon, + required this.label, + required this.description, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22, weight: 500), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12.5, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + selected + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: selected ? cs.primary : cs.outline, + size: 22, + fill: selected ? 1 : 0, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 3335240..3d87892 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../core/config/app_message_actions_style.dart'; import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; @@ -68,6 +69,7 @@ void showMessageActions({ required bool isMe, required String? messageText, required MessageActionsController controller, + required MessageActionsStyle style, required VoidCallback onDispose, }) { final overlay = Overlay.of(context, rootOverlay: true); @@ -80,6 +82,7 @@ void showMessageActions({ isMe: isMe, messageText: messageText, controller: controller, + style: style, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -96,6 +99,7 @@ class _MessageActionsLayer extends StatefulWidget { final bool isMe; final String? messageText; final MessageActionsController controller; + final MessageActionsStyle style; final VoidCallback onDismiss; const _MessageActionsLayer({ @@ -105,6 +109,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.isMe, required this.messageText, required this.controller, + required this.style, required this.onDismiss, }); @@ -127,6 +132,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> bool _showBelow = true; Offset _anchor = Offset.zero; List _buttonCenters = const []; + List _buttonHitRects = const []; + Rect _menuRect = Rect.zero; bool _initialized = false; int _hoveredIndex = -1; @@ -155,15 +162,55 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _initialized = true; _actions = _buildActions(); final screenSize = MediaQuery.sizeOf(context); + if (widget.style == MessageActionsStyle.radial) { + _computeRadialGeometry(screenSize); + } else { + _computeListGeometry(screenSize); + } + widget.controller.addListener(_onControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onControllerUpdate(); + }); + } + + void _computeRadialGeometry(Size screenSize) { _showBelow = widget.tapPoint.dy < screenSize.height * 0.55; _anchor = _showBelow ? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16) : Offset(widget.tapPoint.dx, widget.originRect.top - 16); _buttonCenters = _computeButtonCenters(screenSize); - widget.controller.addListener(_onControllerUpdate); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _onControllerUpdate(); - }); + _buttonHitRects = [ + for (final c in _buttonCenters) + Rect.fromCenter(center: c, width: _hitRadius * 2, height: _hitRadius * 2), + ]; + } + + void _computeListGeometry(Size screenSize) { + final n = _actions.length; + const menuWidth = 244.0; + const itemHeight = 48.0; + const vPad = 8.0; + final menuHeight = n * itemHeight + vPad * 2; + final spaceBelow = screenSize.height - widget.originRect.bottom - 24; + final spaceAbove = widget.originRect.top - 24; + _showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; + final menuY = _showBelow + ? widget.originRect.bottom + 10 + : widget.originRect.top - 10 - menuHeight; + final rawX = widget.isMe + ? widget.originRect.right - menuWidth + : widget.originRect.left; + final menuX = rawX.clamp(8.0, screenSize.width - menuWidth - 8.0).toDouble(); + _menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, menuHeight); + _buttonHitRects = [ + for (int i = 0; i < n; i++) + Rect.fromLTWH( + menuX, + menuY + vPad + i * itemHeight, + menuWidth, + itemHeight, + ), + ]; } @override @@ -180,7 +227,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy), _Action(Symbols.reply, 'Ответить', () => _stub('Ответ')), _Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')), - _Action(Symbols.delete, 'Удалить', () => _stub('Удаление')), + _Action( + Symbols.delete, + 'Удалить', + () => _stub('Удаление'), + destructive: true, + ), ]; } @@ -223,10 +275,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } int _findButtonAt(Offset p) { - for (int i = 0; i < _buttonCenters.length; i++) { - if ((_buttonCenters[i] - p).distance <= _hitRadius) { - return i; - } + for (int i = 0; i < _buttonHitRects.length; i++) { + if (_buttonHitRects[i].contains(p)) return i; } return -1; } @@ -307,8 +357,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), ), ), - ..._buildButtons(t), - _buildLabelBanner(size, t), + if (widget.style == MessageActionsStyle.radial) ...[ + ..._buildButtons(t), + _buildLabelBanner(size, t), + ] else + _buildListMenu(t), ], ), ); @@ -316,6 +369,47 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } + Widget _buildListMenu(double t) { + final cs = Theme.of(context).colorScheme; + final eased = Curves.easeOutCubic.transform(t); + final scale = 0.88 + 0.12 * eased; + return Positioned( + left: _menuRect.left, + top: _menuRect.top, + width: _menuRect.width, + height: _menuRect.height, + child: Opacity( + opacity: eased, + child: Transform.scale( + scale: scale, + alignment: Alignment( + widget.isMe ? 1.0 : -1.0, + _showBelow ? -1.0 : 1.0, + ), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + clipBehavior: Clip.antiAlias, + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + for (int i = 0; i < _actions.length; i++) + _ListMenuItem( + action: _actions[i], + highlighted: _hoveredIndex == i, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ); + } + List _buildButtons(double t) { final n = _actions.length; return [ @@ -409,7 +503,63 @@ class _Action { final IconData icon; final String label; final VoidCallback onTap; - const _Action(this.icon, this.label, this.onTap); + final bool destructive; + const _Action( + this.icon, + this.label, + this.onTap, { + this.destructive = false, + }); +} + +class _ListMenuItem extends StatelessWidget { + final _Action action; + final bool highlighted; + const _ListMenuItem({required this.action, required this.highlighted}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final fg = action.destructive ? cs.error : cs.onSurface; + final hoverBg = action.destructive + ? cs.error.withValues(alpha: 0.14) + : cs.primary.withValues(alpha: 0.16); + return AnimatedContainer( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + height: 48, + color: highlighted ? hoverBg : Colors.transparent, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Haptics.tap(); + action.onTap(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(action.icon, color: fg, size: 22), + const SizedBox(width: 14), + Expanded( + child: Text( + action.label, + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: + highlighted ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } } class _ActionButton extends StatelessWidget { diff --git a/lib/main.dart b/lib/main.dart index b871ce2..cbf9d76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'core/config/app_bubble_behavior.dart'; import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; +import 'core/config/app_message_actions_style.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -90,6 +91,7 @@ void main() async { AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); AppAmoled.current.value = await AppAmoled.load(); AppThemeSchedule.current.value = await AppThemeSchedule.load(); + AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); runApp( KometApp( initialLocale: initialLocale,