Files
Qlyra/lib/frontend/widgets/chat_menu_overlay.dart
T

218 lines
5.6 KiB
Dart

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),
),
],
),
),
);
}
}