feat(chat): фронт троеточия

This commit is contained in:
Jganenokk
2026-06-22 21:10:31 +07:00
parent 0f0d15f884
commit 3644c51a48
2 changed files with 271 additions and 9 deletions
+54 -9
View File
@@ -18,6 +18,7 @@ import 'package:komet/core/utils/logger.dart';
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:komet/frontend/screens/chats/poll_create_screen.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/chat_menu_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart';
import '../../../backend/api.dart';
@@ -1729,13 +1730,15 @@ class _ChatScreenState extends State<ChatScreen>
icon: Icon(Symbols.call, weight: 500, color: cs.onSurface),
onPressed: _startCall,
),
IconButton(
icon: Icon(
Symbols.more_vert,
weight: 500,
color: cs.onSurface,
Builder(
builder: (btnContext) => IconButton(
icon: Icon(
Symbols.more_vert,
weight: 500,
color: cs.onSurface,
),
onPressed: () => _openChatMenu(btnContext),
),
onPressed: () {},
),
],
),
@@ -1874,9 +1877,11 @@ class _ChatScreenState extends State<ChatScreen>
icon: Icon(Symbols.call, weight: 400, color: cs.onSurface),
onPressed: _startCall,
),
IconButton(
icon: Icon(Symbols.more_vert, weight: 400, color: cs.onSurface),
onPressed: () {},
Builder(
builder: (btnContext) => IconButton(
icon: Icon(Symbols.more_vert, weight: 400, color: cs.onSurface),
onPressed: () => _openChatMenu(btnContext),
),
),
],
);
@@ -1999,6 +2004,46 @@ class _ChatScreenState extends State<ChatScreen>
);
}
void _openChatMenu(BuildContext btnContext) {
final box = btnContext.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
final anchorRect = box.localToGlobal(Offset.zero) & box.size;
showChatMenu(
context: context,
anchorRect: anchorRect,
items: [
ChatMenuItem(
icon: Symbols.volume_up,
label: 'Уведомления',
showChevron: true,
dividerAfter: true,
onTap: () {},
),
ChatMenuItem(
icon: Symbols.videocam,
label: 'Видеозвонок',
onTap: () {},
),
ChatMenuItem(icon: Symbols.search, label: 'Поиск', onTap: () {}),
ChatMenuItem(
icon: Symbols.wallpaper,
label: 'Изменить обои',
onTap: () {},
),
ChatMenuItem(
icon: Symbols.mop,
label: 'Очистить историю',
onTap: () {},
),
ChatMenuItem(
icon: Symbols.delete,
label: 'Удалить чат',
onTap: () {},
),
],
);
}
Future<void> _startCall() async {
if (widget.chatType != 'DIALOG') {
showCustomNotification(context, 'Звонки доступны только в диалогах');
+217
View File
@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../core/utils/haptics.dart';
class ChatMenuItem {
final IconData icon;
final String label;
final VoidCallback? onTap;
final bool showChevron;
final bool dividerAfter;
final bool destructive;
const ChatMenuItem({
required this.icon,
required this.label,
this.onTap,
this.showChevron = false,
this.dividerAfter = false,
this.destructive = false,
});
}
void showChatMenu({
required BuildContext context,
required Rect anchorRect,
required List<ChatMenuItem> items,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => _ChatMenuLayer(
anchorRect: anchorRect,
items: items,
onDismiss: () {
if (entry.mounted) entry.remove();
},
),
);
overlay.insert(entry);
Haptics.medium();
}
class _ChatMenuLayer extends StatefulWidget {
final Rect anchorRect;
final List<ChatMenuItem> items;
final VoidCallback onDismiss;
const _ChatMenuLayer({
required this.anchorRect,
required this.items,
required this.onDismiss,
});
@override
State<_ChatMenuLayer> createState() => _ChatMenuLayerState();
}
class _ChatMenuLayerState extends State<_ChatMenuLayer>
with SingleTickerProviderStateMixin {
static const double _menuWidth = 290.0;
static const double _hMargin = 8.0;
static const double _gap = 6.0;
late final AnimationController _animController;
late final Animation<double> _animation;
bool _closing = false;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
reverseDuration: const Duration(milliseconds: 160),
);
_animation = CurvedAnimation(
parent: _animController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_animController.forward();
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
Future<void> _close() async {
if (!mounted || _closing) return;
_closing = true;
try {
await _animController.reverse();
} catch (_) {}
if (!mounted) return;
widget.onDismiss();
}
void _onItemTap(ChatMenuItem item) {
Haptics.tap();
_close().then((_) => item.onTap?.call());
}
Rect _resolveRect(Size screen) {
double left = widget.anchorRect.right - _menuWidth;
left = left.clamp(_hMargin, screen.width - _menuWidth - _hMargin);
double top = widget.anchorRect.bottom + _gap;
return Rect.fromLTWH(left, top, _menuWidth, 0);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final screen = MediaQuery.sizeOf(context);
final rect = _resolveRect(screen);
return AnimatedBuilder(
animation: _animation,
builder: (ctx, child) {
final t = _animation.value.clamp(0.0, 1.0);
final scale = 0.9 + 0.1 * t;
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _close,
behavior: HitTestBehavior.opaque,
child: const SizedBox.expand(),
),
),
Positioned(
left: rect.left,
top: rect.top,
width: rect.width,
child: Opacity(
opacity: t,
child: Transform.scale(
scale: scale,
alignment: Alignment.topRight,
child: child,
),
),
),
],
);
},
child: Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
elevation: 12,
shadowColor: Colors.black.withValues(alpha: 0.45),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 6),
for (final item in widget.items) ...[
_ChatMenuRow(item: item, onTap: () => _onItemTap(item)),
if (item.dividerAfter)
Divider(
height: 1,
thickness: 1,
color: cs.onSurface.withValues(alpha: 0.07),
),
],
const SizedBox(height: 6),
],
),
),
);
}
}
class _ChatMenuRow extends StatelessWidget {
final ChatMenuItem item;
final VoidCallback onTap;
const _ChatMenuRow({required this.item, required this.onTap});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final fg = item.destructive ? cs.error : cs.onSurface;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15),
child: Row(
children: [
Icon(item.icon, size: 24, weight: 350, color: fg),
const SizedBox(width: 18),
Expanded(
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: fg,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
if (item.showChevron)
Icon(
Symbols.chevron_right,
size: 22,
weight: 400,
color: cs.onSurface.withValues(alpha: 0.7),
),
],
),
),
);
}
}