Merge pull request #99 from cogwheel0/feat-strip-reasoning-copy
feat-strip-reasoning-copy
This commit is contained in:
@@ -969,7 +969,37 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _copyMessage(String content) {
|
void _copyMessage(String content) {
|
||||||
Clipboard.setData(ClipboardData(text: content));
|
// Strip reasoning details from the copied content
|
||||||
|
String cleanedContent = content;
|
||||||
|
|
||||||
|
// Remove <details type="reasoning"> blocks
|
||||||
|
cleanedContent = cleanedContent.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'<details\s+type="reasoning"[^>]*>[\s\S]*?<\/details>',
|
||||||
|
multiLine: true,
|
||||||
|
dotAll: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove raw reasoning tags
|
||||||
|
cleanedContent = cleanedContent.replaceAll(
|
||||||
|
RegExp(r'<think>[\s\S]*?<\/think>', multiLine: true, dotAll: true),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
cleanedContent = cleanedContent.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'<reasoning>[\s\S]*?<\/reasoning>',
|
||||||
|
multiLine: true,
|
||||||
|
dotAll: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up any extra whitespace
|
||||||
|
cleanedContent = cleanedContent.trim();
|
||||||
|
|
||||||
|
Clipboard.setData(ClipboardData(text: cleanedContent));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _regenerateMessage(dynamic message) async {
|
void _regenerateMessage(dynamic message) async {
|
||||||
@@ -1248,8 +1278,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
final maxFraction = isTablet ? 0.42 : 0.84;
|
final maxFraction = isTablet ? 0.42 : 0.84;
|
||||||
final edgeFraction = isTablet ? 0.36 : 0.50; // large phone edge
|
final edgeFraction = isTablet ? 0.36 : 0.50; // large phone edge
|
||||||
final scrim = Platform.isIOS
|
final scrim = Platform.isIOS
|
||||||
? context.colorTokens.overlayMedium
|
? context.colorTokens.scrimMedium
|
||||||
: context.colorTokens.overlayStrong;
|
: context.colorTokens.scrimStrong;
|
||||||
|
|
||||||
return ResponsiveDrawerLayout(
|
return ResponsiveDrawerLayout(
|
||||||
maxFraction: maxFraction,
|
maxFraction: maxFraction,
|
||||||
|
|||||||
@@ -435,10 +435,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
|
|
||||||
Widget _buildSegmentedContent() {
|
Widget _buildSegmentedContent() {
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
// Determine if media (attachments or generated images) is rendered above.
|
|
||||||
final hasMediaAbove =
|
|
||||||
(widget.message.attachmentIds?.isNotEmpty ?? false) ||
|
|
||||||
(widget.message.files?.isNotEmpty ?? false);
|
|
||||||
bool firstToolSpacerAdded = false;
|
bool firstToolSpacerAdded = false;
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
for (final seg in _segments) {
|
for (final seg in _segments) {
|
||||||
@@ -450,11 +446,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
}
|
}
|
||||||
children.add(_buildToolCallTile(seg.toolCall!));
|
children.add(_buildToolCallTile(seg.toolCall!));
|
||||||
} else if (seg.isReasoning && seg.reasoning != null) {
|
} else if (seg.isReasoning && seg.reasoning != null) {
|
||||||
// If a reasoning tile is the very first content and sits at the top,
|
|
||||||
// add a small spacer above it for breathing room.
|
|
||||||
if (children.isEmpty && !hasMediaAbove) {
|
|
||||||
children.add(const SizedBox(height: Spacing.sm));
|
|
||||||
}
|
|
||||||
children.add(_buildReasoningTile(seg.reasoning!, idx));
|
children.add(_buildReasoningTile(seg.reasoning!, idx));
|
||||||
} else if ((seg.text ?? '').trim().isNotEmpty) {
|
} else if ((seg.text ?? '').trim().isNotEmpty) {
|
||||||
children.add(_buildEnhancedMarkdownContent(seg.text!));
|
children.add(_buildEnhancedMarkdownContent(seg.text!));
|
||||||
|
|||||||
@@ -210,7 +210,14 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
PlatformUtils.lightHaptic();
|
PlatformUtils.lightHaptic();
|
||||||
widget.onSendMessage(text);
|
widget.onSendMessage(text);
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
// Keep focus and keyboard open; do not collapse automatically
|
|
||||||
|
// Dismiss keyboard after sending to recover screen space
|
||||||
|
_focusNode.unfocus();
|
||||||
|
try {
|
||||||
|
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||||
|
} catch (_) {
|
||||||
|
// Silently handle if keyboard dismissal fails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _insertNewline() {
|
void _insertNewline() {
|
||||||
@@ -786,7 +793,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
Spacing.sm,
|
Spacing.sm,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
_buildOverflowButton(
|
_buildOverflowButton(
|
||||||
tooltip: AppLocalizations.of(context)!.more,
|
tooltip: AppLocalizations.of(context)!.more,
|
||||||
@@ -796,42 +803,47 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedContainer(
|
child: ConstrainedBox(
|
||||||
duration: const Duration(milliseconds: 180),
|
constraints: BoxConstraints(
|
||||||
curve: Curves.easeOutCubic,
|
maxHeight: MediaQuery.of(context).size.height * 0.25,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
minHeight: TouchTarget.input,
|
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
child: AnimatedContainer(
|
||||||
color: composerSurface.withValues(
|
duration: const Duration(milliseconds: 180),
|
||||||
alpha: brightness == Brightness.dark ? 0.9 : 0.2,
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minHeight: TouchTarget.input,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
color: composerSurface.withValues(
|
||||||
color: outlineColor.withValues(
|
alpha: brightness == Brightness.dark ? 0.9 : 0.2,
|
||||||
alpha: brightness == Brightness.dark ? 0.32 : 0.2,
|
|
||||||
),
|
),
|
||||||
width: BorderWidth.micro,
|
borderRadius: BorderRadius.circular(_composerRadius),
|
||||||
),
|
border: Border.all(
|
||||||
),
|
color: outlineColor.withValues(
|
||||||
child: Row(
|
alpha: brightness == Brightness.dark ? 0.32 : 0.2,
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildComposerTextField(
|
|
||||||
brightness: brightness,
|
|
||||||
sendOnEnter: sendOnEnter,
|
|
||||||
placeholderBase: placeholderBase,
|
|
||||||
placeholderFocused: placeholderFocused,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
vertical: Spacing.xs,
|
|
||||||
),
|
|
||||||
isActive: isActive,
|
|
||||||
),
|
),
|
||||||
|
width: BorderWidth.micro,
|
||||||
),
|
),
|
||||||
if (!_hasText && voiceAvailable && !isGenerating)
|
),
|
||||||
_buildInlineMicIcon(voiceAvailable),
|
child: Row(
|
||||||
],
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildComposerTextField(
|
||||||
|
brightness: brightness,
|
||||||
|
sendOnEnter: sendOnEnter,
|
||||||
|
placeholderBase: placeholderBase,
|
||||||
|
placeholderFocused: placeholderFocused,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: Spacing.xs,
|
||||||
|
),
|
||||||
|
isActive: isActive,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_hasText && voiceAvailable && !isGenerating)
|
||||||
|
_buildInlineMicIcon(voiceAvailable),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import 'package:flutter/cupertino.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import 'package:conduit/shared/widgets/chat_action_button.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../../../core/providers/app_providers.dart';
|
import '../../../core/providers/app_providers.dart';
|
||||||
import '../providers/chat_providers.dart';
|
import '../providers/chat_providers.dart';
|
||||||
import '../../../shared/services/tasks/task_queue.dart';
|
import '../../../shared/services/tasks/task_queue.dart';
|
||||||
|
import '../../../shared/utils/conversation_context_menu.dart';
|
||||||
import '../../tools/providers/tools_providers.dart';
|
import '../../tools/providers/tools_providers.dart';
|
||||||
|
|
||||||
class UserMessageBubble extends ConsumerStatefulWidget {
|
class UserMessageBubble extends ConsumerStatefulWidget {
|
||||||
@@ -41,28 +42,15 @@ class UserMessageBubble extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<UserMessageBubble> createState() => _UserMessageBubbleState();
|
ConsumerState<UserMessageBubble> createState() => _UserMessageBubbleState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
||||||
with TickerProviderStateMixin {
|
|
||||||
bool _showActions = false;
|
|
||||||
late AnimationController _fadeController;
|
|
||||||
late AnimationController _slideController;
|
|
||||||
// press state handled by shared ChatActionButton
|
|
||||||
|
|
||||||
bool _isEditing = false;
|
bool _isEditing = false;
|
||||||
late final TextEditingController _editController;
|
late final TextEditingController _editController;
|
||||||
final FocusNode _editFocusNode = FocusNode();
|
final FocusNode _editFocusNode = FocusNode();
|
||||||
|
final GlobalKey _bubbleKey = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_fadeController = AnimationController(
|
|
||||||
duration: AnimationDuration.microInteraction,
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
_slideController = AnimationController(
|
|
||||||
duration: AnimationDuration.messageSlide,
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
_editController = TextEditingController(
|
_editController = TextEditingController(
|
||||||
text: widget.message?.content ?? '',
|
text: widget.message?.content ?? '',
|
||||||
);
|
);
|
||||||
@@ -417,25 +405,56 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_fadeController.dispose();
|
|
||||||
_slideController.dispose();
|
|
||||||
_editController.dispose();
|
_editController.dispose();
|
||||||
_editFocusNode.dispose();
|
_editFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleActions() {
|
Future<void> _showMessageMenu(BuildContext context) async {
|
||||||
setState(() {
|
// Don't show menu while editing - use the visible Save/Cancel buttons instead
|
||||||
_showActions = !_showActions;
|
if (_isEditing) return;
|
||||||
});
|
|
||||||
|
|
||||||
if (_showActions) {
|
final l10n = AppLocalizations.of(context)!;
|
||||||
_fadeController.forward();
|
HapticFeedback.selectionClick();
|
||||||
_slideController.forward();
|
|
||||||
} else {
|
// Get the position of the bubble to show menu below it
|
||||||
_fadeController.reverse();
|
Offset? menuPosition;
|
||||||
_slideController.reverse();
|
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!();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -458,7 +477,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
);
|
);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onLongPress: () => _toggleActions(),
|
onLongPress: () => _showMessageMenu(context),
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -490,6 +509,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
key: _bubbleKey,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: Spacing.chatBubblePadding,
|
horizontal: Spacing.chatBubblePadding,
|
||||||
vertical: Spacing.sm,
|
vertical: Spacing.sm,
|
||||||
@@ -584,12 +604,11 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (hasText) const SizedBox(height: Spacing.xs),
|
|
||||||
|
|
||||||
// Action buttons below the message
|
// Edit action buttons - show Save/Cancel when editing
|
||||||
if (_showActions) ...[
|
if (_isEditing) ...[
|
||||||
const SizedBox(height: Spacing.sm),
|
const SizedBox(height: Spacing.sm),
|
||||||
_buildUserActionButtons(),
|
_buildEditActionButtons(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -597,59 +616,100 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assistant-only message renderer removed.
|
Widget _buildEditActionButtons() {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
|
||||||
// Markdown rendering and typing indicator helpers removed.
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
// Removed unused assistant action buttons builder.
|
|
||||||
|
|
||||||
Widget _buildActionButton({
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
VoidCallback? onTap,
|
|
||||||
}) {
|
|
||||||
return ChatActionButton(icon: icon, label: label, onTap: onTap);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildUserActionButtons() {
|
|
||||||
return Wrap(
|
|
||||||
spacing: Spacing.sm,
|
|
||||||
runSpacing: Spacing.sm,
|
|
||||||
children: [
|
children: [
|
||||||
if (_isEditing) ...[
|
// Cancel button
|
||||||
_buildActionButton(
|
Material(
|
||||||
icon: Platform.isIOS ? CupertinoIcons.check_mark : Icons.check,
|
color: Colors.transparent,
|
||||||
label: AppLocalizations.of(context)!.save,
|
child: InkWell(
|
||||||
onTap: _saveInlineEdit,
|
|
||||||
),
|
|
||||||
_buildActionButton(
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
|
||||||
label: AppLocalizations.of(context)!.cancel,
|
|
||||||
onTap: _cancelInlineEdit,
|
onTap: _cancelInlineEdit,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.md,
|
||||||
|
vertical: Spacing.xs,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.cardBorder,
|
||||||
|
width: BorderWidth.thin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
||||||
|
size: IconSize.xs,
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Text(
|
||||||
|
l10n.cancel,
|
||||||
|
style: AppTypography.standard.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
] else ...[
|
),
|
||||||
_buildActionButton(
|
const SizedBox(width: Spacing.sm),
|
||||||
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
|
// Save button
|
||||||
label: AppLocalizations.of(context)!.edit,
|
Material(
|
||||||
onTap: widget.onEdit ?? _startInlineEdit,
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _saveInlineEdit,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.md,
|
||||||
|
vertical: Spacing.xs,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.buttonPrimary,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.check_mark : Icons.check,
|
||||||
|
size: IconSize.xs,
|
||||||
|
color: theme.buttonPrimaryText,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Text(
|
||||||
|
l10n.save,
|
||||||
|
style: AppTypography.standard.copyWith(
|
||||||
|
color: theme.buttonPrimaryText,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildActionButton(
|
),
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.doc_on_clipboard
|
|
||||||
: Icons.content_copy,
|
|
||||||
label: AppLocalizations.of(context)!.copy,
|
|
||||||
onTap: widget.onCopy,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assistant-only message renderer removed.
|
||||||
|
|
||||||
void _startInlineEdit() {
|
void _startInlineEdit() {
|
||||||
if (_isEditing) return;
|
if (_isEditing) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isEditing = true;
|
_isEditing = true;
|
||||||
_showActions = true; // ensure actions visible for Save/Cancel
|
|
||||||
_editController.text = widget.message.content ?? '';
|
_editController.text = widget.message.content ?? '';
|
||||||
});
|
});
|
||||||
// Request focus after frame to show keyboard
|
// Request focus after frame to show keyboard
|
||||||
@@ -664,7 +724,6 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
if (!_isEditing) return;
|
if (!_isEditing) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isEditing = false;
|
_isEditing = false;
|
||||||
// keep actions panel open; user can close with long-press
|
|
||||||
_editController.text = widget.message.content ?? '';
|
_editController.text = widget.message.content ?? '';
|
||||||
});
|
});
|
||||||
_editFocusNode.unfocus();
|
_editFocusNode.unfocus();
|
||||||
|
|||||||
@@ -1254,9 +1254,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
? null
|
? null
|
||||||
: () => _selectConversation(context, conv.id),
|
: () => _selectConversation(context, conv.id),
|
||||||
onLongPress: null,
|
onLongPress: null,
|
||||||
onMorePressed: () {
|
onMorePressed: (buttonContext) {
|
||||||
showConversationContextMenu(
|
showConversationContextMenu(
|
||||||
context: context,
|
context: buttonContext,
|
||||||
ref: ref,
|
ref: ref,
|
||||||
conversation: conv,
|
conversation: conv,
|
||||||
);
|
);
|
||||||
@@ -1609,7 +1609,7 @@ class _ConversationTileContent extends StatelessWidget {
|
|||||||
final bool pinned;
|
final bool pinned;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final VoidCallback? onMorePressed;
|
final void Function(BuildContext)? onMorePressed;
|
||||||
final Widget? leading;
|
final Widget? leading;
|
||||||
|
|
||||||
const _ConversationTileContent({
|
const _ConversationTileContent({
|
||||||
@@ -1664,22 +1664,29 @@ class _ConversationTileContent extends StatelessWidget {
|
|||||||
} else if (onMorePressed != null) {
|
} else if (onMorePressed != null) {
|
||||||
trailing.addAll([
|
trailing.addAll([
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.sm),
|
||||||
IconButton(
|
Builder(
|
||||||
iconSize: IconSize.sm,
|
builder: (buttonContext) {
|
||||||
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
|
return IconButton(
|
||||||
padding: EdgeInsets.zero,
|
iconSize: IconSize.sm,
|
||||||
constraints: const BoxConstraints(
|
visualDensity: const VisualDensity(
|
||||||
minWidth: TouchTarget.listItem,
|
horizontal: -2,
|
||||||
minHeight: TouchTarget.listItem,
|
vertical: -2,
|
||||||
),
|
),
|
||||||
icon: Icon(
|
padding: EdgeInsets.zero,
|
||||||
Platform.isIOS
|
constraints: const BoxConstraints(
|
||||||
? CupertinoIcons.ellipsis
|
minWidth: TouchTarget.listItem,
|
||||||
: Icons.more_vert_rounded,
|
minHeight: TouchTarget.listItem,
|
||||||
color: theme.iconSecondary,
|
),
|
||||||
),
|
icon: Icon(
|
||||||
onPressed: onMorePressed,
|
Platform.isIOS
|
||||||
tooltip: AppLocalizations.of(context)!.more,
|
? CupertinoIcons.ellipsis
|
||||||
|
: Icons.more_vert_rounded,
|
||||||
|
color: theme.iconSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () => onMorePressed!(buttonContext),
|
||||||
|
tooltip: AppLocalizations.of(context)!.more,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1720,7 +1727,7 @@ class _ConversationTile extends StatelessWidget {
|
|||||||
final Widget? leading;
|
final Widget? leading;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onLongPress;
|
final VoidCallback? onLongPress;
|
||||||
final VoidCallback? onMorePressed;
|
final void Function(BuildContext)? onMorePressed;
|
||||||
|
|
||||||
const _ConversationTile({
|
const _ConversationTile({
|
||||||
required this.title,
|
required this.title,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ class AppColorTokens extends ThemeExtension<AppColorTokens> {
|
|||||||
required this.overlayWeak,
|
required this.overlayWeak,
|
||||||
required this.overlayMedium,
|
required this.overlayMedium,
|
||||||
required this.overlayStrong,
|
required this.overlayStrong,
|
||||||
|
required this.scrimMedium,
|
||||||
|
required this.scrimStrong,
|
||||||
required this.codeBackground,
|
required this.codeBackground,
|
||||||
required this.codeBorder,
|
required this.codeBorder,
|
||||||
required this.codeText,
|
required this.codeText,
|
||||||
@@ -84,6 +86,10 @@ class AppColorTokens extends ThemeExtension<AppColorTokens> {
|
|||||||
final Color overlayMedium;
|
final Color overlayMedium;
|
||||||
final Color overlayStrong;
|
final Color overlayStrong;
|
||||||
|
|
||||||
|
// Scrim tokens (for drawer/modal overlays)
|
||||||
|
final Color scrimMedium;
|
||||||
|
final Color scrimStrong;
|
||||||
|
|
||||||
// Markdown/code tokens
|
// Markdown/code tokens
|
||||||
final Color codeBackground;
|
final Color codeBackground;
|
||||||
final Color codeBorder;
|
final Color codeBorder;
|
||||||
@@ -194,6 +200,14 @@ class AppColorTokens extends ThemeExtension<AppColorTokens> {
|
|||||||
alpha: isLight ? 0.32 : 0.36,
|
alpha: isLight ? 0.32 : 0.36,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Scrim tokens use black to create darkening effect in both modes
|
||||||
|
final Color scrimMedium = Colors.black.withValues(
|
||||||
|
alpha: isLight ? 0.2 : 0.5,
|
||||||
|
);
|
||||||
|
final Color scrimStrong = Colors.black.withValues(
|
||||||
|
alpha: isLight ? 0.32 : 0.6,
|
||||||
|
);
|
||||||
|
|
||||||
final Color codeBackground = mix(variant.muted, neutralTone00, 0.5);
|
final Color codeBackground = mix(variant.muted, neutralTone00, 0.5);
|
||||||
final Color codeBorder = mix(variant.border, neutralTone40, 0.6);
|
final Color codeBorder = mix(variant.border, neutralTone40, 0.6);
|
||||||
final Color codeText = _ensureContrast(
|
final Color codeText = _ensureContrast(
|
||||||
@@ -232,6 +246,8 @@ class AppColorTokens extends ThemeExtension<AppColorTokens> {
|
|||||||
overlayWeak: overlayWeak,
|
overlayWeak: overlayWeak,
|
||||||
overlayMedium: overlayMedium,
|
overlayMedium: overlayMedium,
|
||||||
overlayStrong: overlayStrong,
|
overlayStrong: overlayStrong,
|
||||||
|
scrimMedium: scrimMedium,
|
||||||
|
scrimStrong: scrimStrong,
|
||||||
codeBackground: codeBackground,
|
codeBackground: codeBackground,
|
||||||
codeBorder: codeBorder,
|
codeBorder: codeBorder,
|
||||||
codeText: codeText,
|
codeText: codeText,
|
||||||
@@ -269,6 +285,8 @@ class AppColorTokens extends ThemeExtension<AppColorTokens> {
|
|||||||
Color? overlayWeak,
|
Color? overlayWeak,
|
||||||
Color? overlayMedium,
|
Color? overlayMedium,
|
||||||
Color? overlayStrong,
|
Color? overlayStrong,
|
||||||
|
Color? scrimMedium,
|
||||||
|
Color? scrimStrong,
|
||||||
Color? codeBackground,
|
Color? codeBackground,
|
||||||
Color? codeBorder,
|
Color? codeBorder,
|
||||||
Color? codeText,
|
Color? codeText,
|
||||||
@@ -303,6 +321,8 @@ class AppColorTokens extends ThemeExtension<AppColorTokens> {
|
|||||||
overlayWeak: overlayWeak ?? this.overlayWeak,
|
overlayWeak: overlayWeak ?? this.overlayWeak,
|
||||||
overlayMedium: overlayMedium ?? this.overlayMedium,
|
overlayMedium: overlayMedium ?? this.overlayMedium,
|
||||||
overlayStrong: overlayStrong ?? this.overlayStrong,
|
overlayStrong: overlayStrong ?? this.overlayStrong,
|
||||||
|
scrimMedium: scrimMedium ?? this.scrimMedium,
|
||||||
|
scrimStrong: scrimStrong ?? this.scrimStrong,
|
||||||
codeBackground: codeBackground ?? this.codeBackground,
|
codeBackground: codeBackground ?? this.codeBackground,
|
||||||
codeBorder: codeBorder ?? this.codeBorder,
|
codeBorder: codeBorder ?? this.codeBorder,
|
||||||
codeText: codeText ?? this.codeText,
|
codeText: codeText ?? this.codeText,
|
||||||
@@ -364,6 +384,8 @@ class AppColorTokens extends ThemeExtension<AppColorTokens> {
|
|||||||
overlayWeak: Color.lerp(overlayWeak, other.overlayWeak, t)!,
|
overlayWeak: Color.lerp(overlayWeak, other.overlayWeak, t)!,
|
||||||
overlayMedium: Color.lerp(overlayMedium, other.overlayMedium, t)!,
|
overlayMedium: Color.lerp(overlayMedium, other.overlayMedium, t)!,
|
||||||
overlayStrong: Color.lerp(overlayStrong, other.overlayStrong, t)!,
|
overlayStrong: Color.lerp(overlayStrong, other.overlayStrong, t)!,
|
||||||
|
scrimMedium: Color.lerp(scrimMedium, other.scrimMedium, t)!,
|
||||||
|
scrimStrong: Color.lerp(scrimStrong, other.scrimStrong, t)!,
|
||||||
codeBackground: Color.lerp(codeBackground, other.codeBackground, t)!,
|
codeBackground: Color.lerp(codeBackground, other.codeBackground, t)!,
|
||||||
codeBorder: Color.lerp(codeBorder, other.codeBorder, t)!,
|
codeBorder: Color.lerp(codeBorder, other.codeBorder, t)!,
|
||||||
codeText: Color.lerp(codeText, other.codeText, t)!,
|
codeText: Color.lerp(codeText, other.codeText, t)!,
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import 'dart:io' show Platform;
|
|||||||
import 'package:conduit/core/providers/app_providers.dart';
|
import 'package:conduit/core/providers/app_providers.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import 'package:conduit/shared/theme/theme_extensions.dart';
|
import 'package:conduit/shared/theme/theme_extensions.dart';
|
||||||
import 'package:conduit/shared/widgets/conduit_components.dart';
|
|
||||||
import 'package:conduit/shared/widgets/modal_safe_area.dart';
|
|
||||||
import 'package:conduit/shared/widgets/sheet_handle.dart';
|
|
||||||
import 'package:conduit/shared/widgets/themed_dialogs.dart';
|
import 'package:conduit/shared/widgets/themed_dialogs.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -35,74 +32,78 @@ class ConduitContextMenuAction {
|
|||||||
Future<void> showConduitContextMenu({
|
Future<void> showConduitContextMenu({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required List<ConduitContextMenuAction> actions,
|
required List<ConduitContextMenuAction> actions,
|
||||||
|
Offset? position,
|
||||||
}) async {
|
}) async {
|
||||||
if (actions.isEmpty) return;
|
if (actions.isEmpty) return;
|
||||||
|
|
||||||
final theme = context.conduitTheme;
|
final theme = context.conduitTheme;
|
||||||
|
final RenderBox? overlay =
|
||||||
|
Overlay.of(context).context.findRenderObject() as RenderBox?;
|
||||||
|
|
||||||
await showModalBottomSheet(
|
if (overlay == null) return;
|
||||||
|
|
||||||
|
// Determine menu position
|
||||||
|
final Offset menuPosition = position ?? _getDefaultMenuPosition(context);
|
||||||
|
|
||||||
|
final result = await showMenu<ConduitContextMenuAction>(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
position: RelativeRect.fromLTRB(
|
||||||
builder: (sheetContext) {
|
menuPosition.dx,
|
||||||
Future<void> handleAction(ConduitContextMenuAction action) async {
|
menuPosition.dy,
|
||||||
action.onBeforeClose?.call();
|
overlay.size.width - menuPosition.dx,
|
||||||
Navigator.of(sheetContext).pop();
|
overlay.size.height - menuPosition.dy,
|
||||||
await Future.microtask(action.onSelected);
|
),
|
||||||
}
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||||
List<Widget> buildActionTiles() {
|
),
|
||||||
return actions
|
color: theme.surfaceBackground,
|
||||||
.map(
|
elevation: 4,
|
||||||
(action) => ConduitListItem(
|
items: actions.map((action) {
|
||||||
isCompact: true,
|
return PopupMenuItem<ConduitContextMenuAction>(
|
||||||
leading: Icon(
|
value: action,
|
||||||
Platform.isIOS ? action.cupertinoIcon : action.materialIcon,
|
|
||||||
color: action.destructive ? Colors.red : theme.iconPrimary,
|
|
||||||
size: IconSize.modal,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
action.label,
|
|
||||||
style: AppTypography.standard.copyWith(
|
|
||||||
color: action.destructive ? Colors.red : theme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () => handleAction(action),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
final actionTiles = buildActionTiles();
|
|
||||||
|
|
||||||
return ModalSheetSafeArea(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: Spacing.screenPadding,
|
horizontal: Spacing.sm,
|
||||||
vertical: Spacing.screenPadding,
|
vertical: Spacing.xxs,
|
||||||
),
|
),
|
||||||
child: Container(
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: theme.surfaceBackground,
|
children: [
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
Icon(
|
||||||
boxShadow: ConduitShadows.modal(context),
|
Platform.isIOS ? action.cupertinoIcon : action.materialIcon,
|
||||||
),
|
color: action.destructive ? Colors.red : theme.iconPrimary,
|
||||||
child: Column(
|
size: IconSize.xs,
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
const SizedBox(width: Spacing.sm),
|
||||||
const SizedBox(height: Spacing.sm),
|
Expanded(
|
||||||
const SheetHandle(),
|
child: Text(
|
||||||
const SizedBox(height: Spacing.sm),
|
action.label,
|
||||||
for (var i = 0; i < actionTiles.length; i++) ...[
|
style: AppTypography.standard.copyWith(
|
||||||
if (i != 0) const ConduitDivider(isCompact: true),
|
color: action.destructive ? Colors.red : theme.textPrimary,
|
||||||
actionTiles[i],
|
fontWeight: FontWeight.w500,
|
||||||
],
|
fontSize: 14,
|
||||||
const SizedBox(height: Spacing.sm),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}).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({
|
Future<void> showConversationContextMenu({
|
||||||
|
|||||||
Reference in New Issue
Block a user