add popover
This commit is contained in:
@@ -254,6 +254,74 @@ class _MainAppState extends State<MainApp> {
|
||||
return _groupColors[groupIndex % _groupColors.length];
|
||||
}
|
||||
|
||||
static const _months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
String _formatDate(DateTime d) {
|
||||
return '${_months[d.month - 1]} ${d.day}, ${d.year}';
|
||||
}
|
||||
|
||||
String _formatDateRange(DateTime start, DateTime? end) {
|
||||
final s = _formatDate(start);
|
||||
if (end == null) return s;
|
||||
final e = _formatDate(end);
|
||||
return s == e ? s : '$s – $e';
|
||||
}
|
||||
|
||||
Widget? _buildPopoverContent(String entryId) {
|
||||
final item = _state?.items[entryId];
|
||||
if (item == null) return null;
|
||||
|
||||
final start = DateTime.parse(item.start);
|
||||
final end = item.end != null ? DateTime.parse(item.end!) : null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.description != null && item.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.description!,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDateRange(start, end),
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactDate(DateTime start, DateTime end) {
|
||||
return Text(
|
||||
_formatDateRange(start, end),
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewport = _viewport;
|
||||
@@ -269,6 +337,9 @@ class _MainAppState extends State<MainApp> {
|
||||
? const Center(child: Text('Waiting for state...'))
|
||||
: ZTimelineScope(
|
||||
viewport: viewport,
|
||||
child: EntryPopoverOverlay(
|
||||
popoverContentBuilder: _buildPopoverContent,
|
||||
compactDateBuilder: _buildCompactDate,
|
||||
child: Column(
|
||||
children: [
|
||||
const ZTimelineBreadcrumb(),
|
||||
@@ -291,6 +362,7 @@ class _MainAppState extends State<MainApp> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/entry_drag_state.dart';
|
||||
@@ -16,6 +18,13 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
EntryDragState? _dragState;
|
||||
EntryResizeState? _resizeState;
|
||||
|
||||
// -- Hover state --
|
||||
String? _hoveredEntryId;
|
||||
Rect? _hoveredPillGlobalRect;
|
||||
|
||||
// -- Interaction cursor position (during drag/resize) --
|
||||
Offset? _interactionGlobalPosition;
|
||||
|
||||
/// The current interaction state.
|
||||
ZTimelineInteractionState get state => _state;
|
||||
|
||||
@@ -37,6 +46,37 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// Whether any entry interaction (drag or resize) is active.
|
||||
bool get isInteracting => isDraggingEntry || isResizingEntry;
|
||||
|
||||
/// The currently hovered entry ID, or null.
|
||||
String? get hoveredEntryId => _hoveredEntryId;
|
||||
|
||||
/// The global rect of the hovered pill, or null.
|
||||
Rect? get hoveredPillGlobalRect => _hoveredPillGlobalRect;
|
||||
|
||||
/// The global cursor position during drag/resize, or null.
|
||||
Offset? get interactionGlobalPosition => _interactionGlobalPosition;
|
||||
|
||||
/// Set the hovered entry and its global rect.
|
||||
void setHoveredEntry(String id, Rect globalRect) {
|
||||
if (_hoveredEntryId == id && _hoveredPillGlobalRect == globalRect) return;
|
||||
_hoveredEntryId = id;
|
||||
_hoveredPillGlobalRect = globalRect;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear the hovered entry.
|
||||
void clearHoveredEntry() {
|
||||
if (_hoveredEntryId == null) return;
|
||||
_hoveredEntryId = null;
|
||||
_hoveredPillGlobalRect = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update the cursor position during drag/resize.
|
||||
void updateInteractionPosition(Offset globalPosition) {
|
||||
_interactionGlobalPosition = globalPosition;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update the grabbing state.
|
||||
void setGrabbing(bool value) {
|
||||
if (_state.isGrabbing == value) return;
|
||||
@@ -54,7 +94,10 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// Begin dragging an entry.
|
||||
///
|
||||
/// Sets drag state and marks [isDraggingEntry] as true.
|
||||
/// Clears hover state since the user is now dragging.
|
||||
void beginDrag(TimelineEntry entry) {
|
||||
_hoveredEntryId = null;
|
||||
_hoveredPillGlobalRect = null;
|
||||
_dragState = EntryDragState(
|
||||
entryId: entry.id,
|
||||
originalEntry: entry,
|
||||
@@ -87,6 +130,7 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// End the drag and clear state.
|
||||
void endDrag() {
|
||||
_dragState = null;
|
||||
_interactionGlobalPosition = null;
|
||||
setDraggingEntry(false);
|
||||
}
|
||||
|
||||
@@ -94,7 +138,11 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
void cancelDrag() => endDrag();
|
||||
|
||||
/// Begin resizing an entry edge.
|
||||
///
|
||||
/// Clears hover state since the user is now resizing.
|
||||
void beginResize(TimelineEntry entry, ResizeEdge edge) {
|
||||
_hoveredEntryId = null;
|
||||
_hoveredPillGlobalRect = null;
|
||||
_resizeState = EntryResizeState(
|
||||
entryId: entry.id,
|
||||
originalEntry: entry,
|
||||
@@ -127,6 +175,7 @@ class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
/// End the resize and clear state.
|
||||
void endResize() {
|
||||
_resizeState = null;
|
||||
_interactionGlobalPosition = null;
|
||||
_state = _state.copyWith(isResizingEntry: false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../state/timeline_interaction_notifier.dart';
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
/// Builder for full popover content shown on hover.
|
||||
///
|
||||
/// Return `null` to suppress the popover for a given entry.
|
||||
typedef PopoverContentBuilder = Widget? Function(String entryId);
|
||||
|
||||
/// Builder for compact date labels shown during drag/resize.
|
||||
typedef CompactDateBuilder = Widget Function(DateTime start, DateTime end);
|
||||
|
||||
/// Gap between the pill and the popover.
|
||||
const double _kPopoverGap = 6.0;
|
||||
|
||||
/// Maximum popover width.
|
||||
const double _kMaxPopoverWidth = 280.0;
|
||||
|
||||
/// Animation duration.
|
||||
const Duration _kAnimationDuration = Duration(milliseconds: 120);
|
||||
|
||||
/// Wraps a child and manages a single [OverlayEntry] that shows either:
|
||||
/// - A hover popover (full card) when the user hovers over a pill.
|
||||
/// - A compact date popover near the cursor during drag/resize.
|
||||
class EntryPopoverOverlay extends StatefulWidget {
|
||||
const EntryPopoverOverlay({
|
||||
required this.child,
|
||||
this.popoverContentBuilder,
|
||||
this.compactDateBuilder,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final PopoverContentBuilder? popoverContentBuilder;
|
||||
final CompactDateBuilder? compactDateBuilder;
|
||||
|
||||
@override
|
||||
State<EntryPopoverOverlay> createState() => _EntryPopoverOverlayState();
|
||||
}
|
||||
|
||||
class _EntryPopoverOverlayState extends State<EntryPopoverOverlay> {
|
||||
OverlayEntry? _overlayEntry;
|
||||
ZTimelineInteractionNotifier? _notifier;
|
||||
|
||||
// Track current mode to know when to rebuild.
|
||||
String? _currentHoveredId;
|
||||
bool _currentIsInteracting = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
final newNotifier = scope?.interaction;
|
||||
|
||||
if (newNotifier != _notifier) {
|
||||
_notifier?.removeListener(_onInteractionChanged);
|
||||
_notifier = newNotifier;
|
||||
_notifier?.addListener(_onInteractionChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notifier?.removeListener(_onInteractionChanged);
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onInteractionChanged() {
|
||||
final notifier = _notifier;
|
||||
if (notifier == null) return;
|
||||
|
||||
final hoveredId = notifier.hoveredEntryId;
|
||||
final isInteracting = notifier.isInteracting;
|
||||
final interactionPos = notifier.interactionGlobalPosition;
|
||||
|
||||
// Determine what we should show.
|
||||
final shouldShowHover =
|
||||
hoveredId != null &&
|
||||
!isInteracting &&
|
||||
widget.popoverContentBuilder != null;
|
||||
final shouldShowCompact =
|
||||
isInteracting &&
|
||||
interactionPos != null &&
|
||||
widget.compactDateBuilder != null;
|
||||
|
||||
if (shouldShowHover) {
|
||||
if (_currentHoveredId != hoveredId || _currentIsInteracting) {
|
||||
_removeOverlay();
|
||||
_currentHoveredId = hoveredId;
|
||||
_currentIsInteracting = false;
|
||||
_showHoverPopover(notifier);
|
||||
}
|
||||
} else if (shouldShowCompact) {
|
||||
if (!_currentIsInteracting) {
|
||||
_removeOverlay();
|
||||
_currentIsInteracting = true;
|
||||
_currentHoveredId = null;
|
||||
_showCompactPopover(notifier);
|
||||
} else {
|
||||
// Just mark dirty to reposition.
|
||||
_overlayEntry?.markNeedsBuild();
|
||||
}
|
||||
} else {
|
||||
if (_overlayEntry != null) {
|
||||
_removeOverlay();
|
||||
_currentHoveredId = null;
|
||||
_currentIsInteracting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showHoverPopover(ZTimelineInteractionNotifier notifier) {
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (_) => _HoverPopover(
|
||||
notifier: notifier,
|
||||
contentBuilder: widget.popoverContentBuilder!,
|
||||
),
|
||||
);
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
void _showCompactPopover(ZTimelineInteractionNotifier notifier) {
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (_) => _CompactPopover(
|
||||
notifier: notifier,
|
||||
compactDateBuilder: widget.compactDateBuilder!,
|
||||
),
|
||||
);
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hover popover — positioned above the pill rect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _HoverPopover extends StatelessWidget {
|
||||
const _HoverPopover({required this.notifier, required this.contentBuilder});
|
||||
|
||||
final ZTimelineInteractionNotifier notifier;
|
||||
final PopoverContentBuilder contentBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entryId = notifier.hoveredEntryId;
|
||||
final pillRect = notifier.hoveredPillGlobalRect;
|
||||
if (entryId == null || pillRect == null) return const SizedBox.shrink();
|
||||
|
||||
final content = contentBuilder(entryId);
|
||||
if (content == null) return const SizedBox.shrink();
|
||||
|
||||
return _AnimatedPopoverWrapper(
|
||||
child: CustomSingleChildLayout(
|
||||
delegate: _PopoverLayoutDelegate(pillRect: pillRect),
|
||||
child: IgnorePointer(child: _PopoverCard(child: content)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact popover — positioned near cursor during drag/resize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _CompactPopover extends StatelessWidget {
|
||||
const _CompactPopover({
|
||||
required this.notifier,
|
||||
required this.compactDateBuilder,
|
||||
});
|
||||
|
||||
final ZTimelineInteractionNotifier notifier;
|
||||
final CompactDateBuilder compactDateBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pos = notifier.interactionGlobalPosition;
|
||||
if (pos == null) return const SizedBox.shrink();
|
||||
|
||||
// Determine dates from drag or resize state.
|
||||
DateTime? start;
|
||||
DateTime? end;
|
||||
|
||||
if (notifier.dragState case final drag?) {
|
||||
start = drag.targetStart;
|
||||
final duration = drag.originalEntry.end.difference(
|
||||
drag.originalEntry.start,
|
||||
);
|
||||
end = drag.targetStart.add(duration);
|
||||
} else if (notifier.resizeState case final resize?) {
|
||||
start = resize.targetStart;
|
||||
end = resize.targetEnd;
|
||||
}
|
||||
|
||||
if (start == null || end == null) return const SizedBox.shrink();
|
||||
|
||||
return _AnimatedPopoverWrapper(
|
||||
child: CustomSingleChildLayout(
|
||||
delegate: _CursorPopoverDelegate(cursorPosition: pos),
|
||||
child: IgnorePointer(
|
||||
child: _PopoverCard(
|
||||
compact: true,
|
||||
child: compactDateBuilder(start, end),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout delegates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Positions the popover above the pill, flipping below if not enough room.
|
||||
/// Clamps horizontally to screen bounds.
|
||||
class _PopoverLayoutDelegate extends SingleChildLayoutDelegate {
|
||||
_PopoverLayoutDelegate({required this.pillRect});
|
||||
|
||||
final Rect pillRect;
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
return BoxConstraints(
|
||||
maxWidth: math.min(_kMaxPopoverWidth, constraints.maxWidth - 16),
|
||||
maxHeight: constraints.maxHeight * 0.4,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
// Try above first.
|
||||
final aboveY = pillRect.top - _kPopoverGap - childSize.height;
|
||||
final belowY = pillRect.bottom + _kPopoverGap;
|
||||
final y = aboveY >= 8 ? aboveY : belowY;
|
||||
|
||||
// Center on pill horizontally, clamped to screen.
|
||||
final idealX = pillRect.center.dx - childSize.width / 2;
|
||||
final x = idealX.clamp(8.0, size.width - childSize.width - 8);
|
||||
|
||||
return Offset(x, y);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_PopoverLayoutDelegate oldDelegate) {
|
||||
return pillRect != oldDelegate.pillRect;
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions the compact date label above the cursor.
|
||||
class _CursorPopoverDelegate extends SingleChildLayoutDelegate {
|
||||
_CursorPopoverDelegate({required this.cursorPosition});
|
||||
|
||||
final Offset cursorPosition;
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
return BoxConstraints(
|
||||
maxWidth: math.min(_kMaxPopoverWidth, constraints.maxWidth - 16),
|
||||
maxHeight: 60,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
const offsetAbove = 24.0;
|
||||
final idealX = cursorPosition.dx - childSize.width / 2;
|
||||
final x = idealX.clamp(8.0, size.width - childSize.width - 8);
|
||||
final y = (cursorPosition.dy - offsetAbove - childSize.height).clamp(
|
||||
8.0,
|
||||
size.height - childSize.height - 8,
|
||||
);
|
||||
|
||||
return Offset(x, y);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_CursorPopoverDelegate oldDelegate) {
|
||||
return cursorPosition != oldDelegate.cursorPosition;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared card styling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _PopoverCard extends StatelessWidget {
|
||||
const _PopoverCard({required this.child, this.compact = false});
|
||||
|
||||
final Widget child;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: Padding(
|
||||
padding: compact
|
||||
? const EdgeInsets.symmetric(horizontal: 10, vertical: 6)
|
||||
: const EdgeInsets.all(12),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animation wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _AnimatedPopoverWrapper extends StatelessWidget {
|
||||
const _AnimatedPopoverWrapper({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: _kAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, value, child) {
|
||||
return Opacity(
|
||||
opacity: value,
|
||||
child: Transform.scale(scale: 0.95 + 0.05 * value, child: child),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,9 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
|
||||
_cumulativeDx = 0;
|
||||
|
||||
// Clear hover as safety net when drag/resize begins.
|
||||
scope.interaction.clearHoveredEntry();
|
||||
|
||||
switch (_mode!) {
|
||||
case _InteractionMode.resizeStart:
|
||||
scope.interaction.beginResize(entry, ResizeEdge.start);
|
||||
@@ -113,6 +116,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
|
||||
void _handleResizeUpdate(DragUpdateDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.updateInteractionPosition(details.globalPosition);
|
||||
_cumulativeDx += details.delta.dx;
|
||||
|
||||
final deltaNormalized = _cumulativeDx / widget.contentWidth;
|
||||
@@ -174,6 +178,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.updateInteractionPosition(details.globalPosition);
|
||||
final registry = widget.groupRegistry;
|
||||
if (registry == null) return;
|
||||
|
||||
@@ -322,8 +327,27 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onHoverEnter() {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.attached) return;
|
||||
final topLeft = box.localToGlobal(Offset.zero);
|
||||
final rect = topLeft & box.size;
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.setHoveredEntry(widget.entry.entry.id, rect);
|
||||
}
|
||||
|
||||
void _onHoverExit() {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.clearHoveredEntry();
|
||||
}
|
||||
|
||||
Widget _buildInteractivePill(Widget pill, bool hasResizeHandles) {
|
||||
return GestureDetector(
|
||||
return MouseRegion(
|
||||
opaque: false,
|
||||
hitTestBehavior: HitTestBehavior.deferToChild,
|
||||
onEnter: (_) => _onHoverEnter(),
|
||||
onExit: (_) => _onHoverExit(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown: _onPanDown,
|
||||
onPanStart: _onPanStart,
|
||||
@@ -378,6 +402,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export 'src/state/timeline_viewport_notifier.dart';
|
||||
|
||||
// Widgets
|
||||
export 'src/widgets/breadcrumb_segment_chip.dart';
|
||||
export 'src/widgets/entry_popover_overlay.dart';
|
||||
export 'src/widgets/event_pill.dart';
|
||||
export 'src/widgets/ghost_overlay.dart';
|
||||
export 'src/widgets/interactive_event_pill.dart';
|
||||
|
||||
Reference in New Issue
Block a user