feat(chat): inline action panel with context menu edit flow

This commit is contained in:
cogwheel0
2025-10-24 00:31:29 +05:30
parent c1eae6608d
commit 9c2cf347a7
2 changed files with 141 additions and 80 deletions

View File

@@ -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>
); );
} }
Widget _buildEditActionButtons() {
final l10n = AppLocalizations.of(context)!;
final theme = context.conduitTheme;
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Cancel button
Material(
color: Colors.transparent,
child: InkWell(
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,
),
),
],
),
),
),
),
const SizedBox(width: Spacing.sm),
// Save button
Material(
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,
),
),
],
),
),
),
),
],
);
}
// Assistant-only message renderer removed. // Assistant-only message renderer removed.
// Markdown rendering and typing indicator helpers removed.
// 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: [
if (_isEditing) ...[
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.check_mark : Icons.check,
label: AppLocalizations.of(context)!.save,
onTap: _saveInlineEdit,
),
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
label: AppLocalizations.of(context)!.cancel,
onTap: _cancelInlineEdit,
),
] else ...[
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
label: AppLocalizations.of(context)!.edit,
onTap: widget.onEdit ?? _startInlineEdit,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.doc_on_clipboard
: Icons.content_copy,
label: AppLocalizations.of(context)!.copy,
onTap: widget.onCopy,
),
],
],
);
}
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();

View File

@@ -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,
), ),
), ),
), ),