Browse Source
feat(widget): 添加连麦卡片模型和视频通话小窗组件
feat(widget): 添加连麦卡片模型和视频通话小窗组件
- 创建 LinkMicCardModel 类用于连麦卡片数据结构 - 实现连麦卡片模型的 JSON 序列化和反序列化 - 添加 VideoCallOverlayWidget 组件用于视频通话小窗显示 - 实现小窗拖拽和边缘吸附功能 - 集成通话时长显示和用户信息展示 - 添加点击小窗返回视频通话页面的功能master
2 changed files with 230 additions and 0 deletions
Unified View
Diff Options
@ -0,0 +1,35 @@ |
|||||
|
/// 连麦卡片模型 |
||||
|
class LinkMicCardModel { |
||||
|
final int type; |
||||
|
final int num; |
||||
|
final int? unitSellingPrice; |
||||
|
|
||||
|
LinkMicCardModel({ |
||||
|
required this.type, |
||||
|
required this.num, |
||||
|
this.unitSellingPrice, |
||||
|
}); |
||||
|
|
||||
|
factory LinkMicCardModel.fromJson(Map<String, dynamic> json) { |
||||
|
|
||||
|
return LinkMicCardModel( |
||||
|
type: json['type'] as int? ?? 0, |
||||
|
num: json['num'] as int? ?? 0, |
||||
|
unitSellingPrice: json['unitSellingPrice'] as int? ?? 0, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Map<String, dynamic> toJson() { |
||||
|
return { |
||||
|
'type': type, |
||||
|
'num': num, |
||||
|
'unitSellingPrice': unitSellingPrice, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
String toString() { |
||||
|
return 'LinkMicCardModel(type: $type, num: $num, unitSellingPrice: $unitSellingPrice)'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,195 @@ |
|||||
|
import 'package:cached_network_image/cached_network_image.dart'; |
||||
|
import 'package:dating_touchme_app/controller/message/call_manager.dart'; |
||||
|
import 'package:dating_touchme_app/extension/ex_widget.dart'; |
||||
|
import 'package:dating_touchme_app/generated/assets.dart'; |
||||
|
import 'package:dating_touchme_app/pages/message/video_call_page.dart'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
/// 视频通话小窗组件 |
||||
|
class VideoCallOverlayWidget extends StatefulWidget { |
||||
|
final VoidCallback? onClose; |
||||
|
final String targetUserId; |
||||
|
final String? targetUserName; |
||||
|
final String? targetAvatarUrl; |
||||
|
|
||||
|
const VideoCallOverlayWidget({ |
||||
|
super.key, |
||||
|
this.onClose, |
||||
|
required this.targetUserId, |
||||
|
this.targetUserName, |
||||
|
this.targetAvatarUrl, |
||||
|
}); |
||||
|
|
||||
|
@override |
||||
|
State<VideoCallOverlayWidget> createState() => _VideoCallOverlayWidgetState(); |
||||
|
} |
||||
|
|
||||
|
class _VideoCallOverlayWidgetState extends State<VideoCallOverlayWidget> { |
||||
|
Offset _position = Offset.zero; |
||||
|
bool _isDragging = false; |
||||
|
final CallManager _callManager = CallManager.instance; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
// 初始位置设置为右上角 |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
final size = MediaQuery.of(context).size; |
||||
|
setState(() { |
||||
|
_position = Offset( |
||||
|
size.width - 100.w, |
||||
|
100, |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 吸附到边缘 |
||||
|
void _snapToEdge(double screenWidth) { |
||||
|
final centerX = screenWidth / 2; |
||||
|
final targetX = _position.dx < centerX |
||||
|
? 0.0 |
||||
|
: screenWidth - 100.w; |
||||
|
|
||||
|
setState(() { |
||||
|
_position = Offset(targetX, _position.dy); |
||||
|
_isDragging = false; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 格式化通话时长 |
||||
|
String _formatDuration(int seconds) { |
||||
|
String twoDigits(int n) => n.toString().padLeft(2, '0'); |
||||
|
final minutes = twoDigits(seconds ~/ 60); |
||||
|
final secs = twoDigits(seconds % 60); |
||||
|
return '$minutes:$secs'; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final screenSize = MediaQuery.of(context).size; |
||||
|
|
||||
|
return AnimatedPositioned( |
||||
|
duration: _isDragging |
||||
|
? const Duration(milliseconds: 50) |
||||
|
: const Duration(milliseconds: 300), |
||||
|
curve: _isDragging ? Curves.linear : Curves.easeOut, |
||||
|
left: _position.dx, |
||||
|
top: _position.dy, |
||||
|
child: _buildContent(screenSize), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildContent(Size screenSize) { |
||||
|
return GestureDetector( |
||||
|
onPanStart: (details) { |
||||
|
setState(() { |
||||
|
_isDragging = true; |
||||
|
}); |
||||
|
}, |
||||
|
onPanUpdate: (details) { |
||||
|
setState(() { |
||||
|
_position += details.delta; |
||||
|
_position = Offset( |
||||
|
_position.dx.clamp(0.0, screenSize.width - 100.w), |
||||
|
_position.dy.clamp(0.0, screenSize.height - 100.w), |
||||
|
); |
||||
|
}); |
||||
|
}, |
||||
|
onPanEnd: (details) { |
||||
|
_snapToEdge(screenSize.width); |
||||
|
}, |
||||
|
child: Obx(() { |
||||
|
final callSession = _callManager.currentCall.value; |
||||
|
final isConnected = callSession != null && _callManager.callDurationSeconds.value > 0; |
||||
|
final callDuration = _callManager.callDurationSeconds.value; |
||||
|
|
||||
|
return Container( |
||||
|
width: 100.w, |
||||
|
height: 100.w, |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(8.w), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.3), |
||||
|
blurRadius: 8, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(8.w), |
||||
|
child: Stack( |
||||
|
children: [ |
||||
|
// 背景:头像 |
||||
|
Container( |
||||
|
width: 100.w, |
||||
|
height: 100.w, |
||||
|
color: Colors.black, |
||||
|
child: widget.targetAvatarUrl != null && widget.targetAvatarUrl!.isNotEmpty |
||||
|
? CachedNetworkImage( |
||||
|
imageUrl: widget.targetAvatarUrl!, |
||||
|
fit: BoxFit.cover, |
||||
|
errorWidget: (context, url, error) => Image.asset( |
||||
|
Assets.imagesUserAvatar, |
||||
|
fit: BoxFit.cover, |
||||
|
), |
||||
|
) |
||||
|
: Image.asset( |
||||
|
Assets.imagesUserAvatar, |
||||
|
fit: BoxFit.cover, |
||||
|
), |
||||
|
), |
||||
|
// 半透明遮罩 |
||||
|
Container( |
||||
|
color: Colors.black.withOpacity(0.4), |
||||
|
), |
||||
|
// 内容:昵称和状态 |
||||
|
Positioned( |
||||
|
bottom: 8.w, |
||||
|
left: 4.w, |
||||
|
right: 4.w, |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
// 昵称 |
||||
|
Text( |
||||
|
widget.targetUserName ?? widget.targetUserId, |
||||
|
style: TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontSize: 10.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
SizedBox(height: 2.h), |
||||
|
// 通话状态 |
||||
|
Text( |
||||
|
isConnected ? _formatDuration(callDuration) : '通话中', |
||||
|
style: TextStyle( |
||||
|
color: Colors.white.withOpacity(0.9), |
||||
|
fontSize: 8.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
).onTap(() { |
||||
|
// 点击小窗,返回视频通话页面 |
||||
|
Get.to(() => VideoCallPage( |
||||
|
targetUserId: widget.targetUserId, |
||||
|
isInitiator: callSession?.isInitiator ?? true, |
||||
|
)); |
||||
|
widget.onClose?.call(); |
||||
|
}); |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
Write
Preview
Loading…
Cancel
Save