2025-09-19 23:35:46 +05:30
|
|
|
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/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;
|
2025-10-02 00:30:14 +05:30
|
|
|
await _showConversationError(context, errorMessage);
|
2025-09-19 23:35:46 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> toggleArchive() async {
|
|
|
|
|
final errorMessage = l10n.failedToUpdateArchive;
|
|
|
|
|
try {
|
|
|
|
|
await chat.archiveConversation(ref, conversation.id, !isArchived);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
if (!context.mounted) return;
|
2025-10-02 00:30:14 +05:30
|
|
|
await _showConversationError(context, errorMessage);
|
2025-09-19 23:35:46 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2025-10-02 00:30:14 +05:30
|
|
|
refreshConversationsCache(ref);
|
2025-09-19 23:35:46 +05:30
|
|
|
final active = ref.read(activeConversationProvider);
|
|
|
|
|
if (active?.id == conversationId) {
|
2025-09-21 22:31:44 +05:30
|
|
|
ref
|
|
|
|
|
.read(activeConversationProvider.notifier)
|
|
|
|
|
.set(active!.copyWith(title: newName));
|
2025-09-19 23:35:46 +05:30
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
if (!context.mounted) return;
|
2025-10-02 00:30:14 +05:30
|
|
|
await _showConversationError(context, renameError);
|
2025-09-19 23:35:46 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-09-21 22:31:44 +05:30
|
|
|
ref.read(activeConversationProvider.notifier).clear();
|
2025-09-19 23:35:46 +05:30
|
|
|
ref.read(chat.chatMessagesProvider.notifier).clearMessages();
|
|
|
|
|
}
|
2025-10-02 00:30:14 +05:30
|
|
|
refreshConversationsCache(ref);
|
2025-09-19 23:35:46 +05:30
|
|
|
} catch (_) {
|
|
|
|
|
if (!context.mounted) return;
|
2025-10-02 00:30:14 +05:30
|
|
|
await _showConversationError(context, deleteError);
|
2025-09-19 23:35:46 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-10-02 00:30:14 +05:30
|
|
|
|
|
|
|
|
Future<void> _showConversationError(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
String message,
|
|
|
|
|
) async {
|
|
|
|
|
if (!context.mounted) return;
|
|
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
final theme = context.conduitTheme;
|
|
|
|
|
await ThemedDialogs.show<void>(
|
|
|
|
|
context,
|
|
|
|
|
title: l10n.errorMessage,
|
|
|
|
|
content: Text(message, style: TextStyle(color: theme.textSecondary)),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
|
|
|
child: Text(l10n.ok),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|