diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 9f4fe5b..286cd14 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -53,6 +53,7 @@ class CallLogEntry { final CallStatus status; final int time; final int count; + final bool isGroup; const CallLogEntry({ required this.id, @@ -63,6 +64,7 @@ class CallLogEntry { required this.status, required this.time, this.count = 1, + this.isGroup = false, }); } @@ -197,22 +199,58 @@ class CallsModule { if (!response.isOk || response.payload is! Map) return []; final payload = response.payload as Map; - return parseHistoryPayload(payload, accountId, currentUserId); + return parseHistoryPayload( + payload, + accountId, + currentUserId, + resolver: resolveContacts, + ); + } + + Future>> resolveContacts(List ids) async { + if (ids.isEmpty) return const {}; + final out = >{}; + try { + final resp = await _api.sendRequest(Opcode.contactInfo, { + 'contactIds': ids, + }); + final data = resp.payload; + final contacts = data is Map ? data['contacts'] : null; + if (contacts is List) { + for (final c in contacts) { + if (c is Map) { + final id = c['id']; + if (id is int) out[id] = Map.from(c); + } + } + } + } catch (_) {} + return out; + } + + Future deleteHistory(List historyIds) async { + if (historyIds.isEmpty) return true; + final response = await _api.sendRequest(Opcode.videoChatDeleteHistory, { + 'historyIds': historyIds, + }); + return response.isOk; } /// Парсинг истории звонков (opcode 79: videoChatHistory) static Future> parseHistoryPayload( Map payload, int accountId, - int currentUserId, - ) async { + int currentUserId, { + Future>> Function(List ids)? resolver, + }) async { final history = payload['history']; if (history is! List || history.isEmpty) return []; final recentContacts = await ContactsModule.getContacts(accountId); final contactsMap = {for (final c in recentContacts) c.id: c}; - final List extractedCalls = []; + final parsed = + <({int peerId, CallStatus status, int time, String id})>[]; for (final item in history.whereType()) { final msg = item['message']; @@ -241,26 +279,63 @@ class CallsModule { peerId = senderId; } - final contact = contactsMap[peerId]; - final status = _parseCallStatus(callAttach, isOutgoing); - final time = (msg['time'] as int?) ?? 0; - final msgId = - msg['id']?.toString() ?? - DateTime.now().millisecondsSinceEpoch.toString(); + parsed.add(( + peerId: peerId, + status: _parseCallStatus(callAttach, isOutgoing), + time: (msg['time'] as int?) ?? 0, + id: + msg['id']?.toString() ?? + DateTime.now().millisecondsSinceEpoch.toString(), + )); + } - final name = (contact != null && contact.firstName.isNotEmpty) - ? '${contact.firstName} ${contact.lastName ?? ''}'.trim() - : 'Неизвестный'; + bool localResolved(int id) { + final c = contactsMap[id]; + return c != null && c.firstName.isNotEmpty; + } + + final unresolvedIds = parsed + .map((e) => e.peerId) + .where((id) => id != 0 && !localResolved(id)) + .toSet() + .toList(); + + var fetched = const >{}; + if (unresolvedIds.isNotEmpty && resolver != null) { + fetched = await resolver(unresolvedIds); + } + + final List extractedCalls = []; + for (final e in parsed) { + final contact = contactsMap[e.peerId]; + String name; + String? avatarUrl; + bool isGroup = false; + if (contact != null && contact.firstName.isNotEmpty) { + name = '${contact.firstName} ${contact.lastName ?? ''}'.trim(); + avatarUrl = contact.baseUrl; + } else { + final info = fetched[e.peerId]; + final resolved = _nameFromInfo(info); + if (resolved != null) { + name = resolved; + avatarUrl = (info?['baseUrl'] as String?) ?? contact?.baseUrl; + } else { + name = 'Групповой звонок'; + isGroup = true; + } + } extractedCalls.add( CallLogEntry( - id: msgId, + id: e.id, accountId: accountId, - peerId: peerId, + peerId: e.peerId, name: name, - avatarUrl: contact?.baseUrl, - status: status, - time: time, + avatarUrl: avatarUrl, + status: e.status, + time: e.time, + isGroup: isGroup, ), ); } @@ -268,6 +343,23 @@ class CallsModule { return extractedCalls; } + static String? _nameFromInfo(Map? info) { + if (info == null) return null; + final names = info['names']; + if (names is List && names.isNotEmpty) { + final n = names.first; + if (n is Map) { + final full = n['name']?.toString(); + if (full != null && full.isNotEmpty) return full; + final first = n['firstName']?.toString() ?? ''; + final last = n['lastName']?.toString() ?? ''; + final combined = '$first $last'.trim(); + if (combined.isNotEmpty) return combined; + } + } + return null; + } + static CallStatus _parseCallStatus( Map callAttach, bool isOutgoing, diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 1ab5851..cfee478 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -106,6 +106,7 @@ abstract class Opcode { static const int chatMembersUpdate = 77; // Обновление участников / добавление static const int videoChatStartActive = 78; // Инициация активного звонка static const int videoChatHistory = 79; // История звонков + static const int videoChatDeleteHistory = 164; // Удаление записей истории звонков static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат static const int videoChatJoinByLink = 166; // Вход в звонок по ссылке static const int videoChatMembers = 195; // Участники видеочата @@ -288,6 +289,7 @@ abstract class Opcode { chatMembersUpdate: 'CHAT_MEMBERS_UPDATE', videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE', videoChatHistory: 'VIDEO_CHAT_HISTORY', + videoChatDeleteHistory: 'VIDEO_CHAT_DELETE_HISTORY', videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK', videoChatJoinByLink: 'VIDEO_CHAT_JOIN_BY_LINK', videoChatMembers: 'VIDEO_CHAT_MEMBERS', diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index e2fcdc1..ea92062 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -6,10 +6,13 @@ import '../../../main.dart' show api, accountModule; import '../../../backend/modules/account.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; +import '../../../core/calls/call_controller.dart'; import '../../../backend/modules/calls.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/custom_notification.dart'; import '../../widgets/chat_menu_overlay.dart'; +import 'call_screen.dart'; class CallsTab extends StatefulWidget { const CallsTab({super.key}); @@ -20,6 +23,7 @@ class CallsTab extends StatefulWidget { class _CallsTabState extends State { List _calls = []; + final Set _removing = {}; bool _isLoading = true; int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные' StreamSubscription? _loginSub; @@ -79,6 +83,7 @@ class _CallsTabState extends State { status: last.status, time: last.time, count: last.count + 1, + isGroup: last.isGroup, ), ); } else { @@ -154,16 +159,23 @@ class _CallsTabState extends State { height: 48, decoration: BoxDecoration( shape: BoxShape.circle, + color: call.isGroup ? cs.primaryContainer : null, border: Border.all( color: cs.primary.withValues(alpha: 0.1), width: 1, ), ), - child: KometAvatar( - name: call.name, - imageUrl: call.avatarUrl, - size: 48, - ), + child: call.isGroup + ? Icon( + Symbols.groups, + color: cs.onPrimaryContainer, + size: 26, + ) + : KometAvatar( + name: call.name, + imageUrl: call.avatarUrl, + size: 48, + ), ), const SizedBox(width: 16), Expanded( @@ -244,17 +256,69 @@ class _CallsTabState extends State { icon: Symbols.delete, label: 'Удалить', destructive: true, - onTap: () {}, - ), - ChatMenuItem( - icon: Symbols.call, - label: 'Перезвонить', - onTap: () {}, + onTap: () => _deleteCall(call), ), + if (!call.isGroup) + ChatMenuItem( + icon: Symbols.call, + label: 'Перезвонить', + onTap: () => _callBack(call), + ), ], ); } + Future _deleteCall(CallLogEntry call) async { + setState(() => _removing.add(call.id)); + final historyId = int.tryParse(call.id); + if (historyId != null) { + unawaited(CallsModule(api).deleteHistory([historyId])); + } + await Future.delayed(const Duration(milliseconds: 260)); + if (!mounted) return; + setState(() { + _calls.removeWhere((c) => c.id == call.id); + _removing.remove(call.id); + }); + } + + Future _callBack(CallLogEntry call) async { + if (call.peerId <= 0) { + showCustomNotification(context, 'Не удалось определить собеседника'); + return; + } + final navigator = Navigator.of(context); + final avatarUrl = (call.avatarUrl?.isNotEmpty ?? false) + ? call.avatarUrl + : null; + final active = CallController.instance.activeSession; + if (active != null) { + await navigator.push( + MaterialPageRoute( + builder: (_) => + CallScreen(name: call.name, avatarUrl: avatarUrl, session: active), + ), + ); + return; + } + try { + final session = await CallController.instance.startOutgoing(call.peerId); + if (!mounted) return; + await navigator.push( + MaterialPageRoute( + builder: (_) => CallScreen( + name: call.name, + avatarUrl: avatarUrl, + session: session, + ), + ), + ); + } catch (_) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось начать звонок'); + } + } + Widget _buildTabItem(String label, int index, ColorScheme cs) { final isSelected = _selectedTabIndex == index; return GestureDetector( @@ -369,10 +433,11 @@ class _CallsTabState extends State { padding: const EdgeInsets.only(bottom: 120), itemCount: filteredCalls.length, itemBuilder: (context, index) { - return _buildCallItem( - context, - cs, - filteredCalls[index], + final call = filteredCalls[index]; + return _RemovableCallEntry( + key: ValueKey(call.id), + removing: _removing.contains(call.id), + child: _buildCallItem(context, cs, call), ); }, ), @@ -383,3 +448,51 @@ class _CallsTabState extends State { ); } } + +class _RemovableCallEntry extends StatefulWidget { + final bool removing; + final Widget child; + + const _RemovableCallEntry({ + required Key key, + required this.removing, + required this.child, + }) : super(key: key); + + @override + State<_RemovableCallEntry> createState() => _RemovableCallEntryState(); +} + +class _RemovableCallEntryState extends State<_RemovableCallEntry> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + value: 1.0, + ); + late final Animation _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + ); + + @override + void didUpdateWidget(covariant _RemovableCallEntry oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.removing && !oldWidget.removing) _controller.reverse(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizeTransition( + sizeFactor: _animation, + alignment: Alignment.topCenter, + child: FadeTransition(opacity: _animation, child: widget.child), + ); + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 245d220..d96aaa0 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -1394,6 +1396,12 @@ class _DebugMenuScreenState extends State { ), ), ), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _SyncProbeCard(), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 120)), ], ), @@ -1402,6 +1410,178 @@ class _DebugMenuScreenState extends State { } } +class _SyncProbeCard extends StatefulWidget { + const _SyncProbeCard(); + + @override + State<_SyncProbeCard> createState() => _SyncProbeCardState(); +} + +class _SyncProbeCardState extends State<_SyncProbeCard> { + final _phoneController = TextEditingController(); + final _nameController = TextEditingController(); + bool _loading = false; + String? _result; + + @override + void dispose() { + _phoneController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + Future _send() async { + final phone = _phoneController.text.trim(); + final name = _nameController.text.trim(); + if (phone.isEmpty) { + setState(() => _result = 'Введите номер'); + return; + } + setState(() { + _loading = true; + _result = null; + }); + try { + final packet = await api.sendRequest(Opcode.sync, { + 'contactList': { + phone: {'firstName': name}, + }, + }); + if (!mounted) return; + setState(() { + _loading = false; + _result = _pretty(packet.payload); + }); + } on PacketError catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _result = 'PacketError: ${e.message}'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _result = 'Ошибка: $e'; + }); + } + } + + String _pretty(dynamic payload) { + const encoder = JsonEncoder.withIndent(' '); + try { + return encoder.convert(_jsonSafe(payload)); + } catch (_) { + return payload.toString(); + } + } + + dynamic _jsonSafe(dynamic v) { + if (v is Map) { + return v.map((k, val) => MapEntry(k.toString(), _jsonSafe(val))); + } + if (v is List) return v.map(_jsonSafe).toList(); + if (v is String || v is num || v is bool || v == null) return v; + return v.toString(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sync contactList (21)', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + 'Резолв контакта по номеру и имени, полный ответ сервера', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + const SizedBox(height: 12), + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + enabled: !_loading, + decoration: InputDecoration( + hintText: '+6282233831826', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _nameController, + enabled: !_loading, + decoration: InputDecoration( + hintText: 'Имя', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _loading ? null : _send, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Отправить'), + ), + if (_result != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: SelectableText( + _result!, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ], + ], + ), + ); + } +} + enum _HitKind { dialog, chat, channel, bot, official, contact, user, unknown } class _SearchHit { diff --git a/lib/frontend/widgets/account_switcher_overlay.dart b/lib/frontend/widgets/account_switcher_overlay.dart index 7012edd..090c18c 100644 --- a/lib/frontend/widgets/account_switcher_overlay.dart +++ b/lib/frontend/widgets/account_switcher_overlay.dart @@ -154,8 +154,15 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> final totalItems = _accounts.length; final height = _vPad * 2 + totalItems * _itemHeight + _addItemHeight; - double menuX = widget.tapPoint.dx - _menuWidth / 2; - menuX = menuX.clamp(_hMargin, screen.width - _menuWidth - _hMargin); + final maxWidth = screen.width - 2 * _hMargin; + final menuWidth = maxWidth <= 0 + ? screen.width + : (_menuWidth > maxWidth ? maxWidth : _menuWidth); + + double menuX = widget.tapPoint.dx - menuWidth / 2; + final maxX = screen.width - menuWidth - _hMargin; + if (menuX > maxX) menuX = maxX; + if (menuX < _hMargin) menuX = _hMargin; final bottomInset = MediaQuery.viewPaddingOf(context).bottom; final maxBottom = screen.height - bottomInset - 88; @@ -166,19 +173,19 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> double menuY = menuBottom - height; if (menuY < 24) menuY = 24; - _menuRect = Rect.fromLTWH(menuX, menuY, _menuWidth, height); + _menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, height); _itemHitRects = [ for (int i = 0; i < totalItems; i++) Rect.fromLTWH( menuX, menuY + _vPad + i * _itemHeight, - _menuWidth, + menuWidth, _itemHeight, ), Rect.fromLTWH( menuX, menuY + _vPad + totalItems * _itemHeight, - _menuWidth, + menuWidth, _addItemHeight, ), ];