diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index cb7ccee..019b867 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -17,11 +17,16 @@ class IncomingCall { final bool isVideo; final ConversationParams params; + final String? country; + final bool? isContact; + const IncomingCall({ required this.conversationId, required this.callerId, required this.isVideo, required this.params, + this.country, + this.isContact, }); } @@ -80,6 +85,8 @@ class CallController { callerId: callerId, isVideo: payload['type'] == 'VIDEO', params: params, + country: payload['country'] as String?, + isContact: payload['isContact'] as bool?, ); _pending = incoming; _incoming.add(incoming); diff --git a/lib/core/calls/call_info.dart b/lib/core/calls/call_info.dart new file mode 100644 index 0000000..5b940cb --- /dev/null +++ b/lib/core/calls/call_info.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +class CallInfo { + String? conversationId; + String? topology; + + String? peerPlatform; + String? peerEngine; + + String? peerIp; + String? peerNetwork; + String? path; + + String? audioCodec; + bool record = false; + bool denoise = false; + bool animoji = false; + + String? region; + String? dtlsFingerprint; + final List stun = []; + final List turn = []; +} + +class CallParse { + static Map candidate(String c) { + final parts = c.trim().split(RegExp(r'\s+')); + String? at(int i) => (i >= 0 && i < parts.length) ? parts[i] : null; + int idx(String k) => parts.indexOf(k); + final r = {}; + final tr = at(2); + if (tr != null) r['transport'] = tr.toUpperCase(); + final ip = at(4); + if (ip != null) r['ip'] = ip; + final port = at(5); + if (port != null) r['port'] = port; + final typ = idx('typ'); + if (typ != -1) r['type'] = at(typ + 1) ?? ''; + final nc = idx('network-cost'); + if (nc != -1) r['cost'] = at(nc + 1) ?? ''; + return r; + } + + static String networkLabel(String? cost) { + switch (cost) { + case '0': + return 'VPN'; + case '10': + return 'Wi-Fi / Ethernet'; + case '50': + return 'неизвестно'; + case '900': + case '999': + return 'сотовая'; + default: + return (cost == null || cost.isEmpty) ? '—' : 'cost=$cost'; + } + } + + static String engine(String sdp) { + for (final line in const LineSplitter().convert(sdp)) { + if (!line.startsWith('o=')) continue; + final l = line.toLowerCase(); + if (l.contains('mozilla') || l.contains('sdparta')) return 'Firefox (web)'; + if (l.contains('gstreamer')) return 'GStreamer'; + return 'нативный libwebrtc'; + } + return 'неизвестно'; + } + + static String? audioCodec(String sdp) { + for (final line in const LineSplitter().convert(sdp)) { + if (line.startsWith('a=rtpmap:') && line.toLowerCase().contains('opus')) { + final i = line.indexOf(' '); + if (i != -1) return line.substring(i + 1).trim(); + } + } + return null; + } + + static String? fingerprint(String sdp) { + for (final line in const LineSplitter().convert(sdp)) { + if (line.startsWith('a=fingerprint:')) { + return line.substring('a=fingerprint:'.length).trim(); + } + } + return null; + } + + static bool hasAnimoji(String sdp) => sdp.contains('animoji'); + + static bool isServerIp(String ip) => ip.startsWith('155.212.'); + + static String pathLabel(String? localType, String? remoteType) { + final relay = localType == 'relay' || remoteType == 'relay'; + final via = relay ? 'через сервер (TURN)' : 'прямое (P2P)'; + String t(String? v) => v ?? '?'; + return '$via · ${t(localType)} ↔ ${t(remoteType)}'; + } +} diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index 715c2cd..0c17a5e 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -1,7 +1,11 @@ import 'dart:async'; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform; import 'package:flutter_webrtc/flutter_webrtc.dart'; +import '../utils/logger.dart'; +import 'call_info.dart'; import 'conversation_params.dart'; import 'ws2_signaling.dart'; @@ -9,21 +13,9 @@ enum CallRole { caller, callee } enum CallSessionState { connecting, ringing, active, ended } -/// Один сеанс 1:1 аудиозвонка: связывает сигналинг [Ws2Signaling] с -/// `RTCPeerConnection`. -/// -/// Поток (подтверждён захватом `docs/ws2_capture.log` для звонящего и -/// реконструирован из `ru.ok.android.externcalls.sdk` для вызываемого): -/// - сервер шлёт `connection` → берём ICE-сервера и id собеседника; -/// - звонящий: createOffer → `transmit-data`(offer); ждёт `transmitted-data`(answer); -/// - вызываемый: `transmitted-data`(offer) → createAnswer → `transmit-data`(answer); -/// - обе стороны: ICE-кандидаты через `transmit-data`, приём — через `transmitted-data`; -/// - вызываемый по тапу «принять» шлёт `accept-call`. class CallSession { final Ws2Config ws2Config; - /// Параметры из `vcp` (входящий звонок) — резервный источник ICE-серверов, - /// если их нет в пуше `connection`. Для исходящего может быть `null`. final ConversationParams? params; final CallRole role; @@ -36,6 +28,7 @@ class CallSession { Ws2Signaling? _signaling; RTCPeerConnection? _pc; MediaStream? _localStream; + MediaStream? _remoteStreamRef; int? _peerId; String _peerType = 'USER'; @@ -43,22 +36,32 @@ class CallSession { bool _muted = false; bool _accepted = false; + bool _peerMuted = false; + bool _peerVideo = false; + bool _mediaConnected = false; + + final CallInfo info = CallInfo(); final _state = StreamController.broadcast(); final _remoteStream = StreamController.broadcast(); + final _info = StreamController.broadcast(); Stream get stateStream => _state.stream; Stream get remoteStreamStream => _remoteStream.stream; + MediaStream? get remoteStream => _remoteStreamRef; + + Stream get infoUpdates => _info.stream; + bool get isMuted => _muted; + bool get peerMuted => _peerMuted; + bool get peerVideo => _peerVideo; + bool get mediaConnected => _mediaConnected; CallSessionState _current = CallSessionState.connecting; DateTime? _activeSince; - /// Текущее состояние (для переоткрытия свёрнутого экрана — - /// broadcast-поток не отдаёт последнее значение новым слушателям). CallSessionState get currentState => _current; - /// Длительность разговора в секундах (0, пока не активен). int get elapsedSeconds => _activeSince == null ? 0 : DateTime.now().difference(_activeSince!).inSeconds; @@ -69,8 +72,13 @@ class CallSession { _state.add(s); } + void _notifyInfo() { + if (!_info.isClosed) _info.add(null); + } + Future start() async { _setState(CallSessionState.connecting); + info.region = ws2Config.uri.host; final signaling = Ws2Signaling(ws2Config); _signaling = signaling; signaling.notifications.listen(_onNotification, onError: (_) => _end()); @@ -79,6 +87,7 @@ class CallSession { } Future _onNotification(Map msg) async { + _applyPeerMedia(msg); switch (msg['notification']) { case 'connection': await _onConnection(msg); @@ -89,6 +98,9 @@ class CallSession { case 'accepted-call': _setState(CallSessionState.active); break; + case 'registered-peer': + _applyRegisteredPeer(msg); + break; case 'closed-conversation': _end(); break; @@ -102,6 +114,9 @@ class CallSession { final iceServers = _iceServersFrom(convParams) ?? params?.iceServers ?? const []; _resolvePeer(conversation); + _applyConnectionInfo(msg, iceServers); + + logger.t('[call] connection — role=$role peer=$_peerId'); final pc = await createPeerConnection({ 'iceServers': iceServers, @@ -117,11 +132,24 @@ class CallSession { await pc.addTrack(track, _localStream!); } + await pc.addTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, + init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), + ); + pc.onIceCandidate = _onLocalCandidate; - pc.onTrack = (event) { - if (event.streams.isNotEmpty) _remoteStream.add(event.streams.first); - }; + pc.onTrack = (event) => unawaited(_onRemoteTrack(event)); pc.onConnectionState = (s) { + final connected = + s == RTCPeerConnectionState.RTCPeerConnectionStateConnected; + if (connected != _mediaConnected) { + _mediaConnected = connected; + _notifyInfo(); + if (connected) { + unawaited(_resolvePath()); + unawaited(_collectReceivers()); + } + } if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed || s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) { _end(); @@ -134,22 +162,142 @@ class CallSession { } } + String _videoDir(String sdp) { + var inVideo = false; + String? mline; + var dir = '?'; + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + inVideo = line.startsWith('m=video'); + if (inVideo) mline = line; + } else if (inVideo && + (line == 'a=sendrecv' || + line == 'a=recvonly' || + line == 'a=sendonly' || + line == 'a=inactive')) { + dir = line.substring(2); + } + } + return mline == null ? 'НЕТ m=video' : '$mline -> $dir'; + } + + Future _onRemoteTrack(RTCTrackEvent event) async { + logger.t( + '[call] remote track: ${event.track.kind} streams=${event.streams.length}'); + if (event.streams.isNotEmpty) { + _remoteStreamRef = event.streams.first; + _remoteStream.add(event.streams.first); + } else { + await _collectReceivers(); + } + } + + Future _pushRemoteTrack(MediaStreamTrack track) async { + var stream = _remoteStreamRef; + stream ??= await createLocalMediaStream('komet_remote'); + _remoteStreamRef = stream; + if (!stream.getTracks().any((t) => t.id == track.id)) { + try { + await stream.addTrack(track); + } catch (_) {} + } + _remoteStream.add(stream); + } + + Future _collectReceivers() async { + final pc = _pc; + if (pc == null) return; + try { + for (final tr in await pc.getTransceivers()) { + final track = tr.receiver.track; + if (track != null) { + logger.t('[call] receiver track: ${track.kind}'); + await _pushRemoteTrack(track); + } + } + } catch (_) {} + } + Future _createAndSendOffer() async { final pc = _pc; final peerId = _peerId; if (pc == null || peerId == null) return; final offer = await pc.createOffer({}); - await pc.setLocalDescription(offer); + final raw = offer.sdp ?? ''; + final sdp = _isDesktop ? _forceVp8(raw) : raw; + await pc.setLocalDescription(RTCSessionDescription(sdp, offer.type)); + logger.t('[call] our offer video: ${_videoDir(sdp)}'); await _signaling?.transmitSdp( participantId: peerId, participantType: _peerType, deviceIdx: _peerDeviceIdx, type: offer.type!, - sdp: offer.sdp!, + sdp: sdp, ); } + bool get _isDesktop => + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS; + + String _forceVp8(String sdp) { + final lines = sdp.split('\r\n'); + var mIdx = -1; + for (var i = 0; i < lines.length; i++) { + if (lines[i].startsWith('m=video')) { + mIdx = i; + break; + } + } + if (mIdx == -1) return sdp; + + String? vp8; + for (final l in lines) { + final m = RegExp(r'^a=rtpmap:(\d+) VP8/90000').firstMatch(l); + if (m != null) { + vp8 = m.group(1); + break; + } + } + if (vp8 == null) return sdp; + + String? rtx; + for (final l in lines) { + final m = RegExp('^a=fmtp:(\\d+) apt=$vp8\$').firstMatch(l); + if (m != null) { + rtx = m.group(1); + break; + } + } + + final keep = {vp8, ?rtx}; + final parts = lines[mIdx].split(' '); + if (parts.length <= 3) return sdp; + lines[mIdx] = [...parts.sublist(0, 3), ...keep].join(' '); + + var end = lines.length; + for (var i = mIdx + 1; i < lines.length; i++) { + if (lines[i].startsWith('m=')) { + end = i; + break; + } + } + + final ptLine = RegExp(r'^a=(?:rtpmap|fmtp|rtcp-fb):(\d+)'); + final result = []; + for (var i = 0; i < lines.length; i++) { + if (i > mIdx && i < end) { + final m = ptLine.firstMatch(lines[i]); + if (m != null && !keep.contains(m.group(1))) continue; + } + result.add(lines[i]); + } + return result.join('\r\n'); + } + Future _onTransmittedData(Map msg) async { final pc = _pc; if (pc == null) return; @@ -163,12 +311,22 @@ class CallSession { final desc = sdp['sdp'] as String?; if (type == null || desc == null) return; + _applyRemoteSdp(desc); + logger.t('[call] remote $type video: ${_videoDir(desc)}'); + + if (type == 'answer' && + pc.signalingState != + RTCSignalingState.RTCSignalingStateHaveLocalOffer) { + logger.t('[call] extra answer ignored (state=${pc.signalingState})'); + return; + } + await pc.setRemoteDescription(RTCSessionDescription(desc, type)); if (type == 'offer') { - // Сторона вызываемого: отвечаем answer. final answer = await pc.createAnswer({}); await pc.setLocalDescription(answer); + logger.t('[call] our answer video: ${_videoDir(answer.sdp ?? '')}'); final peerId = _peerId; if (peerId != null) { await _signaling?.transmitSdp( @@ -183,11 +341,13 @@ class CallSession { _setState(CallSessionState.ringing); } } + unawaited(_collectReceivers()); return; } final candidate = data['candidate']; if (candidate is Map) { + _applyRemoteCandidate(candidate['candidate']); await pc.addCandidate(RTCIceCandidate( candidate['candidate'] as String?, candidate['sdpMid'] as String?, @@ -209,17 +369,15 @@ class CallSession { ); } - /// Принять входящий звонок (сторона вызываемого). Future accept() async { if (_accepted) return; _accepted = true; + logger.t('[call] accepted'); await _signaling?.acceptCall(); await _signaling?.changeMediaSettings(isAudioEnabled: !_muted); _setState(CallSessionState.active); } - /// DEBUG: отправить серверу сигнал `change-media-settings` с заданным - /// состоянием микрофона, НЕ трогая реальный аудиотрек. Future sendAudioEnabledSignal(bool enabled) async { await _signaling?.changeMediaSettings(isAudioEnabled: enabled); } @@ -256,6 +414,124 @@ class CallSession { await _signaling?.close(); if (!_state.isClosed) await _state.close(); if (!_remoteStream.isClosed) await _remoteStream.close(); + if (!_info.isClosed) await _info.close(); + } + + void _applyConnectionInfo(Map msg, List iceServers) { + final conv = msg['conversation']; + if (conv is Map) { + info.conversationId = conv['id']?.toString(); + info.topology = conv['topology']?.toString(); + final features = conv['features']; + if (features is List) info.record = features.contains('RECORD'); + final parts = conv['participants']; + if (parts is List) { + for (final p in parts.whereType()) { + if (p['id'] != ws2Config.userId) { + final ms = p['mediaSettings']; + if (ms is Map) { + _peerMuted = ms['isAudioEnabled'] != true; + _peerVideo = ms['isVideoEnabled'] == true; + } + } + } + } + } + final mm = msg['mediaModifiers']; + if (mm is Map) { + info.denoise = mm['denoise'] == true || mm['denoiseAnn'] == true; + } + info.stun.clear(); + info.turn.clear(); + for (final s in iceServers.whereType()) { + final urls = s['urls']; + final list = urls is List ? urls : [urls]; + for (final u in list) { + final str = u.toString(); + if (str.startsWith('stun')) { + info.stun.add(str); + } else if (str.startsWith('turn')) { + info.turn.add(str); + } + } + } + _notifyInfo(); + } + + void _applyPeerMedia(Map msg) { + final ms = msg['mediaSettings']; + if (ms is! Map) return; + final pid = msg['participantId']; + if (_peerId != null && pid != null && pid != _peerId) return; + + final muted = ms['isAudioEnabled'] != true; + final video = ms['isVideoEnabled'] == true; + if (muted != _peerMuted || video != _peerVideo) { + _peerMuted = muted; + _peerVideo = video; + _notifyInfo(); + if (video) unawaited(_collectReceivers()); + } + } + + void _applyRegisteredPeer(Map msg) { + final peer = msg['peerId']; + if (peer is Map && peer['type'] == 'WEB_TRANSPORT') return; + final platform = msg['platform']; + if (platform is String && platform.isNotEmpty) { + info.peerPlatform = platform; + _notifyInfo(); + } + } + + void _applyRemoteSdp(String sdp) { + info.peerEngine = CallParse.engine(sdp); + info.audioCodec ??= CallParse.audioCodec(sdp); + info.dtlsFingerprint ??= CallParse.fingerprint(sdp); + if (CallParse.hasAnimoji(sdp)) info.animoji = true; + _notifyInfo(); + } + + void _applyRemoteCandidate(Object? raw) { + if (raw is! String || raw.isEmpty) return; + final c = CallParse.candidate(raw); + final type = c['type']; + final ip = c['ip']; + if (ip == null) return; + if ((type == 'srflx' || type == 'host') && !CallParse.isServerIp(ip)) { + info.peerIp = ip; + info.peerNetwork = CallParse.networkLabel(c['cost']); + _notifyInfo(); + } + } + + Future _resolvePath() async { + final pc = _pc; + if (pc == null) return; + try { + final stats = await pc.getStats(); + final byId = {for (final r in stats) r.id: r}; + StatsReport? pair; + StatsReport? anySucceeded; + for (final r in stats) { + if (r.type != 'candidate-pair') continue; + if (r.values['state'] != 'succeeded') continue; + anySucceeded ??= r; + if (r.values['nominated'] == true || r.values['selected'] == true) { + pair = r; + break; + } + } + pair ??= anySucceeded; + if (pair == null) return; + final local = byId[pair.values['localCandidateId']]; + final remote = byId[pair.values['remoteCandidateId']]; + info.path = CallParse.pathLabel( + local?.values['candidateType']?.toString(), + remote?.values['candidateType']?.toString(), + ); + _notifyInfo(); + } catch (_) {} } void _resolvePeer(Object? conversation) { diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart index 177c33c..e08a662 100644 --- a/lib/core/calls/ws2_signaling.dart +++ b/lib/core/calls/ws2_signaling.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import '../utils/logger.dart'; import 'conversation_params.dart'; /// Параметры подключения к сигналинг-сокету ws2. @@ -145,6 +146,11 @@ class Ws2Signaling { } if (decoded is! Map) return; + final label = + decoded['notification'] ?? decoded['response'] ?? decoded['type']; + logger.t('[ws2] ← $label'); + logger.t(decoded); + final type = decoded['type']; if (type == 'response') { final seq = decoded['sequence']; diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 2470392..525f52a 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -1,20 +1,29 @@ import 'dart:async'; +import 'dart:math' show cos, pi; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart' show Helper; +import 'package:flutter/services.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' + show + Helper, + MediaStream, + RTCVideoRenderer, + RTCVideoValue, + RTCVideoView, + RTCVideoViewObjectFit; import 'package:material_symbols_icons/symbols.dart'; +import '../../../backend/modules/messages.dart' show ContactCache; +import '../../../core/cache/info_cache.dart'; import '../../../core/calls/call_controller.dart'; +import '../../../core/calls/call_info.dart'; import '../../../core/calls/call_session.dart'; import '../../../core/utils/format.dart'; -/// Экран звонка. Управляется живым [CallSession]. -/// -/// Открывается в одном из режимов: -/// - исходящий/активный: передан [session] (уже запущен); -/// - входящий: передан [incoming] — показываем «принять/отклонить», сессия -/// создаётся при принятии. +const Color _kEndRed = Color(0xFFE5484D); +const Color _kAcceptGreen = Color(0xFF2EC36B); + class CallScreen extends StatefulWidget { final String name; final String? avatarUrl; @@ -34,56 +43,158 @@ class CallScreen extends StatefulWidget { } class _CallScreenState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { CallSession? _session; StreamSubscription? _stateSub; + StreamSubscription? _infoSub; + StreamSubscription? _remoteStreamSub; CallSessionState _state = CallSessionState.connecting; bool _incomingPending = false; - Timer? _timer; bool _isMuted = false; bool _isSpeaker = false; - late AnimationController _pulseController; - late Animation _pulseAnimation; + late final AnimationController _dotsController; + late final AnimationController _videoController; + final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer(); + bool _rendererReady = false; + bool _videoAttached = false; + MediaStream? _pendingStream; + + Color? _seedKey; + ColorScheme? _scheme; + + late String _name = widget.name; + late String? _avatarUrl = widget.avatarUrl; @override void initState() { super.initState(); - _pulseController = AnimationController( + _dotsController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 1500), - )..repeat(reverse: true); - _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + duration: const Duration(milliseconds: 1400), + )..repeat(); + _videoController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), ); + _initRenderer(); _incomingPending = widget.session == null && widget.incoming != null; if (widget.session != null) _bind(widget.session!); + + final incoming = widget.incoming; + if (incoming != null && (_name.isEmpty || _avatarUrl == null)) { + _resolvePeerInfo(incoming.callerId); + } + } + + Future _resolvePeerInfo(int id) async { + var name = ContactCache.get(id); + var avatar = ContactCache.getAvatar(id); + if (name == null || avatar == null) { + final info = await ContactInfoFetch.get(id); + if (info != null) { + name ??= _contactName(info); + avatar ??= info['baseUrl'] as String?; + if (name != null) ContactCache.put(id, name); + ContactCache.putAvatar(id, avatar); + } + } + if (!mounted) return; + setState(() { + if (name != null && name.isNotEmpty) _name = name; + if (avatar != null && avatar.isNotEmpty) _avatarUrl = avatar; + }); + } + + String? _contactName(Map info) { + final names = info['names']; + if (names is! List) return null; + Map? pick; + for (final n in names) { + if (n is! Map) continue; + pick ??= n; + if (n['type'] == 'ONEME') { + pick = n; + break; + } + } + if (pick == null) return null; + final first = (pick['firstName'] as String?) ?? ''; + final last = pick['lastName'] as String?; + final full = (last != null && last.isNotEmpty) ? '$first $last' : first; + return full.trim().isEmpty ? null : full.trim(); + } + + Future _initRenderer() async { + await _remoteRenderer.initialize(); + if (!mounted) return; + _rendererReady = true; + if (_pendingStream != null) { + _remoteRenderer.srcObject = _pendingStream; + _pendingStream = null; + } + setState(() {}); + } + + void _attachStream(MediaStream stream) { + if (!_rendererReady) { + _pendingStream = stream; + return; + } + final hasVideo = stream.getVideoTracks().isNotEmpty; + if (!identical(_remoteRenderer.srcObject, stream)) { + _remoteRenderer.srcObject = stream; + } else if (hasVideo && !_videoAttached) { + _remoteRenderer.srcObject = null; + _remoteRenderer.srcObject = stream; + } else { + return; + } + if (hasVideo) _videoAttached = true; + if (mounted) setState(() {}); + } + + void _syncVideo() { + if (_session?.peerVideo == true) { + _videoController.forward(); + } else { + _videoController.reverse(); + } + } + + ColorScheme _darkScheme(BuildContext context) { + final seed = Theme.of(context).colorScheme.primary; + if (_seedKey != seed || _scheme == null) { + _seedKey = seed; + _scheme = ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.dark, + ); + } + return _scheme!; } void _bind(CallSession session) { _session = session; _state = session.currentState; _stateSub = session.stateStream.listen(_onState); - if (_state == CallSessionState.active) _startActiveTimer(); + _infoSub = session.infoUpdates.listen((_) { + if (!mounted) return; + _syncVideo(); + setState(() {}); + }); + _remoteStreamSub = session.remoteStreamStream.listen(_attachStream); + final existing = session.remoteStream; + if (existing != null) _attachStream(existing); + _syncVideo(); } void _onState(CallSessionState state) { if (!mounted) return; setState(() => _state = state); - if (state == CallSessionState.active) { - _startActiveTimer(); - } else if (state == CallSessionState.ended) { - _close(); - } - } - - void _startActiveTimer() { - _timer ??= Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted) return; - setState(() {}); - }); + if (state == CallSessionState.ended) _close(); } Future _accept() async { @@ -120,7 +231,6 @@ class _CallScreenState extends State void _close() { if (!mounted) return; - _timer?.cancel(); Navigator.of(context).maybePop(); } @@ -139,269 +249,251 @@ class _CallScreenState extends State @override void dispose() { _stateSub?.cancel(); - _timer?.cancel(); - _pulseController.dispose(); + _infoSub?.cancel(); + _remoteStreamSub?.cancel(); + _dotsController.dispose(); + _videoController.dispose(); + _remoteRenderer.srcObject = null; + _remoteRenderer.dispose(); super.dispose(); } + void _showInfoSheet() { + final cs = _darkScheme(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => Theme( + data: Theme.of(context).copyWith(colorScheme: cs), + child: _CallInfoSheet( + session: _session, + incoming: widget.incoming, + name: _displayName, + renderer: _remoteRenderer, + ), + ), + ); + } + @override Widget build(BuildContext context) { - final screenH = MediaQuery.of(context).size.height; + final cs = _darkScheme(context); - return Scaffold( - backgroundColor: const Color(0xFF0E0E14), - body: SafeArea( - child: Stack( - children: [ - Column( - children: [ - const Spacer(flex: 3), - _buildAvatar(screenH), - const SizedBox(height: 24), - _buildName(), - const SizedBox(height: 8), - _buildStatus(), - const Spacer(flex: 2), - _buildActions(), - const SizedBox(height: 48), - ], - ), - Align( - alignment: Alignment.topLeft, - child: Padding( - padding: const EdgeInsets.all(8), - child: IconButton( - icon: const Icon( - Symbols.arrow_back, - color: Colors.white, - weight: 400, + return Theme( + data: Theme.of(context).copyWith(colorScheme: cs), + child: AnnotatedRegion( + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.transparent, + systemNavigationBarColor: cs.surface, + systemNavigationBarIconBrightness: Brightness.light, + ), + child: Scaffold( + backgroundColor: cs.surface, + body: AnimatedBuilder( + animation: _videoController, + builder: (context, _) => _buildBody(cs), + ), + ), + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + final t = Curves.easeInOut.transform(_videoController.value); + final peerBar = _peerStateBar(cs); + final showVideo = t > 0.001 && _remoteRenderer.srcObject != null; + + return Stack( + fit: StackFit.expand, + children: [ + if (showVideo) + Center( + child: Opacity( + opacity: t, + child: FractionallySizedBox( + widthFactor: 0.62 + 0.38 * t, + heightFactor: 0.46 + 0.54 * t, + child: ClipRRect( + borderRadius: BorderRadius.circular(24 * (1 - t)), + child: ValueListenableBuilder( + valueListenable: _remoteRenderer, + builder: (context, value, _) { + final ar = value.aspectRatio > 0 + ? value.aspectRatio + : 16 / 9; + return Center( + child: AspectRatio( + aspectRatio: ar, + child: RepaintBoundary( + child: RTCVideoView( + _remoteRenderer, + objectFit: RTCVideoViewObjectFit + .RTCVideoViewObjectFitCover, + ), + ), + ), + ); + }, ), - tooltip: 'Свернуть', - onPressed: () => Navigator.of(context).maybePop(), ), ), ), - ], - ), - ), - ); - } - - bool get _isRinging => - _incomingPending || - _state == CallSessionState.connecting || - _state == CallSessionState.ringing; - - Widget _buildAvatar(double screenH) { - final size = screenH * 0.18; - final cs = Theme.of(context).colorScheme; - - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final scale = _isRinging ? _pulseAnimation.value : 1.0; - return Transform.scale(scale: scale, child: child); - }, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.primaryContainer.withValues(alpha: 0.2), - border: Border.all( - color: cs.primary.withValues(alpha: 0.3), - width: 2, + ), + if (t > 0.001) + IgnorePointer(child: Opacity(opacity: t, child: _videoScrim(cs))), + SafeArea( + child: Column( + children: [ + _buildTopBar(cs, t), + const Spacer(flex: 2), + _collapse(t, _buildAvatar(cs)), + SizedBox(height: 36 * (1 - t)), + _collapse(t, _buildName(cs)), + SizedBox(height: 12 * (1 - t)), + _collapse(t, _buildStatus(cs)), + if (peerBar != null) ...[ + SizedBox(height: 14 * (1 - t)), + _collapse(t, peerBar), + ], + const Spacer(flex: 5), + _buildControls(cs), + const SizedBox(height: 24), + ], ), ), - child: ClipOval( - child: widget.avatarUrl != null && widget.avatarUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: widget.avatarUrl!, - fit: BoxFit.cover, - memCacheWidth: 360, - memCacheHeight: 360, - errorWidget: (_, _, _) => _fallbackAvatar(size), - ) - : _fallbackAvatar(size), + ], + ); + } + + Widget _collapse(double t, Widget child) { + if (t <= 0.001) return child; + if (t >= 0.999) return const SizedBox.shrink(); + return Opacity( + opacity: 1 - t, + child: ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: 1 - t, + child: child, ), ), ); } - Widget _fallbackAvatar(double size) { - final cs = Theme.of(context).colorScheme; - return Container( - width: size, - height: size, + Widget _videoScrim(ColorScheme cs) { + return DecoratedBox( decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.primaryContainer, - ), - alignment: Alignment.center, - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: size * 0.4, - fontWeight: FontWeight.w600, - ), - ), - ); - } - - Widget _buildName() { - final cs = Theme.of(context).colorScheme; - return Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 26, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ); - } - - Widget _buildStatus() { - final cs = Theme.of(context).colorScheme; - String text; - if (_incomingPending) { - text = 'Входящий звонок'; - } else { - switch (_state) { - case CallSessionState.connecting: - text = 'Соединение…'; - case CallSessionState.ringing: - text = 'Вызов…'; - case CallSessionState.active: - text = formatSecondsMmSs( - _session?.elapsedSeconds ?? 0, - padMinutes: true, - ); - case CallSessionState.ended: - text = 'Звонок завершён'; - } - } - return Text( - text, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w400, - ), - ); - } - - Widget _buildActions() { - if (_incomingPending) return _buildIncomingActions(); - if (_state == CallSessionState.active) return _buildActiveActions(); - return _buildOutgoingActions(); - } - - Widget _buildIncomingActions() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _ActionButton( - icon: Symbols.phone_disabled, - label: 'Отклонить', - color: const Color(0xFFBA1A1A), - onTap: _decline, - ), - const SizedBox(width: 48), - _ActionButton( - icon: Symbols.phone, - label: 'Принять', - color: const Color(0xFF3A691E), - onTap: _accept, - ), - ], - ); - } - - Widget _buildOutgoingActions() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _ActionButton( - icon: Symbols.phone_disabled, - label: 'Отмена', - color: const Color(0xFFBA1A1A), - onTap: _hangup, - ), - ], - ); - } - - Widget _buildActiveActions() { - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _CircleActionButton( - icon: _isMuted ? Symbols.mic_off : Symbols.mic, - active: _isMuted, - onTap: _toggleMute, - ), - const SizedBox(width: 32), - _CircleActionButton( - icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down, - active: _isSpeaker, - onTap: _toggleSpeaker, - ), - const SizedBox(width: 32), - _CircleActionButton( - icon: Symbols.bluetooth_audio, - active: false, - onTap: () {}, - ), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + cs.surface.withValues(alpha: 0.55), + Colors.transparent, + Colors.transparent, + cs.surface.withValues(alpha: 0.65), ], + stops: const [0.0, 0.34, 0.70, 1.0], ), - const SizedBox(height: 40), - _ActionButton( - icon: Symbols.phone_disabled, - label: 'Завершить', - color: const Color(0xFFBA1A1A), - onTap: _hangup, - ), - ], + ), ); } -} -class _ActionButton extends StatelessWidget { - final IconData icon; - final String label; - final Color color; - final VoidCallback onTap; + Widget _buildTopBar(ColorScheme cs, double t) { + final showTimer = t > 0.001 && + _session != null && + _state == CallSessionState.active && + _session!.mediaConnected; + return SizedBox( + height: 48, + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: IconButton( + onPressed: () => Navigator.of(context).maybePop(), + tooltip: 'Свернуть', + icon: Icon( + Symbols.close_fullscreen, + color: cs.onSurface, + weight: 500, + size: 26, + ), + ), + ), + if (_session != null) + Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: _showInfoSheet, + tooltip: 'О звонке', + icon: Icon( + Symbols.info, + color: cs.onSurface, + weight: 500, + size: 26, + ), + ), + ), + if (showTimer) + Align( + alignment: Alignment.center, + child: Opacity( + opacity: t, + child: _ElapsedText( + session: _session!, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + ], + ), + ); + } - const _ActionButton({ - required this.icon, - required this.label, - required this.color, - required this.onTap, - }); + Widget? _peerStateBar(ColorScheme cs) { + final session = _session; + if (session == null) return null; + final pills = [ + if (session.peerMuted) + _statePill(cs, Symbols.mic_off, 'Микрофон выключен'), + if (session.peerVideo) + _statePill(cs, Symbols.videocam, 'Камера включена'), + ]; + if (pills.isEmpty) return null; + return Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: pills, + ); + } - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Column( + Widget _statePill(ColorScheme cs, IconData icon, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(100), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration(shape: BoxShape.circle, color: color), - alignment: Alignment.center, - child: Icon(icon, color: Colors.white, size: 28, fill: 1), - ), - const SizedBox(height: 8), + Icon(icon, size: 16, color: cs.onSurfaceVariant, fill: 1), + const SizedBox(width: 6), Text( label, - style: const TextStyle( - color: Colors.white70, + style: TextStyle( + color: cs.onSurfaceVariant, fontSize: 13, fontWeight: FontWeight.w500, ), @@ -410,40 +502,488 @@ class _ActionButton extends StatelessWidget { ), ); } + + Widget _buildAvatar(ColorScheme cs) { + final avatarSize = + (MediaQuery.of(context).size.shortestSide * 0.42).clamp(128.0, 172.0); + return _avatarCircle(avatarSize, cs); + } + + String get _displayName => _name.isEmpty ? 'Неизвестный' : _name; + + Widget _avatarCircle(double size, ColorScheme cs) { + final url = _avatarUrl; + return Container( + width: size, + height: size, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surfaceContainerHighest, + border: Border.all( + color: Colors.white.withValues(alpha: 0.10), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: (url != null && url.isNotEmpty) + ? CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + memCacheWidth: 420, + memCacheHeight: 420, + errorWidget: (_, _, _) => _avatarFallback(size, cs), + ) + : _avatarFallback(size, cs), + ); + } + + Widget _avatarFallback(double size, ColorScheme cs) { + final letter = _displayName[0].toUpperCase(); + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + letter, + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: size * 0.38, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ); + } + + Widget _buildName(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _displayName, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 30, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + height: 1.1, + ), + ), + ); + } + + Widget _buildStatus(ColorScheme cs) { + if (!_incomingPending && _state == CallSessionState.active) { + final session = _session; + if (session == null) return const SizedBox.shrink(); + if (!session.mediaConnected) { + return _statusWithDots(cs, 'Соединение'); + } + return _ElapsedText( + session: session, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w500, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + } + + if (_incomingPending) { + return Text( + 'Входящий звонок', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + ); + } + + String text; + switch (_state) { + case CallSessionState.connecting: + text = 'Соединение'; + case CallSessionState.ringing: + text = 'Вызов'; + case CallSessionState.active: + text = ''; + case CallSessionState.ended: + text = 'Звонок завершён'; + } + + return _statusWithDots(cs, text); + } + + Widget _statusWithDots(ColorScheme cs, String text) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + text, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + ), + const SizedBox(width: 4), + _CallingDots(animation: _dotsController, color: cs.onSurfaceVariant), + ], + ); + } + + Widget _buildControls(ColorScheme cs) { + if (_incomingPending) return _incomingControls(cs); + return _activeControls(cs); + } + + Widget _incomingControls(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 56), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _CallButton( + icon: Symbols.call_end, + label: 'Отклонить', + background: _kEndRed, + foreground: Colors.white, + onTap: _decline, + ), + _CallButton( + icon: Symbols.call, + label: 'Принять', + background: _kAcceptGreen, + foreground: Colors.white, + onTap: _accept, + ), + ], + ), + ); + } + + Widget _activeControls(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _CallButton( + icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down, + label: 'Динамик', + background: _isSpeaker ? cs.primary : cs.surfaceContainerHighest, + foreground: _isSpeaker ? cs.onPrimary : cs.onSurface, + onTap: _toggleSpeaker, + ), + _CallButton( + icon: Symbols.videocam_off, + label: 'Видео', + background: cs.surfaceContainerHighest, + foreground: cs.onSurface, + onTap: () {}, + ), + _CallButton( + icon: _isMuted ? Symbols.mic_off : Symbols.mic, + label: _isMuted ? 'Вкл. звук' : 'Выкл. звук', + background: _isMuted ? cs.primary : cs.surfaceContainerHighest, + foreground: _isMuted ? cs.onPrimary : cs.onSurface, + onTap: _toggleMute, + ), + _CallButton( + icon: Symbols.call_end, + label: 'Завершить', + background: _kEndRed, + foreground: Colors.white, + onTap: _hangup, + ), + ], + ), + ); + } } -class _CircleActionButton extends StatelessWidget { +class _CallingDots extends StatelessWidget { + final Animation animation; + final Color color; + + const _CallingDots({required this.animation, required this.color}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (context, _) { + final v = animation.value; + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final phase = (v + i / 3) % 1.0; + final alpha = 0.3 + 0.7 * (0.5 - 0.5 * cos(phase * 2 * pi)); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1.5), + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withValues(alpha: alpha), + ), + ), + ); + }), + ); + }, + ); + } +} + +class _CallButton extends StatelessWidget { final IconData icon; - final bool active; + final String label; + final Color background; + final Color foreground; final VoidCallback onTap; - const _CircleActionButton({ + const _CallButton({ required this.icon, - required this.active, + required this.label, + required this.background, + required this.foreground, required this.onTap, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: active - ? Colors.white.withValues(alpha: 0.2) - : Colors.white.withValues(alpha: 0.1), + final cs = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 62, + height: 62, + child: Material( + color: background, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Center( + child: Icon(icon, color: foreground, size: 26, fill: 1), + ), + ), + ), ), - alignment: Alignment.center, - child: Icon( - icon, - color: active ? Colors.white : Colors.white70, - size: 24, - fill: 1, + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } +} + +class _ElapsedText extends StatefulWidget { + final CallSession session; + final TextStyle style; + + const _ElapsedText({required this.session, required this.style}); + + @override + State<_ElapsedText> createState() => _ElapsedTextState(); +} + +class _ElapsedTextState extends State<_ElapsedText> { + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text( + formatSecondsMmSs(widget.session.elapsedSeconds, padMinutes: true), + style: widget.style, + ); + } +} + +class _CallInfoSheet extends StatelessWidget { + final CallSession? session; + final IncomingCall? incoming; + final String name; + final RTCVideoRenderer renderer; + + const _CallInfoSheet({ + required this.session, + required this.incoming, + required this.name, + required this.renderer, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final info = session?.info; + + final rows = >[]; + void add(String k, String? v) { + if (v != null && v.isNotEmpty) rows.add([k, v]); + } + + add('Клиент', _clientLine(info)); + add('Платформа', info?.peerPlatform); + add('Страна', incoming?.country); + final isContact = incoming?.isContact; + if (isContact != null) add('В контактах', isContact ? 'да' : 'нет'); + add('IP собеседника', info?.peerIp); + add('Сеть собеседника', info?.peerNetwork); + add('Путь соединения', info?.path); + add('Кодек', info?.audioCodec); + add('Сервер', info?.region); + add('Топология', info?.topology); + add('Conversation ID', info?.conversationId); + if (info?.dtlsFingerprint != null) { + add('DTLS', _shortFp(info!.dtlsFingerprint!)); + } + if (session != null) { + add('Статус', session!.mediaConnected ? 'соединён' : 'соединение…'); + add('Микрофон собеседника', session!.peerMuted ? 'выключен' : 'включён'); + add('Камера собеседника', session!.peerVideo ? 'включена' : 'выключена'); + } + + final vtracks = renderer.srcObject?.getVideoTracks().length ?? 0; + add('Видео-дорожка', vtracks > 0 ? 'есть ($vtracks)' : 'нет'); + final w = renderer.value.width.toInt(); + final h = renderer.value.height.toInt(); + add('Размер видео', (w > 0 && h > 0) ? '$w×$h' : '—'); + add('Отрисовка кадров', renderer.renderVideo ? 'да' : 'нет'); + + final badges = [ + _badge(cs, Symbols.lock, 'Зашифрован'), + _badge(cs, Symbols.call, 'Аудио'), + if (info?.record == true) _badge(cs, Symbols.radio_button_checked, 'Запись'), + if (info?.denoise == true) + _badge(cs, Symbols.noise_control_on, 'Шумоподавление'), + if (info?.animoji == true) _badge(cs, Symbols.mood, 'Анимодзи'), + ]; + + return SafeArea( + top: false, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'О звонке', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + const SizedBox(height: 2), + Text( + name, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + Wrap(spacing: 8, runSpacing: 8, children: badges), + const SizedBox(height: 16), + if (rows.isEmpty) + Text( + 'Данные появятся после соединения…', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + for (final r in rows) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 150, + child: Text( + r[0], + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SelectableText( + r[1], + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), ), ), ); } + + String? _clientLine(CallInfo? info) { + if (info == null) return null; + final engine = info.peerEngine; + if (engine == null || engine == 'неизвестно') return null; + return engine; + } + + String _shortFp(String fp) => fp.length > 34 ? '${fp.substring(0, 34)}…' : fp; + + Widget _badge(ColorScheme cs, IconData icon, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(100), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: cs.onSurfaceVariant, fill: 1), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 5dccad3..5d4d5ab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -303,29 +303,15 @@ class KometAppState extends State } Future _onIncomingCall(IncomingCall call) async { - String name = 'Входящий звонок'; - String? avatar; - try { - final profile = await AppDatabase.loadActiveProfile(); - if (profile != null) { - final contacts = await ContactsModule.getContacts(profile.id); - for (final c in contacts) { - if (c.id == call.callerId) { - final full = '${c.firstName} ${c.lastName ?? ''}'.trim(); - if (full.isNotEmpty) name = full; - avatar = c.baseUrl; - break; - } - } - } - } catch (_) {} - final navState = KometApp.navigatorKey.currentState; if (navState == null) return; navState.push( MaterialPageRoute( - builder: (_) => - CallScreen(name: name, avatarUrl: avatar, incoming: call), + builder: (_) => CallScreen( + name: ContactCache.get(call.callerId) ?? '', + avatarUrl: ContactCache.getAvatar(call.callerId), + incoming: call, + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index 830d407..08ac60e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -785,10 +785,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -1174,10 +1174,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: "direct main" description: