Browse Source

feat(live): 实现礼物弹窗用户选择与礼物展示功能

- 将 LiveGiftPopup 从 StatelessWidget 改为 StatefulWidget 以支持状态管理
- 新增用户选择逻辑,支持单个用户选中与全选/取消全选功能
- 使用 Obx 监听 RoomController 中的 RTC 频道详情动态构建用户列表
- 过滤掉当前用户自身,最多显示三个可送礼用户
- 用户头像使用 CachedNetworkImage 加载,支持加载占位与错误处理
- 礼物区域支持分页展示,每页最多 8 个礼物(2 行 4 列)
- 支持 Map 和 GiftProductModel 两种数据结构的礼物列表渲染
- LiveRoomGiftItem 组件适配网络图片加载与文本截断显示
- 动态获取并显示礼物名称与价格(单位:支)
- 优化空礼物列表提示与 UI 布局细节
ios
Jolie 4 months ago
parent
commit
0b39b3f6a1
3 changed files with 343 additions and 126 deletions
  1. 26
      lib/pages/discover/live_room_page.dart
  2. 354
      lib/widget/live/live_gift_popup.dart
  3. 89
      lib/widget/live/live_room_gift_item.dart

26
lib/pages/discover/live_room_page.dart

@ -30,14 +30,6 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
final activeGift = ValueNotifier<int?>(null);
List<Map> giftList = [
{"icon": Assets.imagesGift1, "title": "爱心礼物", "price": 30},
{"icon": Assets.imagesGift2, "title": "小小小星星", "price": 30},
{"icon": Assets.imagesGift3, "title": "助威", "price": 30},
{"icon": Assets.imagesGift4, "title": "点赞", "price": 30},
{"icon": Assets.imagesGift5, "title": "崇拜衣柜", "price": 30},
];
final giftNum = ValueNotifier<int>(1);
final activePay = ValueNotifier<int?>(null);
@ -99,12 +91,18 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
SmartDialog.show(
alignment: Alignment.bottomCenter,
maskColor: TDTheme.of(context).fontGyColor2,
builder: (_) => LiveGiftPopup(
activeGift: activeGift,
giftNum: giftNum,
giftList: giftList,
changeActive: changeActive,
),
builder: (_) => Obx(() {
// 使 API giftProducts使
final giftProducts = _roomController.giftProducts;
final giftList = giftProducts.toList();
return LiveGiftPopup(
activeGift: activeGift,
giftNum: giftNum,
giftList: giftList,
changeActive: changeActive,
);
}),
);
}

354
lib/widget/live/live_gift_popup.dart

@ -1,11 +1,16 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dating_touchme_app/controller/discover/room_controller.dart';
import 'package:dating_touchme_app/controller/global.dart';
import 'package:dating_touchme_app/generated/assets.dart';
import 'package:dating_touchme_app/model/rtc/rtc_channel_detail.dart';
import 'package:dating_touchme_app/widget/live/live_room_gift_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_swiper_null_safety/flutter_swiper_null_safety.dart';
import 'package:get/get.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
class LiveGiftPopup extends StatelessWidget {
class LiveGiftPopup extends StatefulWidget {
const LiveGiftPopup({
super.key,
required this.activeGift,
@ -16,9 +21,44 @@ class LiveGiftPopup extends StatelessWidget {
final ValueNotifier<int?> activeGift;
final ValueNotifier<int> giftNum;
final List<Map> giftList;
final List<dynamic> giftList; // List<Map> List<GiftProductModel>
final void Function(int) changeActive;
@override
State<LiveGiftPopup> createState() => _LiveGiftPopupState();
}
class _LiveGiftPopupState extends State<LiveGiftPopup> {
// ID集合
final Set<String> _selectedUserIds = <String>{};
//
void _toggleUserSelection(String userId) {
setState(() {
if (_selectedUserIds.contains(userId)) {
_selectedUserIds.remove(userId);
} else {
_selectedUserIds.add(userId);
}
});
}
// /
void _toggleSelectAll(List<RtcSeatUserInfo> users) {
setState(() {
if (_selectedUserIds.length == users.length) {
//
_selectedUserIds.clear();
} else {
//
_selectedUserIds.clear();
for (var user in users) {
_selectedUserIds.add(user.userId);
}
}
});
}
@override
Widget build(BuildContext context) {
return Material(
@ -32,7 +72,9 @@ class LiveGiftPopup extends StatelessWidget {
Expanded(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(9.w)),
borderRadius: BorderRadius.vertical(
top: Radius.circular(9.w),
),
color: const Color.fromRGBO(22, 19, 28, 1),
),
child: Column(
@ -51,88 +93,166 @@ class LiveGiftPopup extends StatelessWidget {
}
Widget _buildHeader() {
return Container(
height: 53.w,
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: Row(
children: [
Row(
children: [
Text(
"送给: ",
style: TextStyle(fontSize: 13.w, color: Colors.white),
),
SizedBox(width: 6.w),
...List.generate(3, (index) {
return Padding(
padding: EdgeInsets.only(right: 10.w),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.all(
Radius.circular(index == 0 ? 68.w : 34.w),
),
child: Container(
width: 34.w,
height: 34.w,
decoration: BoxDecoration(
return Obx(() {
// RoomController
final roomController = Get.isRegistered<RoomController>()
? Get.find<RoomController>()
: null;
// rtcChannelDetail
final rtcChannelDetail = roomController?.rtcChannelDetail.value;
// ID
final currentUserId = GlobalData().userId ?? GlobalData().userData?.id;
// anchorInfo, maleInfo, femaleInfo
final List<RtcSeatUserInfo> userList = [];
if (rtcChannelDetail?.anchorInfo != null) {
userList.add(rtcChannelDetail!.anchorInfo!);
}
if (rtcChannelDetail?.maleInfo != null) {
userList.add(rtcChannelDetail!.maleInfo!);
}
if (rtcChannelDetail?.femaleInfo != null) {
userList.add(rtcChannelDetail!.femaleInfo!);
}
//
final filteredUserList = userList.where((user) {
// userId miId
return user.userId != currentUserId && user.miId != currentUserId;
}).toList();
// 3
final displayUsers = filteredUserList.take(3).toList();
return Container(
height: 53.w,
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: Row(
children: [
Row(
children: [
Text(
"送给: ",
style: TextStyle(fontSize: 13.w, color: Colors.white),
),
SizedBox(width: 6.w),
...displayUsers.asMap().entries.map((entry) {
final index = entry.key;
final user = entry.value;
final isSelected = _selectedUserIds.contains(user.userId);
return GestureDetector(
onTap: () => _toggleUserSelection(user.userId),
child: Padding(
padding: EdgeInsets.only(right: 10.w),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.all(
Radius.circular(index == 0 ? 68.w : 34.w),
),
border: Border.all(
width: index == 0 ? 2 : 1,
color: const Color.fromRGBO(117, 98, 249, 1),
child: Container(
width: 34.w,
height: 34.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(index == 0 ? 68.w : 34.w),
),
border: Border.all(
width: index == 0 ? 2 : 1,
color: const Color.fromRGBO(117, 98, 249, 1),
),
),
child: user.profilePhoto.isNotEmpty
? CachedNetworkImage(
imageUrl: user.profilePhoto,
width: 34.w,
height: 34.w,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 34.w,
height: 34.w,
color: Colors.grey[300],
child: Center(
child: SizedBox(
width: 16.w,
height: 16.w,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey[600],
),
),
),
),
errorWidget: (context, url, error) =>
Image.asset(
Assets.imagesUserAvatar,
width: 32.w,
height: 32.w,
),
)
: Image.asset(
Assets.imagesUserAvatar,
width: 32.w,
height: 32.w,
),
),
),
child: Image.asset(
Assets.imagesUserAvatar,
width: 32.w,
height: 32.w,
),
),
),
Positioned(
bottom: 0,
right: 2.w,
child: Container(
width: 12.w,
height: 12.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(12.w)),
color: const Color.fromRGBO(117, 98, 249, 1),
),
child: Center(
child: Image.asset(
Assets.imagesCheck,
width: 6.w,
height: 4.w,
if (isSelected)
Positioned(
bottom: 0,
right: 2.w,
child: Container(
width: 12.w,
height: 12.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(12.w),
),
color: const Color.fromRGBO(117, 98, 249, 1),
),
child: Center(
child: Image.asset(
Assets.imagesCheck,
width: 6.w,
height: 4.w,
),
),
),
),
),
),
],
),
],
),
);
}),
],
),
Container(
width: 63.w,
height: 30.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(30.w)),
color: const Color.fromRGBO(117, 98, 249, 1),
),
);
}),
],
),
child: Center(
child: Text(
"全选",
style: TextStyle(fontSize: 13.w, color: Colors.white),
//
if (displayUsers.isNotEmpty)
GestureDetector(
onTap: () => _toggleSelectAll(displayUsers),
child: Container(
width: 63.w,
height: 30.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(30.w)),
color: const Color.fromRGBO(117, 98, 249, 1),
),
child: Center(
child: Text(
_selectedUserIds.length == displayUsers.length
? "取消全选"
: "全选",
style: TextStyle(fontSize: 13.w, color: Colors.white),
),
),
),
),
),
),
],
),
);
],
),
);
});
}
Widget _buildTab() {
@ -163,34 +283,61 @@ class LiveGiftPopup extends StatelessWidget {
}
Widget _buildGiftSwiper() {
if (widget.giftList.isEmpty) {
return Expanded(
child: Center(
child: Text(
'暂无礼物',
style: TextStyle(fontSize: 14.w, color: Colors.white70),
),
),
);
}
// 824
final itemsPerPage = 8;
final totalPages = (widget.giftList.length / itemsPerPage).ceil();
return Expanded(
child: ValueListenableBuilder<int?>(
valueListenable: activeGift,
valueListenable: widget.activeGift,
builder: (context, active, _) {
return Swiper(
autoplay: false,
itemCount: 6,
loop: true,
pagination: const SwiperPagination(
alignment: Alignment.bottomCenter,
builder: TDSwiperDotsPagination(
color: Color.fromRGBO(144, 144, 144, 1),
activeColor: Color.fromRGBO(77, 77, 77, 1),
),
),
itemBuilder: (context, index) {
itemCount: totalPages,
loop: false,
pagination: totalPages > 1
? const SwiperPagination(
alignment: Alignment.bottomCenter,
builder: TDSwiperDotsPagination(
color: Color.fromRGBO(144, 144, 144, 1),
activeColor: Color.fromRGBO(77, 77, 77, 1),
),
)
: null,
itemBuilder: (context, pageIndex) {
final startIndex = pageIndex * itemsPerPage;
final endIndex = (startIndex + itemsPerPage).clamp(
0,
widget.giftList.length,
);
final pageItems = widget.giftList.sublist(startIndex, endIndex);
return Align(
alignment: Alignment.topCenter,
child: Wrap(
spacing: 7.w,
runSpacing: 7.w,
children: [
...giftList.asMap().entries.map(
(entry) => LiveRoomGiftItem(
...pageItems.asMap().entries.map((entry) {
final globalIndex = startIndex + entry.key;
return LiveRoomGiftItem(
item: entry.value,
active: active ?? 0,
index: entry.key,
changeActive: changeActive,
),
),
index: globalIndex,
changeActive: widget.changeActive,
);
}),
],
),
);
@ -209,26 +356,18 @@ class LiveGiftPopup extends StatelessWidget {
children: [
Row(
children: [
Image.asset(
Assets.imagesRoseGift,
width: 21.w,
height: 21.w,
),
Image.asset(Assets.imagesRoseGift, width: 21.w, height: 21.w),
SizedBox(width: 8.w),
Text(
"9",
style: TextStyle(fontSize: 13.w, color: Colors.white),
),
SizedBox(width: 12.w),
Image.asset(
Assets.imagesRoseGift,
width: 68.w,
height: 33.w,
),
Image.asset(Assets.imagesRoseGift, width: 68.w, height: 33.w),
],
),
ValueListenableBuilder<int>(
valueListenable: giftNum,
valueListenable: widget.giftNum,
builder: (context, num, _) {
return Row(
children: [
@ -236,8 +375,8 @@ class LiveGiftPopup extends StatelessWidget {
label: "-",
enabled: num > 1,
onTap: () {
if (giftNum.value <= 1) return;
giftNum.value -= 1;
if (widget.giftNum.value <= 1) return;
widget.giftNum.value -= 1;
},
),
SizedBox(
@ -253,7 +392,7 @@ class LiveGiftPopup extends StatelessWidget {
label: "+",
enabled: true,
onTap: () {
giftNum.value += 1;
widget.giftNum.value += 1;
},
),
SizedBox(width: 9.w),
@ -323,4 +462,3 @@ class LiveGiftPopup extends StatelessWidget {
);
}
}

89
lib/widget/live/live_room_gift_item.dart

@ -1,8 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dating_touchme_app/model/live/gift_product_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class LiveRoomGiftItem extends StatefulWidget {
final Map item;
final dynamic item; // Map GiftProductModel
final int active;
final int index;
final void Function(int) changeActive;
@ -19,6 +21,83 @@ class LiveRoomGiftItem extends StatefulWidget {
}
class _LiveRoomGiftItemState extends State<LiveRoomGiftItem> {
// GiftProductModel
bool get _isGiftProductModel => widget.item is GiftProductModel;
//
Widget _buildImage() {
if (_isGiftProductModel) {
final gift = widget.item as GiftProductModel;
if (gift.mainPic.isNotEmpty) {
return CachedNetworkImage(
imageUrl: gift.mainPic,
width: 41.w,
height: 41.w,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 41.w,
height: 41.w,
color: Colors.grey[300],
child: Center(
child: SizedBox(
width: 20.w,
height: 20.w,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey[600],
),
),
),
),
errorWidget: (context, url, error) => Container(
width: 41.w,
height: 41.w,
color: Colors.grey[300],
child: Icon(Icons.error_outline, size: 20.w, color: Colors.grey),
),
);
} else {
return Container(
width: 41.w,
height: 41.w,
color: Colors.grey[300],
);
}
} else {
final map = widget.item as Map;
final icon = map["icon"] as String?;
if (icon != null && icon.isNotEmpty) {
return Image.asset(icon, width: 41.w, height: 41.w);
} else {
return Container(
width: 41.w,
height: 41.w,
color: Colors.grey[300],
);
}
}
}
//
String _getTitle() {
if (_isGiftProductModel) {
return (widget.item as GiftProductModel).productTitle;
} else {
return (widget.item as Map)["title"]?.toString() ?? '';
}
}
//
String _getPrice() {
if (_isGiftProductModel) {
final price = (widget.item as GiftProductModel).unitSellingPrice;
return "${price.toInt()}";
} else {
final price = (widget.item as Map)["price"];
return "${price ?? 0}";
}
}
@override
Widget build(BuildContext context) {
return InkWell(
@ -49,15 +128,17 @@ class _LiveRoomGiftItemState extends State<LiveRoomGiftItem> {
),
child: Column(
children: [
Image.asset(widget.item["icon"], width: 41.w, height: 41.w),
_buildImage(),
SizedBox(height: 7.w),
Text(
widget.item["title"],
_getTitle(),
style: TextStyle(fontSize: 11.w, color: Colors.white),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 1.w),
Text(
"${widget.item["price"]}",
_getPrice(),
style: TextStyle(
fontSize: 7.w,
color: const Color.fromRGBO(144, 144, 144, 1),

Loading…
Cancel
Save