feat(chat): inline action panel with context menu edit flow
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -54,31 +54,33 @@ Future<void> showConduitContextMenu({
|
|||||||
overlay.size.height - menuPosition.dy,
|
overlay.size.height - menuPosition.dy,
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||||
),
|
),
|
||||||
color: theme.surfaceBackground,
|
color: theme.surfaceBackground,
|
||||||
elevation: 8,
|
elevation: 4,
|
||||||
items: actions.map((action) {
|
items: actions.map((action) {
|
||||||
return PopupMenuItem<ConduitContextMenuAction>(
|
return PopupMenuItem<ConduitContextMenuAction>(
|
||||||
value: action,
|
value: action,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: Spacing.md,
|
horizontal: Spacing.sm,
|
||||||
vertical: Spacing.xs,
|
vertical: Spacing.xxs,
|
||||||
),
|
),
|
||||||
|
height: 36,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Platform.isIOS ? action.cupertinoIcon : action.materialIcon,
|
Platform.isIOS ? action.cupertinoIcon : action.materialIcon,
|
||||||
color: action.destructive ? Colors.red : theme.iconPrimary,
|
color: action.destructive ? Colors.red : theme.iconPrimary,
|
||||||
size: IconSize.sm,
|
size: IconSize.xs,
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.md),
|
const SizedBox(width: Spacing.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
action.label,
|
action.label,
|
||||||
style: AppTypography.standard.copyWith(
|
style: AppTypography.standard.copyWith(
|
||||||
color: action.destructive ? Colors.red : theme.textPrimary,
|
color: action.destructive ? Colors.red : theme.textPrimary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user