feat: папки

This commit is contained in:
Jganenokk
2026-08-10 15:08:06 +07:00
parent 8c8c87daaf
commit b57be4c661
12 changed files with 1842 additions and 273 deletions
+170 -48
View File
@@ -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<int> chatTypes = {
contact,
notContact,
chat,
channel,
bot,
dialog,
org,
};
static const Set<int> roles = {owner, admin};
static const Set<int> showOnly = {
unread,
read,
muted,
notMuted,
markedUnread,
};
static const Map<String, int> _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<String, int> _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<int>? include;
final List<dynamic> filters;
final bool hideEmpty;
final List<int> include;
final List<int> filters;
final List<int> options;
final List<int> favorites;
final List<ChatFolderWidget> widgets;
final List<int>? favorites;
final Map<String, dynamic>? filterSubjects;
final List<int>? 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<int>? _parseIntList(dynamic raw) {
return (raw as List<dynamic>?)?.map((e) {
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<int> _parseIds(dynamic raw) {
if (raw is! List) return <int>[];
return raw
.map((e) {
if (e is int) return e;
if (e is String) return int.tryParse(e) ?? 0;
return 0;
}).toList();
if (e is String) return int.tryParse(e);
return null;
})
.whereType<int>()
.toList();
}
static List<int> _parseCodes(dynamic raw, int? Function(dynamic) parse) {
if (raw is! List) return <int>[];
return raw.map(parse).whereType<int>().toList();
}
static Map<String, dynamic>? _parseMap(dynamic raw) {
if (raw is Map<String, dynamic>) return raw;
if (raw is Map) return Map<String, dynamic>.from(raw);
return null;
}
factory ChatFolder.fromJson(Map<String, dynamic> 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<dynamic>?)?.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<dynamic>?)?.map((w) {
if (w is Map<String, dynamic>) {
return ChatFolderWidget.fromJson(w);
}
return ChatFolderWidget.fromJson(
Map<String, dynamic>.from(w as Map),
);
}).toList() ??
[],
favorites: _parseIntList(json['favorites']),
filterSubjects: json['filterSubjects'] is Map<String, dynamic>
? json['filterSubjects'] as Map<String, dynamic>
: (json['filterSubjects'] is Map
? Map<String, dynamic>.from(
(json['filterSubjects'] as Map).cast<dynamic, dynamic>(),
)
: null),
options: _parseIntList(json['options']),
(json['widgets'] as List<dynamic>?)
?.map(_parseMap)
.whereType<Map<String, dynamic>>()
.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<int>? include,
List<int>? filters,
List<int>? options,
List<int>? 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<String, dynamic> 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,
};
}
+2 -2
View File
@@ -1719,7 +1719,7 @@ class ChatsModule {
: folders.first,
);
final favorites = List<int>.from(allFolder.favorites ?? const []);
final favorites = List<int>.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 <int>[];
final favorites = allFolder.favorites;
final favIndexById = <int, int>{};
for (var i = 0; i < favorites.length; i++) {
favIndexById[favorites[i]] = i + 1;
+397 -138
View File
@@ -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<ChatFolder> folders;
final List<String> 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<int> revision = ValueNotifier<int>(0);
static StreamSubscription<Packet>? _pushSub;
static Future<void> _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<void> _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<dynamic, dynamic>());
await chats.applyFavorites(accountId);
}
static Future<void> 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<ChatFolder> folders,
List<dynamic>? foldersOrder,
) {
if (foldersOrder == null || foldersOrder.isEmpty) return;
static String newFolderId() {
final random = Random.secure();
final bytes = List<int>.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<ChatFolder> folders, List<String> order) {
if (order.isEmpty) return;
final orderIndex = <String, int>{};
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<int> 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 false;
}
return null;
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,43 +169,32 @@ class FoldersModule {
required int myId,
required Set<int> contactIds,
}) {
if (folder.include != null && folder.include!.contains(chat.id)) {
return true;
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;
}
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;
}
}
final roleFilters = folder.filters.where(FolderFilter.roles.contains);
if (roleFilters.isNotEmpty &&
!roleFilters.any((f) => _matchesRole(f, chat, myId))) {
return false;
}
static List<ChatFolder> _parseFolderList(
List<dynamic> json, {
bool lenient = true,
}) {
if (lenient) {
return json
for (final f in folder.filters.where(FolderFilter.showOnly.contains)) {
if (!_matchesRestriction(f, chat)) return false;
}
return true;
}
static List<ChatFolder> _parseFolderList(dynamic raw) {
if (raw is! List) return [];
return raw
.map((e) {
try {
final m = e is Map<String, dynamic>
@@ -142,62 +208,128 @@ class FoldersModule {
.whereType<ChatFolder>()
.toList();
}
return json.map((e) {
final m = e is Map<String, dynamic>
? e
: Map<String, dynamic>.from(e as Map);
return ChatFolder.fromJson(m);
}).toList();
static List<String>? _parseOrder(dynamic raw) {
if (raw is! List) return null;
return raw.map((e) => e.toString()).toList();
}
static Future<List<ChatFolder>> loadFolders(int accountId) async {
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<String, dynamic>;
final foldersJson = map['folders'] as List<dynamic>?;
final folders = foldersJson == null
? <ChatFolder>[]
: _parseFolderList(foldersJson, lenient: false);
final order = map['foldersOrder'] as List<dynamic>?;
sortFoldersInPlace(folders, order);
return folders;
final folders = _parseFolderList(map['folders']);
final order = _parseOrder(map['foldersOrder']) ?? const <String>[];
_sortInPlace(folders, order);
return _FoldersSnapshot(
folders: folders,
order: order,
folderSync: _parseSync(map['folderSync']) ?? 0,
);
} catch (_) {
return [];
return const _FoldersSnapshot();
}
}
static Future<void> _persist(
static Future<void> _saveSnapshot(
int accountId,
List<ChatFolder> folders,
List<dynamic>? 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<List<ChatFolder>> loadFolders(int accountId) async {
return (await _loadSnapshot(accountId)).folders;
}
static Future<List<String>> loadFoldersOrder(int accountId) async {
return (await _loadSnapshot(accountId)).order;
}
static Future<int> loadFolderSync(int accountId) async {
return (await _loadSnapshot(accountId)).folderSync;
}
static Future<void> applyPayload(
int accountId,
Map<dynamic, dynamic> payload,
) async {
final foldersJson = payload['folders'] as List<dynamic>?;
final order = payload['foldersOrder'] as List<dynamic>?;
if (foldersJson == null && order == null) return;
List<ChatFolder> folders;
if (foldersJson != null) {
folders = _parseFolderList(foldersJson);
} else {
folders = await loadFolders(accountId);
Map<dynamic, dynamic> 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 = <ChatFolder>[
..._parseFolderList(foldersRaw),
if (folderRaw is Map)
ChatFolder.fromJson(Map<String, dynamic>.from(folderRaw)),
];
final current = await _loadSnapshot(accountId);
var folders = replace && foldersRaw is List
? List<ChatFolder>.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<ChatFolder> _merge(
List<ChatFolder> current,
List<ChatFolder> incoming,
) {
final merged = List<ChatFolder>.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<void> applyFromLoginConfig(
@@ -206,57 +338,180 @@ class FoldersModule {
) async {
final chatFolders = config['chatFolders'];
if (chatFolders is! Map) return;
final foldersJson = chatFolders['FOLDERS'] as List<dynamic>?;
if (foldersJson == null) return;
final order = chatFolders['foldersOrder'] as List<dynamic>?;
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<ChatFolder?> setFolderFavorites(
static Future<ChatFolder> createFolder(
Api api,
int accountId, {
required String title,
List<int> include = const [],
List<int> filters = const [],
List<int> options = const [],
List<int> favorites = const [],
}) {
return _sendUpdate(
api,
accountId,
id: newFolderId(),
title: title,
include: include,
filters: filters,
options: options,
favorites: favorites,
);
}
static Future<ChatFolder> updateFolder(
Api api,
int accountId,
ChatFolder folder, {
String? title,
List<int>? include,
List<int>? filters,
List<int>? options,
List<int>? 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<ChatFolder> _sendUpdate(
Api api,
int accountId, {
required String id,
required String title,
required List<int> include,
required List<int> filters,
required List<int> options,
required List<int> 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<dynamic, dynamic>());
return ChatFolder.fromJson(Map<String, dynamic>.from(folderJson));
}
static Future<ChatFolder> setFolderFavorites(
Api api,
int accountId,
ChatFolder folder,
List<int> favorites,
) {
return updateFolder(api, accountId, folder, favorites: favorites);
}
static Future<void> deleteFolders(
Api api,
int accountId,
List<String> 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<String, dynamic>
? folderJson
: Map<String, dynamic>.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<String, dynamic>
: <String, dynamic>{};
final existingRaw = snapshot['folders'] as List<dynamic>?;
final existing = existingRaw == null
? <ChatFolder>[]
: _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<dynamic, dynamic>());
}
final order = snapshot['foldersOrder'] as List<dynamic>?;
await _persist(accountId, existing, order);
return updated;
}
static Future<void> reorderFolders(
Api api,
int accountId,
List<String> 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<ChatFolder>.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<List<ChatFolder>> fetchFoldersByIds(
Api api,
int accountId,
List<String> 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<void> 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<dynamic, dynamic>());
await applyPayload(
accountId,
data.cast<dynamic, dynamic>(),
replace: true,
);
}
} finally {
await markFoldersListReady(accountId);
@@ -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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
showAddContactSheet(context);
},
),
const SizedBox(height: 4),
_buildFabMenuItem(
Symbols.create_new_folder,
'Создать папку',
onTap: () {
_toggleFab();
showFolderEditSheet(context);
},
),
],
);
}
@@ -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,
),
),
),
);
}
}
@@ -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<void> 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<void> _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,
),
),
],
),
),
);
}
}
@@ -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<int> _editableFilters = {
FolderFilter.contact,
FolderFilter.notContact,
FolderFilter.chat,
FolderFilter.channel,
FolderFilter.bot,
FolderFilter.unread,
FolderFilter.notMuted,
};
Future<void> showFolderEditSheet(
BuildContext context, {
ChatFolder? folder,
}) async {
final cs = Theme.of(context).colorScheme;
await showModalBottomSheet<void>(
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<int> _types = {};
final Set<int> _chatIds = {};
List<int> _preservedFilters = const [];
bool _onlyUnread = false;
bool _onlyNotMuted = false;
List<CachedChat> _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<void> _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<int> _buildFilters() => [
..._types,
if (_onlyUnread) FolderFilter.unread,
if (_onlyNotMuted) FolderFilter.notMuted,
..._preservedFilters,
];
bool get _canSubmit =>
!_busy &&
_title.text.trim().isNotEmpty &&
(_types.isNotEmpty || _chatIds.isNotEmpty);
Future<void> _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<void> _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<CachedChat> 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<bool> 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,
),
),
],
),
);
}
}
+47
View File
@@ -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;
+2
View File
@@ -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<String> args) async {
}
attachInfoCacheApi(api);
chats.attachGlobalPushHandlers(api);
FoldersModule.attachGlobalPushHandlers(api);
commentsModule.attachPushHandlers(api);
storiesModule.attach();
unawaited(storiesModule.loadCache());
+68
View File
@@ -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<void> _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);
});
}
+114
View File
@@ -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<void> _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<void> _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<SheetButton>(
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<Switch>(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);
});
}
+214
View File
@@ -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<String> 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,
);
});
});
}