второй стиль меню действий: список (как в telegram); выбор в кастомизации

This commit is contained in:
klockky
2026-05-23 08:22:05 +00:00
parent 917e4aec72
commit ac5bd1c24d
7 changed files with 432 additions and 12 deletions
+47
View File
@@ -0,0 +1,47 @@
<!--
Стиль оформления PR (заполняйте по этому шаблону).
Заголовок PR:
feat: краткое перечисление фич + scope → целевая ветка
Примеры:
feat: чаты, группы, файлы, real-time + security-фиксы #17 → dev/0.5.0
fix(ios): исключить Firebase из сборки iOS → dev/0.5.0
Префиксы: feat / fix / refactor / chore / docs (можно со scope, напр. fix(ios)).
Удалите неиспользуемые разделы. Описание — на русском языке.
-->
## Описание
<!-- 1–3 предложения: что переносим и зачем. Укажите исходную и целевую ветку. -->
**Объём:** N файлов, +X / Y.
## Что вошло
<!-- Группируйте по областям с эмодзи. Оставляйте только релевантные блоки. -->
### 💬 Чаты и сообщения
-
### 👤 Профиль и контакты
-
### 🔒 Безопасность
<!-- При наличии: ссылка на issue и номера находок. -->
-
### 🛠 Прочее
-
## Коммиты
<!-- Список `hash` + краткое описание. -->
- `xxxxxxx`
## Проверка
- [ ] `flutter analyze`
- [ ] Сборка `komet` flavor
- [ ] Ручная проверка затронутых сценариев
- [ ] Регресс по безопасности (если затронута)
@@ -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<MessageActionsStyle> current = ValueNotifier(
MessageActionsStyle.radial,
);
static Future<MessageActionsStyle> load() async {
final prefs = await SharedPreferences.getInstance();
return _parse(prefs.getString(prefKey));
}
static Future<void> 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 'Список';
}
}
}
@@ -19,6 +19,7 @@ import '../../../core/protocol/packet.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_cache_extent.dart';
import '../../../core/config/app_message_actions_style.dart';
import '../../../models/attachment.dart'; import '../../../models/attachment.dart';
import '../../widgets/message_bubble.dart'; import '../../widgets/message_bubble.dart';
import '../../widgets/message_actions_overlay.dart'; import '../../widgets/message_actions_overlay.dart';
@@ -1745,6 +1746,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
isMe: widget.isMe, isMe: widget.isMe,
messageText: widget.message.text, messageText: widget.message.text,
controller: controller, controller: controller,
style: AppMessageActionsStyle.current.value,
onDispose: () { onDispose: () {
if (identical(_controller, controller)) { if (identical(_controller, controller)) {
_controller = null; _controller = null;
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import 'appearance_screen.dart'; import 'appearance_screen.dart';
import 'font_settings_screen.dart'; import 'font_settings_screen.dart';
import 'message_actions_screen.dart';
import 'theme_settings_screen.dart'; import 'theme_settings_screen.dart';
class _CustomizationCategory { class _CustomizationCategory {
@@ -43,6 +44,12 @@ class CustomizationScreen extends StatelessWidget {
subtitle: 'Шрифт приложения, свои шрифты, размер текста', subtitle: 'Шрифт приложения, свои шрифты, размер текста',
builder: _buildFontSettings, builder: _buildFontSettings,
), ),
_CustomizationCategory(
icon: Symbols.touch_app,
title: 'Меню действий',
subtitle: 'Радиальное или список — для долгого нажатия на сообщение',
builder: _buildMessageActions,
),
]; ];
static Widget _buildAppearance(BuildContext context) => static Widget _buildAppearance(BuildContext context) =>
@@ -54,6 +61,9 @@ class CustomizationScreen extends StatelessWidget {
static Widget _buildThemeSettings(BuildContext context) => static Widget _buildThemeSettings(BuildContext context) =>
const ThemeSettingsScreen(); const ThemeSettingsScreen();
static Widget _buildMessageActions(BuildContext context) =>
const MessageActionsScreen();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
@@ -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<MessageActionsStyle>(
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,
),
],
),
),
),
);
}
}
+162 -12
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../core/config/app_message_actions_style.dart';
import '../../core/utils/haptics.dart'; import '../../core/utils/haptics.dart';
import 'custom_notification.dart'; import 'custom_notification.dart';
@@ -68,6 +69,7 @@ void showMessageActions({
required bool isMe, required bool isMe,
required String? messageText, required String? messageText,
required MessageActionsController controller, required MessageActionsController controller,
required MessageActionsStyle style,
required VoidCallback onDispose, required VoidCallback onDispose,
}) { }) {
final overlay = Overlay.of(context, rootOverlay: true); final overlay = Overlay.of(context, rootOverlay: true);
@@ -80,6 +82,7 @@ void showMessageActions({
isMe: isMe, isMe: isMe,
messageText: messageText, messageText: messageText,
controller: controller, controller: controller,
style: style,
onDismiss: () { onDismiss: () {
if (entry.mounted) entry.remove(); if (entry.mounted) entry.remove();
onDispose(); onDispose();
@@ -96,6 +99,7 @@ class _MessageActionsLayer extends StatefulWidget {
final bool isMe; final bool isMe;
final String? messageText; final String? messageText;
final MessageActionsController controller; final MessageActionsController controller;
final MessageActionsStyle style;
final VoidCallback onDismiss; final VoidCallback onDismiss;
const _MessageActionsLayer({ const _MessageActionsLayer({
@@ -105,6 +109,7 @@ class _MessageActionsLayer extends StatefulWidget {
required this.isMe, required this.isMe,
required this.messageText, required this.messageText,
required this.controller, required this.controller,
required this.style,
required this.onDismiss, required this.onDismiss,
}); });
@@ -127,6 +132,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
bool _showBelow = true; bool _showBelow = true;
Offset _anchor = Offset.zero; Offset _anchor = Offset.zero;
List<Offset> _buttonCenters = const []; List<Offset> _buttonCenters = const [];
List<Rect> _buttonHitRects = const [];
Rect _menuRect = Rect.zero;
bool _initialized = false; bool _initialized = false;
int _hoveredIndex = -1; int _hoveredIndex = -1;
@@ -155,15 +162,55 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
_initialized = true; _initialized = true;
_actions = _buildActions(); _actions = _buildActions();
final screenSize = MediaQuery.sizeOf(context); 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; _showBelow = widget.tapPoint.dy < screenSize.height * 0.55;
_anchor = _showBelow _anchor = _showBelow
? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16) ? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16)
: Offset(widget.tapPoint.dx, widget.originRect.top - 16); : Offset(widget.tapPoint.dx, widget.originRect.top - 16);
_buttonCenters = _computeButtonCenters(screenSize); _buttonCenters = _computeButtonCenters(screenSize);
widget.controller.addListener(_onControllerUpdate); _buttonHitRects = [
WidgetsBinding.instance.addPostFrameCallback((_) { for (final c in _buttonCenters)
if (mounted) _onControllerUpdate(); 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 @override
@@ -180,7 +227,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy), if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy),
_Action(Symbols.reply, 'Ответить', () => _stub('Ответ')), _Action(Symbols.reply, 'Ответить', () => _stub('Ответ')),
_Action(Symbols.forward, 'Переслать', () => _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) { int _findButtonAt(Offset p) {
for (int i = 0; i < _buttonCenters.length; i++) { for (int i = 0; i < _buttonHitRects.length; i++) {
if ((_buttonCenters[i] - p).distance <= _hitRadius) { if (_buttonHitRects[i].contains(p)) return i;
return i;
}
} }
return -1; return -1;
} }
@@ -307,8 +357,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
), ),
), ),
), ),
..._buildButtons(t), if (widget.style == MessageActionsStyle.radial) ...[
_buildLabelBanner(size, t), ..._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<Widget> _buildButtons(double t) { List<Widget> _buildButtons(double t) {
final n = _actions.length; final n = _actions.length;
return [ return [
@@ -409,7 +503,63 @@ class _Action {
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback onTap; 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 { class _ActionButton extends StatelessWidget {
+2
View File
@@ -16,6 +16,7 @@ import 'core/config/app_bubble_behavior.dart';
import 'core/config/app_bubble_shape.dart'; import 'core/config/app_bubble_shape.dart';
import 'core/config/app_cache_extent.dart'; import 'core/config/app_cache_extent.dart';
import 'core/config/app_fonts.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_mode.dart';
import 'core/config/app_theme_schedule.dart'; import 'core/config/app_theme_schedule.dart';
import 'backend/modules/account.dart'; import 'backend/modules/account.dart';
@@ -90,6 +91,7 @@ void main() async {
AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); AppThemeModeConfig.current.value = await AppThemeModeConfig.load();
AppAmoled.current.value = await AppAmoled.load(); AppAmoled.current.value = await AppAmoled.load();
AppThemeSchedule.current.value = await AppThemeSchedule.load(); AppThemeSchedule.current.value = await AppThemeSchedule.load();
AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load();
runApp( runApp(
KometApp( KometApp(
initialLocale: initialLocale, initialLocale: initialLocale,