второй стиль меню действий: список (как в telegram); выбор в кастомизации
This commit is contained in:
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user