Merge pull request #300 from cogwheel0/native-context-menus-ios

native-context-menus-ios
This commit is contained in:
cogwheel
2025-12-20 22:22:28 +05:30
committed by GitHub
14 changed files with 1067 additions and 756 deletions

View File

@@ -1570,16 +1570,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
height: 1.3,
);
// Keyboard visibility
final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Keyboard visibility - use viewInsetsOf for more efficient partial subscription
final keyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0;
// Whether the messages list can actually scroll (avoids showing button when not needed)
final canScroll =
_scrollController.hasClients &&
_scrollController.position.maxScrollExtent > 0;
// Check if any message is currently streaming (for scroll button indicator)
final isStreamingAnyMessage = ref
.watch(chatMessagesProvider)
.any((msg) => msg.isStreaming);
// Use dedicated streaming provider to avoid iterating all messages on rebuild
final isStreamingAnyMessage = ref.watch(isChatStreamingProvider);
// On keyboard open, if already near bottom, auto-scroll to bottom to keep input visible
if (keyboardVisible && !_lastKeyboardVisible) {
@@ -1601,15 +1599,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
});
}
// Focus composer on app startup once
// Focus composer on app startup once (minimal delay for layout to settle)
if (!_didStartupFocus) {
_didStartupFocus = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 200), () {
if (!mounted) return;
final current = ref.read(inputFocusTriggerProvider);
ref.read(inputFocusTriggerProvider.notifier).set(current + 1);
});
if (!mounted) return;
ref.read(inputFocusTriggerProvider.notifier).increment();
});
}
@@ -1817,18 +1812,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
);
} else if (displayConversationTitle != null) {
titlePill = GestureDetector(
onTap: () {
final conversation = ref.read(
activeConversationProvider,
);
if (conversation == null) return;
showConversationContextMenu(
context: context,
ref: ref,
conversation: conversation,
);
},
final conversation = ref.read(
activeConversationProvider,
);
titlePill = ConduitContextMenu(
actions: buildConversationActions(
context: context,
ref: ref,
conversation: conversation,
),
child: _buildAppBarPill(
context: context,
child: Padding(

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import '../../../shared/theme/theme_extensions.dart';
// app_theme not required here; using theme extension tokens
@@ -183,13 +184,23 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}
_pendingFocus = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Request focus synchronously if we're already in a safe context,
// otherwise defer to next frame
if (WidgetsBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
// We're in a build/layout phase, defer to next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_pendingFocus = false;
if (widget.enabled && !_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
} else {
// Safe to request focus immediately
_pendingFocus = false;
if (widget.enabled && !_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
_focusNode.requestFocus();
}
}
@override
@@ -1032,11 +1043,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
});
});
final messages = ref.watch(chatMessagesProvider);
final isGenerating =
messages.isNotEmpty &&
messages.last.role == 'assistant' &&
messages.last.isStreaming;
// Use dedicated streaming provider to avoid rebuilding on every message change
final isGenerating = ref.watch(isChatStreamingProvider);
final stopGeneration = ref.read(stopGenerationProvider);
final webSearchEnabled = ref.watch(webSearchEnabledProvider);
@@ -1340,9 +1348,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// For compact mode, render text field shell with floating buttons on sides
if (showCompactComposer) {
// Build the text field shell
Widget textFieldShell = AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
Widget textFieldShell = Container(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
constraints: const BoxConstraints(minHeight: TouchTarget.input),
decoration: shellDecoration,
@@ -1404,9 +1410,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}
// For expanded mode with quick pills, use the full shell
Widget shell = AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
Widget shell = Container(
decoration: shellDecoration,
child: ConstrainedBox(
constraints: BoxConstraints(

View File

@@ -433,51 +433,32 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
super.dispose();
}
Future<void> _showMessageMenu(BuildContext context) async {
// Don't show menu while editing - use the visible Save/Cancel buttons instead
if (_isEditing) return;
List<ConduitContextMenuAction> _buildMessageActions(BuildContext context) {
// Don't show menu while editing - return empty list
if (_isEditing) return [];
final l10n = AppLocalizations.of(context)!;
HapticFeedback.selectionClick();
// Get the position of the bubble to show menu below it
Offset? menuPosition;
final RenderBox? renderBox =
_bubbleKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final position = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
// Position menu at bottom-right of the bubble
menuPosition = Offset(
position.dx + size.width,
position.dy + size.height,
);
}
await showConduitContextMenu(
context: context,
position: menuPosition,
actions: [
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_outlined,
label: l10n.edit,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async => _startInlineEdit(),
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.doc_on_clipboard,
materialIcon: Icons.content_copy,
label: l10n.copy,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
if (widget.onCopy != null) {
widget.onCopy!();
}
},
),
],
);
return [
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_outlined,
label: l10n.edit,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async => _startInlineEdit(),
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.doc_on_clipboard,
materialIcon: Icons.content_copy,
label: l10n.copy,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
if (widget.onCopy != null) {
widget.onCopy!();
}
},
),
];
}
@override
@@ -498,10 +479,15 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
final inlineEditFill = context.conduitTheme.surfaceContainer.withValues(
alpha: 0.92,
);
// Use rounded rectangle for multiline, pill for single-line (like chat input)
// Consider multiline if text exceeds ~50 chars or contains newlines
// Check length first (O(1)) to short-circuit before scanning for newlines
final content = widget.message.content;
final isMultiline = content.length > 50 || content.contains('\n');
final bubbleRadius = isMultiline ? AppBorderRadius.xl : AppBorderRadius.pill;
return GestureDetector(
onLongPress: () => _showMessageMenu(context),
behavior: HitTestBehavior.translucent,
return ConduitContextMenu(
actions: _buildMessageActions(context),
child: Container(
width: double.infinity,
margin: const EdgeInsets.only(
@@ -539,9 +525,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
),
decoration: BoxDecoration(
color: context.conduitTheme.chatBubbleUser,
borderRadius: BorderRadius.circular(
AppBorderRadius.messageBubble,
),
borderRadius: BorderRadius.circular(bubbleRadius),
),
child: _isEditing
? Focus(

View File

@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:super_drag_and_drop/super_drag_and_drop.dart';
import '../../../core/providers/app_providers.dart';
import '../../auth/providers/unified_auth_providers.dart';
@@ -18,11 +19,9 @@ import '../../../shared/widgets/loading_states.dart';
import '../../../shared/widgets/themed_dialogs.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../../../core/utils/user_display_name.dart';
import '../../../core/utils/model_icon_utils.dart';
import '../../../core/utils/user_avatar_utils.dart';
import '../../../shared/utils/conversation_context_menu.dart';
import '../../../shared/widgets/user_avatar.dart';
import '../../../shared/widgets/model_avatar.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../../../shared/widgets/responsive_drawer_layout.dart';
import '../../../core/models/model.dart';
@@ -182,9 +181,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
child: Stack(
children: [
// Main scrollable content - extends behind floating elements
Positioned.fill(
child: _buildConversationList(context),
),
Positioned.fill(child: _buildConversationList(context)),
// Floating top area with gradient background (matches app bar pattern)
Positioned(
top: 0,
@@ -241,7 +238,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
),
child: Builder(
builder: (context) {
final bottomPadding = MediaQuery.of(context).viewPadding.bottom;
final bottomPadding = MediaQuery.of(
context,
).viewPadding.bottom;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1003,26 +1002,50 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final expandedMap = ref.watch(_expandedFoldersProvider);
final isExpanded = expandedMap[folderId] ?? defaultExpanded;
final isHover = _dragHoverFolderId == folderId;
return DragTarget<_DragConversationData>(
onWillAcceptWithDetails: (details) {
final baseColor = theme.surfaceContainer;
final hoverColor = theme.buttonPrimary.withValues(alpha: 0.08);
final borderColor = isHover
? theme.buttonPrimary.withValues(alpha: 0.60)
: theme.surfaceContainerHighest.withValues(alpha: 0.40);
Color? overlayForStates(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) {
return theme.buttonPrimary.withValues(alpha: Alpha.buttonPressed);
}
if (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return theme.buttonPrimary.withValues(alpha: Alpha.hover);
}
return Colors.transparent;
}
return DropRegion(
formats: const [], // Local data only
onDropOver: (event) {
setState(() => _dragHoverFolderId = folderId);
return true;
return DropOperation.move;
},
onLeave: (_) => setState(() => _dragHoverFolderId = null),
onAcceptWithDetails: (details) async {
onDropEnter: (_) => setState(() => _dragHoverFolderId = folderId),
onDropLeave: (_) => setState(() => _dragHoverFolderId = null),
onPerformDrop: (event) async {
setState(() {
_dragHoverFolderId = null;
_isDragging = false;
});
// Get local data from the drop event (serialized as Map)
final localData = event.session.items.first.localData;
if (localData is! Map) return;
final conversationId = localData['id'] as String?;
if (conversationId == null) return;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.moveConversationToFolder(details.data.id, folderId);
await api.moveConversationToFolder(conversationId, folderId);
HapticFeedback.selectionClick();
ref
.read(conversationsProvider.notifier)
.updateConversation(
details.data.id,
conversationId,
(conversation) => conversation.copyWith(
folderId: folderId,
updatedAt: DateTime.now(),
@@ -1043,25 +1066,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
}
}
},
builder: (context, candidateData, rejectedData) {
final baseColor = theme.surfaceContainer;
final hoverColor = theme.buttonPrimary.withValues(alpha: 0.08);
final borderColor = isHover
? theme.buttonPrimary.withValues(alpha: 0.60)
: theme.surfaceContainerHighest.withValues(alpha: 0.40);
Color? overlayForStates(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) {
return theme.buttonPrimary.withValues(alpha: Alpha.buttonPressed);
}
if (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return theme.buttonPrimary.withValues(alpha: Alpha.hover);
}
return Colors.transparent;
}
return Material(
child: ConduitContextMenu(
actions: _buildFolderActions(folderId, name),
child: Material(
color: isHover ? hoverColor : baseColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.small),
@@ -1075,10 +1082,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
current[folderId] = next;
ref.read(_expandedFoldersProvider.notifier).set(current);
},
onLongPress: () {
HapticFeedback.selectionClick();
_showFolderContextMenu(context, folderId, name);
},
onLongPress: null, // Handled by ConduitContextMenu
overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
child: ConstrainedBox(
constraints: const BoxConstraints(
@@ -1136,10 +1140,12 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
vertical: 2,
),
decoration: BoxDecoration(
color: context.sidebarTheme.accent
.withValues(alpha: 0.7),
borderRadius:
BorderRadius.circular(AppBorderRadius.xs),
color: context.sidebarTheme.accent.withValues(
alpha: 0.7,
),
borderRadius: BorderRadius.circular(
AppBorderRadius.xs,
),
border: Border.all(
color: context.sidebarTheme.border
.withValues(alpha: 0.35),
@@ -1202,8 +1208,8 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
),
),
),
);
},
),
),
);
}
@@ -1296,37 +1302,33 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
);
}
void _showFolderContextMenu(
BuildContext context,
List<ConduitContextMenuAction> _buildFolderActions(
String folderId,
String folderName,
) {
final l10n = AppLocalizations.of(context)!;
showConduitContextMenu(
context: context,
actions: [
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.rename,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
await _renameFolder(context, folderId, folderName);
},
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: () async {
await _confirmAndDeleteFolder(context, folderId, folderName);
},
),
],
);
return [
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.rename,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
await _renameFolder(context, folderId, folderName);
},
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: () async {
await _confirmAndDeleteFolder(context, folderId, folderName);
},
),
];
}
void _startNewChatInFolder(String folderId) {
@@ -1433,26 +1435,33 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final theme = context.conduitTheme;
final l10n = AppLocalizations.of(context)!;
final isHover = _dragHoverFolderId == '__UNFILE__';
return DragTarget<_DragConversationData>(
onWillAcceptWithDetails: (details) {
return DropRegion(
formats: const [], // Local data only
onDropOver: (event) {
setState(() => _dragHoverFolderId = '__UNFILE__');
return true;
return DropOperation.move;
},
onLeave: (_) => setState(() => _dragHoverFolderId = null),
onAcceptWithDetails: (details) async {
onDropEnter: (_) => setState(() => _dragHoverFolderId = '__UNFILE__'),
onDropLeave: (_) => setState(() => _dragHoverFolderId = null),
onPerformDrop: (event) async {
setState(() {
_dragHoverFolderId = null;
_isDragging = false;
});
// Get local data from the drop event (serialized as Map)
final localData = event.session.items.first.localData;
if (localData is! Map) return;
final conversationId = localData['id'] as String?;
if (conversationId == null) return;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.moveConversationToFolder(details.data.id, null);
await api.moveConversationToFolder(conversationId, null);
HapticFeedback.selectionClick();
ref
.read(conversationsProvider.notifier)
.updateConversation(
details.data.id,
conversationId,
(conversation) => conversation.copyWith(
folderId: null,
updatedAt: DateTime.now(),
@@ -1471,45 +1480,43 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
}
}
},
builder: (context, candidate, rejected) {
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.08)
: theme.surfaceContainer.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.08)
: theme.surfaceContainer.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: isHover
? theme.buttonPrimary.withValues(alpha: 0.5)
: theme.dividerColor.withValues(alpha: 0.5),
width: BorderWidth.standard,
),
? theme.buttonPrimary.withValues(alpha: 0.5)
: theme.dividerColor.withValues(alpha: 0.5),
width: BorderWidth.standard,
),
padding: const EdgeInsets.all(Spacing.sm),
child: Row(
children: [
Icon(
Platform.isIOS
? CupertinoIcons.folder_badge_minus
: Icons.folder_off_outlined,
color: theme.iconPrimary,
size: IconSize.small,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
'Drop here to remove from folder',
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w500,
),
),
padding: const EdgeInsets.all(Spacing.sm),
child: Row(
children: [
Icon(
Platform.isIOS
? CupertinoIcons.folder_badge_minus
: Icons.folder_off_outlined,
color: theme.iconPrimary,
size: IconSize.small,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
'Drop here to remove from folder',
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w500,
),
),
],
),
);
},
),
],
),
),
);
}
@@ -1529,81 +1536,84 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
(ref.watch(chat.isLoadingConversationProvider) == true);
final bool isPinned = conv.pinned == true;
Model? model;
final modelId = (conv.model is String && (conv.model as String).isNotEmpty)
? conv.model as String
: null;
if (modelId != null) {
model = modelsById[modelId];
}
// Check if folders feature is enabled to enable drag
final foldersEnabled = ref.watch(foldersFeatureEnabledProvider);
final dragEnabled = foldersEnabled && !isLoadingSelected;
final api = ref.watch(apiServiceProvider);
final modelIconUrl = resolveModelIconUrlForModel(api, model);
Widget? leading;
if (modelId != null) {
leading = ModelAvatar(
size: 28,
imageUrl: modelIconUrl,
label: model?.name ?? modelId,
);
}
final tile = _ConversationTile(
final tileWidget = _ConversationTile(
title: title,
pinned: isPinned,
selected: isActive,
isLoading: isLoadingSelected,
leading: leading,
onTap: _isLoadingConversation
? null
: () => _selectConversation(context, conv.id),
onLongPress: null,
onMorePressed: (buttonContext) {
showConversationContextMenu(
context: buttonContext,
ref: ref,
conversation: conv,
);
},
);
return RepaintBoundary(
final contextMenuTile = ConduitContextMenu(
actions: buildConversationActions(
context: context,
ref: ref,
conversation: conv,
),
child: Padding(
padding: EdgeInsets.only(
bottom: Spacing.xs,
left: inFolder ? Spacing.md : 0,
),
child: LongPressDraggable<_DragConversationData>(
data: _DragConversationData(id: conv.id, title: title),
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: _ConversationDragFeedback(
title: title,
pinned: isPinned,
theme: theme,
),
childWhenDragging: Opacity(
opacity: 0.5,
child: IgnorePointer(child: tile),
),
onDragStarted: () {
HapticFeedback.lightImpact();
final hasFolder =
(conv.folderId != null && (conv.folderId as String).isNotEmpty);
setState(() {
_isDragging = true;
_draggingHasFolder = hasFolder;
});
},
onDragEnd: (_) => setState(() {
_dragHoverFolderId = null;
_isDragging = false;
_draggingHasFolder = false;
}),
child: tile,
),
padding: EdgeInsets.only(left: inFolder ? Spacing.sm : 0),
child: tileWidget,
),
);
// Wrap with drag support if folders are enabled
Widget tile;
if (dragEnabled) {
tile = DragItemWidget(
allowedOperations: () => [DropOperation.move],
canAddItemToExistingSession: true,
dragItemProvider: (request) async {
// Set drag state when drag starts
HapticFeedback.lightImpact();
final hasFolder =
(conv.folderId != null && (conv.folderId as String).isNotEmpty);
setState(() {
_isDragging = true;
_draggingHasFolder = hasFolder;
});
// Listen for drag completion to reset state
void onDragCompleted() {
if (mounted) {
setState(() {
_dragHoverFolderId = null;
_isDragging = false;
_draggingHasFolder = false;
});
}
request.session.dragCompleted.removeListener(onDragCompleted);
}
request.session.dragCompleted.addListener(onDragCompleted);
// Provide drag data with conversation info as serializable Map
final item = DragItem(localData: {'id': conv.id, 'title': title});
return item;
},
dragBuilder: (context, child) {
// Custom drag preview
return Opacity(
opacity: 0.9,
child: _ConversationDragFeedback(
title: title,
pinned: isPinned,
theme: theme,
),
);
},
child: DraggableWidget(child: contextMenuTile),
);
} else {
tile = contextMenuTile;
}
return RepaintBoundary(child: tile);
}
Widget _buildArchivedHeader(int count) {
@@ -1790,9 +1800,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
width: 36,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppBorderRadius.avatar,
),
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
border: Border.all(
color: conduitTheme.buttonPrimary.withValues(alpha: 0.25),
width: BorderWidth.thin,
@@ -1916,12 +1924,6 @@ class _ExpandedFoldersNotifier extends Notifier<Map<String, bool>> {
void set(Map<String, bool> value) => state = Map<String, bool>.from(value);
}
class _DragConversationData {
final String id;
final String title;
const _DragConversationData({required this.id, required this.title});
}
class _ConversationDragFeedback extends StatelessWidget {
final String title;
final bool pinned;
@@ -1958,7 +1960,6 @@ class _ConversationDragFeedback extends StatelessWidget {
pinned: pinned,
selected: false,
isLoading: false,
onMorePressed: null,
),
),
);
@@ -1970,23 +1971,21 @@ class _ConversationTileContent extends StatelessWidget {
final bool pinned;
final bool selected;
final bool isLoading;
final void Function(BuildContext)? onMorePressed;
final Widget? leading;
const _ConversationTileContent({
required this.title,
required this.pinned,
required this.selected,
required this.isLoading,
this.onMorePressed,
this.leading,
});
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
// Enhanced typography with better visual hierarchy
final textStyle = AppTypography.standard.copyWith(
color: theme.textPrimary,
color: selected ? theme.textPrimary : theme.textSecondary,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
height: 1.4,
);
@@ -1996,20 +1995,30 @@ class _ConversationTileContent extends StatelessWidget {
final hasFiniteWidth = constraints.maxWidth.isFinite;
final textFit = hasFiniteWidth ? FlexFit.tight : FlexFit.loose;
final trailing = <Widget>[];
final trailingWidgets = <Widget>[];
if (pinned) {
trailing.addAll([
const SizedBox(width: Spacing.xs),
Icon(
Platform.isIOS ? CupertinoIcons.pin_fill : Icons.push_pin_rounded,
color: theme.iconSecondary,
size: IconSize.xs,
trailingWidgets.addAll([
const SizedBox(width: Spacing.sm),
Container(
padding: const EdgeInsets.all(Spacing.xxs),
decoration: BoxDecoration(
color: theme.buttonPrimary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
),
child: Icon(
Platform.isIOS
? CupertinoIcons.pin_fill
: Icons.push_pin_rounded,
color: theme.buttonPrimary.withValues(alpha: 0.7),
size: IconSize.xs,
),
),
]);
}
if (isLoading) {
trailing.addAll([
trailingWidgets.addAll([
const SizedBox(width: Spacing.sm),
SizedBox(
width: IconSize.sm,
@@ -2022,47 +2031,11 @@ class _ConversationTileContent extends StatelessWidget {
),
),
]);
} else if (onMorePressed != null) {
trailing.addAll([
const SizedBox(width: Spacing.sm),
Builder(
builder: (buttonContext) {
return IconButton(
iconSize: IconSize.sm,
visualDensity: const VisualDensity(
horizontal: -2,
vertical: -2,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: TouchTarget.listItem,
minHeight: TouchTarget.listItem,
),
icon: Icon(
Platform.isIOS
? CupertinoIcons.ellipsis
: Icons.more_vert_rounded,
color: theme.iconSecondary,
),
onPressed: () => onMorePressed!(buttonContext),
tooltip: AppLocalizations.of(context)!.more,
);
},
),
]);
}
return Row(
mainAxisSize: hasFiniteWidth ? MainAxisSize.max : MainAxisSize.min,
children: [
if (leading != null) ...[
SizedBox(
width: TouchTarget.listItem,
height: TouchTarget.listItem,
child: Center(child: leading!),
),
const SizedBox(width: Spacing.sm),
],
Flexible(
fit: textFit,
child: MiddleEllipsisText(
@@ -2071,7 +2044,7 @@ class _ConversationTileContent extends StatelessWidget {
semanticsLabel: title,
),
),
...trailing,
...trailingWidgets,
],
);
},
@@ -2079,98 +2052,103 @@ class _ConversationTileContent extends StatelessWidget {
}
}
class _ConversationTile extends StatelessWidget {
class _ConversationTile extends StatefulWidget {
final String title;
final bool pinned;
final bool selected;
final bool isLoading;
final Widget? leading;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final void Function(BuildContext)? onMorePressed;
const _ConversationTile({
required this.title,
required this.pinned,
required this.selected,
required this.isLoading,
this.leading,
required this.onTap,
this.onLongPress,
this.onMorePressed,
});
@override
State<_ConversationTile> createState() => _ConversationTileState();
}
class _ConversationTileState extends State<_ConversationTile> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
const BorderRadius borderRadius = BorderRadius.zero;
final Color background = selected
? theme.buttonPrimary.withValues(alpha: 0.1)
: Colors.transparent;
final Color borderColor = selected
? theme.buttonPrimary.withValues(alpha: 0.5)
: Colors.transparent;
final sidebarTheme = context.sidebarTheme;
final borderRadius = BorderRadius.circular(AppBorderRadius.sm);
final List<BoxShadow> shadow = const [];
// Use opaque backgrounds for proper context menu snapshot rendering
final Color baseBackground = sidebarTheme.background;
final Color background = widget.selected
? Color.alphaBlend(
theme.buttonPrimary.withValues(alpha: 0.1),
baseBackground,
)
: (_isHovered
? Color.alphaBlend(
theme.buttonPrimary.withValues(alpha: 0.05),
baseBackground,
)
: baseBackground);
// Border styling
final Color borderColor = widget.selected
? theme.buttonPrimary.withValues(alpha: 0.4)
: Colors.transparent;
Color? overlayForStates(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) {
return theme.buttonPrimary.withValues(alpha: Alpha.buttonPressed);
}
if (states.contains(WidgetState.focused) ||
states.contains(WidgetState.hovered)) {
return theme.buttonPrimary.withValues(alpha: Alpha.hover);
}
return Colors.transparent;
}
return Semantics(
selected: selected,
selected: widget.selected,
button: true,
child: Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
child: InkWell(
borderRadius: borderRadius,
onTap: isLoading ? null : onTap,
onLongPress: onLongPress,
overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: background,
child: MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(
horizontal: Spacing.xs,
vertical: Spacing.xxs,
),
decoration: BoxDecoration(
color: background,
borderRadius: borderRadius,
border: widget.selected
? Border.all(color: borderColor, width: BorderWidth.regular)
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: borderRadius,
child: InkWell(
borderRadius: borderRadius,
border: selected
? Border(
top: BorderSide(
color: borderColor,
width: BorderWidth.thin,
),
bottom: BorderSide(
color: borderColor,
width: BorderWidth.thin,
),
)
: null,
boxShadow: shadow,
),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: TouchTarget.listItem,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
onTap: widget.isLoading ? null : widget.onTap,
overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: TouchTarget.listItem,
),
child: _ConversationTileContent(
title: title,
pinned: pinned,
selected: selected,
isLoading: isLoading,
onMorePressed: onMorePressed,
leading: leading,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: _ConversationTileContent(
title: widget.title,
pinned: widget.pinned,
selected: widget.selected,
isLoading: widget.isLoading,
),
),
),
),

View File

@@ -709,39 +709,50 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
),
),
),
// Actions (more menu)
// Actions (more menu) - uses PopupMenuButton for tap interaction
Padding(
padding: const EdgeInsets.only(right: Spacing.inputPadding),
child: Center(
child: PopupMenuButton<String>(
tooltip: '',
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
onSelected: (value) {
switch (value) {
case 'generate_title':
case 'generate':
HapticFeedback.selectionClick();
_generateTitle();
case 'copy':
HapticFeedback.selectionClick();
_copyToClipboard();
case 'delete':
HapticFeedback.mediumImpact();
_deleteNote();
}
},
offset: const Offset(0, 44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppBorderRadius.card,
),
),
color: conduitTheme.surfaceContainer,
itemBuilder: (context) => [
PopupMenuItem(
value: 'generate_title',
value: 'generate',
child: Row(
children: [
Icon(
Platform.isIOS
? CupertinoIcons.sparkles
: Icons.auto_awesome_rounded,
color: conduitTheme.buttonPrimary,
size: IconSize.md,
size: IconSize.small,
color: conduitTheme.textPrimary,
),
const SizedBox(width: Spacing.sm),
Text(l10n.generateTitle),
Text(
l10n.generateTitle,
style: TextStyle(
color: conduitTheme.textPrimary,
),
),
],
),
),
@@ -753,11 +764,16 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
Platform.isIOS
? CupertinoIcons.doc_on_clipboard
: Icons.copy_rounded,
color: conduitTheme.iconPrimary,
size: IconSize.md,
size: IconSize.small,
color: conduitTheme.textPrimary,
),
const SizedBox(width: Spacing.sm),
Text(l10n.copy),
Text(
l10n.copy,
style: TextStyle(
color: conduitTheme.textPrimary,
),
),
],
),
),
@@ -769,13 +785,15 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
Platform.isIOS
? CupertinoIcons.delete
: Icons.delete_rounded,
size: IconSize.small,
color: conduitTheme.error,
size: IconSize.md,
),
const SizedBox(width: Spacing.sm),
Text(
l10n.delete,
style: TextStyle(color: conduitTheme.error),
style: TextStyle(
color: conduitTheme.error,
),
),
],
),
@@ -823,17 +841,13 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
}
Widget _buildFloatingMetadataBar(BuildContext context) {
final theme = Theme.of(context);
final conduitTheme = context.conduitTheme;
final l10n = AppLocalizations.of(context)!;
final isDark = theme.brightness == Brightness.dark;
final backgroundColor = isDark
? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)!
: Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!;
final borderColor = conduitTheme.cardBorder.withValues(
alpha: isDark ? 0.65 : 0.55,
// Use consistent colors with the floating app bar pills
final backgroundColor = conduitTheme.surfaceContainer.withValues(alpha: 0.9);
final borderColor = conduitTheme.surfaceContainerHighest.withValues(
alpha: 0.4,
);
final dateFormat = DateFormat.MMMd();
@@ -898,7 +912,7 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
child: Text(
'·',
style: AppTypography.tiny.copyWith(
color: theme.textTertiary.withValues(alpha: 0.5),
color: theme.textSecondary.withValues(alpha: 0.5),
),
),
);
@@ -921,14 +935,14 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
children: [
Icon(
icon,
color: theme.textTertiary.withValues(alpha: 0.7),
color: theme.textSecondary,
size: IconSize.xs,
),
const SizedBox(width: Spacing.xxs),
Text(
label,
style: AppTypography.tiny.copyWith(
color: theme.textTertiary.withValues(alpha: 0.7),
color: theme.textSecondary,
fontWeight: FontWeight.w500,
),
),

View File

@@ -414,33 +414,40 @@ class _NotesListPageState extends ConsumerState<NotesListPage> {
return Colors.transparent;
}
return Padding(
padding: const EdgeInsets.only(bottom: Spacing.sm),
child: Container(
decoration: BoxDecoration(
color: sidebarTheme.accent.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(
color: sidebarTheme.border.withValues(alpha: 0.15),
width: BorderWidth.thin,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
// Compute opaque background for proper context menu snapshot rendering
final cardBackground = Color.alphaBlend(
sidebarTheme.accent.withValues(alpha: 0.5),
sidebarTheme.background,
);
return ConduitContextMenu(
actions: _buildNoteActions(context, note),
child: Padding(
padding: const EdgeInsets.only(bottom: Spacing.sm),
child: Material(
color: Colors.transparent,
color: cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
child: InkWell(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(
color: sidebarTheme.border.withValues(alpha: 0.15),
width: BorderWidth.thin,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.card),
overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
onTap: () {
@@ -450,7 +457,7 @@ class _NotesListPageState extends ConsumerState<NotesListPage> {
pathParameters: {'id': note.id},
);
},
onLongPress: () => _showNoteContextMenu(context, note),
onLongPress: null, // Handled by ConduitContextMenu
child: Padding(
padding: const EdgeInsets.all(Spacing.md),
child: Row(
@@ -558,32 +565,13 @@ class _NotesListPageState extends ConsumerState<NotesListPage> {
],
),
),
// More button
Builder(
builder: (buttonContext) => IconButton(
icon: Icon(
Platform.isIOS
? CupertinoIcons.ellipsis
: Icons.more_vert_rounded,
color: sidebarTheme.foreground.withValues(alpha: 0.5),
size: IconSize.md,
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: TouchTarget.badge,
minHeight: TouchTarget.badge,
),
onPressed: () =>
_showNoteContextMenu(buttonContext, note),
),
),
],
),
),
),
),
),
),
);
}
@@ -594,51 +582,51 @@ class _NotesListPageState extends ConsumerState<NotesListPage> {
date.day == now.day;
}
void _showNoteContextMenu(BuildContext context, Note note) {
List<ConduitContextMenuAction> _buildNoteActions(
BuildContext context,
Note note,
) {
final l10n = AppLocalizations.of(context)!;
showConduitContextMenu(
context: context,
actions: [
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.edit,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
context.pushNamed(
RouteNames.noteEditor,
pathParameters: {'id': note.id},
);
},
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.doc_on_clipboard,
materialIcon: Icons.copy_rounded,
label: l10n.copy,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
final messenger = ScaffoldMessenger.of(context);
await Clipboard.setData(ClipboardData(text: note.markdownContent));
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(l10n.noteCopiedToClipboard),
duration: const Duration(seconds: 2),
),
);
},
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: () async => _deleteNote(note),
),
],
);
return [
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.edit,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
context.pushNamed(
RouteNames.noteEditor,
pathParameters: {'id': note.id},
);
},
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.doc_on_clipboard,
materialIcon: Icons.copy_rounded,
label: l10n.copy,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
final messenger = ScaffoldMessenger.of(context);
await Clipboard.setData(ClipboardData(text: note.markdownContent));
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(l10n.noteCopiedToClipboard),
duration: const Duration(seconds: 2),
),
);
},
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: () async => _deleteNote(note),
),
];
}
Widget _buildEmptyState(BuildContext context) {

View File

@@ -243,6 +243,28 @@ class AppTheme {
iconColor: tokens.neutralTone80,
textColor: tokens.neutralOnSurface,
),
popupMenuTheme: PopupMenuThemeData(
color: surfaces.popover,
surfaceTintColor: Colors.transparent,
elevation: Elevation.high,
shadowColor: shadows.shadowLg.first.color,
shape: RoundedRectangleBorder(
borderRadius: shapes.large,
side: BorderSide(
color: surfaces.border.withValues(alpha: 0.15),
width: 0.5,
),
),
textStyle: textTheme.bodyMedium?.copyWith(
color: tokens.neutralOnSurface,
),
labelTextStyle: WidgetStateProperty.all(
textTheme.bodyMedium?.copyWith(
color: tokens.neutralOnSurface,
fontWeight: FontWeight.w500,
),
),
),
textTheme: textTheme,
extensions: <ThemeExtension<dynamic>>[
tokens,

View File

@@ -1,4 +1,4 @@
import 'dart:io' show Platform;
import 'dart:io';
import 'package:conduit/core/providers/app_providers.dart';
import 'package:conduit/l10n/app_localizations.dart';
@@ -8,9 +8,18 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:super_context_menu/super_context_menu.dart';
// ignore: implementation_imports
import 'package:super_context_menu/src/scaffold/mobile/menu_widget_builder.dart'
as mobile;
import 'package:conduit/features/chat/providers/chat_providers.dart' as chat;
/// Re-export super_context_menu types for convenience.
export 'package:super_context_menu/super_context_menu.dart'
show ContextMenuWidget, Menu, MenuAction, MenuSeparator;
/// Defines an action for use in Conduit context menus.
class ConduitContextMenuAction {
final IconData cupertinoIcon;
final IconData materialIcon;
@@ -29,89 +38,326 @@ class ConduitContextMenuAction {
});
}
Future<void> showConduitContextMenu({
required BuildContext context,
required List<ConduitContextMenuAction> actions,
Offset? position,
}) async {
if (actions.isEmpty) return;
/// A context menu widget that provides native iOS appearance and a beautiful
/// Material 3 styled menu on Android.
///
/// On iOS, this uses the native context menu provided by super_context_menu.
/// On Android, it displays a custom Material 3 styled menu that matches the
/// app's theme.
class ConduitContextMenu extends StatelessWidget {
final List<ConduitContextMenuAction> actions;
final Widget child;
final theme = context.conduitTheme;
final RenderBox? overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
const ConduitContextMenu({
super.key,
required this.actions,
required this.child,
});
if (overlay == null) return;
@override
Widget build(BuildContext context) {
// iOS: Use native context menu
if (Platform.isIOS) {
return ContextMenuWidget(
menuProvider: (_) => buildConduitMenu(actions),
child: child,
);
}
// Determine menu position
final Offset menuPosition = position ?? _getDefaultMenuPosition(context);
// Android: Use ContextMenuWidget with custom Material 3 styling
return ContextMenuWidget(
menuProvider: (_) => buildConduitMenu(actions),
mobileMenuWidgetBuilder: _ConduitMobileMenuBuilder(
theme: context.conduitTheme,
),
child: child,
);
}
}
final result = await showMenu<ConduitContextMenuAction>(
context: context,
position: RelativeRect.fromLTRB(
menuPosition.dx,
menuPosition.dy,
overlay.size.width - menuPosition.dx,
overlay.size.height - menuPosition.dy,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.small),
),
color: theme.surfaceBackground,
elevation: 4,
items: actions.map((action) {
return PopupMenuItem<ConduitContextMenuAction>(
value: action,
/// Custom Material 3 styled menu builder for super_context_menu on Android.
class _ConduitMobileMenuBuilder extends mobile.MobileMenuWidgetBuilder {
final ConduitThemeExtension theme;
const _ConduitMobileMenuBuilder({required this.theme});
@override
Widget buildMenuContainer(
BuildContext context,
mobile.MobileMenuInfo menuInfo,
Widget child,
) {
// Use pre-blended shadow color for Impeller compatibility
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
boxShadow: theme.popoverShadows,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
child: child,
),
);
}
@override
Widget buildMenuContainerInner(
BuildContext context,
mobile.MobileMenuInfo menuInfo,
Widget child,
) {
// Use pre-blended border color for Impeller compatibility
final borderColor = Color.lerp(
theme.surfaces.popover,
theme.surfaces.border,
0.15,
)!;
return DecoratedBox(
decoration: BoxDecoration(
color: theme.surfaces.popover,
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
border: Border.all(color: borderColor, width: 0.5),
),
child: child,
);
}
@override
Widget buildMenu(
BuildContext context,
mobile.MobileMenuInfo menuInfo,
Widget child,
) {
return child;
}
@override
Widget buildMenuItemsContainer(
BuildContext context,
mobile.MobileMenuInfo menuInfo,
Widget child,
) {
return child;
}
@override
Widget buildMenuHeader(
BuildContext context,
mobile.MobileMenuInfo menuInfo,
mobile.MobileMenuButtonState state,
) {
// No header needed for simple menus
return const SizedBox.shrink();
}
@override
Widget buildInactiveMenuVeil(
BuildContext context,
mobile.MobileMenuInfo menuInfo,
) {
// Use pre-blended solid color for Impeller compatibility
final veilColor = theme.isDark
? const Color(0x4D000000) // ~30% black
: const Color(0x4D424242); // ~30% grey
return SizedBox.expand(
child: ColoredBox(color: veilColor),
);
}
@override
Widget buildMenuItem(
BuildContext context,
mobile.MobileMenuInfo menuInfo,
mobile.MobileMenuButtonState state,
MenuElement element,
) {
if (element is MenuAction) {
final isDestructive = element.attributes.destructive;
final textColor = isDestructive ? theme.error : theme.textPrimary;
final iconColor = isDestructive ? theme.error : theme.iconPrimary;
final imageWidget = element.image?.asWidget(menuInfo.iconTheme);
// Use ColoredBox for pressed state to avoid Impeller opacity issues
Widget content = Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.xxs,
horizontal: Spacing.md,
vertical: Spacing.sm + 2,
),
height: 36,
child: Row(
children: [
Icon(
Platform.isIOS ? action.cupertinoIcon : action.materialIcon,
color: action.destructive ? Colors.red : theme.iconPrimary,
size: IconSize.xs,
),
const SizedBox(width: Spacing.sm),
if (imageWidget != null)
Padding(
padding: const EdgeInsets.only(right: Spacing.md),
child: IconTheme(
data: IconThemeData(
color: iconColor,
size: IconSize.medium,
),
child: imageWidget,
),
),
Expanded(
child: Text(
action.label,
style: AppTypography.standard.copyWith(
color: action.destructive ? Colors.red : theme.textPrimary,
element.title ?? '',
style: TextStyle(
fontSize: AppTypography.bodyMedium,
fontWeight: FontWeight.w500,
fontSize: 14,
color: textColor,
decoration: TextDecoration.none,
fontFamily: theme.typography.primaryFont.isEmpty
? null
: theme.typography.primaryFont,
fontFamilyFallback: theme.typography.primaryFallback.isEmpty
? null
: theme.typography.primaryFallback,
),
),
),
],
),
);
if (state.pressed) {
content = ColoredBox(
color: theme.surfaceContainer,
child: content,
);
}
return content;
}
if (element is MenuSeparator) {
// Use pre-blended color for Impeller compatibility
final separatorColor = Color.lerp(
theme.surfaces.popover,
theme.dividerColor,
0.4,
)!;
return Divider(
height: 1,
thickness: 0.5,
indent: Spacing.md,
endIndent: Spacing.md,
color: separatorColor,
);
}
// For submenus or other elements, show a simple row
if (element is Menu) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm + 2,
),
child: Row(
children: [
Expanded(
child: Text(
element.title ?? '',
style: TextStyle(
fontSize: AppTypography.bodyMedium,
fontWeight: FontWeight.w500,
color: theme.textPrimary,
decoration: TextDecoration.none,
fontFamily: theme.typography.primaryFont.isEmpty
? null
: theme.typography.primaryFont,
fontFamilyFallback: theme.typography.primaryFallback.isEmpty
? null
: theme.typography.primaryFallback,
),
),
),
Icon(
Icons.chevron_right,
size: IconSize.small,
color: theme.iconSecondary,
),
],
),
);
}
return const SizedBox.shrink();
}
@override
Widget buildOverlayBackground(BuildContext context, double opacity) {
// Use pre-computed hex colors for Impeller compatibility
// These are solid colors at different opacities (0x80 = 50%, 0x66 = 40%)
final overlayColor = Color.lerp(
const Color(0x00000000),
theme.isDark ? const Color(0x80000000) : const Color(0x66000000),
opacity,
)!;
// GestureDetector with opaque behavior ensures hit testing works
// even when the overlay is visually transparent
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: SizedBox.expand(
child: ColoredBox(color: overlayColor),
),
);
}
@override
Widget buildMenuPreviewContainer(BuildContext context, Widget child) {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
boxShadow: theme.popoverShadows,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: child,
),
);
}
}
/// Builds a [Menu] from a list of [ConduitContextMenuAction]s.
///
/// Use this with [ContextMenuWidget.menuProvider]:
/// ```dart
/// ContextMenuWidget(
/// menuProvider: (_) => buildConduitMenu(actions),
/// child: MyWidget(),
/// )
/// ```
Menu buildConduitMenu(List<ConduitContextMenuAction> actions) {
return Menu(
children: actions.map((action) {
return MenuAction(
title: action.label,
callback: () {
HapticFeedback.selectionClick();
action.onBeforeClose?.call();
action.onSelected();
},
attributes: MenuActionAttributes(destructive: action.destructive),
);
}).toList(),
);
if (result != null) {
result.onBeforeClose?.call();
await Future.microtask(result.onSelected);
}
}
Offset _getDefaultMenuPosition(BuildContext context) {
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) {
return Offset.zero;
}
final position = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
return Offset(position.dx + size.width, position.dy);
}
Future<void> showConversationContextMenu({
/// Builds a list of actions for conversation context menus.
///
/// Use with [ConduitContextMenu]:
/// ```dart
/// ConduitContextMenu(
/// actions: buildConversationActions(context: context, ref: ref, conversation: conv),
/// child: MyWidget(),
/// )
/// ```
List<ConduitContextMenuAction> buildConversationActions({
required BuildContext context,
required WidgetRef ref,
required dynamic conversation,
}) async {
if (conversation == null) return;
}) {
if (conversation == null) {
return [];
}
final l10n = AppLocalizations.of(context)!;
final bool isPinned = conversation.pinned == true;
@@ -150,48 +396,58 @@ Future<void> showConversationContextMenu({
await _confirmAndDeleteConversation(context, ref, conversation.id);
}
HapticFeedback.selectionClick();
await showConduitContextMenu(
context: context,
actions: [
ConduitContextMenuAction(
cupertinoIcon: isPinned
? CupertinoIcons.pin_slash
: CupertinoIcons.pin_fill,
materialIcon: isPinned
? Icons.push_pin_outlined
: Icons.push_pin_rounded,
label: isPinned ? l10n.unpin : l10n.pin,
onBeforeClose: () => HapticFeedback.lightImpact(),
onSelected: togglePin,
),
ConduitContextMenuAction(
cupertinoIcon: isArchived
? CupertinoIcons.archivebox_fill
: CupertinoIcons.archivebox,
materialIcon: isArchived
? Icons.unarchive_rounded
: Icons.archive_rounded,
label: isArchived ? l10n.unarchive : l10n.archive,
onBeforeClose: () => HapticFeedback.lightImpact(),
onSelected: toggleArchive,
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.rename,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: rename,
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: deleteConversation,
),
],
return [
ConduitContextMenuAction(
cupertinoIcon:
isPinned ? CupertinoIcons.pin_slash : CupertinoIcons.pin_fill,
materialIcon:
isPinned ? Icons.push_pin_outlined : Icons.push_pin_rounded,
label: isPinned ? l10n.unpin : l10n.pin,
onBeforeClose: () => HapticFeedback.lightImpact(),
onSelected: togglePin,
),
ConduitContextMenuAction(
cupertinoIcon: isArchived
? CupertinoIcons.archivebox_fill
: CupertinoIcons.archivebox,
materialIcon:
isArchived ? Icons.unarchive_rounded : Icons.archive_rounded,
label: isArchived ? l10n.unarchive : l10n.archive,
onBeforeClose: () => HapticFeedback.lightImpact(),
onSelected: toggleArchive,
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.rename,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: rename,
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: deleteConversation,
),
];
}
/// Builds a [Menu] for conversation context actions.
///
/// Use with [ContextMenuWidget.menuProvider].
Menu buildConversationMenu({
required BuildContext context,
required WidgetRef ref,
required dynamic conversation,
}) {
return buildConduitMenu(
buildConversationActions(
context: context,
ref: ref,
conversation: conversation,
),
);
}
@@ -221,9 +477,7 @@ Future<void> _renameConversation(
if (api == null) throw Exception('No API service');
await api.updateConversation(conversationId, title: newName);
HapticFeedback.selectionClick();
ref
.read(conversationsProvider.notifier)
.updateConversation(
ref.read(conversationsProvider.notifier).updateConversation(
conversationId,
(conversation) =>
conversation.copyWith(title: newName, updatedAt: DateTime.now()),

View File

@@ -164,26 +164,6 @@ class IOSEnhancements {
);
}
/// Create iOS-style context menu
static Widget createContextMenu({
required Widget child,
required List<ContextMenuAction> actions,
}) {
return CupertinoContextMenu(
actions: actions
.map(
(action) => CupertinoContextMenuAction(
onPressed: action.onPressed,
isDefaultAction: action.isDefault,
isDestructiveAction: action.isDestructive,
child: Text(action.title),
),
)
.toList(),
child: child,
);
}
/// Create iOS-style action sheet
static void showActionSheet({
required BuildContext context,
@@ -459,20 +439,6 @@ enum ButtonType { filled, outlined, text }
enum CardType { filled, outlined, elevated }
class ContextMenuAction {
final String title;
final VoidCallback onPressed;
final bool isDefault;
final bool isDestructive;
const ContextMenuAction({
required this.title,
required this.onPressed,
this.isDefault = false,
this.isDestructive = false,
});
}
class ActionSheetAction {
final String title;
final VoidCallback onPressed;