Show AI Scribe submenu on hover instead of on click

This commit is contained in:
Théo Poizat
2025-12-11 10:37:32 +01:00
committed by Dat H. Pham
parent 2f3bbe3bbb
commit 0b8a814ad6
@@ -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(), ),
), ),
), ),
), ),