feat: title on the header

This commit is contained in:
cogwheel0
2025-09-19 23:35:46 +05:30
parent c6efb53e3f
commit 1fc1cf9739
3 changed files with 412 additions and 314 deletions

View File

@@ -40,6 +40,7 @@ import '../../../shared/widgets/conduit_components.dart';
import '../../../shared/widgets/middle_ellipsis_text.dart'; import '../../../shared/widgets/middle_ellipsis_text.dart';
import '../../../shared/widgets/modal_safe_area.dart'; import '../../../shared/widgets/modal_safe_area.dart';
import '../../../core/services/settings_service.dart'; import '../../../core/services/settings_service.dart';
import '../../../shared/utils/conversation_context_menu.dart';
// Removed unused PlatformUtils import // Removed unused PlatformUtils import
import '../../../core/services/platform_service.dart' as ps; import '../../../core/services/platform_service.dart' as ps;
import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/gestures.dart' show DragStartBehavior;
@@ -913,6 +914,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
// Use select to watch only connectivity status to reduce rebuilds // Use select to watch only connectivity status to reduce rebuilds
final isOnline = ref.watch(isOnlineProvider.select((status) => status)); final isOnline = ref.watch(isOnlineProvider.select((status) => status));
@@ -924,6 +926,28 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Watch reviewer mode and auto-select model if needed // Watch reviewer mode and auto-select model if needed
final isReviewerMode = ref.watch(reviewerModeProvider); final isReviewerMode = ref.watch(reviewerModeProvider);
final omitProviderInModelName = ref.watch(
appSettingsProvider.select(
(settings) => settings.omitProviderInModelName,
),
);
final conversationTitle = ref.watch(
activeConversationProvider.select((conv) => conv?.title),
);
final displayConversationTitle = (() {
final trimmed = conversationTitle?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
return l10n.newChat;
})();
final formattedModelName = selectedModel != null
? _formatModelDisplayName(
selectedModel.name,
omitProvider: omitProviderInModelName,
)
: null;
// Keyboard visibility // Keyboard visibility
final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Whether the messages list can actually scroll (avoids showing button when not needed) // Whether the messages list can actually scroll (avoids showing button when not needed)
@@ -1023,7 +1047,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
elevation: Elevation.none, elevation: Elevation.none,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
toolbarHeight: kToolbarHeight - 8, toolbarHeight: kToolbarHeight + 8,
centerTitle: true, centerTitle: true,
titleSpacing: 0.0, titleSpacing: 0.0,
leading: _isSelectionMode leading: _isSelectionMode
@@ -1071,13 +1095,35 @@ class _ChatPageState extends ConsumerState<ChatPage> {
(models) => _showModelDropdown(context, ref, models), (models) => _showModelDropdown(context, ref, models),
); );
}, },
onLongPress: () {
final conversation = ref.read(activeConversationProvider);
if (conversation == null) return;
showConversationContextMenu(
context: context,
ref: ref,
conversation: conversation,
);
},
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
MiddleEllipsisText(
displayConversationTitle,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 18,
height: 1.3,
),
textAlign: TextAlign.center,
semanticsLabel: displayConversationTitle,
),
const SizedBox(height: Spacing.xs),
Transform.translate( Transform.translate(
offset: const Offset(0, 0), offset: const Offset(0, 0),
child: SizedBox( child: SizedBox(
height: 28, height: 24,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1114,28 +1160,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
Flexible( Flexible(
child: Builder( child: MiddleEllipsisText(
builder: (context) { formattedModelName!,
final omitProvider = ref style: AppTypography.small.copyWith(
.watch(appSettingsProvider) color: context.conduitTheme.textSecondary,
.omitProviderInModelName; fontWeight: FontWeight.w600,
final label = _formatModelDisplayName( height: 1.2,
selectedModel.name, ),
omitProvider: omitProvider, textAlign: TextAlign.center,
); semanticsLabel: formattedModelName,
return MiddleEllipsisText(
label,
style: AppTypography.headlineSmallStyle
.copyWith(
color: context
.conduitTheme
.textPrimary,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
semanticsLabel: label,
);
},
), ),
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
@@ -1169,7 +1202,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
), ),
), ),
if (ref.watch(reviewerModeProvider)) if (isReviewerMode)
Padding( Padding(
padding: const EdgeInsets.only(top: 2.0), padding: const EdgeInsets.only(top: 2.0),
child: Container( child: Container(
@@ -1210,13 +1243,35 @@ class _ChatPageState extends ConsumerState<ChatPage> {
(models) => _showModelDropdown(context, ref, models), (models) => _showModelDropdown(context, ref, models),
); );
}, },
onLongPress: () {
final conversation = ref.read(activeConversationProvider);
if (conversation == null) return;
showConversationContextMenu(
context: context,
ref: ref,
conversation: conversation,
);
},
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
MiddleEllipsisText(
displayConversationTitle,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 18,
height: 1.3,
),
textAlign: TextAlign.center,
semanticsLabel: displayConversationTitle,
),
const SizedBox(height: Spacing.xs),
Transform.translate( Transform.translate(
offset: const Offset(0, 0), offset: const Offset(0, 0),
child: SizedBox( child: SizedBox(
height: 28, height: 24,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1253,17 +1308,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
Flexible( Flexible(
child: Text( child: MiddleEllipsisText(
'Choose Model', l10n.chooseModel,
style: AppTypography.headlineSmallStyle style: AppTypography.small.copyWith(
.copyWith( color: context.conduitTheme.textSecondary,
color: fontWeight: FontWeight.w600,
context.conduitTheme.textPrimary, height: 1.2,
fontWeight: FontWeight.w600, ),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, textAlign: TextAlign.center,
semanticsLabel: l10n.chooseModel,
), ),
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
@@ -1297,7 +1350,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
), ),
), ),
if (ref.watch(reviewerModeProvider)) if (isReviewerMode)
Padding( Padding(
padding: const EdgeInsets.only(top: 2.0), padding: const EdgeInsets.only(top: 2.0),
child: Container( child: Container(

View File

@@ -8,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/modal_safe_area.dart';
import '../../chat/providers/chat_providers.dart' as chat; import '../../chat/providers/chat_providers.dart' as chat;
// import '../../files/views/files_page.dart'; // import '../../files/views/files_page.dart';
import '../../profile/views/profile_page.dart'; import '../../profile/views/profile_page.dart';
@@ -17,6 +16,7 @@ import '../../../shared/widgets/themed_dialogs.dart';
import '../../../core/auth/auth_state_manager.dart'; import '../../../core/auth/auth_state_manager.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
import '../../../core/utils/user_display_name.dart'; import '../../../core/utils/user_display_name.dart';
import '../../../shared/utils/conversation_context_menu.dart';
class ChatsDrawer extends ConsumerStatefulWidget { class ChatsDrawer extends ConsumerStatefulWidget {
const ChatsDrawer({super.key}); const ChatsDrawer({super.key});
@@ -798,62 +798,31 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
String folderId, String folderId,
String folderName, String folderName,
) { ) {
final theme = context.conduitTheme; final l10n = AppLocalizations.of(context)!;
// Ensure consistent modal padding/insets across the app
// ignore: unnecessary_import
showModalBottomSheet( showConduitContextMenu(
context: context, context: context,
backgroundColor: theme.surfaceBackground, actions: [
shape: const RoundedRectangleBorder( ConduitContextMenuAction(
borderRadius: BorderRadius.vertical( cupertinoIcon: CupertinoIcons.pencil,
top: Radius.circular(AppBorderRadius.lg), materialIcon: Icons.edit_rounded,
label: l10n.rename,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
await _renameFolder(context, folderId, folderName);
},
), ),
), ConduitContextMenuAction(
builder: (sheetContext) { cupertinoIcon: CupertinoIcons.delete,
return ModalSheetSafeArea( materialIcon: Icons.delete_rounded,
padding: const EdgeInsets.symmetric( label: l10n.delete,
horizontal: Spacing.modalPadding, destructive: true,
vertical: Spacing.modalPadding, onBeforeClose: () => HapticFeedback.mediumImpact(),
), onSelected: () async {
child: Column( await _confirmAndDeleteFolder(context, folderId, folderName);
mainAxisSize: MainAxisSize.min, },
children: [ ),
ListTile( ],
leading: Icon(
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_rounded,
color: theme.iconPrimary,
),
title: Text(
AppLocalizations.of(context)!.rename,
style: TextStyle(color: theme.textPrimary),
),
onTap: () async {
HapticFeedback.selectionClick();
Navigator.pop(sheetContext);
await _renameFolder(context, folderId, folderName);
},
),
const Divider(height: 1),
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded,
color: theme.error,
),
title: Text(
AppLocalizations.of(context)!.delete,
style: TextStyle(color: theme.error),
),
onTap: () async {
HapticFeedback.mediumImpact();
Navigator.pop(sheetContext);
await _confirmAndDeleteFolder(context, folderId, folderName);
},
),
],
),
);
},
); );
} }
@@ -1017,8 +986,11 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
: () => _selectConversation(context, conv.id), : () => _selectConversation(context, conv.id),
onLongPress: null, onLongPress: null,
onMorePressed: () { onMorePressed: () {
HapticFeedback.selectionClick(); showConversationContextMenu(
_showConversationContextMenu(context, conv); context: context,
ref: ref,
conversation: conv,
);
}, },
); );
@@ -1321,209 +1293,6 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
), ),
); );
} }
void _showConversationContextMenu(BuildContext context, dynamic conv) {
final theme = context.conduitTheme;
final bool isPinned = conv.pinned == true;
final bool isArchived = conv.archived == true;
HapticFeedback.selectionClick();
showModalBottomSheet(
context: context,
backgroundColor: theme.surfaceBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.lg),
),
),
builder: (sheetContext) {
return ModalSheetSafeArea(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.modalPadding,
vertical: Spacing.modalPadding,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
isPinned
? (Platform.isIOS
? CupertinoIcons.pin_slash
: Icons.push_pin_outlined)
: (Platform.isIOS
? CupertinoIcons.pin_fill
: Icons.push_pin_rounded),
color: theme.iconPrimary,
),
title: Text(
isPinned
? AppLocalizations.of(context)!.unpin
: AppLocalizations.of(context)!.pin,
style: TextStyle(color: theme.textPrimary),
),
onTap: () async {
HapticFeedback.lightImpact();
Navigator.pop(sheetContext);
final pinErrorMessage = AppLocalizations.of(
context,
)!.failedToUpdatePin;
try {
await chat.pinConversation(ref, conv.id, !isPinned);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(
this.context,
pinErrorMessage,
isError: true,
);
}
},
),
ListTile(
leading: Icon(
isArchived
? (Platform.isIOS
? CupertinoIcons.archivebox_fill
: Icons.unarchive_rounded)
: (Platform.isIOS
? CupertinoIcons.archivebox
: Icons.archive_rounded),
color: theme.iconPrimary,
),
title: Text(
isArchived
? AppLocalizations.of(context)!.unarchive
: AppLocalizations.of(context)!.archive,
style: TextStyle(color: theme.textPrimary),
),
onTap: () async {
HapticFeedback.lightImpact();
Navigator.pop(sheetContext);
final archiveErrorMessage = AppLocalizations.of(
context,
)!.failedToUpdateArchive;
try {
await chat.archiveConversation(ref, conv.id, !isArchived);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(
this.context,
archiveErrorMessage,
isError: true,
);
}
},
),
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_rounded,
color: theme.iconPrimary,
),
title: Text(
AppLocalizations.of(context)!.rename,
style: TextStyle(color: theme.textPrimary),
),
onTap: () async {
HapticFeedback.selectionClick();
Navigator.pop(sheetContext);
await _renameConversation(context, conv.id, conv.title ?? '');
},
),
const Divider(height: 1),
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded,
color: theme.error,
),
title: Text(
AppLocalizations.of(context)!.delete,
style: TextStyle(color: theme.error),
),
onTap: () async {
HapticFeedback.mediumImpact();
Navigator.pop(sheetContext);
await _confirmAndDeleteConversation(context, conv.id);
},
),
],
),
);
},
);
}
Future<void> _renameConversation(
BuildContext context,
String conversationId,
String currentTitle,
) async {
final l10n = AppLocalizations.of(context)!;
final newName = await ThemedDialogs.promptTextInput(
context,
title: l10n.renameChat,
hintText: l10n.enterChatName,
initialValue: currentTitle,
confirmText: l10n.save,
cancelText: l10n.cancel,
);
if (!mounted) return;
if (newName == null) return;
if (newName.isEmpty || newName == currentTitle) return;
final renameError = l10n.failedToRenameChat;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.updateConversation(conversationId, title: newName);
HapticFeedback.selectionClick();
// Reflect changes
ref.invalidate(conversationsProvider);
final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).state = active!.copyWith(
title: newName,
);
}
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(this.context, renameError, isError: true);
}
}
Future<void> _confirmAndDeleteConversation(
BuildContext context,
String conversationId,
) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await ThemedDialogs.confirm(
context,
title: l10n.deleteChatTitle,
message: l10n.deleteChatMessage,
confirmText: l10n.delete,
isDestructive: true,
);
if (!mounted) return;
if (!confirmed) return;
final deleteError = l10n.failedToDeleteChat;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.deleteConversation(conversationId);
HapticFeedback.mediumImpact();
// Clear if deleting active
final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).state = null;
ref.read(chat.chatMessagesProvider.notifier).clearMessages();
}
ref.invalidate(conversationsProvider);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(this.context, deleteError, isError: true);
}
}
} }
class _DragConversationData { class _DragConversationData {
@@ -1596,7 +1365,7 @@ class _ConversationTileContent extends StatelessWidget {
final theme = context.conduitTheme; final theme = context.conduitTheme;
final textStyle = AppTypography.standard.copyWith( final textStyle = AppTypography.standard.copyWith(
color: theme.textPrimary, color: theme.textPrimary,
fontWeight: FontWeight.w400, fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
height: 1.4, height: 1.4,
); );
@@ -1696,15 +1465,22 @@ class _ConversationTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.conduitTheme; final theme = context.conduitTheme;
final borderColor = selected final brightness = Theme.of(context).brightness;
? theme.navigationSelected final borderRadius = BorderRadius.circular(AppBorderRadius.navigation);
: pinned final Color background = selected
? theme.navigationSelected.withValues(alpha: 0.30) ? theme.buttonPrimary.withValues(
: theme.surfaceContainerHighest.withValues(alpha: 0.40); alpha: brightness == Brightness.dark ? 0.28 : 0.16,
final backgroundColor = theme.surfaceContainer; )
final highlightColor = theme.navigationSelectedBackground.withValues( : theme.surfaceContainer;
alpha: 0.45, final Color borderColor;
); if (selected) {
borderColor = theme.buttonPrimary.withValues(alpha: 0.7);
} else if (pinned) {
borderColor = theme.buttonPrimary.withValues(alpha: 0.35);
} else {
borderColor = theme.surfaceContainerHighest.withValues(alpha: 0.40);
}
final List<BoxShadow> shadow = selected ? ConduitShadows.low : const [];
Color? overlayForStates(Set<WidgetState> states) { Color? overlayForStates(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) { if (states.contains(WidgetState.pressed)) {
@@ -1721,13 +1497,10 @@ class _ConversationTile extends StatelessWidget {
selected: selected, selected: selected,
button: true, button: true,
child: Material( child: Material(
color: backgroundColor, color: Colors.transparent,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: borderRadius),
borderRadius: BorderRadius.zero,
side: BorderSide(color: borderColor, width: BorderWidth.thin),
),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.zero, borderRadius: borderRadius,
onTap: isLoading ? null : onTap, onTap: isLoading ? null : onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
overlayColor: WidgetStateProperty.resolveWith(overlayForStates), overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
@@ -1735,8 +1508,10 @@ class _ConversationTile extends StatelessWidget {
duration: const Duration(milliseconds: 160), duration: const Duration(milliseconds: 160),
curve: Curves.easeOut, curve: Curves.easeOut,
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? highlightColor : Colors.transparent, color: background,
borderRadius: BorderRadius.zero, borderRadius: borderRadius,
border: Border.all(color: borderColor, width: BorderWidth.thin),
boxShadow: shadow,
), ),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints( constraints: const BoxConstraints(

View File

@@ -0,0 +1,270 @@
import 'dart:io' show Platform;
import 'package:conduit/core/providers/app_providers.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'package:conduit/shared/theme/theme_extensions.dart';
import 'package:conduit/shared/utils/ui_utils.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:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:conduit/features/chat/providers/chat_providers.dart' as chat;
class ConduitContextMenuAction {
final IconData cupertinoIcon;
final IconData materialIcon;
final String label;
final Future<void> Function() onSelected;
final VoidCallback? onBeforeClose;
final bool destructive;
const ConduitContextMenuAction({
required this.cupertinoIcon,
required this.materialIcon,
required this.label,
required this.onSelected,
this.onBeforeClose,
this.destructive = false,
});
}
Future<void> showConduitContextMenu({
required BuildContext context,
required List<ConduitContextMenuAction> actions,
}) async {
if (actions.isEmpty) return;
final theme = context.conduitTheme;
await showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
Future<void> handleAction(ConduitContextMenuAction action) async {
action.onBeforeClose?.call();
Navigator.of(sheetContext).pop();
await Future.microtask(action.onSelected);
}
List<Widget> buildActionTiles() {
return actions
.map(
(action) => ConduitListItem(
isCompact: true,
leading: Icon(
Platform.isIOS ? action.cupertinoIcon : action.materialIcon,
color: action.destructive ? theme.error : theme.iconPrimary,
size: IconSize.modal,
),
title: Text(
action.label,
style: AppTypography.standard.copyWith(
color: action.destructive ? theme.error : theme.textPrimary,
fontWeight: FontWeight.w500,
),
),
onTap: () => handleAction(action),
),
)
.toList();
}
final actionTiles = buildActionTiles();
return ModalSheetSafeArea(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.screenPadding,
vertical: Spacing.screenPadding,
),
child: Container(
decoration: BoxDecoration(
color: theme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
boxShadow: ConduitShadows.modal,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: Spacing.sm),
const SheetHandle(),
const SizedBox(height: Spacing.sm),
for (var i = 0; i < actionTiles.length; i++) ...[
if (i != 0) const ConduitDivider(isCompact: true),
actionTiles[i],
],
const SizedBox(height: Spacing.sm),
],
),
),
);
},
);
}
Future<void> showConversationContextMenu({
required BuildContext context,
required WidgetRef ref,
required dynamic conversation,
}) async {
if (conversation == null) return;
final l10n = AppLocalizations.of(context)!;
final bool isPinned = conversation.pinned == true;
final bool isArchived = conversation.archived == true;
Future<void> togglePin() async {
final errorMessage = l10n.failedToUpdatePin;
try {
await chat.pinConversation(ref, conversation.id, !isPinned);
} catch (_) {
if (!context.mounted) return;
UiUtils.showMessage(context, errorMessage, isError: true);
}
}
Future<void> toggleArchive() async {
final errorMessage = l10n.failedToUpdateArchive;
try {
await chat.archiveConversation(ref, conversation.id, !isArchived);
} catch (_) {
if (!context.mounted) return;
UiUtils.showMessage(context, errorMessage, isError: true);
}
}
Future<void> rename() async {
await _renameConversation(
context,
ref,
conversation.id,
conversation.title ?? '',
);
}
Future<void> deleteConversation() async {
await _confirmAndDeleteConversation(context, ref, conversation.id);
}
HapticFeedback.selectionClick();
await showConduitContextMenu(
context: context,
actions: [
ConduitContextMenuAction(
cupertinoIcon: isPinned
? CupertinoIcons.pin_slash
: CupertinoIcons.pin_fill,
materialIcon: isPinned
? Icons.push_pin_outlined
: Icons.push_pin_rounded,
label: isPinned ? l10n.unpin : l10n.pin,
onBeforeClose: () => HapticFeedback.lightImpact(),
onSelected: togglePin,
),
ConduitContextMenuAction(
cupertinoIcon: isArchived
? CupertinoIcons.archivebox_fill
: CupertinoIcons.archivebox,
materialIcon: isArchived
? Icons.unarchive_rounded
: Icons.archive_rounded,
label: isArchived ? l10n.unarchive : l10n.archive,
onBeforeClose: () => HapticFeedback.lightImpact(),
onSelected: toggleArchive,
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.rename,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: rename,
),
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: deleteConversation,
),
],
);
}
Future<void> _renameConversation(
BuildContext context,
WidgetRef ref,
String conversationId,
String currentTitle,
) async {
final l10n = AppLocalizations.of(context)!;
final newName = await ThemedDialogs.promptTextInput(
context,
title: l10n.renameChat,
hintText: l10n.enterChatName,
initialValue: currentTitle,
confirmText: l10n.save,
cancelText: l10n.cancel,
);
if (!context.mounted) return;
if (newName == null) return;
if (newName.isEmpty || newName == currentTitle) return;
final renameError = l10n.failedToRenameChat;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.updateConversation(conversationId, title: newName);
HapticFeedback.selectionClick();
ref.invalidate(conversationsProvider);
final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).state = active!.copyWith(
title: newName,
);
}
} catch (_) {
if (!context.mounted) return;
UiUtils.showMessage(context, renameError, isError: true);
}
}
Future<void> _confirmAndDeleteConversation(
BuildContext context,
WidgetRef ref,
String conversationId,
) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await ThemedDialogs.confirm(
context,
title: l10n.deleteChatTitle,
message: l10n.deleteChatMessage,
confirmText: l10n.delete,
isDestructive: true,
);
if (!context.mounted) return;
if (!confirmed) return;
final deleteError = l10n.failedToDeleteChat;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.deleteConversation(conversationId);
HapticFeedback.mediumImpact();
final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).state = null;
ref.read(chat.chatMessagesProvider.notifier).clearMessages();
}
ref.invalidate(conversationsProvider);
} catch (_) {
if (!context.mounted) return;
UiUtils.showMessage(context, deleteError, isError: true);
}
}