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(