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 {
|
class ChatFolder {
|
||||||
final String id;
|
final String id;
|
||||||
final String title;
|
final String title;
|
||||||
final String? emoji;
|
final String? emoji;
|
||||||
final List<int>? include;
|
final List<int> include;
|
||||||
final List<dynamic> filters;
|
final List<int> filters;
|
||||||
final bool hideEmpty;
|
final List<int> options;
|
||||||
|
final List<int> favorites;
|
||||||
final List<ChatFolderWidget> widgets;
|
final List<ChatFolderWidget> widgets;
|
||||||
final List<int>? favorites;
|
|
||||||
final Map<String, dynamic>? filterSubjects;
|
final Map<String, dynamic>? filterSubjects;
|
||||||
final List<int>? options;
|
final int updateTime;
|
||||||
|
final int? sourceId;
|
||||||
|
|
||||||
ChatFolder({
|
const ChatFolder({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.emoji,
|
this.emoji,
|
||||||
this.include,
|
this.include = const [],
|
||||||
required this.filters,
|
this.filters = const [],
|
||||||
required this.hideEmpty,
|
this.options = const [],
|
||||||
required this.widgets,
|
this.favorites = const [],
|
||||||
this.favorites,
|
this.widgets = const [],
|
||||||
this.filterSubjects,
|
this.filterSubjects,
|
||||||
this.options,
|
this.updateTime = 0,
|
||||||
|
this.sourceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
static List<int>? _parseIntList(dynamic raw) {
|
bool get hideEmpty => options.contains(FolderOption.hideEmpty);
|
||||||
return (raw as List<dynamic>?)?.map((e) {
|
|
||||||
if (e is int) return e;
|
bool get canDelete => !options.contains(FolderOption.noDelete);
|
||||||
if (e is String) return int.tryParse(e) ?? 0;
|
|
||||||
return 0;
|
bool get canEditTitle => !options.contains(FolderOption.noTitleEdit);
|
||||||
}).toList();
|
|
||||||
|
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);
|
||||||
|
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) {
|
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(
|
return ChatFolder(
|
||||||
id: json['id']?.toString() ?? '',
|
id: json['id']?.toString() ?? '',
|
||||||
title: json['title']?.toString() ?? '',
|
title: json['title']?.toString() ?? '',
|
||||||
emoji: json['emoji']?.toString(),
|
emoji: json['emoji']?.toString(),
|
||||||
include: _parseIntList(json['include']),
|
include: _parseIds(json['include']),
|
||||||
filters:
|
filters: _parseCodes(json['filters'], FolderFilter.parse),
|
||||||
(json['filters'] as List<dynamic>?)?.map((e) {
|
options: options,
|
||||||
if (e is int) return e;
|
favorites: _parseIds(json['favorites']),
|
||||||
if (e is String) return int.tryParse(e) ?? e;
|
|
||||||
return e;
|
|
||||||
}).toList() ??
|
|
||||||
[],
|
|
||||||
hideEmpty: json['hideEmpty'] ?? false,
|
|
||||||
widgets:
|
widgets:
|
||||||
(json['widgets'] as List<dynamic>?)?.map((w) {
|
(json['widgets'] as List<dynamic>?)
|
||||||
if (w is Map<String, dynamic>) {
|
?.map(_parseMap)
|
||||||
return ChatFolderWidget.fromJson(w);
|
.whereType<Map<String, dynamic>>()
|
||||||
}
|
.map(ChatFolderWidget.fromJson)
|
||||||
return ChatFolderWidget.fromJson(
|
.toList() ??
|
||||||
Map<String, dynamic>.from(w as Map),
|
const [],
|
||||||
);
|
filterSubjects: _parseMap(json['filterSubjects']),
|
||||||
}).toList() ??
|
updateTime: json['updateTime'] is int ? json['updateTime'] as int : 0,
|
||||||
[],
|
sourceId: json['sourceId'] is int ? json['sourceId'] as int : null,
|
||||||
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']),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'title': title,
|
'title': title,
|
||||||
if (emoji != null) 'emoji': emoji,
|
if (emoji != null) 'emoji': emoji,
|
||||||
if (include != null) 'include': include,
|
'include': include,
|
||||||
'filters': filters,
|
'filters': filters,
|
||||||
'hideEmpty': hideEmpty,
|
'options': options,
|
||||||
|
'favorites': favorites,
|
||||||
'widgets': widgets.map((w) => w.toJson()).toList(),
|
'widgets': widgets.map((w) => w.toJson()).toList(),
|
||||||
if (favorites != null) 'favorites': favorites,
|
|
||||||
if (filterSubjects != null) 'filterSubjects': filterSubjects,
|
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,
|
: folders.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
final favorites = List<int>.from(allFolder.favorites ?? const []);
|
final favorites = List<int>.from(allFolder.favorites);
|
||||||
if (pin) {
|
if (pin) {
|
||||||
for (final id in chatIds) {
|
for (final id in chatIds) {
|
||||||
if (!favorites.contains(id)) favorites.add(id);
|
if (!favorites.contains(id)) favorites.add(id);
|
||||||
@@ -1771,7 +1771,7 @@ class ChatsModule {
|
|||||||
FoldersModule.isAllChatsFolder,
|
FoldersModule.isAllChatsFolder,
|
||||||
orElse: () => folders.first,
|
orElse: () => folders.first,
|
||||||
);
|
);
|
||||||
final favorites = allFolder.favorites ?? const <int>[];
|
final favorites = allFolder.favorites;
|
||||||
final favIndexById = <int, int>{};
|
final favIndexById = <int, int>{};
|
||||||
for (var i = 0; i < favorites.length; i++) {
|
for (var i = 0; i < favorites.length; i++) {
|
||||||
favIndexById[favorites[i]] = i + 1;
|
favIndexById[favorites[i]] = i + 1;
|
||||||
|
|||||||
+414
-155
@@ -1,4 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
import '../models/chat_folder.dart';
|
import '../models/chat_folder.dart';
|
||||||
@@ -6,11 +10,54 @@ import 'chats.dart';
|
|||||||
import '../../core/protocol/opcode_map.dart';
|
import '../../core/protocol/opcode_map.dart';
|
||||||
import '../../core/protocol/packet.dart';
|
import '../../core/protocol/packet.dart';
|
||||||
import '../../core/storage/app_database.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 {
|
class FoldersModule {
|
||||||
static const _syncKey = 'chat_folders_snapshot';
|
static const _syncKey = 'chat_folders_snapshot';
|
||||||
static const _listReadyKey = 'chat_folders_list_ready';
|
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 {
|
static Future<void> markFoldersListReady(int accountId) async {
|
||||||
await AppDatabase.setSyncValue(accountId, _listReadyKey, '1');
|
await AppDatabase.setSyncValue(accountId, _listReadyKey, '1');
|
||||||
}
|
}
|
||||||
@@ -23,7 +70,7 @@ class FoldersModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static bool isAllChatsFolder(ChatFolder f) {
|
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();
|
final t = f.title.trim().toLowerCase();
|
||||||
return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats';
|
return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats';
|
||||||
}
|
}
|
||||||
@@ -36,14 +83,21 @@ class FoldersModule {
|
|||||||
return folders.first.id;
|
return folders.first.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void sortFoldersInPlace(
|
static String newFolderId() {
|
||||||
List<ChatFolder> folders,
|
final random = Random.secure();
|
||||||
List<dynamic>? foldersOrder,
|
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||||
) {
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
if (foldersOrder == null || foldersOrder.isEmpty) return;
|
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>{};
|
final orderIndex = <String, int>{};
|
||||||
for (var i = 0; i < foldersOrder.length; i++) {
|
for (var i = 0; i < order.length; i++) {
|
||||||
orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i);
|
orderIndex.putIfAbsent(order[i], () => i);
|
||||||
}
|
}
|
||||||
folders.sort((a, b) {
|
folders.sort((a, b) {
|
||||||
final aIndex = orderIndex[a.id] ?? -1;
|
final aIndex = orderIndex[a.id] ?? -1;
|
||||||
@@ -55,35 +109,58 @@ class FoldersModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static const int filterUnread = 0;
|
static bool _matchesType(
|
||||||
static const int filterChannel = 2;
|
int filter,
|
||||||
static const int filterGroup = 3;
|
CachedChat chat, {
|
||||||
static const int filterContact = 8;
|
required int myId,
|
||||||
static const int filterNotContact = 9;
|
required Set<int> contactIds,
|
||||||
static const int filterBot = 10;
|
}) {
|
||||||
|
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) {
|
switch (filter) {
|
||||||
if (raw is int) return raw;
|
case FolderFilter.channel:
|
||||||
if (raw is String) {
|
return chat.type == 'CHANNEL';
|
||||||
final n = int.tryParse(raw);
|
case FolderFilter.chat:
|
||||||
if (n != null) return n;
|
return chat.type == 'CHAT' || chat.type == 'GROUP';
|
||||||
switch (raw) {
|
case FolderFilter.dialog:
|
||||||
case 'UNREAD':
|
return isDialog;
|
||||||
return filterUnread;
|
case FolderFilter.contact:
|
||||||
case 'CHANNEL':
|
return isDialog && !isBot && isContact;
|
||||||
return filterChannel;
|
case FolderFilter.notContact:
|
||||||
case 'GROUP':
|
return isDialog && !isBot && !isSelf && !isContact;
|
||||||
case 'CHAT':
|
case FolderFilter.bot:
|
||||||
return filterGroup;
|
return isBot;
|
||||||
case 'CONTACT':
|
|
||||||
return filterContact;
|
|
||||||
case 'NOT_CONTACT':
|
|
||||||
return filterNotContact;
|
|
||||||
case 'BOT':
|
|
||||||
return filterBot;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _matchesRole(int filter, CachedChat chat, int myId) {
|
||||||
|
switch (filter) {
|
||||||
|
case FolderFilter.owner:
|
||||||
|
return chat.owner == myId;
|
||||||
|
case FolderFilter.admin:
|
||||||
|
return chat.owner == myId || chat.admins.contains(myId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _matchesRestriction(int filter, CachedChat chat) {
|
||||||
|
switch (filter) {
|
||||||
|
case FolderFilter.unread:
|
||||||
|
return chat.unreadCount > 0;
|
||||||
|
case FolderFilter.read:
|
||||||
|
return chat.unreadCount == 0;
|
||||||
|
case FolderFilter.muted:
|
||||||
|
return chat.isMuted;
|
||||||
|
case FolderFilter.notMuted:
|
||||||
|
return !chat.isMuted;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool chatMatchesFolder(
|
static bool chatMatchesFolder(
|
||||||
@@ -92,112 +169,167 @@ class FoldersModule {
|
|||||||
required int myId,
|
required int myId,
|
||||||
required Set<int> contactIds,
|
required Set<int> contactIds,
|
||||||
}) {
|
}) {
|
||||||
if (folder.include != null && folder.include!.contains(chat.id)) {
|
if (!folder.include.contains(chat.id)) {
|
||||||
return true;
|
final typeFilters = folder.filters
|
||||||
}
|
.where(FolderFilter.chatTypes.contains)
|
||||||
if (folder.filters.isEmpty) return false;
|
|
||||||
|
|
||||||
final isDialog = chat.type == 'DIALOG';
|
|
||||||
final isBot = isDialog && chat.options.contains('BOT');
|
|
||||||
final peerId = isDialog ? chat.id ^ myId : null;
|
|
||||||
final isSelf = peerId != null && peerId == myId;
|
|
||||||
final isContact =
|
|
||||||
isDialog && !isSelf && peerId != null && contactIds.contains(peerId);
|
|
||||||
|
|
||||||
for (final raw in folder.filters) {
|
|
||||||
switch (_filterCode(raw)) {
|
|
||||||
case filterUnread:
|
|
||||||
if (chat.unreadCount > 0) return true;
|
|
||||||
case filterChannel:
|
|
||||||
if (chat.type == 'CHANNEL') return true;
|
|
||||||
case filterGroup:
|
|
||||||
if (chat.type == 'CHAT' || chat.type == 'GROUP') return true;
|
|
||||||
case filterContact:
|
|
||||||
if (isDialog && !isBot && isContact) return true;
|
|
||||||
case filterNotContact:
|
|
||||||
if (isDialog && !isBot && !isSelf && !isContact) return true;
|
|
||||||
case filterBot:
|
|
||||||
if (isBot) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<ChatFolder> _parseFolderList(
|
|
||||||
List<dynamic> json, {
|
|
||||||
bool lenient = true,
|
|
||||||
}) {
|
|
||||||
if (lenient) {
|
|
||||||
return json
|
|
||||||
.map((e) {
|
|
||||||
try {
|
|
||||||
final m = e is Map<String, dynamic>
|
|
||||||
? e
|
|
||||||
: Map<String, dynamic>.from(e as Map);
|
|
||||||
return ChatFolder.fromJson(m);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.whereType<ChatFolder>()
|
|
||||||
.toList();
|
.toList();
|
||||||
|
if (typeFilters.isEmpty) return false;
|
||||||
|
final matchesType = typeFilters.any(
|
||||||
|
(f) => _matchesType(f, chat, myId: myId, contactIds: contactIds),
|
||||||
|
);
|
||||||
|
if (!matchesType) return false;
|
||||||
}
|
}
|
||||||
return json.map((e) {
|
|
||||||
final m = e is Map<String, dynamic>
|
final roleFilters = folder.filters.where(FolderFilter.roles.contains);
|
||||||
? e
|
if (roleFilters.isNotEmpty &&
|
||||||
: Map<String, dynamic>.from(e as Map);
|
!roleFilters.any((f) => _matchesRole(f, chat, myId))) {
|
||||||
return ChatFolder.fromJson(m);
|
return false;
|
||||||
}).toList();
|
}
|
||||||
|
|
||||||
|
for (final f in folder.filters.where(FolderFilter.showOnly.contains)) {
|
||||||
|
if (!_matchesRestriction(f, chat)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<ChatFolder>> loadFolders(int accountId) async {
|
static List<ChatFolder> _parseFolderList(dynamic raw) {
|
||||||
|
if (raw is! List) return [];
|
||||||
|
return raw
|
||||||
|
.map((e) {
|
||||||
|
try {
|
||||||
|
final m = e is Map<String, dynamic>
|
||||||
|
? e
|
||||||
|
: Map<String, dynamic>.from(e as Map);
|
||||||
|
return ChatFolder.fromJson(m);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.whereType<ChatFolder>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String>? _parseOrder(dynamic raw) {
|
||||||
|
if (raw is! List) return null;
|
||||||
|
return raw.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _parseSync(dynamic raw) => raw is int ? raw : null;
|
||||||
|
|
||||||
|
static Future<_FoldersSnapshot> _loadSnapshot(int accountId) async {
|
||||||
final raw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
final raw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
||||||
if (raw == null || raw.isEmpty) return [];
|
if (raw == null || raw.isEmpty) return const _FoldersSnapshot();
|
||||||
try {
|
try {
|
||||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
final foldersJson = map['folders'] as List<dynamic>?;
|
final folders = _parseFolderList(map['folders']);
|
||||||
final folders = foldersJson == null
|
final order = _parseOrder(map['foldersOrder']) ?? const <String>[];
|
||||||
? <ChatFolder>[]
|
_sortInPlace(folders, order);
|
||||||
: _parseFolderList(foldersJson, lenient: false);
|
return _FoldersSnapshot(
|
||||||
final order = map['foldersOrder'] as List<dynamic>?;
|
folders: folders,
|
||||||
sortFoldersInPlace(folders, order);
|
order: order,
|
||||||
return folders;
|
folderSync: _parseSync(map['folderSync']) ?? 0,
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return [];
|
return const _FoldersSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _persist(
|
static Future<void> _saveSnapshot(
|
||||||
int accountId,
|
int accountId,
|
||||||
List<ChatFolder> folders,
|
_FoldersSnapshot snapshot,
|
||||||
List<dynamic>? order,
|
|
||||||
) async {
|
) 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(
|
await AppDatabase.setSyncValue(
|
||||||
accountId,
|
accountId,
|
||||||
_syncKey,
|
_syncKey,
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'folders': folders.map((f) => f.toJson()).toList(),
|
'folders': snapshot.folders.map((f) => f.toJson()).toList(),
|
||||||
'foldersOrder': order,
|
'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(
|
static Future<void> applyPayload(
|
||||||
int accountId,
|
int accountId,
|
||||||
Map<dynamic, dynamic> payload,
|
Map<dynamic, dynamic> payload, {
|
||||||
) async {
|
bool replace = false,
|
||||||
final foldersJson = payload['folders'] as List<dynamic>?;
|
}) async {
|
||||||
final order = payload['foldersOrder'] as List<dynamic>?;
|
final foldersRaw = payload['folders'];
|
||||||
if (foldersJson == null && order == null) return;
|
final folderRaw = payload['folder'];
|
||||||
|
final orderRaw = payload['foldersOrder'];
|
||||||
List<ChatFolder> folders;
|
final syncRaw = payload['folderSync'];
|
||||||
if (foldersJson != null) {
|
if (foldersRaw == null &&
|
||||||
folders = _parseFolderList(foldersJson);
|
folderRaw == null &&
|
||||||
} else {
|
orderRaw == null &&
|
||||||
folders = await loadFolders(accountId);
|
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(
|
static Future<void> applyFromLoginConfig(
|
||||||
@@ -206,57 +338,180 @@ class FoldersModule {
|
|||||||
) async {
|
) async {
|
||||||
final chatFolders = config['chatFolders'];
|
final chatFolders = config['chatFolders'];
|
||||||
if (chatFolders is! Map) return;
|
if (chatFolders is! Map) return;
|
||||||
final foldersJson = chatFolders['FOLDERS'] as List<dynamic>?;
|
if (chatFolders['FOLDERS'] == null) return;
|
||||||
if (foldersJson == null) return;
|
await applyPayload(accountId, {
|
||||||
final order = chatFolders['foldersOrder'] as List<dynamic>?;
|
'folders': chatFolders['FOLDERS'],
|
||||||
final folders = _parseFolderList(foldersJson);
|
'foldersOrder': chatFolders['foldersOrder'],
|
||||||
sortFoldersInPlace(folders, order);
|
'folderSync': chatFolders['folderSync'],
|
||||||
await _persist(accountId, folders, order);
|
}, replace: true);
|
||||||
await markFoldersListReady(accountId);
|
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,
|
Api api,
|
||||||
int accountId,
|
int accountId,
|
||||||
ChatFolder folder,
|
ChatFolder folder,
|
||||||
List<int> favorites,
|
List<int> favorites,
|
||||||
|
) {
|
||||||
|
return updateFolder(api, accountId, folder, favorites: favorites);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteFolders(
|
||||||
|
Api api,
|
||||||
|
int accountId,
|
||||||
|
List<String> folderIds,
|
||||||
) async {
|
) async {
|
||||||
final packet = await api.sendRequest(Opcode.foldersUpdate, {
|
if (folderIds.isEmpty) return;
|
||||||
'id': folder.id,
|
final packet = await api.sendRequest(Opcode.foldersDelete, {
|
||||||
'title': folder.title,
|
'folderIds': folderIds,
|
||||||
'include': folder.include ?? const [],
|
|
||||||
'favorites': favorites,
|
|
||||||
'filters': folder.filters,
|
|
||||||
'options': folder.options ?? const [],
|
|
||||||
});
|
});
|
||||||
throwIfPacketError(packet);
|
throwIfPacketError(packet);
|
||||||
final data = packet.payload;
|
|
||||||
if (data is! Map) return null;
|
final removed = folderIds.toSet();
|
||||||
final folderJson = data['folder'];
|
final current = await _loadSnapshot(accountId);
|
||||||
if (folderJson is! Map) return null;
|
await _saveSnapshot(
|
||||||
final updated = ChatFolder.fromJson(
|
accountId,
|
||||||
folderJson is Map<String, dynamic>
|
_FoldersSnapshot(
|
||||||
? folderJson
|
folders: current.folders.where((f) => !removed.contains(f.id)).toList(),
|
||||||
: Map<String, dynamic>.from(folderJson),
|
order: current.order,
|
||||||
|
folderSync: current.folderSync,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final currentRaw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
final data = packet.payload;
|
||||||
final snapshot = (currentRaw != null && currentRaw.isNotEmpty)
|
if (data is Map) {
|
||||||
? jsonDecode(currentRaw) as Map<String, dynamic>
|
await applyPayload(accountId, data.cast<dynamic, 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 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 {
|
static Future<void> syncFromServer(Api api, int accountId) async {
|
||||||
@@ -267,7 +522,11 @@ class FoldersModule {
|
|||||||
throwIfPacketError(packet);
|
throwIfPacketError(packet);
|
||||||
final data = packet.payload;
|
final data = packet.payload;
|
||||||
if (data is Map) {
|
if (data is Map) {
|
||||||
await applyPayload(accountId, data.cast<dynamic, dynamic>());
|
await applyPayload(
|
||||||
|
accountId,
|
||||||
|
data.cast<dynamic, dynamic>(),
|
||||||
|
replace: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await markFoldersListReady(accountId);
|
await markFoldersListReady(accountId);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import 'chat_screen.dart';
|
|||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'create_channel_flow.dart';
|
import 'create_channel_flow.dart';
|
||||||
import 'create_group_flow.dart';
|
import 'create_group_flow.dart';
|
||||||
|
import 'folder_action_sheet.dart';
|
||||||
|
import 'folder_edit_sheet.dart';
|
||||||
import '../contacts/add_contact_sheet.dart';
|
import '../contacts/add_contact_sheet.dart';
|
||||||
import '../../widgets/adaptive_shell.dart';
|
import '../../widgets/adaptive_shell.dart';
|
||||||
import '../../../core/crypto/message_decryption_cache.dart';
|
import '../../../core/crypto/message_decryption_cache.dart';
|
||||||
@@ -649,6 +651,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
KometSettings.hideAllChatsFolder.addListener(_requestReload);
|
KometSettings.hideAllChatsFolder.addListener(_requestReload);
|
||||||
KometSettings.showHiddenChats.addListener(_requestReload);
|
KometSettings.showHiddenChats.addListener(_requestReload);
|
||||||
ContactsModule.revision.addListener(_requestReload);
|
ContactsModule.revision.addListener(_requestReload);
|
||||||
|
FoldersModule.revision.addListener(_requestReload);
|
||||||
bannersModule.activeBanner.addListener(_onActiveInformerChanged);
|
bannersModule.activeBanner.addListener(_onActiveInformerChanged);
|
||||||
_maybeLoadStories();
|
_maybeLoadStories();
|
||||||
_typingSub = api.pushStream
|
_typingSub = api.pushStream
|
||||||
@@ -856,12 +859,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
p.id,
|
p.id,
|
||||||
)).map((c) => c.id).toSet();
|
)).map((c) => c.id).toSet();
|
||||||
|
|
||||||
final allChatsFolder = ChatFolder(
|
const allChatsFolder = ChatFolder(
|
||||||
id: 'all.chat.folder',
|
id: FoldersModule.allChatsFolderId,
|
||||||
title: 'Все чаты',
|
title: 'Все чаты',
|
||||||
filters: [],
|
|
||||||
hideEmpty: false,
|
|
||||||
widgets: [],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.archiveMode) {
|
if (widget.archiveMode) {
|
||||||
@@ -1318,6 +1318,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
KometSettings.hideAllChatsFolder.removeListener(_requestReload);
|
KometSettings.hideAllChatsFolder.removeListener(_requestReload);
|
||||||
KometSettings.showHiddenChats.removeListener(_requestReload);
|
KometSettings.showHiddenChats.removeListener(_requestReload);
|
||||||
ContactsModule.revision.removeListener(_requestReload);
|
ContactsModule.revision.removeListener(_requestReload);
|
||||||
|
FoldersModule.revision.removeListener(_requestReload);
|
||||||
bannersModule.activeBanner.removeListener(_onActiveInformerChanged);
|
bannersModule.activeBanner.removeListener(_onActiveInformerChanged);
|
||||||
_loginSub?.cancel();
|
_loginSub?.cancel();
|
||||||
_stateSub?.cancel();
|
_stateSub?.cancel();
|
||||||
@@ -1724,10 +1725,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < _folders.length; i++) ...[
|
for (var i = 0; i < _folders.length; i++) ...[
|
||||||
if (i > 0) const SizedBox(width: 8),
|
if (i > 0) const SizedBox(width: 8),
|
||||||
_buildFolderChip(
|
_buildFolderChip(_folders[i]),
|
||||||
_folderChipLabel(_folders[i]),
|
|
||||||
folderId: _folders[i].id,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1742,10 +1740,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
for (var i = 0; i < _folders.length; i++) ...[
|
for (var i = 0; i < _folders.length; i++) ...[
|
||||||
if (i > 0) const SizedBox(width: 8),
|
if (i > 0) const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildFolderChip(
|
child: _buildFolderChip(_folders[i]),
|
||||||
_folderChipLabel(_folders[i]),
|
|
||||||
folderId: _folders[i].id,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -2648,11 +2643,16 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFolderChip(String title, {required String folderId}) {
|
Widget _buildFolderChip(ChatFolder folder) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final folderId = folder.id;
|
||||||
final isSelected = _selectedFolderId == folderId;
|
final isSelected = _selectedFolderId == folderId;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _selectFolder(folderId),
|
onTap: () => _selectFolder(folderId),
|
||||||
|
onLongPress: () {
|
||||||
|
Haptics.medium();
|
||||||
|
showFolderActionSheet(context, folder: folder);
|
||||||
|
},
|
||||||
child: GlossyPill(
|
child: GlossyPill(
|
||||||
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
@@ -2660,7 +2660,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
depth: 4,
|
depth: 4,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
_folderChipLabel(folder),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected ? cs.onPrimaryContainer : cs.primary,
|
color: isSelected ? cs.onPrimaryContainer : cs.primary,
|
||||||
@@ -3159,7 +3159,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
bool _isBotDialog(int contactId, CachedChat chat) {
|
bool _isBotDialog(int contactId, CachedChat chat) {
|
||||||
if (contactId == 0 || contactId == _profile?.id) return false;
|
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');
|
return chat.options.contains('BOT');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3272,6 +3273,15 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
showAddContactSheet(context);
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SheetButton(
|
child: SheetButton(
|
||||||
label: 'Отменить',
|
label: 'Отменить',
|
||||||
filled: false,
|
filled: false,
|
||||||
onTap: () => Navigator.pop(context),
|
onTap: () => Navigator.pop(context),
|
||||||
cs: cs,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SheetButton(
|
child: SheetButton(
|
||||||
label: 'Далее',
|
label: 'Далее',
|
||||||
filled: true,
|
filled: true,
|
||||||
onTap: () => setState(() => _step = _Step.groupDetails),
|
onTap: () => setState(() => _step = _Step.groupDetails),
|
||||||
cs: cs,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -482,20 +480,18 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SheetButton(
|
child: SheetButton(
|
||||||
label: 'Отменить',
|
label: 'Отменить',
|
||||||
filled: false,
|
filled: false,
|
||||||
onTap: _creating ? null : () => Navigator.pop(context),
|
onTap: _creating ? null : () => Navigator.pop(context),
|
||||||
cs: cs,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SheetButton(
|
child: SheetButton(
|
||||||
label: _creating ? 'Создаю...' : 'Создать',
|
label: _creating ? 'Создаю...' : 'Создать',
|
||||||
filled: true,
|
filled: true,
|
||||||
onTap: canCreate ? _create : null,
|
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.
|
/// Standard rounded top shape for modal bottom sheets.
|
||||||
const RoundedRectangleBorder kSheetShape = AppShape.sheetBorder;
|
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.
|
/// The little drag "grabber" pill shown at the top of a bottom sheet.
|
||||||
class SheetGrabber extends StatelessWidget {
|
class SheetGrabber extends StatelessWidget {
|
||||||
final EdgeInsetsGeometry margin;
|
final EdgeInsetsGeometry margin;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import 'backend/modules/chats.dart';
|
|||||||
import 'backend/modules/comments.dart';
|
import 'backend/modules/comments.dart';
|
||||||
import 'backend/modules/contacts.dart';
|
import 'backend/modules/contacts.dart';
|
||||||
import 'backend/modules/file_uploader.dart';
|
import 'backend/modules/file_uploader.dart';
|
||||||
|
import 'backend/modules/folders.dart';
|
||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
import 'backend/modules/outbox.dart';
|
import 'backend/modules/outbox.dart';
|
||||||
import 'backend/modules/polls.dart';
|
import 'backend/modules/polls.dart';
|
||||||
@@ -193,6 +194,7 @@ void main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
attachInfoCacheApi(api);
|
attachInfoCacheApi(api);
|
||||||
chats.attachGlobalPushHandlers(api);
|
chats.attachGlobalPushHandlers(api);
|
||||||
|
FoldersModule.attachGlobalPushHandlers(api);
|
||||||
commentsModule.attachPushHandlers(api);
|
commentsModule.attachPushHandlers(api);
|
||||||
storiesModule.attach();
|
storiesModule.attach();
|
||||||
unawaited(storiesModule.loadCache());
|
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