diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 44bf89b..ecf4786 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1,7 +1,10 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../api.dart'; @@ -115,6 +118,34 @@ class CachedChat { } class ChatsModule { + static final ValueNotifier chatsChanged = ValueNotifier(0); + static void _bump() => chatsChanged.value = chatsChanged.value + 1; + + 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 parsed = _parseChat( + chat, + accountId, + accountId, + const {}, + const {}, + const {}, + existing, + cachedAt, + ); + if (parsed == null) return null; + await AppDatabase.saveChats([parsed.toDbRow()]); + _bump(); + return parsed; + } + /// Парсит и кэширует чаты из payload opcode 19. /// /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же @@ -166,6 +197,7 @@ class ChatsModule { if (rows.isNotEmpty) { await AppDatabase.saveChats(rows); + _bump(); } } catch (e) { logger.e("Ошибка при синке: $e"); @@ -355,4 +387,56 @@ class ChatsModule { }); return packet.payload; } + + static Future createGroupChat( + Api api, { + required String title, + required List userIds, + bool notify = true, + }) async { + final payload = { + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch, + 'attaches': [ + { + '_type': 'CONTROL', + 'event': 'new', + 'chatType': 'CHAT', + 'title': title, + 'userIds': userIds, + }, + ], + }, + 'notify': notify, + }; + final packet = await api.sendRequest(Opcode.msgSend, payload); + if (!packet.isOk) return null; + final data = packet.payload; + if (data is! Map) return null; + final chat = data['chat']; + if (chat is! Map) return null; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + return cacheServerChat(chat, accountId); + } + + static Future requestChatPhotoUploadUrl(Api api) async { + final packet = await api.sendRequest(Opcode.photoUpload, {'count': 1}); + if (!packet.isOk) return null; + final data = packet.payload; + if (data is! Map) return null; + return data['url'] as String?; + } + + static Future setChatPhoto( + Api api, { + required int chatId, + required String photoToken, + }) async { + final packet = await api.sendRequest(Opcode.chatUpdate, { + 'chatId': chatId, + 'photoToken': photoToken, + }); + return packet.isOk; + } } diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index c6e7903..9b9193f 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -1,6 +1,7 @@ import 'dart:async'; -import 'dart:convert' show utf8; +import 'dart:convert' show jsonDecode, utf8; import 'dart:io'; +import 'dart:typed_data'; import '../api.dart'; import '../../core/config/proxy_config.dart'; @@ -163,12 +164,18 @@ class FileUploader { ); } - void _writeHeaders(Socket socket, Uri uri, String filename, int total) { + void _writeHeaders( + Socket socket, + Uri uri, + String filename, + int total, { + String contentType = 'application/x-binary; charset=x-user-defined', + }) { 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: application/x-binary; charset=x-user-defined\r\n') + ..write('Content-Type: $contentType\r\n') ..write('Content-Disposition: attachment; filename=$filename\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') @@ -178,6 +185,91 @@ class FileUploader { socket.add(utf8.encode(headers.toString())); } + Future uploadImage(Uri uri, Uint8List bytes, {String filename = 'avatar.jpg'}) async { + Socket? socket; + try { + socket = await _openSocket(uri); + _writeHeaders(socket, uri, filename, bytes.length, contentType: 'image/jpeg'); + socket.add(bytes); + await socket.flush(); + + final response = await _readFullResponse( + socket, + timeout: const Duration(minutes: 2), + ); + try { + socket.destroy(); + } catch (_) {} + + if (response == null) return null; + final (status, body) = response; + if (status != 200) return null; + return _parsePhotoToken(body); + } catch (_) { + try { + socket?.destroy(); + } catch (_) {} + return null; + } + } + + Future<(int, String)?> _readFullResponse( + Socket socket, { + required Duration timeout, + }) { + final bytes = []; + final completer = Completer<(int, String)?>(); + Timer? timer; + StreamSubscription>? sub; + + void finish() { + timer?.cancel(); + sub?.cancel(); + if (completer.isCompleted) return; + final headerEnd = _findHeaderEnd(bytes); + if (headerEnd == -1) { + completer.complete(null); + return; + } + final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true); + final statusLine = headerStr.split('\r\n').first; + final parts = statusLine.split(' '); + final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; + final body = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true); + completer.complete((status, body)); + } + + void fail() { + timer?.cancel(); + sub?.cancel(); + if (!completer.isCompleted) completer.complete(null); + } + + sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish); + timer = Timer(timeout, fail); + return completer.future; + } + + String? _parsePhotoToken(String body) { + try { + final json = jsonDecode(body); + if (json is Map) { + final photos = json['photos']; + if (photos is Map) { + for (final v in photos.values) { + if (v is Map) { + final token = v['token']; + if (token is String && token.isNotEmpty) return token; + } + } + } + final pt = json['photoToken']; + if (pt is String && pt.isNotEmpty) return pt; + } + } catch (_) {} + return null; + } + Future _readResponse( Socket socket, { required Duration autoForceAfter, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index ab2714d..53a28d3 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -7,6 +7,7 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; +import 'create_group_flow.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -244,9 +245,14 @@ class _ChatListScreenState extends State _reloadChatsAndFolders(); } }); + ChatsModule.chatsChanged.addListener(_onChatsChanged); _reloadChatsAndFolders(); } + void _onChatsChanged() { + if (mounted) _reloadChatsAndFolders(); + } + Future _reloadChatsAndFolders() async { final p = await AppDatabase.loadActiveProfile(); if (p == null) { @@ -700,6 +706,7 @@ class _ChatListScreenState extends State @override void dispose() { + ChatsModule.chatsChanged.removeListener(_onChatsChanged); _loginSub?.cancel(); _stateSub?.cancel(); _fabController.dispose(); @@ -2086,7 +2093,14 @@ Navigator.push( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ - _buildFabMenuItem(Symbols.group_add, 'Создать группу'), + _buildFabMenuItem( + Symbols.group_add, + 'Создать группу', + onTap: () { + _toggleFab(); + showCreateGroupFlow(context); + }, + ), const SizedBox(height: 4), _buildFabMenuItem(Symbols.campaign, 'Создать канал'), const SizedBox(height: 4), @@ -2095,7 +2109,7 @@ Navigator.push( ); } - Widget _buildFabMenuItem(IconData icon, String title) { + Widget _buildFabMenuItem(IconData icon, String title, {VoidCallback? onTap}) { final cs = Theme.of(context).colorScheme; return Container( width: 220, @@ -2111,9 +2125,7 @@ Navigator.push( ], ), child: InkWell( - onTap: () { - // Action logic here - }, + onTap: onTap, borderRadius: BorderRadius.circular(100), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart new file mode 100644 index 0000000..d651d85 --- /dev/null +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -0,0 +1,594 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/contacts.dart'; +import '../../../core/storage/token_storage.dart'; +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; +import 'chat_screen.dart'; + +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, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => _GroupDetailsSheet(participants: selected), + ); +} + +class _ParticipantsPickerSheet extends StatefulWidget { + const _ParticipantsPickerSheet(); + + @override + State<_ParticipantsPickerSheet> createState() => _ParticipantsPickerSheetState(); +} + +class _ParticipantsPickerSheetState extends State<_ParticipantsPickerSheet> { + final TextEditingController _search = TextEditingController(); + List _all = []; + final Set _selectedIds = {}; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final myId = await TokenStorage.getActiveAccountId(); + if (myId == null) { + if (mounted) setState(() => _loading = false); + return; + } + final list = await ContactsModule.getContacts(myId); + list.removeWhere((c) => c.id == myId); + list.sort((a, b) => _displayName(a).toLowerCase().compareTo(_displayName(b).toLowerCase())); + if (!mounted) return; + setState(() { + _all = list; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + String _displayName(CachedContact c) { + final last = c.lastName ?? ''; + return last.isEmpty ? c.firstName : '${c.firstName} $last'; + } + + 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(); + } + + Future _pickAvatar() async { + if (_creating) return; + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result == null || result.files.isEmpty) return; + final path = result.files.first.path; + if (path == null) return; + setState(() => _avatar = File(path)); + } + + Future _create() async { + final title = _title.text.trim(); + if (title.isEmpty || _creating) return; + setState(() => _creating = true); + try { + final chat = await ChatsModule.createGroupChat( + api, + title: title, + userIds: widget.participants.map((c) => c.id).toList(), + ); + if (!mounted) return; + if (chat == null) { + showCustomNotification(context, 'Не удалось создать группу'); + setState(() => _creating = false); + return; + } + + if (_avatar != null) { + final url = await ChatsModule.requestChatPhotoUploadUrl(api); + if (url != null) { + final bytes = await _avatar!.readAsBytes(); + final token = await fileUploader.uploadImage(Uri.parse(url), bytes); + if (token != null) { + await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); + } + } + } + + if (!mounted) return; + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ChatScreen( + chatId: chat.id, + name: chat.title ?? title, + imageUrl: chat.iconUrl ?? '', + chatType: chat.type, + ), + ), + ); + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _creating = false); + } + } + } + + @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), + ), + 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, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _Avatar extends StatelessWidget { + final CachedContact contact; + final double size; + final ColorScheme cs; + const _Avatar({required this.contact, required this.size, required this.cs}); + + @override + Widget build(BuildContext context) { + final url = contact.baseUrl; + if (url != null && url.isNotEmpty) { + return ClipOval( + child: CachedNetworkImage( + imageUrl: url, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, _) => _initials(cs, size), + errorWidget: (_, _, _) => _initials(cs, size), + ), + ); + } + return _initials(cs, size); + } + + Widget _initials(ColorScheme cs, double size) { + final initial = contact.firstName.isNotEmpty + ? contact.firstName[0].toUpperCase() + : '?'; + return Container( + width: size, + height: size, + decoration: BoxDecoration(color: cs.primaryContainer, shape: BoxShape.circle), + alignment: Alignment.center, + child: Text( + initial, + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: size * 0.4, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _SelectedChip extends StatelessWidget { + final CachedContact contact; + final String label; + final VoidCallback onRemove; + final ColorScheme cs; + const _SelectedChip({ + required this.contact, + required this.label, + required this.onRemove, + required this.cs, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onRemove, + child: Container( + padding: const EdgeInsets.fromLTRB(4, 4, 12, 4), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Avatar(contact: contact, size: 24, cs: cs), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 140), + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} + +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, + ), + ), + ), + ); + } +}