удаление чатов и фикс загрузки ботов в chatsэ
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../api.dart';
|
||||
import 'messages.dart' show ContactCache;
|
||||
|
||||
Map<int, int> _parseParticipants(dynamic raw) {
|
||||
try {
|
||||
@@ -43,6 +46,8 @@ class CachedChat {
|
||||
final int seenTime;
|
||||
final Map<int, int> participants;
|
||||
final Set<String> options;
|
||||
final int? owner;
|
||||
final Set<int> admins;
|
||||
|
||||
CachedChat({
|
||||
required this.id,
|
||||
@@ -63,12 +68,16 @@ class CachedChat {
|
||||
required this.seenTime,
|
||||
required this.participants,
|
||||
this.options = const {},
|
||||
this.owner,
|
||||
this.admins = const {},
|
||||
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
|
||||
? lastMsgText.replaceAll('\n', ' ')
|
||||
: lastMsgText;
|
||||
|
||||
bool get isOfficial => options.contains('OFFICIAL');
|
||||
|
||||
bool iAmAdmin(int myId) => owner == myId || admins.contains(myId);
|
||||
|
||||
factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
|
||||
id: row['id'] as int,
|
||||
accountId: row['account_id'] as int,
|
||||
@@ -88,6 +97,8 @@ class CachedChat {
|
||||
seenTime: row['seen_time'] as int,
|
||||
participants: _parseParticipants(row['participants']),
|
||||
options: _decodeOptions(row['options']),
|
||||
owner: row['owner'] as int?,
|
||||
admins: _decodeAdmins(row['admins']),
|
||||
);
|
||||
|
||||
static Set<String> _decodeOptions(dynamic raw) {
|
||||
@@ -95,6 +106,15 @@ class CachedChat {
|
||||
return raw.split(',').where((s) => s.isNotEmpty).toSet();
|
||||
}
|
||||
|
||||
static Set<int> _decodeAdmins(dynamic raw) {
|
||||
if (raw is! String || raw.isEmpty) return const {};
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => int.tryParse(s.trim()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
@@ -114,6 +134,8 @@ class CachedChat {
|
||||
'seen_time': seenTime,
|
||||
'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))),
|
||||
'options': options.isEmpty ? null : options.join(','),
|
||||
'owner': owner,
|
||||
'admins': admins.isEmpty ? null : admins.join(','),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,15 +143,68 @@ class ChatsModule {
|
||||
static final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
||||
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||
|
||||
static final Set<int> _pendingContactUpdates = {};
|
||||
static Timer? _contactFlushTimer;
|
||||
static const _contactFlushDelay = Duration(milliseconds: 250);
|
||||
|
||||
static void applyContactUpdate(int contactId) {
|
||||
_pendingContactUpdates.add(contactId);
|
||||
_contactFlushTimer ??= Timer(_contactFlushDelay, _flushContactUpdates);
|
||||
}
|
||||
|
||||
static Future<void> _flushContactUpdates() async {
|
||||
_contactFlushTimer = null;
|
||||
if (_pendingContactUpdates.isEmpty) return;
|
||||
final ids = _pendingContactUpdates.toList();
|
||||
_pendingContactUpdates.clear();
|
||||
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
|
||||
final updates = <Map<String, dynamic>>[];
|
||||
for (final contactId in ids) {
|
||||
final name = ContactCache.get(contactId);
|
||||
if (name == null) continue;
|
||||
final avatar = ContactCache.getAvatar(contactId);
|
||||
final options = ContactCache.getOptions(contactId) ?? const <String>{};
|
||||
|
||||
final rows = await AppDatabase.findDialogChatsByParticipant(
|
||||
accountId,
|
||||
contactId,
|
||||
);
|
||||
for (final row in rows) {
|
||||
final cached = CachedChat.fromDbRow(row);
|
||||
final sameTitle = cached.title == name;
|
||||
final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
|
||||
final sameOptions = cached.options.length == options.length &&
|
||||
cached.options.containsAll(options);
|
||||
if (sameTitle && sameAvatar && sameOptions) continue;
|
||||
final newRow = Map<String, dynamic>.from(row);
|
||||
newRow['title'] = name;
|
||||
newRow['icon_url'] = avatar;
|
||||
newRow['options'] = options.isEmpty ? null : options.join(',');
|
||||
updates.add(newRow);
|
||||
}
|
||||
}
|
||||
if (updates.isNotEmpty) {
|
||||
await AppDatabase.saveChats(updates);
|
||||
_bump();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<CachedChat?> cacheServerChat(
|
||||
Map<dynamic, dynamic> chat,
|
||||
int accountId,
|
||||
) async {
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
final existingRows = await AppDatabase.loadChats(accountId);
|
||||
final existing = {
|
||||
for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row),
|
||||
};
|
||||
final id = chat['id'];
|
||||
Map<int, CachedChat> existing = const {};
|
||||
if (id is int) {
|
||||
final rows = await AppDatabase.loadChat(accountId, id);
|
||||
if (rows.isNotEmpty) {
|
||||
existing = {id: CachedChat.fromDbRow(rows.first)};
|
||||
}
|
||||
}
|
||||
final parsed = _parseChat(
|
||||
chat,
|
||||
accountId,
|
||||
@@ -140,7 +215,10 @@ class ChatsModule {
|
||||
existing,
|
||||
cachedAt,
|
||||
);
|
||||
if (parsed == null) return null;
|
||||
if (parsed == null) {
|
||||
logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
|
||||
return null;
|
||||
}
|
||||
await AppDatabase.saveChats([parsed.toDbRow()]);
|
||||
_bump();
|
||||
return parsed;
|
||||
@@ -306,6 +384,12 @@ class ChatsModule {
|
||||
if (config is Map) {
|
||||
favIndex = config['favIndex'] as int?;
|
||||
dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0;
|
||||
} else {
|
||||
final ex = existing[id];
|
||||
if (ex != null) {
|
||||
favIndex = ex.favIndex;
|
||||
dontDisturbUntil = ex.dontDisturbUntil;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -320,6 +404,31 @@ class ChatsModule {
|
||||
}
|
||||
Map<int, int> participants = _parseParticipants(chat['participants']);
|
||||
|
||||
int? owner;
|
||||
final ownerRaw = chat['owner'];
|
||||
if (ownerRaw is int) {
|
||||
owner = ownerRaw;
|
||||
} else if (ownerRaw is String) {
|
||||
owner = int.tryParse(ownerRaw);
|
||||
}
|
||||
|
||||
Set<int> admins = const {};
|
||||
final adminsRaw = chat['admins'];
|
||||
if (adminsRaw is List) {
|
||||
admins = adminsRaw
|
||||
.map((e) => e is int ? e : int.tryParse(e.toString()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
} else {
|
||||
final adminParticipants = chat['adminParticipants'];
|
||||
if (adminParticipants is Map) {
|
||||
admins = adminParticipants.keys
|
||||
.map((k) => k is int ? k : int.tryParse(k.toString()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
|
||||
return CachedChat(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
@@ -339,6 +448,8 @@ class ChatsModule {
|
||||
seenTime: seenTime,
|
||||
participants: participants,
|
||||
options: options,
|
||||
owner: owner,
|
||||
admins: admins,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при парсинге чата: $e");
|
||||
@@ -410,13 +521,25 @@ class ChatsModule {
|
||||
'notify': notify,
|
||||
};
|
||||
final packet = await api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!packet.isOk) return null;
|
||||
if (!packet.isOk) {
|
||||
logger.w('createGroupChat: server error payload=${packet.payload}');
|
||||
return null;
|
||||
}
|
||||
final data = packet.payload;
|
||||
if (data is! Map) return null;
|
||||
if (data is! Map) {
|
||||
logger.w('createGroupChat: payload is not a Map: $data');
|
||||
return null;
|
||||
}
|
||||
final chat = data['chat'];
|
||||
if (chat is! Map) return null;
|
||||
if (chat is! Map) {
|
||||
logger.w('createGroupChat: response has no chat field: $data');
|
||||
return null;
|
||||
}
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return null;
|
||||
if (accountId == null) {
|
||||
logger.w('createGroupChat: no active account id');
|
||||
return null;
|
||||
}
|
||||
return cacheServerChat(chat, accountId);
|
||||
}
|
||||
|
||||
@@ -439,4 +562,63 @@ class ChatsModule {
|
||||
});
|
||||
return packet.isOk;
|
||||
}
|
||||
|
||||
static Future<String?> deleteChat(
|
||||
Api api, {
|
||||
required int chatId,
|
||||
required int lastEventTime,
|
||||
required bool forAll,
|
||||
}) async {
|
||||
try {
|
||||
await api.sendRequest(Opcode.chatDelete, {
|
||||
'chatId': chatId,
|
||||
'lastEventTime': lastEventTime,
|
||||
'forAll': forAll,
|
||||
});
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.deleteChat(chatId, accountId);
|
||||
_bump();
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
logger.w('deleteChat $chatId: ${e.message}');
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
logger.w('deleteChat $chatId: $e');
|
||||
return 'Не удалось удалить чат';
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CachedChat>> refreshChats(
|
||||
Api api,
|
||||
List<int> chatIds,
|
||||
) async {
|
||||
if (chatIds.isEmpty) return const [];
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.chatInfo, {
|
||||
'chatIds': chatIds,
|
||||
});
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return const [];
|
||||
final list = payload['chats'];
|
||||
if (list is! List) return const [];
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return const [];
|
||||
final out = <CachedChat>[];
|
||||
for (final c in list) {
|
||||
if (c is Map) {
|
||||
final cached = await cacheServerChat(c, accountId);
|
||||
if (cached != null) out.add(cached);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} on PacketError catch (e) {
|
||||
logger.w('refreshChats: ${e.message}');
|
||||
return const [];
|
||||
} catch (e) {
|
||||
logger.w('refreshChats: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../api.dart';
|
||||
import '../../core/config/proxy_config.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/transport/proxy_connector.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'messages.dart';
|
||||
|
||||
sealed class UploadEvent {
|
||||
@@ -189,7 +190,12 @@ class FileUploader {
|
||||
Socket? socket;
|
||||
try {
|
||||
socket = await _openSocket(uri);
|
||||
_writeHeaders(socket, uri, filename, bytes.length, contentType: 'image/jpeg');
|
||||
_writeImageHeaders(
|
||||
socket,
|
||||
uri,
|
||||
bytes.length,
|
||||
contentType: _contentTypeForFilename(filename),
|
||||
);
|
||||
socket.add(bytes);
|
||||
await socket.flush();
|
||||
|
||||
@@ -201,11 +207,22 @@ class FileUploader {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) return null;
|
||||
if (response == null) {
|
||||
logger.w('uploadImage: empty/timed-out response');
|
||||
return null;
|
||||
}
|
||||
final (status, body) = response;
|
||||
if (status != 200) return null;
|
||||
return _parsePhotoToken(body);
|
||||
} catch (_) {
|
||||
if (status != 200) {
|
||||
logger.w('uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}');
|
||||
return null;
|
||||
}
|
||||
final token = _parsePhotoToken(body);
|
||||
if (token == null) {
|
||||
logger.w('uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}');
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadImage: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
@@ -213,6 +230,40 @@ class FileUploader {
|
||||
}
|
||||
}
|
||||
|
||||
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: $contentType\r\n')
|
||||
..write('Content-Length: $total\r\n')
|
||||
..write('Connection: keep-alive\r\n')
|
||||
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
}
|
||||
|
||||
String _contentTypeForFilename(String filename) {
|
||||
final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : '';
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'heic':
|
||||
case 'heif':
|
||||
return 'image/heic';
|
||||
case 'bmp':
|
||||
return 'image/bmp';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
default:
|
||||
return 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
Future<(int, String)?> _readFullResponse(
|
||||
Socket socket, {
|
||||
required Duration timeout,
|
||||
@@ -232,10 +283,15 @@ class FileUploader {
|
||||
return;
|
||||
}
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final statusLine = headerStr.split('\r\n').first;
|
||||
final parts = statusLine.split(' ');
|
||||
final lines = headerStr.split('\r\n');
|
||||
final parts = lines.first.split(' ');
|
||||
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
||||
final body = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
||||
final chunked = lines.skip(1).any(
|
||||
(l) => l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||
l.toLowerCase().contains('chunked'),
|
||||
);
|
||||
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
||||
final body = chunked ? _decodeChunked(rawBody) : rawBody;
|
||||
completer.complete((status, body));
|
||||
}
|
||||
|
||||
@@ -250,6 +306,31 @@ class FileUploader {
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
String _decodeChunked(String body) {
|
||||
final out = StringBuffer();
|
||||
var i = 0;
|
||||
while (i < body.length) {
|
||||
final lineEnd = body.indexOf('\r\n', i);
|
||||
if (lineEnd < 0) break;
|
||||
final sizeStr = body.substring(i, lineEnd).split(';').first.trim();
|
||||
if (sizeStr.isEmpty) {
|
||||
i = lineEnd + 2;
|
||||
continue;
|
||||
}
|
||||
final size = int.tryParse(sizeStr, radix: 16);
|
||||
if (size == null) break;
|
||||
if (size == 0) break;
|
||||
final dataStart = lineEnd + 2;
|
||||
if (dataStart + size > body.length) break;
|
||||
out.write(body.substring(dataStart, dataStart + size));
|
||||
i = dataStart + size;
|
||||
if (i + 2 <= body.length && body.substring(i, i + 2) == '\r\n') {
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
String? _parsePhotoToken(String body) {
|
||||
try {
|
||||
final json = jsonDecode(body);
|
||||
@@ -266,7 +347,9 @@ class FileUploader {
|
||||
final pt = json['photoToken'];
|
||||
if (pt is String && pt.isNotEmpty) return pt;
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logger.w('parsePhotoToken: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'chats.dart' show ChatsModule;
|
||||
|
||||
class ContactCache {
|
||||
static final Map<int, String> _nameCache = {};
|
||||
@@ -21,6 +22,7 @@ class ContactCache {
|
||||
|
||||
static String? get(int id) => _nameCache[id];
|
||||
static String? getAvatar(int id) => _avatarCache[id];
|
||||
static Set<String>? getOptions(int id) => _optionsCache[id];
|
||||
static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false;
|
||||
}
|
||||
|
||||
@@ -641,6 +643,7 @@ class MessagesModule {
|
||||
ContactCache.putOptions(contactId, rawOpts.whereType<String>().toSet());
|
||||
}
|
||||
|
||||
ChatsModule.applyContactUpdate(contactId);
|
||||
return fullName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class AppDatabase {
|
||||
final dbPath = await getDatabasesPath();
|
||||
return openDatabase(
|
||||
join(dbPath, 'komet.db'),
|
||||
version: 9,
|
||||
version: 10,
|
||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, _) => _createTables(db),
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
@@ -201,6 +201,14 @@ class AppDatabase {
|
||||
'ALTER TABLE chats_cache ADD COLUMN options TEXT',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 10) {
|
||||
await db.execute(
|
||||
'ALTER TABLE chats_cache ADD COLUMN owner INTEGER',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE chats_cache ADD COLUMN admins TEXT',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -272,6 +280,8 @@ class AppDatabase {
|
||||
seen_time INTEGER NOT NULL DEFAULT 0,
|
||||
participants TEXT NOT NULL DEFAULT "",
|
||||
options TEXT,
|
||||
owner INTEGER,
|
||||
admins TEXT,
|
||||
PRIMARY KEY (id, account_id)
|
||||
)
|
||||
''';
|
||||
@@ -461,6 +471,27 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> findDialogChatsByParticipant(
|
||||
int accountId,
|
||||
int contactId,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
'chats_cache',
|
||||
where: "account_id = ? AND type = 'DIALOG' AND participants LIKE ?",
|
||||
whereArgs: [accountId, '%"$contactId":%'],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> deleteChat(int chatId, int accountId) async {
|
||||
final db = await _instance;
|
||||
await db.delete(
|
||||
'chats_cache',
|
||||
where: 'id = ? AND account_id = ?',
|
||||
whereArgs: [chatId, accountId],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> clearChatsCache(int accountId) async {
|
||||
final db = await _instance;
|
||||
await db.delete(
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'dart:ui' as ui;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'create_group_flow.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
@@ -61,6 +62,8 @@ class ChatListScreen extends StatefulWidget {
|
||||
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||
}
|
||||
|
||||
enum _DeleteKind { personalLike, ownerGroup, blocked }
|
||||
|
||||
class _ChatListScreenState extends State<ChatListScreen>
|
||||
with TickerProviderStateMixin {
|
||||
String? _selectedFolderId;
|
||||
@@ -168,6 +171,176 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
});
|
||||
}
|
||||
|
||||
List<CachedChat> _selectedChatObjects() {
|
||||
if (_selectedChats.isEmpty) return const [];
|
||||
final ids = _selectedChats;
|
||||
return _chats.where((c) => ids.contains(c.id.toString())).toList();
|
||||
}
|
||||
|
||||
_DeleteKind _categorizeChat(CachedChat c, int myId) {
|
||||
if (c.type == 'DIALOG') return _DeleteKind.personalLike;
|
||||
if (c.iAmAdmin(myId)) return _DeleteKind.ownerGroup;
|
||||
return _DeleteKind.blocked;
|
||||
}
|
||||
|
||||
_DeleteKind? _selectionDeleteCategory() {
|
||||
if (_sessionState != SessionState.online) return null;
|
||||
final myId = _profile?.id;
|
||||
if (myId == null) return null;
|
||||
final selected = _selectedChatObjects();
|
||||
if (selected.isEmpty) return null;
|
||||
final cats = selected.map((c) => _categorizeChat(c, myId)).toSet();
|
||||
if (cats.contains(_DeleteKind.blocked)) return null;
|
||||
if (cats.length > 1) return null;
|
||||
return cats.single;
|
||||
}
|
||||
|
||||
Future<void> _onDeleteTap() async {
|
||||
final selectedBefore = _selectedChatObjects();
|
||||
if (selectedBefore.isEmpty) return;
|
||||
final myId = _profile?.id;
|
||||
if (myId == null) return;
|
||||
|
||||
await ChatsModule.refreshChats(api, selectedBefore.map((c) => c.id).toList());
|
||||
if (!mounted) return;
|
||||
|
||||
final selectedAfter = _selectedChatObjects();
|
||||
if (selectedAfter.isEmpty) return;
|
||||
final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet();
|
||||
if (cats.contains(_DeleteKind.blocked) || cats.length > 1) {
|
||||
showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз');
|
||||
return;
|
||||
}
|
||||
final kind = cats.single;
|
||||
|
||||
final confirmed = await _showDeleteConfirmDialog(selectedAfter, kind);
|
||||
if (!mounted || confirmed != true) return;
|
||||
|
||||
final errors = <String>[];
|
||||
for (final c in selectedAfter) {
|
||||
final forAll = kind == _DeleteKind.ownerGroup;
|
||||
final err = await ChatsModule.deleteChat(
|
||||
api,
|
||||
chatId: c.id,
|
||||
lastEventTime: c.lastEventTime,
|
||||
forAll: forAll,
|
||||
);
|
||||
if (err != null) errors.add(err);
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (errors.isNotEmpty) {
|
||||
final msg = errors.length == 1
|
||||
? errors.first
|
||||
: 'Не удалось удалить ${errors.length} чат(ов): ${errors.first}';
|
||||
showCustomNotification(context, msg);
|
||||
}
|
||||
_clearSelection();
|
||||
}
|
||||
|
||||
Future<bool?> _showDeleteConfirmDialog(
|
||||
List<CachedChat> selected,
|
||||
_DeleteKind kind,
|
||||
) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final count = selected.length;
|
||||
final single = count == 1 ? selected.first : null;
|
||||
|
||||
String title;
|
||||
String body;
|
||||
String primaryLabel;
|
||||
switch (kind) {
|
||||
case _DeleteKind.personalLike:
|
||||
title = single != null
|
||||
? 'Удалить чат с ${single.title ?? ''}?'
|
||||
: 'Удалить $count чатов?';
|
||||
body = 'Восстановить переписку не получится';
|
||||
primaryLabel = count == 1 ? 'Удалить чат' : 'Удалить';
|
||||
case _DeleteKind.ownerGroup:
|
||||
title = single != null
|
||||
? 'Хотите удалить чат «${single.title ?? ''}»?'
|
||||
: 'Удалить $count групп у всех?';
|
||||
body = single != null
|
||||
? 'Передайте права владельца, чтобы остальные участники могли продолжить общение'
|
||||
: 'Действие нельзя отменить';
|
||||
primaryLabel = count == 1 ? 'Удалить чат у всех' : 'Удалить у всех';
|
||||
case _DeleteKind.blocked:
|
||||
return Future.value(false);
|
||||
}
|
||||
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
body,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (kind == _DeleteKind.ownerGroup && single != null) ...[
|
||||
Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
'Передать права и выйти',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface.withValues(alpha: 0.4),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx, true),
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
primaryLabel,
|
||||
style: TextStyle(
|
||||
color: cs.onError,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isInitialLoading = true;
|
||||
DateTime _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@@ -393,7 +566,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (!mounted) return;
|
||||
_contactRebuildTimer?.cancel();
|
||||
_contactRebuildTimer = Timer(const Duration(milliseconds: 120), () {
|
||||
if (mounted) setState(() {});
|
||||
if (!mounted) return;
|
||||
_cachedChatsBody = null;
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1644,10 +1819,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
if (_selectionDeleteCategory() != null)
|
||||
IconButton(
|
||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||
onPressed: _onDeleteTap,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.archive, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
|
||||
@@ -12,19 +12,10 @@ import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||
|
||||
Future<void> showCreateGroupFlow(BuildContext context) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final selected = await showModalBottomSheet<List<CachedContact>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => const _ParticipantsPickerSheet(),
|
||||
);
|
||||
if (selected == null) return;
|
||||
if (!context.mounted) return;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -32,30 +23,43 @@ Future<void> showCreateGroupFlow(BuildContext context) async {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => _GroupDetailsSheet(participants: selected),
|
||||
builder: (_) => const _CreateGroupFlow(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ParticipantsPickerSheet extends StatefulWidget {
|
||||
const _ParticipantsPickerSheet();
|
||||
enum _Step { pickParticipants, groupDetails }
|
||||
|
||||
class _CreateGroupFlow extends StatefulWidget {
|
||||
const _CreateGroupFlow();
|
||||
|
||||
@override
|
||||
State<_ParticipantsPickerSheet> createState() => _ParticipantsPickerSheetState();
|
||||
State<_CreateGroupFlow> createState() => _CreateGroupFlowState();
|
||||
}
|
||||
|
||||
class _ParticipantsPickerSheetState extends State<_ParticipantsPickerSheet> {
|
||||
final TextEditingController _search = TextEditingController();
|
||||
class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
_Step _step = _Step.pickParticipants;
|
||||
List<CachedContact> _all = [];
|
||||
final Set<int> _selectedIds = {};
|
||||
final List<CachedContact> _selected = [];
|
||||
bool _loading = true;
|
||||
final TextEditingController _search = TextEditingController();
|
||||
final TextEditingController _title = TextEditingController();
|
||||
File? _avatar;
|
||||
bool _creating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadContacts();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
_title.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadContacts() async {
|
||||
try {
|
||||
final myId = await TokenStorage.getActiveAccountId();
|
||||
if (myId == null) {
|
||||
@@ -75,223 +79,18 @@ class _ParticipantsPickerSheetState extends State<_ParticipantsPickerSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String _displayName(CachedContact c) {
|
||||
final last = c.lastName ?? '';
|
||||
return last.isEmpty ? c.firstName : '${c.firstName} $last';
|
||||
void _toggle(CachedContact c) {
|
||||
setState(() {
|
||||
final idx = _selected.indexWhere((x) => x.id == c.id);
|
||||
if (idx >= 0) {
|
||||
_selected.removeAt(idx);
|
||||
} else {
|
||||
_selected.add(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _statusText(CachedContact c) {
|
||||
if (c.isBot) return 'Бот';
|
||||
return 'Был(-а) недавно';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final viewInsets = MediaQuery.of(context).viewInsets;
|
||||
final query = _search.text.trim().toLowerCase();
|
||||
final filtered = query.isEmpty
|
||||
? _all
|
||||
: _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList();
|
||||
final selected = _all.where((c) => _selectedIds.contains(c.id)).toList();
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Выберите участников',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in selected)
|
||||
_SelectedChip(
|
||||
contact: c,
|
||||
label: _displayName(c),
|
||||
onRemove: () => setState(() => _selectedIds.remove(c.id)),
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final c = filtered[i];
|
||||
final picked = _selectedIds.contains(c.id);
|
||||
final dim = c.isBot;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (picked) {
|
||||
_selectedIds.remove(c.id);
|
||||
} else {
|
||||
_selectedIds.add(c.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_Avatar(contact: c, size: 40, cs: cs),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_displayName(c),
|
||||
style: TextStyle(
|
||||
color: dim
|
||||
? cs.onSurface.withValues(alpha: 0.5)
|
||||
: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_statusText(c),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (picked)
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Symbols.check, color: cs.onPrimary, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Далее',
|
||||
filled: true,
|
||||
onTap: () {
|
||||
final picked = _all
|
||||
.where((c) => _selectedIds.contains(c.id))
|
||||
.toList();
|
||||
Navigator.pop(context, picked);
|
||||
},
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupDetailsSheet extends StatefulWidget {
|
||||
final List<CachedContact> participants;
|
||||
const _GroupDetailsSheet({required this.participants});
|
||||
|
||||
@override
|
||||
State<_GroupDetailsSheet> createState() => _GroupDetailsSheetState();
|
||||
}
|
||||
|
||||
class _GroupDetailsSheetState extends State<_GroupDetailsSheet> {
|
||||
final TextEditingController _title = TextEditingController();
|
||||
File? _avatar;
|
||||
bool _creating = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
bool _isSelected(int id) => _selected.any((c) => c.id == id);
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
if (_creating) return;
|
||||
@@ -299,18 +98,27 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> {
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final path = result.files.first.path;
|
||||
if (path == null) return;
|
||||
setState(() => _avatar = File(path));
|
||||
final file = File(path);
|
||||
final size = await file.length();
|
||||
if (size > _maxAvatarBytes) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _avatar = file);
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final title = _title.text.trim();
|
||||
if (title.isEmpty || _creating) return;
|
||||
setState(() => _creating = true);
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
try {
|
||||
final chat = await ChatsModule.createGroupChat(
|
||||
api,
|
||||
title: title,
|
||||
userIds: widget.participants.map((c) => c.id).toList(),
|
||||
userIds: _selected.map((c) => c.id).toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (chat == null) {
|
||||
@@ -323,17 +131,22 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> {
|
||||
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
||||
if (url != null) {
|
||||
final bytes = await _avatar!.readAsBytes();
|
||||
final token = await fileUploader.uploadImage(Uri.parse(url), bytes);
|
||||
final token = await fileUploader.uploadImage(
|
||||
Uri.parse(url),
|
||||
bytes,
|
||||
filename: _avatar!.uri.pathSegments.last,
|
||||
);
|
||||
if (token != null) {
|
||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
||||
} else if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
navigator.pop();
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
@@ -351,113 +164,304 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String _displayName(CachedContact c) {
|
||||
final last = c.lastName ?? '';
|
||||
return last.isEmpty ? c.firstName : '${c.firstName} $last';
|
||||
}
|
||||
|
||||
String _statusText(CachedContact c) => c.isBot ? 'Бот' : 'Был(-а) недавно';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final viewInsets = MediaQuery.of(context).viewInsets;
|
||||
final canCreate = _title.text.trim().isNotEmpty && !_creating;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _creating ? null : () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, anim) {
|
||||
final offset = child.key == const ValueKey(_Step.pickParticipants)
|
||||
? Offset(-0.05, 0)
|
||||
: Offset(0.05, 0);
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(begin: offset, end: Offset.zero).animate(anim),
|
||||
child: FadeTransition(opacity: anim, child: child),
|
||||
);
|
||||
},
|
||||
child: _step == _Step.pickParticipants
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey(_Step.pickParticipants),
|
||||
child: _buildPickerStep(),
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey(_Step.groupDetails),
|
||||
child: _buildDetailsStep(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Создать группу',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _creating
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.maybePop(context);
|
||||
},
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _avatar != null
|
||||
? Image.file(_avatar!, fit: BoxFit.cover)
|
||||
: Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _title,
|
||||
onChanged: (_) => setState(() {}),
|
||||
enabled: !_creating,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Название группы',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: _creating ? null : () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: _creating ? 'Создаю...' : 'Создать',
|
||||
filled: true,
|
||||
onTap: canCreate ? _create : null,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPickerStep() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final query = _search.text.trim().toLowerCase();
|
||||
final filtered = query.isEmpty
|
||||
? _all
|
||||
: _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList();
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Выберите участников',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_selected.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in _selected)
|
||||
_SelectedChip(
|
||||
contact: c,
|
||||
label: _displayName(c),
|
||||
onRemove: () => _toggle(c),
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final c = filtered[i];
|
||||
final picked = _isSelected(c.id);
|
||||
final dim = c.isBot;
|
||||
return InkWell(
|
||||
onTap: () => _toggle(c),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_Avatar(contact: c, size: 40, cs: cs),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_displayName(c),
|
||||
style: TextStyle(
|
||||
color: dim
|
||||
? cs.onSurface.withValues(alpha: 0.5)
|
||||
: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_statusText(c),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (picked)
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Symbols.check, color: cs.onPrimary, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Далее',
|
||||
filled: true,
|
||||
onTap: () => setState(() => _step = _Step.groupDetails),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailsStep() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final canCreate = _title.text.trim().isNotEmpty && !_creating;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _creating
|
||||
? null
|
||||
: () => setState(() => _step = _Step.pickParticipants),
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Создать группу',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _creating ? null : () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _avatar != null
|
||||
? Image.file(_avatar!, fit: BoxFit.cover)
|
||||
: Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _title,
|
||||
onChanged: (_) => setState(() {}),
|
||||
enabled: !_creating,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Название группы',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: _creating ? null : () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: _creating ? 'Создаю...' : 'Создать',
|
||||
filled: true,
|
||||
onTap: canCreate ? _create : null,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
@@ -570,9 +574,7 @@ class _SheetButton extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
? (disabled
|
||||
? cs.primary.withValues(alpha: 0.4)
|
||||
: cs.primary)
|
||||
? (disabled ? cs.primary.withValues(alpha: 0.4) : cs.primary)
|
||||
: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user