feat: title on the header
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
270
lib/shared/utils/conversation_context_menu.dart
Normal file
270
lib/shared/utils/conversation_context_menu.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user