diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 98a0067..44f0d2c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 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 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 ); } + 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 _startCall() async { if (widget.chatType != 'DIALOG') { showCustomNotification(context, 'Звонки доступны только в диалогах'); diff --git a/lib/frontend/widgets/chat_menu_overlay.dart b/lib/frontend/widgets/chat_menu_overlay.dart new file mode 100644 index 0000000..030dfb9 --- /dev/null +++ b/lib/frontend/widgets/chat_menu_overlay.dart @@ -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 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 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 _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 _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), + ), + ], + ), + ), + ); + } +}