22-06-2026+19-07
This commit is contained in:
parent
3c93e4148e
commit
d116fbde43
|
|
@ -1,5 +1,10 @@
|
|||
class AppConstants {
|
||||
//static const baseUrl = '192.168.0.180:8000';
|
||||
static const baseUrl = 'https://api.chepuhagram.ru';
|
||||
static const wsUrl = 'wss://api.chepuhagram.ru';
|
||||
|
||||
// TURN Server Configuration
|
||||
static const turnUrl = 'turns:turn.chepuhagram.ru:5349';
|
||||
static const stunUrl = 'stuns:turn.chepuhagram.ru:5349';
|
||||
static const turnUsername = 'chepuha_user';
|
||||
static const turnCredential = 'byybg3456bFYTfY8ytFb323yTfYBtF';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:web_socket_channel/status.dart' as status;
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:chepuhagram/core/constants.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:chepuhagram/domain/services/webrtc_service.dart';
|
||||
import 'package:chepuhagram/main.dart';
|
||||
import 'package:chepuhagram/presentation/screens/call_screen.dart';
|
||||
|
||||
class SocketService with WidgetsBindingObserver {
|
||||
static final SocketService _instance = SocketService._internal();
|
||||
|
|
@ -14,6 +17,7 @@ class SocketService with WidgetsBindingObserver {
|
|||
|
||||
SocketService._internal() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initMessageListener();
|
||||
}
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
|
|
@ -36,8 +40,47 @@ class SocketService with WidgetsBindingObserver {
|
|||
|
||||
void _initMessageListener() {
|
||||
messages.listen((data) {
|
||||
if (data['type'] == 'call_accepted') {
|
||||
WebRTCService().handleOffer(data['call_id'], data['sdp']);
|
||||
final type = data['type'];
|
||||
print("SocketService: Получено сигнальное сообщение: $type");
|
||||
if (type == 'call_init') {
|
||||
final callId = data['call_id']?.toString() ?? '';
|
||||
final callerId = int.tryParse(data['caller_id']?.toString() ?? '') ?? 0;
|
||||
final callerUsername = data['caller_username']?.toString() ?? 'Пользователь';
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
showIncomingCallKit(
|
||||
callId: callId,
|
||||
callerName: callerUsername,
|
||||
callerId: callerId,
|
||||
);
|
||||
} else {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
if (CallScreen.currentActiveCallId != null) {
|
||||
print("SocketService: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId");
|
||||
} else {
|
||||
CallScreen.currentActiveCallId = callId;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
callId: callId,
|
||||
isIncoming: true,
|
||||
callerName: callerUsername,
|
||||
targetUserId: callerId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type == 'call_accepted' ||
|
||||
type == 'offer' ||
|
||||
type == 'answer' ||
|
||||
type == 'ice_candidate' ||
|
||||
type == 'hangup' ||
|
||||
type == 'decline' ||
|
||||
type == 'video_toggle') {
|
||||
WebRTCService().handleSignalingMessage(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,379 @@
|
|||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||
import 'package:chepuhagram/core/constants.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class WebRTCService {
|
||||
static final WebRTCService _instance = WebRTCService._internal();
|
||||
factory WebRTCService() => _instance;
|
||||
WebRTCService._internal();
|
||||
|
||||
RTCPeerConnection? _peerConnection;
|
||||
MediaStream? _localStream;
|
||||
Function()? onCallEnded;
|
||||
Function(bool)? onRemoteVideoToggled;
|
||||
int? _targetUserId;
|
||||
int? _myUserId;
|
||||
bool _isInitiator = false;
|
||||
final List<RTCIceCandidate> _remoteIceCandidatesQueue = [];
|
||||
bool _isReadyForSignaling = false;
|
||||
final List<Map<String, dynamic>> _incomingSignalingQueue = [];
|
||||
bool isCallAcceptedLocally = false;
|
||||
bool _tracksAdded = false;
|
||||
|
||||
// Конфигурация STUN-серверов (Google STUN)
|
||||
// Конфигурация STUN и TURN-серверов
|
||||
final Map<String, dynamic> _config = {
|
||||
"iceServers": [
|
||||
{"urls": "stun:stun.l.google.com:19302"},
|
||||
{"urls": "stun:stun1.l.google.com:19302"},
|
||||
]
|
||||
{"urls": "stun:stun2.l.google.com:19302"},
|
||||
{
|
||||
"urls": AppConstants.turnUrl,
|
||||
"username": AppConstants.turnUsername,
|
||||
"credential": AppConstants.turnCredential,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Инициализация PeerConnection
|
||||
Future<void> initPeerConnection(String callId, Function(MediaStream) onRemoteStream) async {
|
||||
_peerConnection = await createPeerConnection(_config);
|
||||
/// Локальный стрим (камера + микрофон)
|
||||
Future<void> startLocalStream(
|
||||
Function(MediaStream) onLocalStreamCallback, {
|
||||
bool videoDefaultEnabled = false,
|
||||
}) async {
|
||||
try {
|
||||
_localStream?.getTracks().forEach((track) => track.stop());
|
||||
_localStream?.dispose();
|
||||
|
||||
// Слушаем удаленный поток
|
||||
_peerConnection!.onAddStream = (stream) {
|
||||
onRemoteStream(stream);
|
||||
};
|
||||
|
||||
// Отправляем ICE-кандидаты на сервер через SocketService
|
||||
_peerConnection!.onIceCandidate = (candidate) {
|
||||
SocketService().sendMessage({
|
||||
"type": "ice_candidate",
|
||||
"call_id": callId,
|
||||
"candidate": candidate.toMap(),
|
||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': {
|
||||
'mandatory': {
|
||||
'minWidth': '1280',
|
||||
'minHeight': '720',
|
||||
'minFrameRate': '30',
|
||||
},
|
||||
'facingMode': 'user',
|
||||
'optional': [],
|
||||
},
|
||||
});
|
||||
// Выключаем видео по умолчанию
|
||||
_localStream!.getVideoTracks().forEach((track) {
|
||||
track.enabled = videoDefaultEnabled;
|
||||
});
|
||||
};
|
||||
|
||||
// Получаем локальный поток (микрофон + камера)
|
||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': true,
|
||||
});
|
||||
// Если PeerConnection уже инициализирован, сразу же добавляем локальные треки
|
||||
if (_peerConnection != null && !_tracksAdded) {
|
||||
_localStream!.getTracks().forEach((track) {
|
||||
_peerConnection!.addTrack(track, _localStream!);
|
||||
});
|
||||
_tracksAdded = true;
|
||||
}
|
||||
|
||||
// Добавляем треки в соединение
|
||||
_localStream!.getTracks().forEach((track) {
|
||||
_peerConnection!.addTrack(track, _localStream!);
|
||||
});
|
||||
onLocalStreamCallback(_localStream!);
|
||||
} catch (e) {
|
||||
print("Ошибка получения локального стрима: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleOffer(String callId, String remoteSdp) async {
|
||||
// 1. Инициализируем соединение, если оно еще не создано
|
||||
if (_peerConnection == null) {
|
||||
await initPeerConnection(callId, (stream) {
|
||||
// Здесь можно добавить callback для отрисовки видео, если нужно
|
||||
print("Remote stream received");
|
||||
});
|
||||
/// Инициализация PeerConnection
|
||||
Future<void> initPeerConnection(
|
||||
String callId,
|
||||
int targetUserId,
|
||||
Function(MediaStream) onRemoteStream, [
|
||||
int? myUserId,
|
||||
bool isInitiator = false,
|
||||
]) async {
|
||||
_targetUserId = targetUserId;
|
||||
_isInitiator = isInitiator;
|
||||
if (myUserId != null) {
|
||||
_myUserId = myUserId;
|
||||
}
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final alwaysRelay = prefs.getBool('privacy_always_relay_calls') ?? false;
|
||||
|
||||
final Map<String, dynamic> currentConfig = {
|
||||
"iceServers": alwaysRelay
|
||||
? [
|
||||
{
|
||||
"urls": AppConstants.turnUrl,
|
||||
"username": AppConstants.turnUsername,
|
||||
"credential": AppConstants.turnCredential,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{"urls": "stun:stun.l.google.com:19302"},
|
||||
{"urls": "stun:stun1.l.google.com:19302"},
|
||||
{"urls": "stun:stun2.l.google.com:19302"},
|
||||
{"urls": AppConstants.stunUrl},
|
||||
{
|
||||
"urls": AppConstants.turnUrl,
|
||||
"username": AppConstants.turnUsername,
|
||||
"credential": AppConstants.turnCredential,
|
||||
},
|
||||
],
|
||||
if (alwaysRelay) "iceTransportPolicy": "relay",
|
||||
};
|
||||
|
||||
_peerConnection = await createPeerConnection(currentConfig);
|
||||
|
||||
// Слушаем удаленный поток
|
||||
_peerConnection!.onAddStream = (stream) {
|
||||
onRemoteStream(stream);
|
||||
};
|
||||
|
||||
// Отправляем ICE-кандидаты на сервер через SocketService
|
||||
_peerConnection!.onIceCandidate = (candidate) {
|
||||
if (_targetUserId != null) {
|
||||
SocketService().sendMessage({
|
||||
"type": "ice_candidate",
|
||||
"call_id": callId,
|
||||
"receiver_id": _targetUserId,
|
||||
"sender_id": _myUserId,
|
||||
"candidate": candidate.toMap(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Добавляем треки в соединение
|
||||
if (_localStream != null && !_tracksAdded) {
|
||||
_localStream!.getTracks().forEach((track) {
|
||||
_peerConnection!.addTrack(track, _localStream!);
|
||||
});
|
||||
_tracksAdded = true;
|
||||
}
|
||||
|
||||
// Дрейним очередь сигналов, так как PeerConnection теперь готов!
|
||||
_drainSignalingQueue();
|
||||
} catch (e) {
|
||||
print("Ошибка инициализации PeerConnection: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Создаем ответ (это вызывает ваш метод createAnswer)
|
||||
await createAnswer(callId, remoteSdp);
|
||||
}
|
||||
/// Обработка сигнальных сообщений
|
||||
void handleSignalingMessage(Map<String, dynamic> data) async {
|
||||
final type = data['type'];
|
||||
final needsPeerConnection =
|
||||
(type == 'offer' || type == 'answer' || type == 'ice_candidate');
|
||||
|
||||
/// Создание Offer (вызывает инициатор звонка)
|
||||
Future<void> createOffer(String callId) async {
|
||||
RTCSessionDescription offer = await _peerConnection!.createOffer();
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
if (!_isReadyForSignaling ||
|
||||
(needsPeerConnection && _peerConnection == null)) {
|
||||
_incomingSignalingQueue.add(data);
|
||||
print(
|
||||
"WebRTCService: Queueing message: $type (ready: $_isReadyForSignaling, pc: ${_peerConnection != null})",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
SocketService().sendMessage({
|
||||
"type": "offer",
|
||||
"call_id": callId,
|
||||
"sdp": offer.sdp,
|
||||
});
|
||||
final callId = data['call_id'];
|
||||
print("WebRTCService: Получено сигнальное сообщение: $type");
|
||||
|
||||
if (type == 'call_accepted') {
|
||||
if (_isInitiator) {
|
||||
await createOffer(callId);
|
||||
} else {
|
||||
// Мы не инициатор, значит звонок принят на другом нашем устройстве!
|
||||
if (!isCallAcceptedLocally) {
|
||||
print(
|
||||
"WebRTCService: Call accepted on other device. Ending call locally.",
|
||||
);
|
||||
onCallEnded?.call();
|
||||
FlutterCallkitIncoming.endAllCalls();
|
||||
}
|
||||
}
|
||||
} else if (type == 'offer') {
|
||||
final senderId = int.tryParse(data['sender_id']?.toString() ?? '') ?? 0;
|
||||
if (senderId != 0) {
|
||||
_targetUserId = senderId;
|
||||
}
|
||||
final sdp = data['sdp']?.toString() ?? '';
|
||||
await handleOffer(callId, sdp);
|
||||
} else if (type == 'answer') {
|
||||
final sdp = data['sdp']?.toString() ?? '';
|
||||
await _peerConnection?.setRemoteDescription(
|
||||
RTCSessionDescription(sdp, 'answer'),
|
||||
);
|
||||
await _processRemoteIceCandidatesQueue();
|
||||
} else if (type == 'ice_candidate') {
|
||||
final candidate = data['candidate'];
|
||||
if (candidate != null) {
|
||||
await addRemoteIceCandidate(Map<String, dynamic>.from(candidate));
|
||||
}
|
||||
} else if (type == 'video_toggle') {
|
||||
final enabled = data['video_enabled'] == true;
|
||||
onRemoteVideoToggled?.call(enabled);
|
||||
} else if (type == 'hangup' || type == 'decline') {
|
||||
if (onCallEnded != null) {
|
||||
onCallEnded?.call();
|
||||
} else {
|
||||
print(
|
||||
"WebRTCService: Call declined or hung up on other device. Dismissing CallKit.",
|
||||
);
|
||||
FlutterCallkitIncoming.endAllCalls();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Создание Answer (вызывает получатель звонка)
|
||||
Future<void> createAnswer(String callId, String remoteSdp) async {
|
||||
await _peerConnection!.setRemoteDescription(
|
||||
RTCSessionDescription(remoteSdp, 'offer'),
|
||||
);
|
||||
/// Создание Offer (вызывает инициатор звонка после call_accepted)
|
||||
Future<void> createOffer(String callId, [int? targetUserId]) async {
|
||||
final tId = targetUserId ?? _targetUserId;
|
||||
if (_peerConnection == null || tId == null || tId == 0) return;
|
||||
try {
|
||||
RTCSessionDescription offer = await _peerConnection!.createOffer();
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
RTCSessionDescription answer = await _peerConnection!.createAnswer();
|
||||
await _peerConnection!.setLocalDescription(answer);
|
||||
SocketService().sendMessage({
|
||||
"type": "offer",
|
||||
"call_id": callId,
|
||||
"receiver_id": tId,
|
||||
"sender_id": _myUserId,
|
||||
"sdp": offer.sdp,
|
||||
});
|
||||
} catch (e) {
|
||||
print("Ошибка при создании offer: $e");
|
||||
}
|
||||
}
|
||||
|
||||
SocketService().sendMessage({
|
||||
"type": "answer",
|
||||
"call_id": callId,
|
||||
"sdp": answer.sdp,
|
||||
});
|
||||
/// Обработка Offer и отправка Answer
|
||||
Future<void> handleOffer(
|
||||
String callId,
|
||||
String remoteSdp, [
|
||||
int? targetUserId,
|
||||
]) async {
|
||||
final tId = targetUserId ?? _targetUserId;
|
||||
if (_peerConnection == null || tId == null || tId == 0) return;
|
||||
try {
|
||||
await _peerConnection!.setRemoteDescription(
|
||||
RTCSessionDescription(remoteSdp, 'offer'),
|
||||
);
|
||||
await _processRemoteIceCandidatesQueue();
|
||||
|
||||
RTCSessionDescription answer = await _peerConnection!.createAnswer();
|
||||
await _peerConnection!.setLocalDescription(answer);
|
||||
|
||||
SocketService().sendMessage({
|
||||
"type": "answer",
|
||||
"call_id": callId,
|
||||
"receiver_id": tId,
|
||||
"sender_id": _myUserId,
|
||||
"sdp": answer.sdp,
|
||||
});
|
||||
} catch (e) {
|
||||
print("Ошибка при обработке offer и создании answer: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка ICE кандидатов от удаленного собеседника
|
||||
Future<void> addRemoteIceCandidate(Map<String, dynamic> candidateMap) async {
|
||||
await _peerConnection!.addCandidate(
|
||||
RTCIceCandidate(
|
||||
candidateMap['candidate'],
|
||||
candidateMap['sdpMid'],
|
||||
candidateMap['sdpMLineIndex'],
|
||||
),
|
||||
if (_peerConnection == null) return;
|
||||
final candidate = RTCIceCandidate(
|
||||
candidateMap['candidate'],
|
||||
candidateMap['sdpMid'],
|
||||
candidateMap['sdpMLineIndex'],
|
||||
);
|
||||
|
||||
final remoteDesc = await _peerConnection!.getRemoteDescription();
|
||||
if (remoteDesc == null) {
|
||||
_remoteIceCandidatesQueue.add(candidate);
|
||||
print(
|
||||
"WebRTCService: Remote description is not set yet. Queued ICE candidate.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _peerConnection!.addCandidate(candidate);
|
||||
} catch (e) {
|
||||
print("Ошибка добавления удаленного ICE кандидата: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Процесс добавления сохраненных в очереди ICE кандидатов
|
||||
Future<void> _processRemoteIceCandidatesQueue() async {
|
||||
if (_peerConnection == null) return;
|
||||
print(
|
||||
"WebRTCService: Processing queued remote ICE candidates (${_remoteIceCandidatesQueue.length})",
|
||||
);
|
||||
for (var candidate in _remoteIceCandidatesQueue) {
|
||||
try {
|
||||
await _peerConnection!.addCandidate(candidate);
|
||||
} catch (e) {
|
||||
print("Ошибка добавления удаленного ICE кандидата из очереди: $e");
|
||||
}
|
||||
}
|
||||
_remoteIceCandidatesQueue.clear();
|
||||
}
|
||||
|
||||
/// Включение/выключение звука
|
||||
void toggleMute(bool isMuted) {
|
||||
_localStream?.getAudioTracks().forEach((track) {
|
||||
track.enabled = !isMuted;
|
||||
});
|
||||
}
|
||||
|
||||
/// Включение/выключение видео
|
||||
void toggleVideo(bool isVideoOff) {
|
||||
_localStream?.getVideoTracks().forEach((track) {
|
||||
track.enabled = !isVideoOff;
|
||||
});
|
||||
}
|
||||
|
||||
/// Переключение вывода звука (динамик громкой связи / разговорный динамик)
|
||||
void toggleSpeakerphone(bool isSpeakerOn) {
|
||||
try {
|
||||
Helper.setSpeakerphoneOn(isSpeakerOn);
|
||||
print("WebRTCService: Громкая связь переключена в: $isSpeakerOn");
|
||||
} catch (e) {
|
||||
print("Ошибка при переключении динамика: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Переключение камеры
|
||||
Future<void> switchCamera() async {
|
||||
if (_localStream != null) {
|
||||
final videoTrack = _localStream!.getVideoTracks().firstOrNull;
|
||||
if (videoTrack != null) {
|
||||
await Helper.switchCamera(videoTrack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Пометить WebRTCService как готовый для обмена сообщениями и обработать накопившуюся очередь
|
||||
void setReadyForSignaling() {
|
||||
_isReadyForSignaling = true;
|
||||
print("WebRTCService: Ready for signaling.");
|
||||
_drainSignalingQueue();
|
||||
}
|
||||
|
||||
void _drainSignalingQueue() {
|
||||
if (!_isReadyForSignaling) return;
|
||||
print(
|
||||
"WebRTCService: Draining signaling queue of size: ${_incomingSignalingQueue.length}",
|
||||
);
|
||||
|
||||
final List<Map<String, dynamic>> queueCopy = List.from(
|
||||
_incomingSignalingQueue,
|
||||
);
|
||||
_incomingSignalingQueue.clear();
|
||||
|
||||
for (var data in queueCopy) {
|
||||
handleSignalingMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Очистка ресурсов
|
||||
void dispose() {
|
||||
print("WebRTCService: Очистка ресурсов WebRTC");
|
||||
_tracksAdded = false;
|
||||
_remoteIceCandidatesQueue.clear();
|
||||
_incomingSignalingQueue.clear();
|
||||
_isReadyForSignaling = false;
|
||||
isCallAcceptedLocally = false;
|
||||
_localStream?.getTracks().forEach((track) => track.stop());
|
||||
_localStream?.dispose();
|
||||
_localStream = null;
|
||||
_peerConnection?.close();
|
||||
_peerConnection = null;
|
||||
}
|
||||
}
|
||||
158
lib/main.dart
158
lib/main.dart
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
import 'package:chepuhagram/logic/contact_provider.dart';
|
||||
import 'package:chepuhagram/core/theme_manager.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
|
|
@ -8,6 +9,7 @@ import 'package:firebase_core/firebase_core.dart';
|
|||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||
|
|
@ -271,6 +273,56 @@ void main() async {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> showIncomingCallKit({
|
||||
required String callId,
|
||||
required String callerName,
|
||||
required int callerId,
|
||||
}) async {
|
||||
final params = CallKitParams(
|
||||
id: callId,
|
||||
nameCaller: callerName,
|
||||
appName: 'Chepuhagram',
|
||||
avatar: '',
|
||||
handle: callerName,
|
||||
type: 0, // 0 - audio, 1 - video
|
||||
duration: 30000,
|
||||
textAccept: 'Принять',
|
||||
textDecline: 'Отклонить',
|
||||
missedCallNotification: const NotificationParams(
|
||||
showNotification: true,
|
||||
subtitle: 'Пропущенный звонок',
|
||||
),
|
||||
extra: <String, dynamic>{
|
||||
'callerId': callerId,
|
||||
},
|
||||
android: const AndroidParams(
|
||||
isCustomNotification: true,
|
||||
isShowLogo: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
backgroundColor: '#0F0F12',
|
||||
actionColor: '#4CAF50',
|
||||
textColor: '#ffffff',
|
||||
incomingCallNotificationChannelName: 'Входящие звонки',
|
||||
),
|
||||
ios: const IOSParams(
|
||||
handleType: 'generic',
|
||||
supportsVideo: false,
|
||||
maximumCallGroups: 1,
|
||||
maximumCallsPerCallGroup: 1,
|
||||
audioSessionMode: 'default',
|
||||
audioSessionActive: true,
|
||||
audioSessionPreferredSampleRate: 44100.0,
|
||||
audioSessionPreferredIOBufferDuration: 0.005,
|
||||
supportsDTMF: false,
|
||||
supportsHolding: false,
|
||||
supportsGrouping: false,
|
||||
supportsUngrouping: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
),
|
||||
);
|
||||
await FlutterCallkitIncoming.showCallkitIncoming(params);
|
||||
}
|
||||
|
||||
void initCallkitListener() {
|
||||
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||||
print('Skipping CallKit event listener on non-mobile platform.');
|
||||
|
|
@ -281,10 +333,12 @@ void initCallkitListener() {
|
|||
FlutterCallkitIncoming.onEvent.listen((event) {
|
||||
if (event == null) return;
|
||||
|
||||
final callerId = int.tryParse(event.body['extra']?['callerId']?.toString() ?? '') ??
|
||||
int.tryParse(event.body['callerId']?.toString() ?? '') ?? 0;
|
||||
|
||||
switch (event.event) {
|
||||
case Event.actionCallIncoming:
|
||||
// Звонок получен, но CallKit уже показал экран.
|
||||
// Здесь можно логировать или обновить статус в БД.
|
||||
print("Incoming call: ${event.body['id']}");
|
||||
break;
|
||||
|
||||
|
|
@ -294,31 +348,76 @@ void initCallkitListener() {
|
|||
|
||||
case Event.actionCallAccept:
|
||||
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ"
|
||||
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
|
||||
SocketService().sendMessage({
|
||||
"type": "call_accept",
|
||||
"call_id": event.body['id'],
|
||||
});
|
||||
Future(() async {
|
||||
final context = navigatorKey.currentContext;
|
||||
var myId = 0;
|
||||
if (context != null) {
|
||||
try {
|
||||
myId = Provider.of<AuthProvider>(context, listen: false).currentUserId ?? 0;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (myId == 0) {
|
||||
final storage = FlutterSecureStorage();
|
||||
final userIdStr = await storage.read(key: 'user_id');
|
||||
myId = int.tryParse(userIdStr ?? '') ?? 0;
|
||||
}
|
||||
|
||||
// 2. Переходим на экран звонка
|
||||
navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
callId: event.body['id'],
|
||||
isIncoming: true,
|
||||
callerName: event.body['nameCaller'] ?? 'Unknown',
|
||||
onAccept: () {},
|
||||
onHangup: () => _handleHangupGlobal(event.body['id']),
|
||||
),
|
||||
),
|
||||
);
|
||||
final socket = SocketService();
|
||||
if (!socket.isConnected()) {
|
||||
print("CallKit action: Socket is not connected, connecting...");
|
||||
try {
|
||||
await socket.connect(ApiService());
|
||||
} catch (e) {
|
||||
print("CallKit action error connecting socket: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
|
||||
socket.sendMessage({
|
||||
"type": "call_accepted",
|
||||
"call_id": event.body['id'],
|
||||
"receiver_id": callerId,
|
||||
"sender_id": myId,
|
||||
});
|
||||
|
||||
// 2. Переходим на экран звонка
|
||||
final callId = event.body['id']?.toString() ?? '';
|
||||
if (CallScreen.currentActiveCallId != null) {
|
||||
print("CallKit action: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId");
|
||||
} else {
|
||||
CallScreen.currentActiveCallId = callId;
|
||||
navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
callId: callId,
|
||||
isIncoming: true,
|
||||
callerName: event.body['nameCaller'] ?? 'Unknown',
|
||||
targetUserId: callerId,
|
||||
startAccepted: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case Event.actionCallDecline:
|
||||
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ"
|
||||
SocketService().sendMessage({
|
||||
"type": "decline",
|
||||
"call_id": event.body['id'],
|
||||
Future(() async {
|
||||
final socket = SocketService();
|
||||
if (!socket.isConnected()) {
|
||||
print("CallKit action: Socket is not connected for decline, connecting...");
|
||||
try {
|
||||
await socket.connect(ApiService());
|
||||
} catch (e) {
|
||||
print("CallKit action error connecting socket: $e");
|
||||
}
|
||||
}
|
||||
socket.sendMessage({
|
||||
"type": "decline",
|
||||
"call_id": event.body['id'],
|
||||
"receiver_id": callerId,
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
|
|
@ -504,8 +603,23 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|||
} catch (e) {
|
||||
print('Error processing background message: $e');
|
||||
}
|
||||
} else if (message.data['type'] == 'call_init') {
|
||||
try {
|
||||
final callId = message.data['call_id']?.toString() ?? '';
|
||||
final callerName = message.data['caller_username']?.toString() ?? 'Пользователь';
|
||||
final callerId = int.tryParse(message.data['caller_id']?.toString() ?? '') ?? 0;
|
||||
|
||||
print("Фоновый звонок через FCM: callId=$callId, callerName=$callerName, callerId=$callerId");
|
||||
await showIncomingCallKit(
|
||||
callId: callId,
|
||||
callerName: callerName,
|
||||
callerId: callerId,
|
||||
);
|
||||
} catch (e) {
|
||||
print("Error showing incoming call kit in background: $e");
|
||||
}
|
||||
} else {
|
||||
print('Message type is not enc_message: ${message.data['type']}');
|
||||
print('Message type is not recognized: ${message.data['type']}');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,90 +1,506 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:chepuhagram/domain/services/webrtc_service.dart';
|
||||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class CallScreen extends StatefulWidget {
|
||||
final String callId;
|
||||
final bool isIncoming;
|
||||
final String callerName;
|
||||
final VoidCallback onAccept;
|
||||
final VoidCallback onHangup;
|
||||
final int targetUserId;
|
||||
final bool startAccepted;
|
||||
|
||||
static String? currentActiveCallId;
|
||||
|
||||
const CallScreen({
|
||||
super.key,
|
||||
required this.callId,
|
||||
required this.isIncoming,
|
||||
required this.callerName,
|
||||
required this.onAccept,
|
||||
required this.onHangup,
|
||||
required this.targetUserId,
|
||||
this.startAccepted = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CallScreen> createState() => _CallScreenState();
|
||||
}
|
||||
|
||||
class _CallScreenState extends State<CallScreen> {
|
||||
// Рендереры для видеопотока
|
||||
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
|
||||
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
|
||||
|
||||
bool _isAccepted = false;
|
||||
bool _isMuted = false;
|
||||
bool _isVideoOff = true;
|
||||
bool _isSpeakerOn = false;
|
||||
bool _isRemoteVideoOn = false;
|
||||
bool _renderersInitialized = false;
|
||||
bool _isPopped = false;
|
||||
|
||||
Timer? _durationTimer;
|
||||
int _durationSeconds = 0;
|
||||
|
||||
// Анимация пульсации для экрана вызова
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_localRenderer.initialize();
|
||||
_remoteRenderer.initialize();
|
||||
CallScreen.currentActiveCallId = widget.callId;
|
||||
_isAccepted = widget.startAccepted;
|
||||
_initRenderers();
|
||||
_setupPulseAnimation();
|
||||
_setupWebRTC();
|
||||
}
|
||||
|
||||
Future<void> _initRenderers() async {
|
||||
await _localRenderer.initialize();
|
||||
await _remoteRenderer.initialize();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_renderersInitialized = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setupPulseAnimation() {
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat();
|
||||
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.6).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeOut),
|
||||
);
|
||||
}
|
||||
|
||||
void _setupWebRTC() async {
|
||||
// 1. Устанавливаем обработчик завершения звонка
|
||||
WebRTCService().onCallEnded = () {
|
||||
if (mounted && !_isPopped) {
|
||||
_isPopped = true;
|
||||
if (CallScreen.currentActiveCallId == widget.callId) {
|
||||
CallScreen.currentActiveCallId = null;
|
||||
}
|
||||
_stopTimer();
|
||||
FlutterCallkitIncoming.endAllCalls();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
};
|
||||
|
||||
// Слушаем изменение состояния камеры собеседника
|
||||
WebRTCService().onRemoteVideoToggled = (enabled) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRemoteVideoOn = enabled;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Если звонок исходящий или входящий и уже принят через CallKit,
|
||||
// сразу же создаем/инициализируем PeerConnection, чтобы быть готовыми принимать входящие сигналы (offer и т.д.)
|
||||
if (!widget.isIncoming || widget.startAccepted) {
|
||||
if (widget.startAccepted || !widget.isIncoming) {
|
||||
WebRTCService().isCallAcceptedLocally = true;
|
||||
}
|
||||
await _initConnection();
|
||||
}
|
||||
|
||||
// 3. Запускаем локальный стрим (камера открывается параллельно/следом)
|
||||
await WebRTCService().startLocalStream((localStream) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localRenderer.srcObject = localStream;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Помечаем WebRTCService как готовый для обмена сигналами
|
||||
WebRTCService().setReadyForSignaling();
|
||||
}
|
||||
|
||||
Future<void> _initConnection() async {
|
||||
var myId = context.read<AuthProvider>().currentUserId ?? 0;
|
||||
if (myId == 0) {
|
||||
final storage = FlutterSecureStorage();
|
||||
final userIdStr = await storage.read(key: 'user_id');
|
||||
myId = int.tryParse(userIdStr ?? '') ?? 0;
|
||||
}
|
||||
|
||||
final socket = SocketService();
|
||||
if (!socket.isConnected()) {
|
||||
print("CallScreen: Socket not connected during init, connecting...");
|
||||
try {
|
||||
await socket.connect(ApiService());
|
||||
} catch (e) {
|
||||
print("CallScreen socket connection error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
await WebRTCService().initPeerConnection(
|
||||
widget.callId,
|
||||
widget.targetUserId,
|
||||
(remoteStream) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_remoteRenderer.srcObject = remoteStream;
|
||||
_isAccepted = true;
|
||||
});
|
||||
_startTimer();
|
||||
}
|
||||
},
|
||||
myId,
|
||||
!widget.isIncoming,
|
||||
);
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_durationTimer?.cancel();
|
||||
_durationSeconds = 0;
|
||||
_durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_durationSeconds++;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTimer() {
|
||||
_durationTimer?.cancel();
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final int minutes = seconds ~/ 60;
|
||||
final int remainingSeconds = seconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _handleAccept() async {
|
||||
setState(() {
|
||||
_isAccepted = true;
|
||||
});
|
||||
WebRTCService().isCallAcceptedLocally = true;
|
||||
final myId = context.read<AuthProvider>().currentUserId ?? 0;
|
||||
// Сообщаем вызывающему, что вызов принят
|
||||
SocketService().sendMessage({
|
||||
"type": "call_accepted",
|
||||
"call_id": widget.callId,
|
||||
"receiver_id": widget.targetUserId,
|
||||
"sender_id": myId,
|
||||
});
|
||||
// Инициализируем WebRTC-соединение
|
||||
await _initConnection();
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
void _handleDecline() {
|
||||
SocketService().sendMessage({
|
||||
"type": "decline",
|
||||
"call_id": widget.callId,
|
||||
"receiver_id": widget.targetUserId,
|
||||
});
|
||||
_cleanupAndPop();
|
||||
}
|
||||
|
||||
void _handleHangup() {
|
||||
SocketService().sendMessage({
|
||||
"type": "hangup",
|
||||
"call_id": widget.callId,
|
||||
"receiver_id": widget.targetUserId,
|
||||
});
|
||||
_cleanupAndPop();
|
||||
}
|
||||
|
||||
void _cleanupAndPop() {
|
||||
if (_isPopped) return;
|
||||
_isPopped = true;
|
||||
if (CallScreen.currentActiveCallId == widget.callId) {
|
||||
CallScreen.currentActiveCallId = null;
|
||||
}
|
||||
_stopTimer();
|
||||
WebRTCService().dispose();
|
||||
FlutterCallkitIncoming.endAllCalls();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleMute() {
|
||||
setState(() {
|
||||
_isMuted = !_isMuted;
|
||||
});
|
||||
WebRTCService().toggleMute(_isMuted);
|
||||
}
|
||||
|
||||
void _toggleVideo() {
|
||||
setState(() {
|
||||
_isVideoOff = !_isVideoOff;
|
||||
});
|
||||
WebRTCService().toggleVideo(_isVideoOff);
|
||||
|
||||
// Уведомляем собеседника о переключении нашей камеры
|
||||
final myId = context.read<AuthProvider>().currentUserId ?? 0;
|
||||
SocketService().sendMessage({
|
||||
"type": "video_toggle",
|
||||
"call_id": widget.callId,
|
||||
"receiver_id": widget.targetUserId,
|
||||
"sender_id": myId,
|
||||
"video_enabled": !_isVideoOff,
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleSpeaker() {
|
||||
setState(() {
|
||||
_isSpeakerOn = !_isSpeakerOn;
|
||||
});
|
||||
WebRTCService().toggleSpeakerphone(_isSpeakerOn);
|
||||
}
|
||||
|
||||
void _switchCamera() async {
|
||||
await WebRTCService().switchCamera();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopTimer();
|
||||
_pulseController.dispose();
|
||||
_localRenderer.srcObject = null;
|
||||
_remoteRenderer.srcObject = null;
|
||||
_localRenderer.dispose();
|
||||
_remoteRenderer.dispose();
|
||||
WebRTCService().onCallEnded = null;
|
||||
WebRTCService().onRemoteVideoToggled = null;
|
||||
WebRTCService().dispose();
|
||||
if (CallScreen.currentActiveCallId == widget.callId) {
|
||||
CallScreen.currentActiveCallId = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final initials = widget.callerName.isNotEmpty
|
||||
? widget.callerName.split(RegExp(r'\s+')).take(2).map((p) => p[0].toUpperCase()).join()
|
||||
: '?';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: const Color(0xFF0F0F12),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Основной контент (Видео или Аватар)
|
||||
// 1. Задний фон / Удаленное видео
|
||||
Positioned.fill(
|
||||
child: _isAccepted
|
||||
? RTCVideoView(_remoteRenderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover)
|
||||
: Center(child: Text(widget.callerName, style: const TextStyle(color: Colors.white, fontSize: 24))),
|
||||
child: _renderersInitialized && _isAccepted && _isRemoteVideoOn && _remoteRenderer.srcObject != null
|
||||
? RTCVideoView(
|
||||
_remoteRenderer,
|
||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
)
|
||||
: _buildWaitingScreen(initials),
|
||||
),
|
||||
|
||||
// Панель управления
|
||||
// 2. Плавающее локальное видео (картинка в картинке) после ответа
|
||||
if (_renderersInitialized && _isAccepted && !_isVideoOff && _localRenderer.srcObject != null)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 16,
|
||||
right: 16,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
width: 110,
|
||||
height: 160,
|
||||
color: Colors.black26,
|
||||
child: RTCVideoView(
|
||||
_localRenderer,
|
||||
mirror: true,
|
||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 3. Статус звонка и таймер
|
||||
Positioned(
|
||||
bottom: 50,
|
||||
top: MediaQuery.of(context).padding.top + 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
child: Column(
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
backgroundColor: Colors.red,
|
||||
onPressed: () {
|
||||
widget.onHangup();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Icon(Icons.call_end, color: Colors.white),
|
||||
),
|
||||
if (widget.isIncoming && !_isAccepted)
|
||||
FloatingActionButton(
|
||||
backgroundColor: Colors.green,
|
||||
onPressed: () {
|
||||
setState(() => _isAccepted = true);
|
||||
widget.onAccept();
|
||||
},
|
||||
child: const Icon(Icons.call, color: Colors.white),
|
||||
Text(
|
||||
widget.callerName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_isAccepted
|
||||
? _formatDuration(_durationSeconds)
|
||||
: (widget.isIncoming ? 'Входящий звонок...' : 'Вызов...'),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 4. Панель управления звонком
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
left: 20,
|
||||
right: 20,
|
||||
child: _buildControlsBar(colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaitingScreen(String initials) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF1E1E24), Color(0xFF0F0F12)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (!_isAccepted)
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 140 * _pulseAnimation.value,
|
||||
height: 140 * _pulseAnimation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.blueAccent.withOpacity(1.0 - (_pulseAnimation.value - 1.0) / 0.6),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Container(
|
||||
width: 130,
|
||||
height: 130,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.blueAccent, Colors.indigoAccent],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlsBar(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.08)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Кнопка звука
|
||||
_buildCircleButton(
|
||||
icon: _isMuted ? Icons.mic_off : Icons.mic,
|
||||
isActive: !_isMuted,
|
||||
onPressed: _toggleMute,
|
||||
),
|
||||
// Кнопка динамика
|
||||
_buildCircleButton(
|
||||
icon: _isSpeakerOn ? Icons.volume_up : Icons.volume_down,
|
||||
isActive: _isSpeakerOn,
|
||||
onPressed: _toggleSpeaker,
|
||||
),
|
||||
// Кнопка камеры
|
||||
_buildCircleButton(
|
||||
icon: _isVideoOff ? Icons.videocam_off : Icons.videocam,
|
||||
isActive: !_isVideoOff,
|
||||
onPressed: _toggleVideo,
|
||||
),
|
||||
// Поворот камеры
|
||||
_buildCircleButton(
|
||||
icon: Icons.flip_camera_ios,
|
||||
isActive: true,
|
||||
onPressed: _switchCamera,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Кнопка Отклонить/Принять или Завершить
|
||||
if (widget.isIncoming && !_isAccepted) ...[
|
||||
FloatingActionButton(
|
||||
heroTag: 'decline_btn',
|
||||
backgroundColor: Colors.redAccent,
|
||||
onPressed: _handleDecline,
|
||||
child: const Icon(Icons.call_end, color: Colors.white, size: 28),
|
||||
),
|
||||
FloatingActionButton(
|
||||
heroTag: 'accept_btn',
|
||||
backgroundColor: Colors.greenAccent,
|
||||
onPressed: _handleAccept,
|
||||
child: const Icon(Icons.call, color: Colors.white, size: 28),
|
||||
),
|
||||
] else ...[
|
||||
FloatingActionButton(
|
||||
heroTag: 'hangup_btn',
|
||||
backgroundColor: Colors.redAccent,
|
||||
onPressed: _handleHangup,
|
||||
child: const Icon(Icons.call_end, color: Colors.white, size: 28),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCircleButton({
|
||||
required IconData icon,
|
||||
required bool isActive,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive ? Colors.white.withOpacity(0.12) : Colors.red.withOpacity(0.2),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: isActive ? Colors.white : Colors.redAccent, size: 24),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import 'package:chepuhagram/data/datasources/ws_client.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import '/logic/contact_provider.dart';
|
||||
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||
import '../../domain/services/api_service.dart';
|
||||
import 'dart:math';
|
||||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||
|
|
@ -841,6 +842,15 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
|||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_currentContact.id != 0)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Позвонить',
|
||||
onPressed: () => _initiateCall(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
|
|
@ -1315,10 +1325,12 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
|||
}
|
||||
|
||||
void _initiateCall(BuildContext context) {
|
||||
final myUsername = context.read<AuthProvider>().username ?? 'Пользователь';
|
||||
// Отправляем сигнал на сервер
|
||||
SocketService().sendMessage({
|
||||
"type": "call_init",
|
||||
"receiver_id": widget.contact.id,
|
||||
"caller_username": myUsername,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2948,25 +2960,24 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
|||
Duration offset = now.timeZoneOffset;
|
||||
if (data['type'] == 'call_created') {
|
||||
final String serverCallId = data['call_id'];
|
||||
final String targetName = data['receiver_name'] ?? "Пользователь";
|
||||
// Переходим на экран звонка, используя ID от сервера
|
||||
navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
callId: serverCallId,
|
||||
isIncoming: false, // Мы инициатор
|
||||
callerName: targetName,
|
||||
onAccept: () async {},
|
||||
onHangup: () {
|
||||
// Отправляем сигнал отмены на сервер
|
||||
SocketService().sendMessage({
|
||||
"type": "hangup",
|
||||
"call_id": serverCallId,
|
||||
});
|
||||
},
|
||||
final String displayName = '${widget.contact.name} ${widget.contact.surname}'.trim();
|
||||
final String targetName = displayName.isNotEmpty ? displayName : widget.contact.username;
|
||||
|
||||
if (CallScreen.currentActiveCallId != null) {
|
||||
print("ChatScreen: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $serverCallId");
|
||||
} else {
|
||||
CallScreen.currentActiveCallId = serverCallId;
|
||||
navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
callId: serverCallId,
|
||||
isIncoming: false, // Мы инициатор
|
||||
callerName: targetName,
|
||||
targetUserId: widget.contact.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data['type'] == 'all_chat_read') {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
|||
import '../screens/settings_screen.dart';
|
||||
import '../screens/new_chat_screen.dart';
|
||||
import '../screens/chat_screen.dart';
|
||||
import 'call_screen.dart';
|
||||
import 'my_profile_screen.dart';
|
||||
import '/logic/contact_provider.dart';
|
||||
import '/logic/auth_provider.dart';
|
||||
|
|
@ -1693,6 +1694,43 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
|
||||
Future<void> _handleIncomingMessage(dynamic data) async {
|
||||
if (data is RemoteMessage) {
|
||||
if (data.data['type'] == 'call_init') {
|
||||
try {
|
||||
final callId = data.data['call_id']?.toString() ?? '';
|
||||
final callerName = data.data['caller_username']?.toString() ?? 'Пользователь';
|
||||
final callerId = int.tryParse(data.data['caller_id']?.toString() ?? '') ?? 0;
|
||||
print("Входящий звонок через FCM в фокусе: callId=$callId");
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
await showIncomingCallKit(
|
||||
callId: callId,
|
||||
callerName: callerName,
|
||||
callerId: callerId,
|
||||
);
|
||||
} else {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
if (CallScreen.currentActiveCallId != null) {
|
||||
print("ContactsScreen: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId");
|
||||
} else {
|
||||
CallScreen.currentActiveCallId = callId;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
callId: callId,
|
||||
isIncoming: true,
|
||||
callerName: callerName,
|
||||
targetUserId: callerId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("Error showing incoming call kit: $e");
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _handleFCMMessage(data);
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
print('WebSocket message received in ContactsScreen: $data');
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
static const _showAvatarKey = 'privacy_show_avatar';
|
||||
static const _showAboutKey = 'privacy_show_about';
|
||||
static const _showLastOnlineKey = 'privacy_show_last_online';
|
||||
static const _alwaysRelayCallsKey = 'privacy_always_relay_calls';
|
||||
|
||||
bool _showEmail = true;
|
||||
bool _showPhone = true;
|
||||
bool _showAvatar = true;
|
||||
bool _showAbout = true;
|
||||
bool _showLastOnline = true;
|
||||
bool _alwaysRelayCalls = false;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
|
|
@ -38,6 +40,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
_showAvatar = prefs.getBool(_showAvatarKey) ?? true;
|
||||
_showAbout = prefs.getBool(_showAboutKey) ?? true;
|
||||
_showLastOnline = prefs.getBool(_showLastOnlineKey) ?? true;
|
||||
_alwaysRelayCalls = prefs.getBool(_alwaysRelayCallsKey) ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +90,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
await _savePreference(_showAvatarKey, _showAvatar);
|
||||
await _savePreference(_showAboutKey, _showAbout);
|
||||
await _savePreference(_showLastOnlineKey, _showLastOnline);
|
||||
await _savePreference(_alwaysRelayCallsKey, _alwaysRelayCalls);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
|
@ -145,6 +149,30 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8.0, bottom: 12),
|
||||
child: Text('Звонки:', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||
),
|
||||
child: _buildSwitchTile('Всегда направлять звонки через сервер', _alwaysRelayCalls, (v) {
|
||||
setState(() => _alwaysRelayCalls = v);
|
||||
_savePreference(_alwaysRelayCallsKey, v);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'При включении звонки будут направляться только через TURN сервер. Это скрывает ваш IP-адрес от собеседника, но может ухудшить качество связи.',
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 12, height: 1.4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
|
|
|
|||
|
|
@ -449,20 +449,59 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
if message_data.get("type") == "call_init":
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
call_id = str(uuid.uuid4())
|
||||
caller_username = message_data.get("caller_username")
|
||||
|
||||
call_data = {
|
||||
"type": "call_init",
|
||||
"call_id": call_id,
|
||||
"caller_username": message_data.get("caller_username"),
|
||||
"caller_username": caller_username,
|
||||
"caller_id": str(user_id)
|
||||
}
|
||||
sent = await manager.send_personal_message(call_data, str(receiver_id))
|
||||
if sent:
|
||||
await manager.send_personal_message({"type": "call_created", "call_id": call_id}, str(user_id))
|
||||
|
||||
elif message_data.get("type") in ["offer", "answer", "ice_candidate", "hangup", "decline", "call_accepted"]:
|
||||
# 1. Отправляем в WebSocket
|
||||
await manager.send_personal_message(call_data, str(receiver_id))
|
||||
|
||||
# 2. Отправляем высокоприоритетный пуш через FCM
|
||||
try:
|
||||
res = await db.execute(
|
||||
select(models.User.fcm_token)
|
||||
.where(models.User.id == receiver_id)
|
||||
)
|
||||
receiver_fcm = res.scalar()
|
||||
if receiver_fcm:
|
||||
fcm_message = messaging.Message(
|
||||
token=receiver_fcm,
|
||||
data={
|
||||
"type": "call_init",
|
||||
"call_id": call_id,
|
||||
"caller_username": str(caller_username),
|
||||
"caller_id": str(user_id),
|
||||
},
|
||||
android=messaging.AndroidConfig(priority="high"),
|
||||
apns=messaging.APNSConfig(
|
||||
payload=messaging.APNSPayload(
|
||||
aps=messaging.Aps(sound="default", content_available=True)
|
||||
)
|
||||
)
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, messaging.send, fcm_message)
|
||||
print(f"DEBUG: FCM Call Push sent to user {receiver_id}")
|
||||
except Exception as ex:
|
||||
print(f"DEBUG: Error sending FCM Call Push: {ex}")
|
||||
|
||||
# 3. Подтверждаем инициатору
|
||||
await manager.send_personal_message({"type": "call_created", "call_id": call_id}, str(user_id))
|
||||
|
||||
elif message_data.get("type") in ["offer", "answer", "ice_candidate", "video_toggle"]:
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
await manager.send_personal_message(message_data, str(receiver_id))
|
||||
|
||||
elif message_data.get("type") in ["hangup", "decline", "call_accepted"]:
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
await manager.send_personal_message(message_data, str(receiver_id))
|
||||
await manager.send_personal_message(message_data, str(user_id))
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
|
|
|
|||
Loading…
Reference in New Issue