Chepuhagram/lib/presentation/screens/call_screen.dart

506 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 int targetUserId;
final bool startAccepted;
static String? currentActiveCallId;
const CallScreen({
super.key,
required this.callId,
required this.isIncoming,
required this.callerName,
required this.targetUserId,
this.startAccepted = false,
});
@override
State<CallScreen> createState() => _CallScreenState();
}
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();
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: const Color(0xFF0F0F12),
body: Stack(
children: [
// 1. Задний фон / Удаленное видео
Positioned.fill(
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(
top: MediaQuery.of(context).padding.top + 80,
left: 0,
right: 0,
child: Column(
children: [
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,
),
);
}
}