Show AI Scribe submenu on hover instead of on click
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||||
@@ -24,6 +26,7 @@ class _AIScribeMenuContentState extends State<AIScribeMenu> {
|
|||||||
final Map<AIScribeMenuCategory, GlobalKey> _categoryKeys = {};
|
final Map<AIScribeMenuCategory, GlobalKey> _categoryKeys = {};
|
||||||
final GlobalKey _menuKey = GlobalKey();
|
final GlobalKey _menuKey = GlobalKey();
|
||||||
OverlayEntry? _submenuOverlay;
|
OverlayEntry? _submenuOverlay;
|
||||||
|
Timer? _closeTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -36,6 +39,7 @@ class _AIScribeMenuContentState extends State<AIScribeMenu> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_closeTimer?.cancel();
|
||||||
_removeSubmenu();
|
_removeSubmenu();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -63,9 +67,11 @@ class _AIScribeMenuContentState extends State<AIScribeMenu> {
|
|||||||
position: menuPosition,
|
position: menuPosition,
|
||||||
parentSize: menuSize,
|
parentSize: menuSize,
|
||||||
onActionSelected: (action) {
|
onActionSelected: (action) {
|
||||||
|
_closeTimer?.cancel();
|
||||||
_removeSubmenu();
|
_removeSubmenu();
|
||||||
widget.onActionSelected(action);
|
widget.onActionSelected(action);
|
||||||
},
|
},
|
||||||
|
onHover: _cancelClose,
|
||||||
onDismiss: () {
|
onDismiss: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hoveredCategory = null;
|
_hoveredCategory = null;
|
||||||
@@ -78,17 +84,32 @@ class _AIScribeMenuContentState extends State<AIScribeMenu> {
|
|||||||
Overlay.of(context).insert(_submenuOverlay!);
|
Overlay.of(context).insert(_submenuOverlay!);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleCategoryClick(AIScribeMenuCategory category) {
|
void _handleCategoryHover(AIScribeMenuCategory category, bool isHovering) {
|
||||||
setState(() {
|
if (isHovering) {
|
||||||
// Toggle submenu - close if already open, open if closed
|
_closeTimer?.cancel();
|
||||||
if (_hoveredCategory == category) {
|
_closeTimer = null;
|
||||||
_hoveredCategory = null;
|
|
||||||
_removeSubmenu();
|
setState(() {
|
||||||
} else {
|
|
||||||
_hoveredCategory = category;
|
_hoveredCategory = category;
|
||||||
_showSubmenu(category);
|
_showSubmenu(category);
|
||||||
}
|
});
|
||||||
});
|
} else {
|
||||||
|
// Delay closing to allow mouse to reach submenu
|
||||||
|
_closeTimer?.cancel();
|
||||||
|
_closeTimer = Timer(const Duration(milliseconds: 150), () {
|
||||||
|
if (mounted && _hoveredCategory == category) {
|
||||||
|
setState(() {
|
||||||
|
_hoveredCategory = null;
|
||||||
|
_removeSubmenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelClose() {
|
||||||
|
_closeTimer?.cancel();
|
||||||
|
_closeTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -112,7 +133,7 @@ class _AIScribeMenuContentState extends State<AIScribeMenu> {
|
|||||||
label: category.getLabel(context),
|
label: category.getLabel(context),
|
||||||
hasSubmenu: true,
|
hasSubmenu: true,
|
||||||
isHovered: _hoveredCategory == category,
|
isHovered: _hoveredCategory == category,
|
||||||
onTap: () => _handleCategoryClick(category),
|
onHover: (isHovering) => _handleCategoryHover(category, isHovering),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -130,33 +151,38 @@ class _AIScribeMenuContentState extends State<AIScribeMenu> {
|
|||||||
|
|
||||||
Widget _buildMenuItem({
|
Widget _buildMenuItem({
|
||||||
required String label,
|
required String label,
|
||||||
required VoidCallback onTap,
|
VoidCallback? onTap,
|
||||||
|
void Function(bool)? onHover,
|
||||||
bool hasSubmenu = false,
|
bool hasSubmenu = false,
|
||||||
bool isHovered = false
|
bool isHovered = false
|
||||||
}) {
|
}) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: AIScribeSizes.menuItemHeight,
|
height: AIScribeSizes.menuItemHeight,
|
||||||
child: InkWell(
|
child: MouseRegion(
|
||||||
onTap: onTap,
|
onEnter: onHover != null ? (_) => onHover(true) : null,
|
||||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
|
onExit: onHover != null ? (_) => onHover(false) : null,
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: AIScribeSizes.menuItemPadding,
|
onTap: onTap,
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
child: Padding(
|
||||||
children: [
|
padding: AIScribeSizes.menuItemPadding,
|
||||||
Expanded(
|
child: Row(
|
||||||
child: Text(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
label,
|
children: [
|
||||||
style: AIScribeTextStyles.menuItem,
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: AIScribeTextStyles.menuItem,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (hasSubmenu)
|
||||||
if (hasSubmenu)
|
const Icon(
|
||||||
const Icon(
|
Icons.chevron_right,
|
||||||
Icons.chevron_right,
|
size: AIScribeSizes.iconSize,
|
||||||
size: AIScribeSizes.iconSize,
|
color: AIScribeColors.textPrimary,
|
||||||
color: AIScribeColors.textPrimary,
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -170,6 +196,7 @@ class _SubmenuPanel extends StatefulWidget {
|
|||||||
final Offset position;
|
final Offset position;
|
||||||
final Size parentSize;
|
final Size parentSize;
|
||||||
final Function(AIScribeMenuAction) onActionSelected;
|
final Function(AIScribeMenuAction) onActionSelected;
|
||||||
|
final VoidCallback onHover;
|
||||||
final VoidCallback onDismiss;
|
final VoidCallback onDismiss;
|
||||||
|
|
||||||
const _SubmenuPanel({
|
const _SubmenuPanel({
|
||||||
@@ -177,6 +204,7 @@ class _SubmenuPanel extends StatefulWidget {
|
|||||||
required this.position,
|
required this.position,
|
||||||
required this.parentSize,
|
required this.parentSize,
|
||||||
required this.onActionSelected,
|
required this.onActionSelected,
|
||||||
|
required this.onHover,
|
||||||
required this.onDismiss,
|
required this.onDismiss,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,36 +237,40 @@ class _SubmenuPanelState extends State<_SubmenuPanel> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: adjustedLeft,
|
left: adjustedLeft,
|
||||||
top: adjustedTop,
|
top: adjustedTop,
|
||||||
child: PointerInterceptor(
|
child: MouseRegion(
|
||||||
child: Material(
|
onEnter: (_) => widget.onHover(),
|
||||||
color: Colors.white,
|
onExit: (_) => widget.onDismiss(),
|
||||||
elevation: AIScribeSizes.dialogElevation,
|
child: PointerInterceptor(
|
||||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
|
child: Material(
|
||||||
child: Container(
|
color: Colors.white,
|
||||||
width: AIScribeSizes.menuWidth,
|
elevation: AIScribeSizes.dialogElevation,
|
||||||
constraints: const BoxConstraints(maxHeight: AIScribeSizes.submenuMaxHeight),
|
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
|
||||||
child: Column(
|
child: Container(
|
||||||
mainAxisSize: MainAxisSize.min,
|
width: AIScribeSizes.menuWidth,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
constraints: const BoxConstraints(maxHeight: AIScribeSizes.submenuMaxHeight),
|
||||||
children: widget.category.actions.map((action) {
|
child: Column(
|
||||||
return SizedBox(
|
mainAxisSize: MainAxisSize.min,
|
||||||
height: AIScribeSizes.menuItemHeight,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: InkWell(
|
children: widget.category.actions.map((action) {
|
||||||
onTap: () => widget.onActionSelected(action),
|
return SizedBox(
|
||||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
|
height: AIScribeSizes.menuItemHeight,
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: AIScribeSizes.menuItemPadding,
|
onTap: () => widget.onActionSelected(action),
|
||||||
child: Align(
|
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
|
||||||
alignment: Alignment.centerLeft,
|
child: Padding(
|
||||||
child: Text(
|
padding: AIScribeSizes.menuItemPadding,
|
||||||
action.getLabel(context),
|
child: Align(
|
||||||
style: AIScribeTextStyles.menuItem,
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
action.getLabel(context),
|
||||||
|
style: AIScribeTextStyles.menuItem,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}).toList(),
|
||||||
}).toList(),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user