Browse Source
feat(chat): 添加视频通话功能
feat(chat): 添加视频通话功能
- 添加视频通话页面实现视频通话界面和功能 - 在聊天页面中启用视频通话按钮并实现通话发起逻辑 - 添加通话管理器处理视频通话状态和时长 - 更新资源文件添加视频通话相关图标 - 实现通话消息解析显示视频通话状态 - 添加通话时长计时和用户信息显示功能master
7 changed files with 500 additions and 19 deletions
Split View
Diff Options
-
BINassets/images/video_call.png
-
24lib/controller/message/conversation_controller.dart
-
4lib/generated/assets.dart
-
24lib/im/im_manager.dart
-
26lib/pages/message/chat_page.dart
-
427lib/pages/message/video_call_page.dart
-
14lib/widget/message/chat_input_bar.dart
@ -0,0 +1,427 @@ |
|||
import 'dart:async'; |
|||
import 'dart:ui'; |
|||
import 'package:cached_network_image/cached_network_image.dart'; |
|||
import 'package:dating_touchme_app/generated/assets.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter/services.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../../controller/message/call_manager.dart'; |
|||
import '../../controller/message/conversation_controller.dart'; |
|||
import '../../model/home/marriage_data.dart'; |
|||
|
|||
/// 视频通话页面 |
|||
class VideoCallPage extends StatefulWidget { |
|||
final String targetUserId; |
|||
final MarriageData? userData; |
|||
final bool isInitiator; // 是否是发起方 |
|||
|
|||
const VideoCallPage({ |
|||
super.key, |
|||
required this.targetUserId, |
|||
this.userData, |
|||
this.isInitiator = true, |
|||
}); |
|||
|
|||
@override |
|||
State<VideoCallPage> createState() => _VideoCallPageState(); |
|||
} |
|||
|
|||
class _VideoCallPageState extends State<VideoCallPage> { |
|||
final CallManager _callManager = CallManager.instance; |
|||
|
|||
bool _isMicMuted = false; |
|||
bool _isSpeakerOn = false; |
|||
Duration _callDuration = Duration.zero; |
|||
Timer? _durationTimer; |
|||
|
|||
String? _targetUserName; |
|||
String? _targetAvatarUrl; |
|||
|
|||
// 通话是否已接通 |
|||
bool _isCallConnected = false; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
_initializeCall(); |
|||
_loadUserInfo(); |
|||
_initCallStatus(); |
|||
_startDurationTimer(); |
|||
|
|||
// 设置系统UI样式 |
|||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); |
|||
SystemChrome.setPreferredOrientations([ |
|||
DeviceOrientation.portraitUp, |
|||
]); |
|||
} |
|||
|
|||
/// 初始化通话状态 |
|||
void _initCallStatus() { |
|||
// 检查当前通话状态 |
|||
final callSession = _callManager.currentCall.value; |
|||
if (callSession != null && _callManager.callDurationSeconds.value > 0) { |
|||
// 如果通话已存在且已经开始计时,说明已接通 |
|||
_isCallConnected = true; |
|||
_callDuration = Duration(seconds: _callManager.callDurationSeconds.value); |
|||
} else { |
|||
// 否则是未接通状态 |
|||
_isCallConnected = false; |
|||
} |
|||
} |
|||
|
|||
/// 加载用户信息 |
|||
Future<void> _loadUserInfo() async { |
|||
// 优先使用传入的 userData |
|||
if (widget.userData != null) { |
|||
setState(() { |
|||
_targetUserName = widget.userData!.nickName.isNotEmpty |
|||
? widget.userData!.nickName |
|||
: widget.targetUserId; |
|||
_targetAvatarUrl = widget.userData!.profilePhoto; |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
// 如果没有传入 userData,尝试从 ConversationController 获取 |
|||
try { |
|||
if (Get.isRegistered<ConversationController>()) { |
|||
final conversationController = Get.find<ConversationController>(); |
|||
|
|||
// 先从缓存中获取 |
|||
final cachedUserInfo = conversationController.getCachedUserInfo(widget.targetUserId); |
|||
if (cachedUserInfo != null && (cachedUserInfo.nickName != null || cachedUserInfo.avatarUrl != null)) { |
|||
setState(() { |
|||
_targetUserName = cachedUserInfo.nickName ?? widget.targetUserId; |
|||
_targetAvatarUrl = cachedUserInfo.avatarUrl; |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
// 如果缓存中没有,尝试从 IM 加载 |
|||
final userInfo = await conversationController.loadContact(widget.targetUserId); |
|||
if (userInfo != null && (userInfo.nickName != null || userInfo.avatarUrl != null)) { |
|||
setState(() { |
|||
_targetUserName = userInfo.nickName ?? widget.targetUserId; |
|||
_targetAvatarUrl = userInfo.avatarUrl; |
|||
}); |
|||
return; |
|||
} |
|||
} |
|||
} catch (e) { |
|||
print('⚠️ [VideoCallPage] 加载用户信息失败: $e'); |
|||
} |
|||
|
|||
// 如果都获取不到,使用默认值 |
|||
setState(() { |
|||
_targetUserName = widget.targetUserId; |
|||
_targetAvatarUrl = null; |
|||
}); |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_durationTimer?.cancel(); |
|||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); |
|||
SystemChrome.setPreferredOrientations(DeviceOrientation.values); |
|||
super.dispose(); |
|||
} |
|||
|
|||
/// 初始化通话 |
|||
Future<void> _initializeCall() async { |
|||
try { |
|||
// TODO: 初始化RTC Engine并加入频道 |
|||
// await _rtcManager.initialize(appId: 'your_app_id'); |
|||
// await _rtcManager.joinChannel(token: 'token', channelId: 'channel_id', uid: uid); |
|||
} catch (e) { |
|||
print('初始化通话失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 开始通话时长计时器 |
|||
void _startDurationTimer() { |
|||
// 监听 CallManager 的通话状态变化 |
|||
_callManager.currentCall.listen((callSession) { |
|||
if (mounted) { |
|||
final wasConnected = _isCallConnected; |
|||
// 如果通话存在且已经开始计时,说明已接通 |
|||
if (callSession != null && _callManager.callDurationSeconds.value > 0) { |
|||
_isCallConnected = true; |
|||
if (!wasConnected) { |
|||
// 刚接通,同步时长 |
|||
_callDuration = Duration(seconds: _callManager.callDurationSeconds.value); |
|||
} |
|||
} else if (callSession == null) { |
|||
_isCallConnected = false; |
|||
} |
|||
setState(() {}); |
|||
} |
|||
}); |
|||
|
|||
// 监听通话时长变化(已接通时更新) |
|||
_callManager.callDurationSeconds.listen((seconds) { |
|||
if (mounted && _isCallConnected) { |
|||
setState(() { |
|||
_callDuration = Duration(seconds: seconds); |
|||
}); |
|||
} else if (mounted && !_isCallConnected && seconds > 0) { |
|||
// 如果时长开始增加,说明刚接通 |
|||
setState(() { |
|||
_isCallConnected = true; |
|||
_callDuration = Duration(seconds: seconds); |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
// 如果未接通,使用本地计时器检查状态变化 |
|||
if (!_isCallConnected) { |
|||
_durationTimer = Timer.periodic(Duration(seconds: 1), (timer) { |
|||
if (mounted) { |
|||
final callSession = _callManager.currentCall.value; |
|||
final duration = _callManager.callDurationSeconds.value; |
|||
|
|||
// 检查是否已接通(通话存在且时长大于0) |
|||
if (callSession != null && duration > 0) { |
|||
_isCallConnected = true; |
|||
_callDuration = Duration(seconds: duration); |
|||
timer.cancel(); |
|||
setState(() {}); |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
/// 格式化通话时长 |
|||
String _formatDuration(Duration duration) { |
|||
String twoDigits(int n) => n.toString().padLeft(2, '0'); |
|||
final minutes = twoDigits(duration.inMinutes.remainder(60)); |
|||
final seconds = twoDigits(duration.inSeconds.remainder(60)); |
|||
return '$minutes:$seconds'; |
|||
} |
|||
|
|||
/// 切换麦克风状态 |
|||
void _toggleMic() { |
|||
setState(() { |
|||
_isMicMuted = !_isMicMuted; |
|||
}); |
|||
// TODO: 调用RTC Manager切换麦克风 |
|||
// _rtcManager.enableAudio(!_isMicMuted); |
|||
} |
|||
|
|||
/// 切换扬声器状态 |
|||
void _toggleSpeaker() { |
|||
setState(() { |
|||
_isSpeakerOn = !_isSpeakerOn; |
|||
}); |
|||
// TODO: 调用RTC Manager切换扬声器 |
|||
// _rtcManager.setEnableSpeakerphone(_isSpeakerOn); |
|||
} |
|||
|
|||
/// 挂断通话 |
|||
void _hangUp() async { |
|||
try { |
|||
// TODO: 离开RTC频道 |
|||
// await _rtcManager.leaveChannel(); |
|||
|
|||
// 结束通话(传递通话时长) |
|||
await _callManager.endCall(callDuration: _callDuration.inSeconds); |
|||
|
|||
// 返回上一页 |
|||
Get.back(); |
|||
} catch (e) { |
|||
print('挂断通话失败: $e'); |
|||
Get.back(); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Scaffold( |
|||
backgroundColor: Colors.black, |
|||
body: Stack( |
|||
children: [ |
|||
// 背景视频/头像(模糊) |
|||
_buildBackground(), |
|||
|
|||
// 用户信息 |
|||
_buildUserInfo(), |
|||
|
|||
// 通话时长 |
|||
_buildCallDuration(), |
|||
|
|||
// 底部控制按钮 |
|||
_buildControlButtons(), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建背景 |
|||
Widget _buildBackground() { |
|||
return SizedBox( |
|||
width: double.infinity, |
|||
height: 1.sh, |
|||
child: _targetAvatarUrl != null && _targetAvatarUrl!.isNotEmpty |
|||
? ImageFiltered( |
|||
imageFilter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), |
|||
child: CachedNetworkImage( |
|||
imageUrl: _targetAvatarUrl!, |
|||
fit: BoxFit.cover, |
|||
width: double.infinity, |
|||
height: double.infinity, |
|||
errorWidget: (context, url, error) => _buildDefaultBackground(), |
|||
), |
|||
) |
|||
: _buildDefaultBackground(), |
|||
); |
|||
} |
|||
|
|||
/// 构建默认背景 |
|||
Widget _buildDefaultBackground() { |
|||
return Container( |
|||
color: Colors.black, |
|||
); |
|||
} |
|||
|
|||
/// 构建用户信息 |
|||
Widget _buildUserInfo() { |
|||
return Positioned( |
|||
top: MediaQuery.of(context).size.height * 0.15, |
|||
left: 0, |
|||
right: 0, |
|||
child: Column( |
|||
children: [ |
|||
// 头像 |
|||
ClipOval( |
|||
child: _targetAvatarUrl != null && _targetAvatarUrl!.isNotEmpty |
|||
? CachedNetworkImage( |
|||
imageUrl: _targetAvatarUrl!, |
|||
width: 120.w, |
|||
height: 120.w, |
|||
fit: BoxFit.cover, |
|||
errorWidget: (context, url, error) => Image.asset( |
|||
Assets.imagesUserAvatar, |
|||
width: 120.w, |
|||
height: 120.w, |
|||
fit: BoxFit.cover, |
|||
), |
|||
) |
|||
: Image.asset( |
|||
Assets.imagesUserAvatar, |
|||
width: 120.w, |
|||
height: 120.w, |
|||
fit: BoxFit.cover, |
|||
), |
|||
), |
|||
SizedBox(height: 16.h), |
|||
// 用户名 |
|||
Text( |
|||
_targetUserName ?? widget.targetUserId, |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 24.sp, |
|||
fontWeight: FontWeight.w600, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建通话时长 |
|||
Widget _buildCallDuration() { |
|||
return Positioned( |
|||
bottom: MediaQuery.of(context).size.height * 0.25, |
|||
left: 0, |
|||
right: 0, |
|||
child: Center( |
|||
child: Text( |
|||
_isCallConnected ? _formatDuration(_callDuration) : '正在呼叫中', |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建控制按钮 |
|||
Widget _buildControlButtons() { |
|||
return Positioned( |
|||
bottom: 40.h, |
|||
left: 0, |
|||
right: 0, |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|||
children: [ |
|||
// 免提按钮 |
|||
_buildControlButton( |
|||
icon: Icons.volume_up, |
|||
label: '免提', |
|||
isActive: _isSpeakerOn, |
|||
onTap: _toggleSpeaker, |
|||
), |
|||
// 麦克风按钮 |
|||
_buildControlButton( |
|||
icon: Icons.mic, |
|||
label: '麦克风', |
|||
isActive: !_isMicMuted, |
|||
onTap: _toggleMic, |
|||
), |
|||
// 挂断按钮 |
|||
_buildControlButton( |
|||
icon: Icons.call_end, |
|||
label: '挂断', |
|||
isActive: true, |
|||
onTap: _hangUp, |
|||
isHangUp: true, |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建控制按钮 |
|||
Widget _buildControlButton({ |
|||
required IconData icon, |
|||
required String label, |
|||
required bool isActive, |
|||
required VoidCallback onTap, |
|||
bool isHangUp = false, |
|||
}) { |
|||
return GestureDetector( |
|||
onTap: onTap, |
|||
child: Column( |
|||
children: [ |
|||
Container( |
|||
width: 56.w, |
|||
height: 56.w, |
|||
decoration: BoxDecoration( |
|||
color: isHangUp |
|||
? Color(0xFFFF3B30) |
|||
: (isActive ? Colors.white.withOpacity(0.3) : Colors.white.withOpacity(0.2)), |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: Icon( |
|||
icon, |
|||
color: Colors.white, |
|||
size: 28.w, |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Text( |
|||
label, |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
} |
|||
|
|||
Write
Preview
Loading…
Cancel
Save