diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index ecf4786..308b8a2 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -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 _parseParticipants(dynamic raw) { try { @@ -43,6 +46,8 @@ class CachedChat { final int seenTime; final Map participants; final Set options; + final int? owner; + final Set 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 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 _decodeOptions(dynamic raw) { @@ -95,6 +106,15 @@ class CachedChat { return raw.split(',').where((s) => s.isNotEmpty).toSet(); } + static Set _decodeAdmins(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw + .split(',') + .map((s) => int.tryParse(s.trim())) + .whereType() + .toSet(); + } + Map 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 chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; + static final Set _pendingContactUpdates = {}; + static Timer? _contactFlushTimer; + static const _contactFlushDelay = Duration(milliseconds: 250); + + static void applyContactUpdate(int contactId) { + _pendingContactUpdates.add(contactId); + _contactFlushTimer ??= Timer(_contactFlushDelay, _flushContactUpdates); + } + + static Future _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 = >[]; + 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 {}; + + 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.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 cacheServerChat( Map 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 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 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 admins = const {}; + final adminsRaw = chat['admins']; + if (adminsRaw is List) { + admins = adminsRaw + .map((e) => e is int ? e : int.tryParse(e.toString())) + .whereType() + .toSet(); + } else { + final adminParticipants = chat['adminParticipants']; + if (adminParticipants is Map) { + admins = adminParticipants.keys + .map((k) => k is int ? k : int.tryParse(k.toString())) + .whereType() + .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 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> refreshChats( + Api api, + List 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 = []; + 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 []; + } + } } diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 9b9193f..2aae308 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -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; } diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 2c0a718..e64996e 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -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 _nameCache = {}; @@ -21,6 +22,7 @@ class ContactCache { static String? get(int id) => _nameCache[id]; static String? getAvatar(int id) => _avatarCache[id]; + static Set? 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().toSet()); } + ChatsModule.applyContactUpdate(contactId); return fullName; } } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7f7ff13..06dab07 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -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>> 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 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 clearChatsCache(int accountId) async { final db = await _instance; await db.delete( diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 53a28d3..1597438 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -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 createState() => _ChatListScreenState(); } +enum _DeleteKind { personalLike, ownerGroup, blocked } + class _ChatListScreenState extends State with TickerProviderStateMixin { String? _selectedFolderId; @@ -168,6 +171,176 @@ class _ChatListScreenState extends State }); } + List _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 _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 = []; + 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 _showDeleteConfirmDialog( + List 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( + 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 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 ), ), 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: () {}, diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index d651d85..fed22a6 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -12,19 +12,10 @@ import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import 'chat_screen.dart'; +const int _maxAvatarBytes = 8 * 1024 * 1024; + Future showCreateGroupFlow(BuildContext context) async { final cs = Theme.of(context).colorScheme; - final selected = await showModalBottomSheet>( - 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( context: context, isScrollControlled: true, @@ -32,30 +23,43 @@ Future 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 _all = []; - final Set _selectedIds = {}; + final List _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 _load() async { + @override + void dispose() { + _search.dispose(); + _title.dispose(); + super.dispose(); + } + + Future _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 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 _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 _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(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), ),