Merge pull request #300 from cogwheel0/native-context-menus-ios
native-context-menus-ios
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user