feat(chat): фронт троеточия
This commit is contained in:
@@ -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, 'Звонки доступны только в диалогах');
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user