From d5bfccc4c752fa151bd918ccc37e01070e448731 Mon Sep 17 00:00:00 2001 From: Tristan the Katana <50181095+felixmuinde@users.noreply.github.com> Date: Wed, 13 May 2026 00:13:11 +0300 Subject: [PATCH] feat(mobile): add mass delete for chats (#1779) * feat(mobile): add mass delete for chats Long-press any chat to enter selection mode, tap items to select/deselect, use Select All to toggle all, then delete with a single confirmation. Swipe-to-delete continues to work outside selection mode. * fix(mobile): address PR review comments on mass delete - Wrap each deleteChat call in its own try-catch so a single network failure doesn't abort the entire Future.wait operation - Add null-safe casting for deletedCount and failedIds in provider - Fix misleading error snackbar copy ("Some chats could not be deleted" implied partial failure; provider only returns false on total failure) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- mobile/lib/providers/chat_provider.dart | 36 +++++ mobile/lib/screens/chat_list_screen.dart | 163 ++++++++++++++++++++--- mobile/lib/services/chat_service.dart | 28 ++++ 3 files changed, 209 insertions(+), 18 deletions(-) diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart index 317535fdb..2eb6ed212 100644 --- a/mobile/lib/providers/chat_provider.dart +++ b/mobile/lib/providers/chat_provider.dart @@ -321,6 +321,42 @@ class ChatProvider with ChangeNotifier { } } + /// Delete multiple chats + Future deleteMultipleChats({ + required String accessToken, + required List chatIds, + }) async { + try { + final result = await _chatService.deleteMultipleChats( + accessToken: accessToken, + chatIds: chatIds, + ); + + final deletedCount = (result['deletedCount'] as int?) ?? 0; + if (result['success'] == true || deletedCount > 0) { + final failedIds = ((result['failedIds'] as List?) ?? []).cast().toSet(); + final deleted = chatIds.toSet().difference(failedIds); + _chats.removeWhere((c) => deleted.contains(c.id)); + + if (_currentChat != null && deleted.contains(_currentChat!.id)) { + _currentChat = null; + } + + notifyListeners(); + return true; + } + + _errorMessage = 'Failed to delete chats'; + notifyListeners(); + return false; + } catch (e) { + debugPrint('deleteMultipleChats error: $e'); + _errorMessage = 'Something went wrong. Please try again.'; + notifyListeners(); + return false; + } + } + /// Start polling for new messages (AI responses) void _startPolling(String accessToken, String chatId) { _pollingTimer?.cancel(); diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart index 3c1606ecd..7868a30f5 100644 --- a/mobile/lib/screens/chat_list_screen.dart +++ b/mobile/lib/screens/chat_list_screen.dart @@ -12,6 +12,9 @@ class ChatListScreen extends StatefulWidget { } class _ChatListScreenState extends State { + bool _isSelectionMode = false; + final Set _selectedChatIds = {}; + @override void initState() { super.initState(); @@ -35,6 +38,89 @@ class _ChatListScreenState extends State { await _loadChats(); } + void _toggleSelectionMode() { + setState(() { + _isSelectionMode = !_isSelectionMode; + _selectedChatIds.clear(); + }); + } + + void _toggleSelectAll(List allIds) { + setState(() { + if (_selectedChatIds.length == allIds.length) { + _selectedChatIds.clear(); + } else { + _selectedChatIds + ..clear() + ..addAll(allIds); + } + }); + } + + void _toggleChatSelection(String id) { + setState(() { + if (_selectedChatIds.contains(id)) { + _selectedChatIds.remove(id); + } else { + _selectedChatIds.add(id); + } + }); + } + + Future _deleteSelectedChats() async { + final authProvider = Provider.of(context, listen: false); + final chatProvider = Provider.of(context, listen: false); + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Chats'), + content: Text( + 'Delete ${_selectedChatIds.length} chat(s)? This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + final success = await chatProvider.deleteMultipleChats( + accessToken: accessToken, + chatIds: _selectedChatIds.toList(), + ); + + if (!mounted) return; + + setState(() { + _isSelectionMode = false; + _selectedChatIds.clear(); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success ? 'Chats deleted' : 'Failed to delete chats', + ), + backgroundColor: success ? Colors.green : Colors.red, + ), + ); + } + Future _openNewChat() async { if (!mounted) return; @@ -74,17 +160,38 @@ class _ChatListScreenState extends State { title: const Text('Chats'), centerTitle: false, actions: [ - Padding( - padding: const EdgeInsets.only(top: 12, right: 12), - child: InkWell( - onTap: _handleRefresh, - child: const SizedBox( - width: 36, - height: 36, - child: Icon(Icons.refresh), + if (_isSelectionMode) ...[ + IconButton( + icon: const Icon(Icons.delete), + onPressed: _selectedChatIds.isNotEmpty ? _deleteSelectedChats : null, + ), + IconButton( + icon: const Icon(Icons.select_all), + onPressed: () { + final allIds = Provider.of(context, listen: false) + .chats + .map((c) => c.id) + .toList(); + _toggleSelectAll(allIds); + }, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: _toggleSelectionMode, + ), + ] else ...[ + Padding( + padding: const EdgeInsets.only(top: 12, right: 12), + child: InkWell( + onTap: _handleRefresh, + child: const SizedBox( + width: 36, + height: 36, + child: Icon(Icons.refresh), + ), ), ), - ), + ], ], ), body: Consumer( @@ -166,9 +273,12 @@ class _ChatListScreenState extends State { itemCount: chatProvider.chats.length, itemBuilder: (context, index) { final chat = chatProvider.chats[index]; + final isSelected = _selectedChatIds.contains(chat.id); return Dismissible( key: Key(chat.id), - direction: DismissDirection.endToStart, + direction: _isSelectionMode + ? DismissDirection.none + : DismissDirection.endToStart, background: Container( color: Colors.red, alignment: Alignment.centerRight, @@ -208,13 +318,18 @@ class _ChatListScreenState extends State { } }, child: ListTile( - leading: CircleAvatar( - backgroundColor: colorScheme.primaryContainer, - child: Icon( - Icons.chat, - color: colorScheme.onPrimaryContainer, - ), - ), + leading: _isSelectionMode + ? Checkbox( + value: isSelected, + onChanged: (_) => _toggleChatSelection(chat.id), + ) + : CircleAvatar( + backgroundColor: colorScheme.primaryContainer, + child: Icon( + Icons.chat, + color: colorScheme.onPrimaryContainer, + ), + ), title: Text( chat.title, maxLines: 1, @@ -223,7 +338,7 @@ class _ChatListScreenState extends State { subtitle: chat.lastMessageAt != null ? Text(_formatDateTime(chat.lastMessageAt!)) : null, - trailing: chat.messageCount != null + trailing: chat.messageCount != null && !_isSelectionMode ? Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -241,6 +356,10 @@ class _ChatListScreenState extends State { ) : null, onTap: () async { + if (_isSelectionMode) { + _toggleChatSelection(chat.id); + return; + } await Navigator.push( context, MaterialPageRoute( @@ -249,6 +368,14 @@ class _ChatListScreenState extends State { ); _loadChats(); }, + onLongPress: _isSelectionMode + ? null + : () { + setState(() { + _isSelectionMode = true; + _selectedChatIds.add(chat.id); + }); + }, ), ); }, diff --git a/mobile/lib/services/chat_service.dart b/mobile/lib/services/chat_service.dart index 1e55e57da..37b927f4c 100644 --- a/mobile/lib/services/chat_service.dart +++ b/mobile/lib/services/chat_service.dart @@ -331,6 +331,34 @@ class ChatService { } } + /// Delete multiple chats in parallel + Future> deleteMultipleChats({ + required String accessToken, + required List chatIds, + }) async { + final results = await Future.wait( + chatIds.map((id) async { + try { + return await deleteChat(accessToken: accessToken, chatId: id); + } catch (_) { + return {'success': false}; + } + }), + eagerError: false, + ); + final failedIds = chatIds + .asMap() + .entries + .where((e) => results[e.key]['success'] != true) + .map((e) => e.value) + .toList(); + return { + 'success': failedIds.isEmpty, + 'deletedCount': chatIds.length - failedIds.length, + 'failedIds': failedIds, + }; + } + /// Retry the last assistant response in a chat Future> retryMessage({ required String accessToken,