288 lines
7.8 KiB
Dart
288 lines
7.8 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../../core/utils/haptics.dart';
|
|
import 'animated_overlay_popup.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,
|
|
Widget? header,
|
|
Widget? footer,
|
|
bool compact = false,
|
|
}) {
|
|
final overlay = Overlay.of(context, rootOverlay: true);
|
|
late OverlayEntry entry;
|
|
entry = OverlayEntry(
|
|
builder: (ctx) => _ChatMenuLayer(
|
|
anchorRect: anchorRect,
|
|
items: items,
|
|
header: header,
|
|
footer: footer,
|
|
compact: compact,
|
|
onDismiss: () {
|
|
if (entry.mounted) entry.remove();
|
|
},
|
|
),
|
|
);
|
|
overlay.insert(entry);
|
|
Haptics.medium();
|
|
}
|
|
|
|
class _ChatMenuLayer extends StatefulWidget {
|
|
final Rect anchorRect;
|
|
final List<ChatMenuItem> items;
|
|
final Widget? header;
|
|
final Widget? footer;
|
|
final bool compact;
|
|
final VoidCallback onDismiss;
|
|
|
|
const _ChatMenuLayer({
|
|
required this.anchorRect,
|
|
required this.items,
|
|
required this.onDismiss,
|
|
this.header,
|
|
this.footer,
|
|
this.compact = false,
|
|
});
|
|
|
|
@override
|
|
State<_ChatMenuLayer> createState() => _ChatMenuLayerState();
|
|
}
|
|
|
|
class _MenuLayout extends SingleChildLayoutDelegate {
|
|
static const double menuWidth = 290.0;
|
|
static const double compactMenuWidth = 226.0;
|
|
static const double margin = 8.0;
|
|
static const double gap = 6.0;
|
|
|
|
final Rect anchor;
|
|
final EdgeInsets safeArea;
|
|
final bool compact;
|
|
|
|
const _MenuLayout({
|
|
required this.anchor,
|
|
required this.safeArea,
|
|
this.compact = false,
|
|
});
|
|
|
|
@override
|
|
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
|
final width = math.min(
|
|
compact ? compactMenuWidth : menuWidth,
|
|
constraints.maxWidth - margin * 2,
|
|
);
|
|
final available =
|
|
constraints.maxHeight - safeArea.top - safeArea.bottom - margin * 2;
|
|
return BoxConstraints(
|
|
minWidth: math.max(0, width),
|
|
maxWidth: math.max(0, width),
|
|
maxHeight: math.max(120.0, available),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Offset getPositionForChild(Size size, Size childSize) {
|
|
final maxLeft = math.max(margin, size.width - childSize.width - margin);
|
|
final left = (anchor.right - childSize.width).clamp(margin, maxLeft);
|
|
|
|
final topLimit = safeArea.top + margin;
|
|
final bottomLimit = size.height - safeArea.bottom - margin;
|
|
final below = anchor.bottom + gap;
|
|
final above = anchor.top - gap - childSize.height;
|
|
|
|
double top;
|
|
if (below + childSize.height <= bottomLimit) {
|
|
top = below;
|
|
} else if (above >= topLimit) {
|
|
top = above;
|
|
} else {
|
|
top = bottomLimit - childSize.height;
|
|
}
|
|
return Offset(left, math.max(topLimit, top));
|
|
}
|
|
|
|
@override
|
|
bool shouldRelayout(_MenuLayout oldDelegate) =>
|
|
oldDelegate.anchor != anchor ||
|
|
oldDelegate.safeArea != safeArea ||
|
|
oldDelegate.compact != compact;
|
|
}
|
|
|
|
class _ChatMenuLayerState extends State<_ChatMenuLayer>
|
|
with SingleTickerProviderStateMixin, AnimatedOverlayPopup<_ChatMenuLayer> {
|
|
@override
|
|
Duration get overlayForwardDuration => const Duration(milliseconds: 220);
|
|
|
|
@override
|
|
Duration get overlayReverseDuration => const Duration(milliseconds: 160);
|
|
|
|
@override
|
|
VoidCallback get onOverlayDismiss => widget.onDismiss;
|
|
|
|
void _onItemTap(ChatMenuItem item) {
|
|
Haptics.tap();
|
|
closeOverlay().then((_) => item.onTap?.call());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final safeArea = MediaQuery.paddingOf(context);
|
|
return AnimatedBuilder(
|
|
animation: overlayAnimation,
|
|
builder: (ctx, child) {
|
|
final t = overlayAnimation.value.clamp(0.0, 1.0);
|
|
final scale = 0.9 + 0.1 * t;
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
onTap: closeOverlay,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: const SizedBox.expand(),
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: CustomSingleChildLayout(
|
|
delegate: _MenuLayout(
|
|
anchor: widget.anchorRect,
|
|
safeArea: safeArea,
|
|
compact: widget.compact,
|
|
),
|
|
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: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (widget.header != null) ...[
|
|
widget.header!,
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
color: cs.onSurface.withValues(alpha: 0.07),
|
|
),
|
|
],
|
|
SizedBox(height: widget.compact ? 4 : 6),
|
|
for (final item in widget.items) ...[
|
|
_ChatMenuRow(
|
|
item: item,
|
|
compact: widget.compact,
|
|
onTap: () => _onItemTap(item),
|
|
),
|
|
if (item.dividerAfter)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
color: cs.onSurface.withValues(alpha: 0.07),
|
|
),
|
|
],
|
|
SizedBox(height: widget.compact ? 4 : 6),
|
|
if (widget.footer != null) ...[
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
color: cs.onSurface.withValues(alpha: 0.07),
|
|
),
|
|
widget.footer!,
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChatMenuRow extends StatelessWidget {
|
|
final ChatMenuItem item;
|
|
final VoidCallback onTap;
|
|
final bool compact;
|
|
|
|
const _ChatMenuRow({
|
|
required this.item,
|
|
required this.onTap,
|
|
this.compact = false,
|
|
});
|
|
|
|
@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: EdgeInsets.symmetric(
|
|
horizontal: compact ? 14 : 18,
|
|
vertical: compact ? 10 : 15,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(item.icon, size: compact ? 20 : 24, weight: 350, color: fg),
|
|
SizedBox(width: compact ? 12 : 18),
|
|
Expanded(
|
|
child: Text(
|
|
item.label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: fg,
|
|
fontSize: compact ? 14 : 16,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
if (item.showChevron)
|
|
Icon(
|
|
Symbols.chevron_right,
|
|
size: 22,
|
|
weight: 400,
|
|
color: cs.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|