feat: папки
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user