From b57be4c66109480b7c8859735dde1e180785b729 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 10 Aug 2026 15:05:33 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/models/chat_folder.dart | 220 ++++-- lib/backend/modules/chats.dart | 4 +- lib/backend/modules/folders.dart | 569 +++++++++++----- .../screens/chats/chat_list_screen.dart | 42 +- .../screens/chats/create_group_flow.dart | 55 +- .../screens/chats/folder_action_sheet.dart | 148 ++++ .../screens/chats/folder_edit_sheet.dart | 632 ++++++++++++++++++ lib/frontend/widgets/sheet_helpers.dart | 47 ++ lib/main.dart | 2 + test/folder_action_sheet_test.dart | 68 ++ test/folder_edit_sheet_test.dart | 114 ++++ test/folders_module_test.dart | 214 ++++++ 12 files changed, 1842 insertions(+), 273 deletions(-) create mode 100644 lib/frontend/screens/chats/folder_action_sheet.dart create mode 100644 lib/frontend/screens/chats/folder_edit_sheet.dart create mode 100644 test/folder_action_sheet_test.dart create mode 100644 test/folder_edit_sheet_test.dart create mode 100644 test/folders_module_test.dart diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart index a5b3d02..e171b77 100644 --- a/lib/backend/models/chat_folder.dart +++ b/lib/backend/models/chat_folder.dart @@ -1,83 +1,205 @@ +class FolderFilter { + static const int unread = 0; + static const int read = 1; + static const int channel = 2; + static const int chat = 3; + static const int dialog = 4; + static const int owner = 5; + static const int admin = 6; + static const int muted = 7; + static const int contact = 8; + static const int notContact = 9; + static const int bot = 10; + static const int notMuted = 11; + static const int markedUnread = 12; + static const int org = 13; + + static const Set chatTypes = { + contact, + notContact, + chat, + channel, + bot, + dialog, + org, + }; + + static const Set roles = {owner, admin}; + + static const Set showOnly = { + unread, + read, + muted, + notMuted, + markedUnread, + }; + + static const Map _byName = { + 'UNREAD': unread, + 'READ': read, + 'CHANNEL': channel, + 'CHAT': chat, + 'GROUP': chat, + 'DIALOG': dialog, + 'OWNER': owner, + 'ADMIN': admin, + 'MUTED': muted, + 'CONTACT': contact, + 'NOT_CONTACT': notContact, + 'BOT': bot, + 'NOT_MUTED': notMuted, + 'MARKED_UNREAD': markedUnread, + 'ORG': org, + }; + + static int? parse(dynamic raw) { + if (raw is int) return raw; + if (raw is String) return int.tryParse(raw) ?? _byName[raw]; + return null; + } +} + +class FolderOption { + static const int hideEmpty = 0; + static const int noDelete = 1; + static const int noTitleEdit = 2; + static const int noFiltersEdit = 3; + static const int chatSuggest = 4; + + static const Map _byName = { + 'HIDE_EMPTY': hideEmpty, + 'NO_DELETE': noDelete, + 'NO_TITLE_EDIT': noTitleEdit, + 'NO_FILTERS_EDIT': noFiltersEdit, + 'CHAT_SUGGEST': chatSuggest, + }; + + static int? parse(dynamic raw) { + if (raw is int) return raw; + if (raw is String) return int.tryParse(raw) ?? _byName[raw]; + return null; + } +} + class ChatFolder { final String id; final String title; final String? emoji; - final List? include; - final List filters; - final bool hideEmpty; + final List include; + final List filters; + final List options; + final List favorites; final List widgets; - final List? favorites; final Map? filterSubjects; - final List? options; + final int updateTime; + final int? sourceId; - ChatFolder({ + const ChatFolder({ required this.id, required this.title, this.emoji, - this.include, - required this.filters, - required this.hideEmpty, - required this.widgets, - this.favorites, + this.include = const [], + this.filters = const [], + this.options = const [], + this.favorites = const [], + this.widgets = const [], this.filterSubjects, - this.options, + this.updateTime = 0, + this.sourceId, }); - static List? _parseIntList(dynamic raw) { - return (raw as List?)?.map((e) { - if (e is int) return e; - if (e is String) return int.tryParse(e) ?? 0; - return 0; - }).toList(); + bool get hideEmpty => options.contains(FolderOption.hideEmpty); + + bool get canDelete => !options.contains(FolderOption.noDelete); + + bool get canEditTitle => !options.contains(FolderOption.noTitleEdit); + + bool get canEditFilters => !options.contains(FolderOption.noFiltersEdit); + + static List _parseIds(dynamic raw) { + if (raw is! List) return []; + return raw + .map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e); + return null; + }) + .whereType() + .toList(); + } + + static List _parseCodes(dynamic raw, int? Function(dynamic) parse) { + if (raw is! List) return []; + return raw.map(parse).whereType().toList(); + } + + static Map? _parseMap(dynamic raw) { + if (raw is Map) return raw; + if (raw is Map) return Map.from(raw); + return null; } factory ChatFolder.fromJson(Map json) { + final options = _parseCodes(json['options'], FolderOption.parse); + if (json['hideEmpty'] == true && + !options.contains(FolderOption.hideEmpty)) { + options.add(FolderOption.hideEmpty); + } return ChatFolder( id: json['id']?.toString() ?? '', title: json['title']?.toString() ?? '', emoji: json['emoji']?.toString(), - include: _parseIntList(json['include']), - filters: - (json['filters'] as List?)?.map((e) { - if (e is int) return e; - if (e is String) return int.tryParse(e) ?? e; - return e; - }).toList() ?? - [], - hideEmpty: json['hideEmpty'] ?? false, + include: _parseIds(json['include']), + filters: _parseCodes(json['filters'], FolderFilter.parse), + options: options, + favorites: _parseIds(json['favorites']), widgets: - (json['widgets'] as List?)?.map((w) { - if (w is Map) { - return ChatFolderWidget.fromJson(w); - } - return ChatFolderWidget.fromJson( - Map.from(w as Map), - ); - }).toList() ?? - [], - favorites: _parseIntList(json['favorites']), - filterSubjects: json['filterSubjects'] is Map - ? json['filterSubjects'] as Map - : (json['filterSubjects'] is Map - ? Map.from( - (json['filterSubjects'] as Map).cast(), - ) - : null), - options: _parseIntList(json['options']), + (json['widgets'] as List?) + ?.map(_parseMap) + .whereType>() + .map(ChatFolderWidget.fromJson) + .toList() ?? + const [], + filterSubjects: _parseMap(json['filterSubjects']), + updateTime: json['updateTime'] is int ? json['updateTime'] as int : 0, + sourceId: json['sourceId'] is int ? json['sourceId'] as int : null, ); } + ChatFolder copyWith({ + String? title, + String? emoji, + List? include, + List? filters, + List? options, + List? favorites, + int? updateTime, + }) => ChatFolder( + id: id, + title: title ?? this.title, + emoji: emoji ?? this.emoji, + include: include ?? this.include, + filters: filters ?? this.filters, + options: options ?? this.options, + favorites: favorites ?? this.favorites, + widgets: widgets, + filterSubjects: filterSubjects, + updateTime: updateTime ?? this.updateTime, + sourceId: sourceId, + ); + Map toJson() => { 'id': id, 'title': title, if (emoji != null) 'emoji': emoji, - if (include != null) 'include': include, + 'include': include, 'filters': filters, - 'hideEmpty': hideEmpty, + 'options': options, + 'favorites': favorites, 'widgets': widgets.map((w) => w.toJson()).toList(), - if (favorites != null) 'favorites': favorites, if (filterSubjects != null) 'filterSubjects': filterSubjects, - if (options != null) 'options': options, + 'updateTime': updateTime, + if (sourceId != null) 'sourceId': sourceId, }; } diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index e981719..7239889 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1719,7 +1719,7 @@ class ChatsModule { : folders.first, ); - final favorites = List.from(allFolder.favorites ?? const []); + final favorites = List.from(allFolder.favorites); if (pin) { for (final id in chatIds) { if (!favorites.contains(id)) favorites.add(id); @@ -1771,7 +1771,7 @@ class ChatsModule { FoldersModule.isAllChatsFolder, orElse: () => folders.first, ); - final favorites = allFolder.favorites ?? const []; + final favorites = allFolder.favorites; final favIndexById = {}; for (var i = 0; i < favorites.length; i++) { favIndexById[favorites[i]] = i + 1; diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart index c179c75..74e6883 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -1,4 +1,8 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; import '../api.dart'; import '../models/chat_folder.dart'; @@ -6,11 +10,54 @@ import 'chats.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; + +class _FoldersSnapshot { + final List folders; + final List order; + final int folderSync; + + const _FoldersSnapshot({ + this.folders = const [], + this.order = const [], + this.folderSync = 0, + }); +} class FoldersModule { static const _syncKey = 'chat_folders_snapshot'; static const _listReadyKey = 'chat_folders_list_ready'; + static const String allChatsFolderId = 'all.chat.folder'; + static const int titleMaxLength = 20; + + static final ValueNotifier revision = ValueNotifier(0); + + static StreamSubscription? _pushSub; + static Future _pushQueue = Future.value(); + + static void attachGlobalPushHandlers(Api api) { + _pushSub?.cancel(); + _pushSub = api.pushStream + .where((p) => p.opcode == Opcode.notifFolders) + .listen(_enqueuePush); + } + + static void _enqueuePush(Packet packet) { + _pushQueue = _pushQueue + .then((_) => _handleFoldersPush(packet)) + .catchError((Object _) {}); + } + + static Future _handleFoldersPush(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + await applyPayload(accountId, payload.cast()); + await chats.applyFavorites(accountId); + } + static Future markFoldersListReady(int accountId) async { await AppDatabase.setSyncValue(accountId, _listReadyKey, '1'); } @@ -23,7 +70,7 @@ class FoldersModule { } static bool isAllChatsFolder(ChatFolder f) { - if (f.id == 'all.chat.folder') return true; + if (f.id == allChatsFolderId) return true; final t = f.title.trim().toLowerCase(); return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats'; } @@ -36,14 +83,21 @@ class FoldersModule { return folders.first.id; } - static void sortFoldersInPlace( - List folders, - List? foldersOrder, - ) { - if (foldersOrder == null || foldersOrder.isEmpty) return; + static String newFolderId() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; + } + + static void _sortInPlace(List folders, List order) { + if (order.isEmpty) return; final orderIndex = {}; - for (var i = 0; i < foldersOrder.length; i++) { - orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i); + for (var i = 0; i < order.length; i++) { + orderIndex.putIfAbsent(order[i], () => i); } folders.sort((a, b) { final aIndex = orderIndex[a.id] ?? -1; @@ -55,35 +109,58 @@ class FoldersModule { }); } - static const int filterUnread = 0; - static const int filterChannel = 2; - static const int filterGroup = 3; - static const int filterContact = 8; - static const int filterNotContact = 9; - static const int filterBot = 10; + static bool _matchesType( + int filter, + CachedChat chat, { + required int myId, + required Set contactIds, + }) { + final isDialog = chat.type == 'DIALOG'; + final isBot = isDialog && chat.options.contains('BOT'); + final peerId = isDialog ? chat.id ^ myId : null; + final isSelf = peerId != null && peerId == myId; + final isContact = + isDialog && !isSelf && peerId != null && contactIds.contains(peerId); - static int? _filterCode(dynamic raw) { - if (raw is int) return raw; - if (raw is String) { - final n = int.tryParse(raw); - if (n != null) return n; - switch (raw) { - case 'UNREAD': - return filterUnread; - case 'CHANNEL': - return filterChannel; - case 'GROUP': - case 'CHAT': - return filterGroup; - case 'CONTACT': - return filterContact; - case 'NOT_CONTACT': - return filterNotContact; - case 'BOT': - return filterBot; - } + switch (filter) { + case FolderFilter.channel: + return chat.type == 'CHANNEL'; + case FolderFilter.chat: + return chat.type == 'CHAT' || chat.type == 'GROUP'; + case FolderFilter.dialog: + return isDialog; + case FolderFilter.contact: + return isDialog && !isBot && isContact; + case FolderFilter.notContact: + return isDialog && !isBot && !isSelf && !isContact; + case FolderFilter.bot: + return isBot; } - return null; + return false; + } + + static bool _matchesRole(int filter, CachedChat chat, int myId) { + switch (filter) { + case FolderFilter.owner: + return chat.owner == myId; + case FolderFilter.admin: + return chat.owner == myId || chat.admins.contains(myId); + } + return false; + } + + static bool _matchesRestriction(int filter, CachedChat chat) { + switch (filter) { + case FolderFilter.unread: + return chat.unreadCount > 0; + case FolderFilter.read: + return chat.unreadCount == 0; + case FolderFilter.muted: + return chat.isMuted; + case FolderFilter.notMuted: + return !chat.isMuted; + } + return true; } static bool chatMatchesFolder( @@ -92,112 +169,167 @@ class FoldersModule { required int myId, required Set contactIds, }) { - if (folder.include != null && folder.include!.contains(chat.id)) { - return true; - } - if (folder.filters.isEmpty) return false; - - final isDialog = chat.type == 'DIALOG'; - final isBot = isDialog && chat.options.contains('BOT'); - final peerId = isDialog ? chat.id ^ myId : null; - final isSelf = peerId != null && peerId == myId; - final isContact = - isDialog && !isSelf && peerId != null && contactIds.contains(peerId); - - for (final raw in folder.filters) { - switch (_filterCode(raw)) { - case filterUnread: - if (chat.unreadCount > 0) return true; - case filterChannel: - if (chat.type == 'CHANNEL') return true; - case filterGroup: - if (chat.type == 'CHAT' || chat.type == 'GROUP') return true; - case filterContact: - if (isDialog && !isBot && isContact) return true; - case filterNotContact: - if (isDialog && !isBot && !isSelf && !isContact) return true; - case filterBot: - if (isBot) return true; - } - } - return false; - } - - static List _parseFolderList( - List json, { - bool lenient = true, - }) { - if (lenient) { - return json - .map((e) { - try { - final m = e is Map - ? e - : Map.from(e as Map); - return ChatFolder.fromJson(m); - } catch (_) { - return null; - } - }) - .whereType() + if (!folder.include.contains(chat.id)) { + final typeFilters = folder.filters + .where(FolderFilter.chatTypes.contains) .toList(); + if (typeFilters.isEmpty) return false; + final matchesType = typeFilters.any( + (f) => _matchesType(f, chat, myId: myId, contactIds: contactIds), + ); + if (!matchesType) return false; } - return json.map((e) { - final m = e is Map - ? e - : Map.from(e as Map); - return ChatFolder.fromJson(m); - }).toList(); + + final roleFilters = folder.filters.where(FolderFilter.roles.contains); + if (roleFilters.isNotEmpty && + !roleFilters.any((f) => _matchesRole(f, chat, myId))) { + return false; + } + + for (final f in folder.filters.where(FolderFilter.showOnly.contains)) { + if (!_matchesRestriction(f, chat)) return false; + } + return true; } - static Future> loadFolders(int accountId) async { + static List _parseFolderList(dynamic raw) { + if (raw is! List) return []; + return raw + .map((e) { + try { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + } + + static List? _parseOrder(dynamic raw) { + if (raw is! List) return null; + return raw.map((e) => e.toString()).toList(); + } + + static int? _parseSync(dynamic raw) => raw is int ? raw : null; + + static Future<_FoldersSnapshot> _loadSnapshot(int accountId) async { final raw = await AppDatabase.getSyncValue(accountId, _syncKey); - if (raw == null || raw.isEmpty) return []; + if (raw == null || raw.isEmpty) return const _FoldersSnapshot(); try { final map = jsonDecode(raw) as Map; - final foldersJson = map['folders'] as List?; - final folders = foldersJson == null - ? [] - : _parseFolderList(foldersJson, lenient: false); - final order = map['foldersOrder'] as List?; - sortFoldersInPlace(folders, order); - return folders; + final folders = _parseFolderList(map['folders']); + final order = _parseOrder(map['foldersOrder']) ?? const []; + _sortInPlace(folders, order); + return _FoldersSnapshot( + folders: folders, + order: order, + folderSync: _parseSync(map['folderSync']) ?? 0, + ); } catch (_) { - return []; + return const _FoldersSnapshot(); } } - static Future _persist( + static Future _saveSnapshot( int accountId, - List folders, - List? order, + _FoldersSnapshot snapshot, ) async { + final known = snapshot.folders.map((f) => f.id).toSet(); + final ordered = snapshot.order.where(known.contains).toList(); + final orderedSet = ordered.toSet(); + final order = [ + ...ordered, + ...known.where((id) => !orderedSet.contains(id)), + ]; await AppDatabase.setSyncValue( accountId, _syncKey, jsonEncode({ - 'folders': folders.map((f) => f.toJson()).toList(), + 'folders': snapshot.folders.map((f) => f.toJson()).toList(), 'foldersOrder': order, + 'folderSync': snapshot.folderSync, }), ); + revision.value++; + } + + static Future> loadFolders(int accountId) async { + return (await _loadSnapshot(accountId)).folders; + } + + static Future> loadFoldersOrder(int accountId) async { + return (await _loadSnapshot(accountId)).order; + } + + static Future loadFolderSync(int accountId) async { + return (await _loadSnapshot(accountId)).folderSync; } static Future applyPayload( int accountId, - Map payload, - ) async { - final foldersJson = payload['folders'] as List?; - final order = payload['foldersOrder'] as List?; - if (foldersJson == null && order == null) return; - - List folders; - if (foldersJson != null) { - folders = _parseFolderList(foldersJson); - } else { - folders = await loadFolders(accountId); + Map payload, { + bool replace = false, + }) async { + final foldersRaw = payload['folders']; + final folderRaw = payload['folder']; + final orderRaw = payload['foldersOrder']; + final syncRaw = payload['folderSync']; + if (foldersRaw == null && + folderRaw == null && + orderRaw == null && + syncRaw == null) { + return; } - sortFoldersInPlace(folders, order); - await _persist(accountId, folders, order); + + final incoming = [ + ..._parseFolderList(foldersRaw), + if (folderRaw is Map) + ChatFolder.fromJson(Map.from(folderRaw)), + ]; + + final current = await _loadSnapshot(accountId); + var folders = replace && foldersRaw is List + ? List.from(incoming) + : _merge(current.folders, incoming); + + final order = _parseOrder(orderRaw) ?? current.order; + if (orderRaw is List && order.isNotEmpty) { + final known = order.toSet(); + final fresh = incoming.map((f) => f.id).toSet(); + folders = folders + .where((f) => known.contains(f.id) || fresh.contains(f.id)) + .toList(); + } + + _sortInPlace(folders, order); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: folders, + order: order, + folderSync: _parseSync(syncRaw) ?? current.folderSync, + ), + ); + } + + static List _merge( + List current, + List incoming, + ) { + final merged = List.from(current); + for (final folder in incoming) { + final idx = merged.indexWhere((f) => f.id == folder.id); + if (idx >= 0) { + merged[idx] = folder; + } else { + merged.add(folder); + } + } + return merged; } static Future applyFromLoginConfig( @@ -206,57 +338,180 @@ class FoldersModule { ) async { final chatFolders = config['chatFolders']; if (chatFolders is! Map) return; - final foldersJson = chatFolders['FOLDERS'] as List?; - if (foldersJson == null) return; - final order = chatFolders['foldersOrder'] as List?; - final folders = _parseFolderList(foldersJson); - sortFoldersInPlace(folders, order); - await _persist(accountId, folders, order); + if (chatFolders['FOLDERS'] == null) return; + await applyPayload(accountId, { + 'folders': chatFolders['FOLDERS'], + 'foldersOrder': chatFolders['foldersOrder'], + 'folderSync': chatFolders['folderSync'], + }, replace: true); await markFoldersListReady(accountId); } - static Future setFolderFavorites( + static Future createFolder( + Api api, + int accountId, { + required String title, + List include = const [], + List filters = const [], + List options = const [], + List favorites = const [], + }) { + return _sendUpdate( + api, + accountId, + id: newFolderId(), + title: title, + include: include, + filters: filters, + options: options, + favorites: favorites, + ); + } + + static Future updateFolder( + Api api, + int accountId, + ChatFolder folder, { + String? title, + List? include, + List? filters, + List? options, + List? favorites, + }) { + return _sendUpdate( + api, + accountId, + id: folder.id, + title: title ?? folder.title, + include: include ?? folder.include, + filters: filters ?? folder.filters, + options: options ?? folder.options, + favorites: favorites ?? folder.favorites, + ); + } + + static Future _sendUpdate( + Api api, + int accountId, { + required String id, + required String title, + required List include, + required List filters, + required List options, + required List favorites, + }) async { + final packet = await api.sendRequest(Opcode.foldersUpdate, { + 'id': id, + 'title': title.trim(), + 'include': include, + 'filters': filters, + 'options': options, + 'favorites': favorites, + }); + throwIfPacketError(packet); + + final data = packet.payload; + final folderJson = data is Map ? data['folder'] : null; + if (folderJson is! Map) { + throw StateError('FOLDERS_UPDATE: сервер не вернул папку'); + } + await applyPayload(accountId, data.cast()); + return ChatFolder.fromJson(Map.from(folderJson)); + } + + static Future setFolderFavorites( Api api, int accountId, ChatFolder folder, List favorites, + ) { + return updateFolder(api, accountId, folder, favorites: favorites); + } + + static Future deleteFolders( + Api api, + int accountId, + List folderIds, ) async { - final packet = await api.sendRequest(Opcode.foldersUpdate, { - 'id': folder.id, - 'title': folder.title, - 'include': folder.include ?? const [], - 'favorites': favorites, - 'filters': folder.filters, - 'options': folder.options ?? const [], + if (folderIds.isEmpty) return; + final packet = await api.sendRequest(Opcode.foldersDelete, { + 'folderIds': folderIds, }); throwIfPacketError(packet); - final data = packet.payload; - if (data is! Map) return null; - final folderJson = data['folder']; - if (folderJson is! Map) return null; - final updated = ChatFolder.fromJson( - folderJson is Map - ? folderJson - : Map.from(folderJson), + + final removed = folderIds.toSet(); + final current = await _loadSnapshot(accountId); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: current.folders.where((f) => !removed.contains(f.id)).toList(), + order: current.order, + folderSync: current.folderSync, + ), ); - final currentRaw = await AppDatabase.getSyncValue(accountId, _syncKey); - final snapshot = (currentRaw != null && currentRaw.isNotEmpty) - ? jsonDecode(currentRaw) as Map - : {}; - final existingRaw = snapshot['folders'] as List?; - final existing = existingRaw == null - ? [] - : _parseFolderList(existingRaw, lenient: false); - final idx = existing.indexWhere((f) => f.id == updated.id); - if (idx >= 0) { - existing[idx] = updated; - } else { - existing.add(updated); + final data = packet.payload; + if (data is Map) { + await applyPayload(accountId, data.cast()); } - final order = snapshot['foldersOrder'] as List?; - await _persist(accountId, existing, order); - return updated; + } + + static Future reorderFolders( + Api api, + int accountId, + List order, + ) async { + if (order.isEmpty) return; + final packet = await api.sendRequest(Opcode.foldersReorder, { + 'foldersOrder': order, + }); + throwIfPacketError(packet); + + final current = await _loadSnapshot(accountId); + final folders = List.from(current.folders); + _sortInPlace(folders, order); + final data = packet.payload; + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: folders, + order: order, + folderSync: + (data is Map ? _parseSync(data['folderSync']) : null) ?? + current.folderSync, + ), + ); + } + + static Future> fetchFoldersByIds( + Api api, + int accountId, + List folderIds, + ) async { + if (folderIds.isEmpty) return const []; + final packet = await api.sendRequest(Opcode.foldersGetById, { + 'folderIds': folderIds, + }); + throwIfPacketError(packet); + + final data = packet.payload; + if (data is! Map) return const []; + final folders = _parseFolderList(data['folders']); + + final missing = folderIds.toSet()..removeAll(folders.map((f) => f.id)); + final current = await _loadSnapshot(accountId); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: _merge( + current.folders.where((f) => !missing.contains(f.id)).toList(), + folders, + ), + order: current.order, + folderSync: _parseSync(data['folderSync']) ?? current.folderSync, + ), + ); + return folders; } static Future syncFromServer(Api api, int accountId) async { @@ -267,7 +522,11 @@ class FoldersModule { throwIfPacketError(packet); final data = packet.payload; if (data is Map) { - await applyPayload(accountId, data.cast()); + await applyPayload( + accountId, + data.cast(), + replace: true, + ); } } finally { await markFoldersListReady(accountId); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index f01b6a3..92e34a1 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -10,6 +10,8 @@ import 'chat_screen.dart'; import 'search_screen.dart'; import 'create_channel_flow.dart'; import 'create_group_flow.dart'; +import 'folder_action_sheet.dart'; +import 'folder_edit_sheet.dart'; import '../contacts/add_contact_sheet.dart'; import '../../widgets/adaptive_shell.dart'; import '../../../core/crypto/message_decryption_cache.dart'; @@ -649,6 +651,7 @@ class _ChatListScreenState extends State KometSettings.hideAllChatsFolder.addListener(_requestReload); KometSettings.showHiddenChats.addListener(_requestReload); ContactsModule.revision.addListener(_requestReload); + FoldersModule.revision.addListener(_requestReload); bannersModule.activeBanner.addListener(_onActiveInformerChanged); _maybeLoadStories(); _typingSub = api.pushStream @@ -856,12 +859,9 @@ class _ChatListScreenState extends State p.id, )).map((c) => c.id).toSet(); - final allChatsFolder = ChatFolder( - id: 'all.chat.folder', + const allChatsFolder = ChatFolder( + id: FoldersModule.allChatsFolderId, title: 'Все чаты', - filters: [], - hideEmpty: false, - widgets: [], ); if (widget.archiveMode) { @@ -1318,6 +1318,7 @@ class _ChatListScreenState extends State KometSettings.hideAllChatsFolder.removeListener(_requestReload); KometSettings.showHiddenChats.removeListener(_requestReload); ContactsModule.revision.removeListener(_requestReload); + FoldersModule.revision.removeListener(_requestReload); bannersModule.activeBanner.removeListener(_onActiveInformerChanged); _loginSub?.cancel(); _stateSub?.cancel(); @@ -1724,10 +1725,7 @@ class _ChatListScreenState extends State children: [ for (var i = 0; i < _folders.length; i++) ...[ if (i > 0) const SizedBox(width: 8), - _buildFolderChip( - _folderChipLabel(_folders[i]), - folderId: _folders[i].id, - ), + _buildFolderChip(_folders[i]), ], ], ); @@ -1742,10 +1740,7 @@ class _ChatListScreenState extends State for (var i = 0; i < _folders.length; i++) ...[ if (i > 0) const SizedBox(width: 8), Expanded( - child: _buildFolderChip( - _folderChipLabel(_folders[i]), - folderId: _folders[i].id, - ), + child: _buildFolderChip(_folders[i]), ), ], ], @@ -2648,11 +2643,16 @@ class _ChatListScreenState extends State return true; } - Widget _buildFolderChip(String title, {required String folderId}) { + Widget _buildFolderChip(ChatFolder folder) { final cs = Theme.of(context).colorScheme; + final folderId = folder.id; final isSelected = _selectedFolderId == folderId; return GestureDetector( onTap: () => _selectFolder(folderId), + onLongPress: () { + Haptics.medium(); + showFolderActionSheet(context, folder: folder); + }, child: GlossyPill( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(50), @@ -2660,7 +2660,7 @@ class _ChatListScreenState extends State depth: 4, child: Center( child: Text( - title, + _folderChipLabel(folder), textAlign: TextAlign.center, style: TextStyle( color: isSelected ? cs.onPrimaryContainer : cs.primary, @@ -3159,7 +3159,8 @@ class _ChatListScreenState extends State bool _isBotDialog(int contactId, CachedChat chat) { if (contactId == 0 || contactId == _profile?.id) return false; - if (ContactCache.getOptions(contactId)?.contains('BOT') == true) return true; + if (ContactCache.getOptions(contactId)?.contains('BOT') == true) + return true; return chat.options.contains('BOT'); } @@ -3272,6 +3273,15 @@ class _ChatListScreenState extends State showAddContactSheet(context); }, ), + const SizedBox(height: 4), + _buildFabMenuItem( + Symbols.create_new_folder, + 'Создать папку', + onTap: () { + _toggleFab(); + showFolderEditSheet(context); + }, + ), ], ); } diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index d36b925..82e3a81 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -377,20 +377,18 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { child: Row( children: [ Expanded( - child: _SheetButton( + child: SheetButton( label: 'Отменить', filled: false, onTap: () => Navigator.pop(context), - cs: cs, ), ), const SizedBox(width: 12), Expanded( - child: _SheetButton( + child: SheetButton( label: 'Далее', filled: true, onTap: () => setState(() => _step = _Step.groupDetails), - cs: cs, ), ), ], @@ -482,20 +480,18 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { child: Row( children: [ Expanded( - child: _SheetButton( + child: SheetButton( label: 'Отменить', filled: false, onTap: _creating ? null : () => Navigator.pop(context), - cs: cs, ), ), const SizedBox(width: 12), Expanded( - child: _SheetButton( + child: SheetButton( label: _creating ? 'Создаю...' : 'Создать', filled: true, onTap: canCreate ? _create : null, - cs: cs, ), ), ], @@ -552,46 +548,3 @@ class _SelectedChip extends StatelessWidget { ); } } - -class _SheetButton extends StatelessWidget { - final String label; - final bool filled; - final VoidCallback? onTap; - final ColorScheme cs; - const _SheetButton({ - required this.label, - required this.filled, - required this.onTap, - required this.cs, - }); - - @override - Widget build(BuildContext context) { - final disabled = onTap == null; - return GestureDetector( - onTap: onTap, - child: Container( - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: filled - ? (disabled ? cs.primary.withValues(alpha: 0.4) : cs.primary) - : cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(22), - ), - child: Text( - label, - style: TextStyle( - color: filled - ? cs.onPrimary - : (disabled - ? cs.onSurface.withValues(alpha: 0.4) - : cs.onSurface), - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - ); - } -} diff --git a/lib/frontend/screens/chats/folder_action_sheet.dart b/lib/frontend/screens/chats/folder_action_sheet.dart new file mode 100644 index 0000000..2bc72a6 --- /dev/null +++ b/lib/frontend/screens/chats/folder_action_sheet.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/models/chat_folder.dart'; +import '../../../backend/modules/folders.dart'; +import '../../../core/protocol/packet.dart'; +import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../main.dart'; +import '../../widgets/confirm_dialog.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; +import 'folder_edit_sheet.dart'; + +enum _FolderAction { edit, create, delete } + +Future showFolderActionSheet( + BuildContext context, { + required ChatFolder folder, +}) async { + final cs = Theme.of(context).colorScheme; + final isAllChats = folder.id == FoldersModule.allChatsFolderId; + final canEdit = !isAllChats && (folder.canEditTitle || folder.canEditFilters); + final canDelete = !isAllChats && folder.canDelete; + + final action = await showModalBottomSheet<_FolderAction>( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SheetGrabber(), + Padding( + padding: const EdgeInsets.fromLTRB(24, 6, 24, 10), + child: Text( + folder.title, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (canEdit) + _ActionRow( + icon: Symbols.edit, + label: 'Изменить', + onTap: () => Navigator.pop(ctx, _FolderAction.edit), + ), + _ActionRow( + icon: Symbols.create_new_folder, + label: 'Новая папка', + onTap: () => Navigator.pop(ctx, _FolderAction.create), + ), + if (canDelete) + _ActionRow( + icon: Symbols.delete, + label: 'Удалить', + color: cs.error, + onTap: () => Navigator.pop(ctx, _FolderAction.delete), + ), + const SizedBox(height: 12), + ], + ), + ), + ); + + if (action == null || !context.mounted) return; + + switch (action) { + case _FolderAction.edit: + await showFolderEditSheet(context, folder: folder); + case _FolderAction.create: + await showFolderEditSheet(context); + case _FolderAction.delete: + await _confirmDelete(context, folder); + } +} + +Future _confirmDelete(BuildContext context, ChatFolder folder) async { + final confirmed = await showConfirmDialog( + context, + message: 'Удалить папку «${folder.title}»? Чаты останутся на месте.', + confirmLabel: 'Удалить', + destructive: true, + ); + if (!confirmed || !context.mounted) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null || !context.mounted) return; + + try { + await FoldersModule.deleteFolders(api, accountId, [folder.id]); + Haptics.success(); + } catch (e) { + Haptics.error(); + if (!context.mounted) return; + showCustomNotification( + context, + e is PacketError ? e.message : 'Не удалось удалить папку', + ); + } +} + +class _ActionRow extends StatelessWidget { + final IconData icon; + final String label; + final Color? color; + final VoidCallback onTap; + + const _ActionRow({ + required this.icon, + required this.label, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tint = color ?? cs.onSurface; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + children: [ + Icon(icon, color: tint, size: 22), + const SizedBox(width: 16), + Text( + label, + style: TextStyle( + color: tint, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/folder_edit_sheet.dart b/lib/frontend/screens/chats/folder_edit_sheet.dart new file mode 100644 index 0000000..e7c3e2a --- /dev/null +++ b/lib/frontend/screens/chats/folder_edit_sheet.dart @@ -0,0 +1,632 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/models/chat_folder.dart'; +import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/cloud_storage.dart'; +import '../../../backend/modules/folders.dart'; +import '../../../backend/modules/messages.dart'; +import '../../../core/protocol/packet.dart'; +import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../main.dart'; +import '../../widgets/confirm_dialog.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; + +typedef _ChatType = ({int filter, IconData icon, String label}); + +const List<_ChatType> _chatTypes = [ + (filter: FolderFilter.contact, icon: Symbols.person, label: 'Контакты'), + ( + filter: FolderFilter.notContact, + icon: Symbols.person_off, + label: 'Не в контактах', + ), + (filter: FolderFilter.chat, icon: Symbols.group, label: 'Группы'), + (filter: FolderFilter.channel, icon: Symbols.campaign, label: 'Каналы'), + (filter: FolderFilter.bot, icon: Symbols.smart_toy, label: 'Боты'), +]; + +const Set _editableFilters = { + FolderFilter.contact, + FolderFilter.notContact, + FolderFilter.chat, + FolderFilter.channel, + FolderFilter.bot, + FolderFilter.unread, + FolderFilter.notMuted, +}; + +Future showFolderEditSheet( + BuildContext context, { + ChatFolder? folder, +}) async { + final cs = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => _FolderEditSheet(folder: folder), + ); +} + +class _FolderEditSheet extends StatefulWidget { + final ChatFolder? folder; + + const _FolderEditSheet({this.folder}); + + @override + State<_FolderEditSheet> createState() => _FolderEditSheetState(); +} + +class _FolderEditSheetState extends State<_FolderEditSheet> { + final TextEditingController _title = TextEditingController(); + final TextEditingController _search = TextEditingController(); + + final Set _types = {}; + final Set _chatIds = {}; + List _preservedFilters = const []; + bool _onlyUnread = false; + bool _onlyNotMuted = false; + + List _chats = []; + int _myId = 0; + bool _loading = true; + bool _busy = false; + + bool get _isNew => widget.folder == null; + + @override + void initState() { + super.initState(); + final folder = widget.folder; + if (folder != null) { + _title.text = folder.title; + _types.addAll( + folder.filters.where((f) => _chatTypes.any((t) => t.filter == f)), + ); + _chatIds.addAll(folder.include); + _onlyUnread = folder.filters.contains(FolderFilter.unread); + _onlyNotMuted = folder.filters.contains(FolderFilter.notMuted); + _preservedFilters = folder.filters + .where((f) => !_editableFilters.contains(f)) + .toList(); + } + _load(); + } + + @override + void dispose() { + _title.dispose(); + _search.dispose(); + super.dispose(); + } + + Future _load() async { + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + if (mounted) setState(() => _loading = false); + return; + } + final list = await chats.getChats(accountId); + list.removeWhere(CloudStorageModule.isCloudStorageGroup); + if (!mounted) return; + setState(() { + _myId = accountId; + _chats = list; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + int _peerId(CachedChat chat) { + for (final entry in chat.participants.entries) { + if (entry.key != _myId) return entry.key; + } + return _myId; + } + + String _chatTitle(CachedChat chat) { + if (chat.id == 0) return 'Избранное'; + if (chat.type == 'DIALOG') { + return ContactCache.get(_peerId(chat)) ?? chat.title ?? 'Пользователь'; + } + return chat.title ?? 'Чат'; + } + + String? _chatAvatar(CachedChat chat) { + if (chat.type == 'DIALOG' && chat.id != 0) { + return ContactCache.getAvatar(_peerId(chat)) ?? chat.iconUrl; + } + return chat.iconUrl; + } + + List _buildFilters() => [ + ..._types, + if (_onlyUnread) FolderFilter.unread, + if (_onlyNotMuted) FolderFilter.notMuted, + ..._preservedFilters, + ]; + + bool get _canSubmit => + !_busy && + _title.text.trim().isNotEmpty && + (_types.isNotEmpty || _chatIds.isNotEmpty); + + Future _submit() async { + if (!_canSubmit) return; + if (_myId == 0) { + showCustomNotification(context, 'Нет активного аккаунта'); + return; + } + setState(() => _busy = true); + final navigator = Navigator.of(context); + try { + final title = _title.text.trim(); + final folder = widget.folder; + if (folder == null) { + await FoldersModule.createFolder( + api, + _myId, + title: title, + include: _chatIds.toList(), + filters: _buildFilters(), + ); + } else { + await FoldersModule.updateFolder( + api, + _myId, + folder, + title: title, + include: _chatIds.toList(), + filters: _buildFilters(), + ); + } + Haptics.success(); + if (mounted) navigator.pop(); + } catch (e) { + Haptics.error(); + if (!mounted) return; + setState(() => _busy = false); + showCustomNotification( + context, + e is PacketError ? e.message : 'Не удалось сохранить папку', + ); + } + } + + Future _delete() async { + final folder = widget.folder; + if (folder == null || _busy) return; + if (_myId == 0) { + showCustomNotification(context, 'Нет активного аккаунта'); + return; + } + final confirmed = await showConfirmDialog( + context, + message: 'Удалить папку «${folder.title}»? Чаты останутся на месте.', + confirmLabel: 'Удалить', + destructive: true, + ); + if (!confirmed || !mounted) return; + + setState(() => _busy = true); + final navigator = Navigator.of(context); + try { + await FoldersModule.deleteFolders(api, _myId, [folder.id]); + Haptics.success(); + if (mounted) navigator.pop(); + } catch (e) { + Haptics.error(); + if (!mounted) return; + setState(() => _busy = false); + showCustomNotification( + context, + e is PacketError ? e.message : 'Не удалось удалить папку', + ); + } + } + + void _clearSelection() { + setState(() { + _types.clear(); + _chatIds.clear(); + _onlyUnread = false; + _onlyNotMuted = false; + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final viewInsets = MediaQuery.of(context).viewInsets; + final query = _search.text.trim().toLowerCase(); + final types = query.isEmpty + ? _chatTypes + : _chatTypes + .where((t) => t.label.toLowerCase().contains(query)) + .toList(); + final visibleChats = query.isEmpty + ? _chats + : _chats + .where((c) => _chatTitle(c).toLowerCase().contains(query)) + .toList(); + + return Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(cs), + Flexible( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + physics: const BouncingScrollPhysics(), + children: [ + _buildTitleCard(cs), + const SizedBox(height: 12), + _buildPickerCard(cs, types, visibleChats), + if (widget.folder?.canEditFilters ?? true) ...[ + const SizedBox(height: 12), + _buildShowOnlyCard(cs), + ], + ], + ), + ), + _buildActions(cs), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(ColorScheme cs) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + _isNew ? 'Новая папка' : 'Изменение папки', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: _busy ? null : () => Navigator.pop(context), + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + ), + ], + ), + ); + + Widget _buildCard(ColorScheme cs, Widget child) => Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: child, + ); + + Widget _buildTitleCard(ColorScheme cs) { + final canEditTitle = widget.folder?.canEditTitle ?? true; + return _buildCard( + cs, + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _title, + enabled: canEditTitle && !_busy, + onChanged: (_) => setState(() {}), + maxLength: FoldersModule.titleMaxLength, + inputFormatters: [ + LengthLimitingTextInputFormatter( + FoldersModule.titleMaxLength, + ), + ], + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + hintText: 'Название папки', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + counterText: '', + ), + ), + ), + const SizedBox(width: 12), + Text( + '${_title.text.characters.length}/${FoldersModule.titleMaxLength}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ); + } + + Widget _buildPickerCard( + ColorScheme cs, + List<_ChatType> types, + List visibleChats, + ) { + final canEditFilters = widget.folder?.canEditFilters ?? true; + return _buildCard( + cs, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + child: TextField( + controller: _search, + onChanged: (_) => setState(() {}), + style: TextStyle(color: cs.onSurface, fontSize: 14), + decoration: InputDecoration( + hintText: 'Найти по имени', + hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + prefixIcon: Icon( + Symbols.search, + color: cs.onSurfaceVariant, + size: 20, + ), + isDense: true, + border: InputBorder.none, + ), + ), + ), + if (types.isNotEmpty && canEditFilters) ...[ + _buildSectionLabel(cs, 'ТИПЫ ЧАТОВ'), + for (final type in types) + _buildRow( + cs, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + shape: BoxShape.circle, + ), + child: Icon(type.icon, color: cs.onSurface, size: 20), + ), + title: type.label, + selected: _types.contains(type.filter), + onTap: () => setState(() { + if (!_types.remove(type.filter)) _types.add(type.filter); + Haptics.selection(); + }), + ), + ], + if (_loading) ...[ + _buildSectionLabel(cs, 'ЧАТЫ И КАНАЛЫ'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Center(child: SmallSpinner(size: 28)), + ), + ] else if (visibleChats.isNotEmpty) ...[ + _buildSectionLabel(cs, 'ЧАТЫ И КАНАЛЫ'), + for (final chat in visibleChats) + _buildRow( + cs, + leading: KometAvatar( + name: _chatTitle(chat), + size: 40, + imageUrl: _chatAvatar(chat), + ), + title: _chatTitle(chat), + subtitle: chat.id == 0 ? 'Сообщения себе' : null, + selected: _chatIds.contains(chat.id), + onTap: () => setState(() { + if (!_chatIds.remove(chat.id)) _chatIds.add(chat.id); + Haptics.selection(); + }), + ), + ], + if (!_loading && types.isEmpty && visibleChats.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + child: Text( + 'Ничего не найдено', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ), + ], + ), + ); + } + + Widget _buildSectionLabel(ColorScheme cs, String text) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), + child: Text( + text, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + ), + ), + ); + + Widget _buildRow( + ColorScheme cs, { + required Widget leading, + required String title, + String? subtitle, + required bool selected, + required VoidCallback onTap, + }) => InkWell( + onTap: _busy ? null : onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + leading, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.8), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + AnimatedScale( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutBack, + scale: selected ? 1 : 0, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: Icon(Symbols.check, color: cs.onPrimary, size: 16), + ), + ), + ], + ), + ), + ); + + Widget _buildShowOnlyCard(ColorScheme cs) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionLabel(cs, 'ПОКАЗЫВАТЬ ТОЛЬКО'), + _buildCard( + cs, + Column( + children: [ + _buildToggle( + cs, + icon: Symbols.notifications, + title: 'Чаты с уведомлениями', + value: _onlyNotMuted, + onChanged: (v) => setState(() => _onlyNotMuted = v), + ), + _buildToggle( + cs, + icon: Symbols.mark_chat_unread, + title: 'Непрочитанные чаты', + value: _onlyUnread, + onChanged: (v) => setState(() => _onlyUnread = v), + ), + ], + ), + ), + ], + ); + + Widget _buildToggle( + ColorScheme cs, { + required IconData icon, + required String title, + required bool value, + required ValueChanged onChanged, + }) => Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 8, 4), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + ), + Switch( + value: value, + onChanged: _busy + ? null + : (v) { + Haptics.selection(); + onChanged(v); + }, + ), + ], + ), + ); + + Widget _buildActions(ColorScheme cs) { + final folder = widget.folder; + final hasSelection = + _types.isNotEmpty || + _chatIds.isNotEmpty || + _onlyUnread || + _onlyNotMuted; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + Expanded( + child: folder == null + ? SheetButton( + label: 'Очистить выбор', + filled: false, + onTap: hasSelection && !_busy ? _clearSelection : null, + ) + : SheetButton( + label: 'Удалить папку', + filled: false, + color: cs.error, + onTap: folder.canDelete && !_busy ? _delete : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SheetButton( + label: _isNew ? 'Создать папку' : 'Сохранить', + filled: true, + onTap: _canSubmit ? _submit : null, + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/widgets/sheet_helpers.dart b/lib/frontend/widgets/sheet_helpers.dart index 67e7e45..66669c4 100644 --- a/lib/frontend/widgets/sheet_helpers.dart +++ b/lib/frontend/widgets/sheet_helpers.dart @@ -5,6 +5,53 @@ import '../../core/config/app_shape.dart'; /// Standard rounded top shape for modal bottom sheets. const RoundedRectangleBorder kSheetShape = AppShape.sheetBorder; +/// Pill-shaped action button for the bottom row of a modal sheet. +class SheetButton extends StatelessWidget { + final String label; + final bool filled; + final VoidCallback? onTap; + final Color? color; + + const SheetButton({ + super.key, + required this.label, + required this.filled, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final disabled = onTap == null; + final fill = color ?? cs.primary; + final labelColor = filled ? cs.onPrimary : (color ?? cs.onSurface); + return GestureDetector( + onTap: onTap, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled + ? (disabled ? fill.withValues(alpha: 0.4) : fill) + : cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(22), + ), + child: Text( + label, + style: TextStyle( + color: disabled + ? labelColor.withValues(alpha: filled ? 0.85 : 0.4) + : labelColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} + /// The little drag "grabber" pill shown at the top of a bottom sheet. class SheetGrabber extends StatelessWidget { final EdgeInsetsGeometry margin; diff --git a/lib/main.dart b/lib/main.dart index 8b7c858..ede895a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -58,6 +58,7 @@ import 'backend/modules/chats.dart'; import 'backend/modules/comments.dart'; import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; +import 'backend/modules/folders.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/outbox.dart'; import 'backend/modules/polls.dart'; @@ -193,6 +194,7 @@ void main(List args) async { } attachInfoCacheApi(api); chats.attachGlobalPushHandlers(api); + FoldersModule.attachGlobalPushHandlers(api); commentsModule.attachPushHandlers(api); storiesModule.attach(); unawaited(storiesModule.loadCache()); diff --git a/test/folder_action_sheet_test.dart b/test/folder_action_sheet_test.dart new file mode 100644 index 0000000..3b2f952 --- /dev/null +++ b/test/folder_action_sheet_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/models/chat_folder.dart'; +import 'package:komet/backend/modules/folders.dart'; +import 'package:komet/frontend/screens/chats/folder_action_sheet.dart'; + +Future _openSheet(WidgetTester tester, ChatFolder folder) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showFolderActionSheet(context, folder: folder), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a user folder offers edit, create and delete', (tester) async { + await _openSheet( + tester, + const ChatFolder(id: 'synthetic-folder', title: 'Работа'), + ); + + expect(find.text('Работа'), findsOneWidget); + expect(find.text('Изменить'), findsOneWidget); + expect(find.text('Новая папка'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + }); + + testWidgets('the all-chats folder can only spawn a new folder', ( + tester, + ) async { + await _openSheet( + tester, + const ChatFolder(id: FoldersModule.allChatsFolderId, title: 'Все чаты'), + ); + + expect(find.text('Изменить'), findsNothing); + expect(find.text('Удалить'), findsNothing); + expect(find.text('Новая папка'), findsOneWidget); + }); + + testWidgets('server options hide the forbidden actions', (tester) async { + await _openSheet( + tester, + const ChatFolder( + id: 'synthetic-system-folder', + title: 'Каналы', + options: [ + FolderOption.noDelete, + FolderOption.noTitleEdit, + FolderOption.noFiltersEdit, + ], + ), + ); + + expect(find.text('Изменить'), findsNothing); + expect(find.text('Удалить'), findsNothing); + expect(find.text('Новая папка'), findsOneWidget); + }); +} diff --git a/test/folder_edit_sheet_test.dart b/test/folder_edit_sheet_test.dart new file mode 100644 index 0000000..75aae22 --- /dev/null +++ b/test/folder_edit_sheet_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/models/chat_folder.dart'; +import 'package:komet/frontend/screens/chats/folder_edit_sheet.dart'; +import 'package:komet/frontend/widgets/sheet_helpers.dart'; + +Future _openSheet(WidgetTester tester, {ChatFolder? folder}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showFolderEditSheet(context, folder: folder), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); +} + +Future _scrollToBottom(WidgetTester tester) async { + await tester.drag(find.byType(ListView), const Offset(0, -600)); + await tester.pump(const Duration(milliseconds: 300)); +} + +SheetButton _button(WidgetTester tester, String label) => + tester.widget( + find.byWidgetPredicate((w) => w is SheetButton && w.label == label), + ); + +void main() { + testWidgets('the create layout shows every picker section', (tester) async { + await _openSheet(tester); + + expect(find.text('Новая папка'), findsOneWidget); + expect(find.text('Название папки'), findsOneWidget); + expect(find.text('0/20'), findsOneWidget); + expect(find.text('ТИПЫ ЧАТОВ'), findsOneWidget); + expect(find.text('Контакты'), findsOneWidget); + expect(find.text('Не в контактах'), findsOneWidget); + expect(find.text('Группы'), findsOneWidget); + expect(find.text('Каналы'), findsOneWidget); + expect(find.text('Боты'), findsOneWidget); + + await _scrollToBottom(tester); + expect(find.text('ПОКАЗЫВАТЬ ТОЛЬКО'), findsOneWidget); + expect(find.text('Чаты с уведомлениями'), findsOneWidget); + expect(find.text('Непрочитанные чаты'), findsOneWidget); + expect(find.text('Очистить выбор'), findsOneWidget); + expect(find.text('Создать папку'), findsOneWidget); + }); + + testWidgets('creating needs both a name and a selection', (tester) async { + await _openSheet(tester); + + expect(_button(tester, 'Создать папку').onTap, isNull); + expect(_button(tester, 'Очистить выбор').onTap, isNull); + + await tester.enterText(find.byType(TextField).first, 'Работа'); + await tester.pump(); + expect(_button(tester, 'Создать папку').onTap, isNull); + expect(find.text('6/20'), findsOneWidget); + + await tester.tap(find.text('Каналы')); + await tester.pump(const Duration(milliseconds: 300)); + expect(_button(tester, 'Создать папку').onTap, isNotNull); + expect(_button(tester, 'Очистить выбор').onTap, isNotNull); + + await tester.tap(find.text('Очистить выбор')); + await tester.pump(const Duration(milliseconds: 300)); + expect(_button(tester, 'Создать папку').onTap, isNull); + }); + + testWidgets('the edit layout preloads the folder and offers delete', ( + tester, + ) async { + await _openSheet( + tester, + folder: const ChatFolder( + id: 'synthetic-folder', + title: 'Каналы', + filters: [FolderFilter.channel, FolderFilter.unread], + ), + ); + + expect(find.text('Изменение папки'), findsOneWidget); + expect(find.text('6/20'), findsOneWidget); + expect(find.text('Удалить папку'), findsOneWidget); + expect(find.text('Сохранить'), findsOneWidget); + expect(_button(tester, 'Сохранить').onTap, isNotNull); + expect(_button(tester, 'Удалить папку').onTap, isNotNull); + + await _scrollToBottom(tester); + final unreadSwitch = tester.widget(find.byType(Switch).last); + expect(unreadSwitch.value, isTrue); + }); + + testWidgets('a folder the server locks cannot be deleted', (tester) async { + await _openSheet( + tester, + folder: const ChatFolder( + id: 'synthetic-system-folder', + title: 'Каналы', + options: [FolderOption.noDelete], + ), + ); + + expect(_button(tester, 'Удалить папку').onTap, isNull); + }); +} diff --git a/test/folders_module_test.dart b/test/folders_module_test.dart new file mode 100644 index 0000000..83e3a82 --- /dev/null +++ b/test/folders_module_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/models/chat_folder.dart'; +import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/folders.dart'; + +const int _me = 4242; +const int _contactId = 777; + +CachedChat _chat({ + required int id, + required String type, + int unreadCount = 0, + int dontDisturbUntil = 0, + Set options = const {}, + int? owner, +}) => CachedChat( + id: id, + accountId: _me, + type: type, + title: 'chat $id', + unreadCount: unreadCount, + lastEventTime: 1700000000000, + cachedAt: 0, + dontDisturbUntil: dontDisturbUntil, + isOnline: false, + seenTime: 0, + participants: {_me: 1700000000000}, + options: options, + owner: owner, +); + +bool _matches(CachedChat chat, ChatFolder folder) => + FoldersModule.chatMatchesFolder( + chat, + folder, + myId: _me, + contactIds: const {_contactId}, + ); + +void main() { + group('ChatFolder.fromJson', () { + test('reads the server payload of FOLDERS_UPDATE', () { + final folder = ChatFolder.fromJson(const { + 'id': '6fd177c5-ba2e-4360-9593-3ae798326806', + 'title': 'Каналы', + 'include': [111, -222], + 'filters': [0, 11, 2], + 'favorites': [111], + 'options': [1, 2], + 'updateTime': 1700000000000, + 'sourceId': 1, + }); + + expect(folder.id, '6fd177c5-ba2e-4360-9593-3ae798326806'); + expect(folder.include, [111, -222]); + expect(folder.filters, [ + FolderFilter.unread, + FolderFilter.notMuted, + FolderFilter.channel, + ]); + expect(folder.favorites, [111]); + expect(folder.updateTime, 1700000000000); + expect(folder.sourceId, 1); + expect(folder.canDelete, isFalse); + expect(folder.canEditTitle, isFalse); + expect(folder.canEditFilters, isTrue); + }); + + test('normalizes legacy string filters and the hideEmpty flag', () { + final folder = ChatFolder.fromJson(const { + 'id': 'legacy', + 'title': 'legacy', + 'filters': ['CHANNEL', 'GROUP'], + 'hideEmpty': true, + }); + + expect(folder.filters, [FolderFilter.channel, FolderFilter.chat]); + expect(folder.options, [FolderOption.hideEmpty]); + expect(folder.hideEmpty, isTrue); + }); + }); + + group('chatMatchesFolder', () { + const channelsFolder = ChatFolder( + id: 'f1', + title: 'Каналы', + filters: [FolderFilter.channel], + ); + + test('type filters are combined with OR', () { + const folder = ChatFolder( + id: 'f2', + title: 'Каналы и боты', + filters: [FolderFilter.channel, FolderFilter.bot], + ); + + expect(_matches(_chat(id: 1, type: 'CHANNEL'), folder), isTrue); + expect( + _matches(_chat(id: 2, type: 'DIALOG', options: {'BOT'}), folder), + isTrue, + ); + expect(_matches(_chat(id: 3, type: 'CHAT'), folder), isFalse); + }); + + test('a folder without filters only holds its included chats', () { + const folder = ChatFolder(id: 'f3', title: 'Свои', include: [10]); + + expect(_matches(_chat(id: 10, type: 'CHAT'), folder), isTrue); + expect(_matches(_chat(id: 11, type: 'CHAT'), folder), isFalse); + }); + + test('show-only filters narrow both types and included chats', () { + const folder = ChatFolder( + id: 'f4', + title: 'Непрочитанные каналы', + include: [10], + filters: [FolderFilter.channel, FolderFilter.unread], + ); + + expect( + _matches(_chat(id: 1, type: 'CHANNEL', unreadCount: 3), folder), + isTrue, + ); + expect(_matches(_chat(id: 2, type: 'CHANNEL'), folder), isFalse); + expect( + _matches(_chat(id: 10, type: 'CHAT', unreadCount: 1), folder), + isTrue, + ); + expect(_matches(_chat(id: 10, type: 'CHAT'), folder), isFalse); + }); + + test('show-only filters are combined with AND', () { + const folder = ChatFolder( + id: 'f5', + title: 'Непрочитанные с уведомлениями', + filters: [ + FolderFilter.channel, + FolderFilter.unread, + FolderFilter.notMuted, + ], + ); + + expect( + _matches(_chat(id: 1, type: 'CHANNEL', unreadCount: 1), folder), + isTrue, + ); + expect( + _matches( + _chat(id: 2, type: 'CHANNEL', unreadCount: 1, dontDisturbUntil: -1), + folder, + ), + isFalse, + ); + }); + + test('an expired mute counts as not muted', () { + const folder = ChatFolder( + id: 'f6', + title: 'С уведомлениями', + filters: [FolderFilter.channel, FolderFilter.notMuted], + ); + + expect( + _matches( + _chat(id: 1, type: 'CHANNEL', dontDisturbUntil: 1700000000000), + folder, + ), + isTrue, + ); + }); + + test('unknown filters do not empty a folder', () { + const folder = ChatFolder( + id: 'f7', + title: 'Каналы', + filters: [FolderFilter.channel, FolderFilter.markedUnread], + ); + + expect(_matches(_chat(id: 1, type: 'CHANNEL'), folder), isTrue); + }); + + test('role filters keep only chats with that role', () { + const folder = ChatFolder( + id: 'f8', + title: 'Мои группы', + filters: [FolderFilter.chat, FolderFilter.owner], + ); + + expect(_matches(_chat(id: 1, type: 'CHAT', owner: _me), folder), isTrue); + expect(_matches(_chat(id: 2, type: 'CHAT', owner: 5), folder), isFalse); + }); + + test('channels folder ignores groups and dialogs', () { + expect(_matches(_chat(id: 1, type: 'CHANNEL'), channelsFolder), isTrue); + expect(_matches(_chat(id: 2, type: 'CHAT'), channelsFolder), isFalse); + expect(_matches(_chat(id: 3, type: 'DIALOG'), channelsFolder), isFalse); + }); + }); + + group('newFolderId', () { + test('generates distinct uuid v4 ids', () { + final a = FoldersModule.newFolderId(); + final b = FoldersModule.newFolderId(); + + expect(a, isNot(b)); + expect( + RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ).hasMatch(a), + isTrue, + ); + }); + }); +}