From ce0331e4c645126ccb9588003759e3bfeb1c8f04 Mon Sep 17 00:00:00 2001 From: cuiyouliang <799699717@qq.com> Date: Sat, 30 Aug 2025 16:49:21 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E8=81=9A=E5=90=88=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=202=E3=80=81=E5=85=AC=E5=85=B1=E8=A7=86=E9=A2=91=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/api/common_api.dart | 3 + lib/api/video_api.dart | 4 +- lib/controller/shop_index_controller.dart | 1 + lib/pages/chat/chat.dart | 3 +- lib/pages/goods/detail.dart | 8 +- lib/pages/index/index.dart | 3 +- lib/pages/search/search-result.dart | 878 +++++++++---- lib/pages/video/commonVideo.dart | 1404 +++++++++++++++++++++ lib/router/index.dart | 2 + lib/utils/index.dart | 9 + 10 files changed, 2053 insertions(+), 262 deletions(-) create mode 100644 lib/pages/video/commonVideo.dart diff --git a/lib/api/common_api.dart b/lib/api/common_api.dart index 1efbc8e..d43eb47 100644 --- a/lib/api/common_api.dart +++ b/lib/api/common_api.dart @@ -14,5 +14,8 @@ class CommonApi { // 获取字典枚举 static const String dictionaryApi = '/app/sys/dict/type/'; + // 聚合搜索 + static const String aggregationSearchApi = '/app/common/search'; + ///resource/oss/upload } diff --git a/lib/api/video_api.dart b/lib/api/video_api.dart index ab1831b..0cc561a 100644 --- a/lib/api/video_api.dart +++ b/lib/api/video_api.dart @@ -8,9 +8,11 @@ class VideoApi { // post static const String myPublicList = '/app/vlog/myPublicList'; // 我发布的视频 static const String myLikedList = '/app/vlog/myLikedList'; // 我点赞的视频 - static const String videoCommentList = '/app/comment/list'; // 视频评论列表 + static const String videoCommentList = '/app/comment/page'; // 视频评论列表 static const String doVideoComment = '/app/comment/publish'; // 发布评论 static const String reportVideoApi = '/app/feedback/add'; // 投诉视频 + static const String videoDetailApi = '/app/vlog/detail/'; // 根据视频Id获取视频系详情 + diff --git a/lib/controller/shop_index_controller.dart b/lib/controller/shop_index_controller.dart index 0fec11b..bbf1b2e 100644 --- a/lib/controller/shop_index_controller.dart +++ b/lib/controller/shop_index_controller.dart @@ -117,6 +117,7 @@ class ShopIndexController extends GetxController with GetSingleTickerProviderSta }); final data = res['data']['records']; + print('商品返回数据------------------------->${data}'); tab.dataList.addAll(data); // logger.w(res); diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 3f3839a..a0df04f 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -597,7 +597,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), onTap: () { // 这里带上分享人的ID - Get.toNamed('/goods'); + // Get.toNamed('/goods'); + Get.toNamed('/goods', arguments: {}); }, ), )); diff --git a/lib/pages/goods/detail.dart b/lib/pages/goods/detail.dart index 0987c77..ca966dc 100644 --- a/lib/pages/goods/detail.dart +++ b/lib/pages/goods/detail.dart @@ -48,13 +48,13 @@ class _GoodsState extends State { @override void initState() { super.initState(); - final shopId = Get.arguments; + final goodsId = Get.arguments['goodsId']; scrollController.addListener(() { setState(() { scrollOffset = scrollController.offset; }); }); - shopDetail(shopId); + shopDetail(goodsId); } @override @@ -64,9 +64,9 @@ class _GoodsState extends State { } ///商品详情 - void shopDetail(shopId) async { + void shopDetail(goodsId) async { try { - final res = await Http.get('${ShopApi.shopDetail}/$shopId'); + final res = await Http.get('${ShopApi.shopDetail}/$goodsId'); logger.e(res['data']); setState(() { shopObj = res['data']; // 注意取 data 部分 diff --git a/lib/pages/index/index.dart b/lib/pages/index/index.dart index 9eaee83..1e33e79 100644 --- a/lib/pages/index/index.dart +++ b/lib/pages/index/index.dart @@ -104,7 +104,8 @@ class _IndexPageState extends State with SingleTickerProviderStateMix ), ), onTap: () { - Get.toNamed('/goods', arguments: item['id']); + // Get.toNamed('/goods', arguments: item['id']); + Get.toNamed('/goods', arguments: {'goodsId': item['id']}); }, ); } diff --git a/lib/pages/search/search-result.dart b/lib/pages/search/search-result.dart index 67bd791..f94cc2f 100644 --- a/lib/pages/search/search-result.dart +++ b/lib/pages/search/search-result.dart @@ -1,6 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; +import 'package:loopin/IM/im_core.dart'; +import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_service.dart' hide logger; import 'package:loopin/service/http.dart'; +import 'package:loopin/api/common_api.dart'; +import 'package:loopin/utils/index.dart'; import 'package:loopin/components/my_toast.dart'; import '../../behavior/custom_scroll_behavior.dart'; @@ -18,11 +24,24 @@ class _SearchResultPageState extends State with SingleTickerPr final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); + // 分页参数 + static const int _pageSize = 20; + int _videoCurrentPage = 1; + int _productCurrentPage = 1; + int _userCurrentPage = 1; + bool _videoHasMore = true; + bool _productHasMore = true; + bool _userHasMore = true; + // 三个tab的数据 List _videoResults = []; List _productResults = []; List _userResults = []; bool _isLoading = true; + bool _isLoadingMore = false; + + // 统一的高度常量 + static const double _itemCornerRadius = 8; @override void initState() { @@ -61,7 +80,7 @@ class _SearchResultPageState extends State with SingleTickerPr void _onTabChanged() { if (_tabController.indexIsChanging) { // 加载对应Tab的数据 - _loadTabData(_tabController.index); + _loadTabData(_tabController.index, isRefresh: true); } } @@ -84,6 +103,8 @@ class _SearchResultPageState extends State with SingleTickerPr setState(() { _searchQuery = newQuery; _isLoading = true; + // 重置分页状态 + _resetPagination(); }); // 关闭键盘 @@ -93,6 +114,19 @@ class _SearchResultPageState extends State with SingleTickerPr _loadInitialData(); } + // 重置分页状态 + void _resetPagination() { + _videoCurrentPage = 1; + _productCurrentPage = 1; + _userCurrentPage = 1; + _videoHasMore = true; + _productHasMore = true; + _userHasMore = true; + _videoResults.clear(); + _productResults.clear(); + _userResults.clear(); + } + // 加载初始数据 Future _loadInitialData() async { try { @@ -100,8 +134,8 @@ class _SearchResultPageState extends State with SingleTickerPr _isLoading = true; }); - // 加载第一个Tab的数据 - await _loadTabData(_initialTabIndex); + // 加载当前Tab的数据 + await _loadTabData(_tabController.index, isRefresh: true); } catch (e) { print('加载数据失败: $e'); MyToast().tip( @@ -117,64 +151,210 @@ class _SearchResultPageState extends State with SingleTickerPr } // 加载指定Tab的数据 - Future _loadTabData(int tabIndex) async { + Future _loadTabData(int tabIndex, {bool isRefresh = false}) async { try { - setState(() { - _isLoading = true; - }); + if (isRefresh) { + setState(() { + _isLoading = true; + }); + } else { + setState(() { + _isLoadingMore = true; + }); + } + + int currentPage; + List currentResults; + bool hasMore; switch (tabIndex) { case 0: // 视频 - final res = await Http.get('/api/search/videos', params: { - 'keyword': _searchQuery, - 'page': 1, - 'current': 20, - }); - if (res['code'] == 200) { - setState(() { - _videoResults = res['data']['list'] ?? []; - }); - } + currentPage = isRefresh ? 1 : _videoCurrentPage + 1; + currentResults = _videoResults; + hasMore = _videoHasMore; break; case 1: // 商品 - final res = await Http.get('/api/search/products', params: { - 'keyword': _searchQuery, - 'page': 1, - 'current': 20, - }); - if (res['code'] == 200) { - setState(() { - _productResults = res['data']['list'] ?? []; - }); - } + currentPage = isRefresh ? 1 : _productCurrentPage + 1; + currentResults = _productResults; + hasMore = _productHasMore; break; case 2: // 用户 - final res = await Http.get('/api/search/users', params: { - 'keyword': _searchQuery, - 'page': 1, - 'current': 20, - }); - if (res['code'] == 200) { - setState(() { - _userResults = res['data']['list'] ?? []; - }); - } + currentPage = isRefresh ? 1 : _userCurrentPage + 1; + currentResults = _userResults; + hasMore = _userHasMore; break; + default: + return; + } + + // 如果没有更多数据,直接返回 + if (!hasMore && !isRefresh) { + setState(() { + _isLoadingMore = false; + }); + return; + } + + final data = { + 'title': _searchQuery, + 'size': _pageSize, + 'type': tabIndex+1, // 1-视频;2-商品;3-用户 + 'current': currentPage, + }; + final res = await Http.post(CommonApi.aggregationSearchApi, data: data); + + if (res['code'] == 200) { + final newData = res['data']['records'] ?? []; + final total = res['data']['total'] ?? 0; + print('搜索数据结果$newData'); + print('搜索数据参数$data'); + setState(() { + switch (tabIndex) { + case 0: + if (isRefresh) { + _videoResults = newData; + _videoCurrentPage = 1; + } else { + _videoResults.addAll(newData); + _videoCurrentPage = currentPage; + } + _videoHasMore = _videoResults.length < total; + break; + case 1: + if (isRefresh) { + _productResults = newData; + _productCurrentPage = 1; + } else { + _productResults.addAll(newData); + _productCurrentPage = currentPage; + } + _productHasMore = _productResults.length < total; + break; + case 2: + if (isRefresh) { + _userResults = newData; + _userCurrentPage = 1; + } else { + _userResults.addAll(newData); + _userCurrentPage = currentPage; + } + _userHasMore = _userResults.length < total; + break; + } + }); } } catch (e) { print('加载Tab数据失败: $e'); - MyToast().tip( - title: '加载失败', - position: 'center', - type: 'error', - ); + if (!isRefresh) { + MyToast().tip( + title: '加载更多失败', + position: 'center', + type: 'error', + ); + } } finally { setState(() { - _isLoading = false; + if (isRefresh) { + _isLoading = false; + } else { + _isLoadingMore = false; + } }); } } + // 加载更多数据 + void _loadMoreData(int tabIndex) { + if (_isLoadingMore) return; + _loadTabData(tabIndex, isRefresh: false); + } + + // 点击关注按钮 + onFocusBtnClick (user,index) async { + final vlogerId = user['id']; + final doIFollowVloger = user['doIFollowVloger']; + print('是否关注此用户------------->${doIFollowVloger}'); + print('此用户UserId------------->${vlogerId}'); + if (doIFollowVloger == false) { + final res = await ImService.instance.followUser(userIDList: [vlogerId]); + print('关注结果------------->${res.success}'); + if (res.success) { + setState(() { + _userResults[index]['doIFollowVloger'] = !_userResults[index]['doIFollowVloger']; + }); + } + }else{ + final res = await ImService.instance.followUser(userIDList: [vlogerId]); + print('取消关注结果------------->${res.success}'); + if (res.success) { + setState(() { + _userResults[index]['doIFollowVloger'] = !_userResults[index]['doIFollowVloger']; + }); + } + } + } + // 构建加载更多组件 + Widget _buildLoadMoreWidget(int tabIndex) { + if (_isLoadingMore) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), // 减少垂直间距 + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + bool hasMore; + switch (tabIndex) { + case 0: + hasMore = _videoHasMore; + break; + case 1: + hasMore = _productHasMore; + break; + case 2: + hasMore = _userHasMore; + break; + default: + hasMore = false; + } + + if (!hasMore && _getCurrentResults(tabIndex).isNotEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), // 减少垂直间距 + child: Text( + '没有更多数据了', + style: TextStyle( + fontSize: 12, // 减小字体 + color: Colors.grey, + ), + ), + ), + ); + } + + return Container(); +} + + // 获取当前Tab的数据 + List _getCurrentResults(int tabIndex) { + switch (tabIndex) { + case 0: + return _videoResults; + case 1: + return _productResults; + case 2: + return _userResults; + default: + return []; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -246,30 +426,29 @@ class _SearchResultPageState extends State with SingleTickerPr ), body: Column( children: [ - Container( - color: Colors.white, - padding: const EdgeInsets.only(top: 8), - child: TabBar( - controller: _tabController, - // isScrollable: true, - indicatorColor: Colors.pink, - labelColor: Colors.pink, - unselectedLabelColor: Colors.grey, - labelStyle: const TextStyle(fontWeight: FontWeight.bold), - unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.normal), - padding: const EdgeInsets.only(left: 16), // 添加左侧内边距 - indicatorPadding: EdgeInsets.zero, - labelPadding: const EdgeInsets.symmetric(horizontal: 16), // 保持标签间距 - indicatorSize: TabBarIndicatorSize.label, // 改为label宽度,与文字一样宽 - dividerColor: Colors.transparent, - tabs: const [ - Tab(text: '视频'), - Tab(text: '商品'), - Tab(text: '用户'), - ], - ), + Container( + color: Colors.white, + padding: const EdgeInsets.only(top: 8), + child: TabBar( + controller: _tabController, + indicatorColor: Colors.pink, + labelColor: Colors.pink, + unselectedLabelColor: Colors.grey, + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.normal), + padding: const EdgeInsets.only(left: 16), + indicatorPadding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 16), + indicatorSize: TabBarIndicatorSize.label, + dividerColor: Colors.transparent, + tabs: const [ + Tab(text: '视频'), + Tab(text: '商品'), + Tab(text: '用户'), + ], ), - // Tab内容区域 + ), + // Tab内容区域 Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -292,124 +471,302 @@ class _SearchResultPageState extends State with SingleTickerPr // 视频Tab Widget _buildVideoTab() { - if (_videoResults.isEmpty) { + if (_videoResults.isEmpty && !_isLoading) { return _buildEmptyView('暂无视频结果'); } - return ScrollConfiguration( - behavior: CustomScrollBehavior().copyWith(scrollbars: false), - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: _videoResults.length, - itemBuilder: (context, index) { - final video = _videoResults[index] as Map; - return _buildVideoItem(video); - }, + return NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent && + !_isLoadingMore && + _videoHasMore) { + _loadMoreData(0); + } + return false; + }, + child: Column( + children: [ + Expanded( + child: ScrollConfiguration( + behavior: CustomScrollBehavior().copyWith(scrollbars: false), + child: GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.65, + ), + itemCount: _videoResults.length, + itemBuilder: (context, index) { + final video = _videoResults[index] as Map; + return _buildVideoItem(video); + }, + ), + ), + ), + _buildVideoLoadMoreWidget(), + ], ), ); } + // 视频项构建 Widget _buildVideoItem(Map video) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 视频封面 - Container( - width: 120, - height: 80, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: Colors.grey[200], - image: video['cover'] != null - ? DecorationImage( - image: NetworkImage(video['cover'].toString()), - fit: BoxFit.cover, - ) - : null, + return GestureDetector( + onTap: () { + // 视频点击事件处理 + print('点击了视频: ${video['id']}'); + Get.toNamed('/videoDetail', arguments: {'videoId': video['id']}); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_itemCornerRadius), + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), ), - child: video['cover'] == null - ? const Icon(Icons.videocam, color: Colors.grey) - : null, - ), - const SizedBox(width: 12), - // 视频信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - video['title']?.toString() ?? '无标题', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - video['author']?.toString() ?? '未知作者', - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Row( + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 视频封面 - 宽度100%,高度自适应 + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(_itemCornerRadius), + topRight: Radius.circular(_itemCornerRadius), + ), + child: Container( + width: double.infinity, + color: Colors.grey[200], + child: video['cover'] != null && video['cover'].toString().isNotEmpty + ? Image.network( + video['cover'].toString(), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 120, + alignment: Alignment.center, + child: const Icon( + Icons.videocam, + color: Colors.grey, + size: 40, // 设置一个合理的默认大小 + ), + ); + }, + ) + : Container( + height: 200, + alignment: Alignment.center, + child: LayoutBuilder( + builder: (context, constraints) { + return Icon( + Icons.videocam, + color: Colors.grey, + ); + }, + ), + ), + ), + ), + // 视频信息 - 固定在容器底部 + Expanded( + child: Padding( + padding: const EdgeInsets.all(6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, // 内容靠底部对齐 children: [ - Text( - '#${video['tag']?.toString() ?? '无标签'}', + Text( + video['title']?.toString() ?? '无标题', style: const TextStyle( - fontSize: 12, - color: Colors.blue, + fontSize: 11, + fontWeight: FontWeight.w500, ), + maxLines: 1, // 改为 1,限制为单行 + overflow: TextOverflow.ellipsis, // 超出部分显示省略号 ), - const SizedBox(width: 8), - Text( - video['location']?.toString() ?? '', - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), + const SizedBox(height: 4), + Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey[300], + image: video['avatar'] != null && video['avatar'].toString().isNotEmpty + ? DecorationImage( + image: NetworkImage(video['avatar'].toString()), + fit: BoxFit.cover, + ) + : null, + ), + child: video['avatar'] == null || video['avatar'].toString().isEmpty + ? const Icon(Icons.person, size: 10, color: Colors.grey) + : null, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + video['nickname']?.toString() ?? '未知作者', + style: const TextStyle( + fontSize: 9, + color: Colors.grey, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Row( + children: [ + const Icon(Icons.favorite_border, size: 10, color: Colors.grey), + const SizedBox(width: 2), + Text( + Utils().formatLikeCount(video['likeCounts'] ?? 0), + style: const TextStyle( + fontSize: 9, + color: Colors.grey, + ), + ), + ], + ), + ], ), ], ), - ], + ), ), - ), - ], + ], + ), ), ); } + // 视频加载更多组件 + Widget _buildVideoLoadMoreWidget() { + if (_isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + if (!_videoHasMore && _videoResults.isNotEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Center( + child: Text( + '没有更多数据了', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ), + ); + } + + return Container(); + } + // 商品Tab Widget _buildProductTab() { - if (_productResults.isEmpty) { + if (_productResults.isEmpty && !_isLoading) { return _buildEmptyView('暂无商品结果'); } - return ScrollConfiguration( - behavior: CustomScrollBehavior().copyWith(scrollbars: false), - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: _productResults.length, - itemBuilder: (context, index) { - final product = _productResults[index] as Map; - return _buildProductItem(product); - }, + return NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent && + !_isLoadingMore && + _productHasMore) { + _loadMoreData(1); + } + return false; + }, + child: Column( + children: [ + Expanded( + child: ScrollConfiguration( + behavior: CustomScrollBehavior().copyWith(scrollbars: false), + child: GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.55, + ), + itemCount: _productResults.length, + itemBuilder: (context, index) { + final product = _productResults[index] as Map; + return _buildProductItem(product); + }, + ), + ), + ), + _buildProductLoadMoreWidget(), + ], ), ); } - Widget _buildProductItem(Map product) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), + // 商品加载更多组件 + Widget _buildProductLoadMoreWidget() { + if (_isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + if (!_productHasMore && _productResults.isNotEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Center( + child: Text( + '没有更多数据了', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ), + ); + } + + return Container(); + } + + Widget _buildProductItem(Map product) { + return GestureDetector( + onTap: () { + // 商品点击事件处理 + print('点击了商品: ${product['id']}'); + Get.toNamed('/goods', arguments: {'goodsId': product['id']}); + }, + child: Container( decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_itemCornerRadius), color: Colors.white, - borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.1), @@ -418,108 +775,118 @@ class _SearchResultPageState extends State with SingleTickerPr ), ], ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 商品图片 - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), + // 商品图片 - 宽度100%,高度自适应 + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(_itemCornerRadius), + topRight: Radius.circular(_itemCornerRadius), + ), + child: Container( + width: double.infinity, color: Colors.grey[200], - image: product['image'] != null - ? DecorationImage( - image: NetworkImage(product['image'].toString()), + child: product['pic'] != null + ? Image.network( + product['pic'].toString(), fit: BoxFit.cover, ) - : null, + : Container( + height: 110, + child: const Center( + child: Icon(Icons.shopping_bag, color: Colors.grey, size: 32), + ), + ), ), - child: product['image'] == null - ? const Icon(Icons.shopping_bag, color: Colors.grey) - : null, ), - const SizedBox(width: 12), - // 商品信息 + // 商品信息 - 固定在容器底部 Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - product['name']?.toString() ?? '未知商品', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 6), - Text( - '¥${product['price']?.toString() ?? '0.00'}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.pink, - ), - ), - const SizedBox(height: 6), - Row( - children: [ - Text( - '已售 ${product['sold']?.toString() ?? '0'}', - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), + child: Padding( + padding: const EdgeInsets.all(6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, // 内容靠底部对齐 + children: [ + Text( + product['name']?.toString() ?? '未知商品', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 12), - Text( - '${product['comments']?.toString() ?? '0'}条评论', - style: const TextStyle( - fontSize: 12, - color: Colors.grey, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '¥${product['price']?.toString() ?? '0.00'}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.pink, + ), ), - ), - ], - ), - ], + Text( + '已售 ${product['sales']?.toString() ?? '0'}', + style: const TextStyle( + fontSize: 9, + color: Colors.grey, + ), + ), + ], + ), + ], + ), ), ), - // 购买按钮 - IconButton( - onPressed: () { - // 跳转到商品详情 - }, - icon: const Icon(Icons.shopping_cart, size: 20), - ), ], ), - ); - } + ), + ); +} // 用户Tab Widget _buildUserTab() { - if (_userResults.isEmpty) { + if (_userResults.isEmpty && !_isLoading) { return _buildEmptyView('暂无用户结果'); } - return ScrollConfiguration( - behavior: CustomScrollBehavior().copyWith(scrollbars: false), - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: _userResults.length, - itemBuilder: (context, index) { - final user = _userResults[index] as Map; - return _buildUserItem(user); - }, + return NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent && + !_isLoadingMore && + _userHasMore) { + _loadMoreData(2); + } + return false; + }, + child: ScrollConfiguration( + behavior: CustomScrollBehavior().copyWith(scrollbars: false), + child: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _userResults.length + 1, + itemBuilder: (context, index) { + if (index == _userResults.length) { + return _buildLoadMoreWidget(2); + } + final user = _userResults[index] as Map; + return _buildUserItem(user,index); + }, + ), ), ); } - Widget _buildUserItem(Map user) { + Widget _buildUserItem(Map user, int index) { + // 判断当前用户是否已被关注 + bool isFollowing = user['doIFollowVloger'] ?? false; + return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), @@ -533,10 +900,9 @@ class _SearchResultPageState extends State with SingleTickerPr ), child: Row( children: [ - // 用户头像 Container( - width: 50, - height: 50, + width: 45, + height: 45, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.grey[200], @@ -548,53 +914,55 @@ class _SearchResultPageState extends State with SingleTickerPr : null, ), child: user['avatar'] == null - ? const Icon(Icons.person, color: Colors.grey) + ? const Icon(Icons.person, color: Colors.grey, size: 20) : null, ), - const SizedBox(width: 12), - // 用户信息 + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - user['name']?.toString() ?? '未知用户', + user['nickname']?.toString() ?? '未知用户', style: const TextStyle( - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( - '粉丝: ${user['fans']?.toString() ?? '0'}', + '粉丝: ${user['fansCount']?.toString() ?? '0'}', style: const TextStyle( - fontSize: 12, + fontSize: 11, color: Colors.grey, ), ), ], ), ), - // 关注按钮 ElevatedButton( - onPressed: () { - // 关注用户 + onPressed: () async { + await onFocusBtnClick(user, index); }, style: ElevatedButton.styleFrom( - backgroundColor: Colors.pink, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + backgroundColor: isFollowing ? Colors.grey[300] : Colors.pink, + foregroundColor: isFollowing ? Colors.grey[600] : Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), ), + // 添加边框样式 + side: isFollowing + ? BorderSide(color: Colors.grey[400]!, width: 0.5) + : BorderSide.none, ), - child: const Text( - '关注', - style: TextStyle(fontSize: 12), + child: Text( + isFollowing ? '已关注' : '关注', + style: const TextStyle(fontSize: 11), ), ), ], - ) + ), ); } @@ -604,12 +972,12 @@ class _SearchResultPageState extends State with SingleTickerPr child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.search_off, size: 60, color: Colors.grey), - const SizedBox(height: 16), + const Icon(Icons.search_off, size: 50, color: Colors.grey), + const SizedBox(height: 12), Text( message, style: const TextStyle( - fontSize: 16, + fontSize: 14, color: Colors.grey, ), ), diff --git a/lib/pages/video/commonVideo.dart b/lib/pages/video/commonVideo.dart new file mode 100644 index 0000000..6215768 --- /dev/null +++ b/lib/pages/video/commonVideo.dart @@ -0,0 +1,1404 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; +import 'package:loopin/IM/im_core.dart'; +import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_service.dart' hide logger; +import 'package:loopin/api/video_api.dart'; +import 'package:loopin/components/my_toast.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/models/summary_type.dart'; +import 'package:loopin/service/http.dart'; +import 'package:loopin/utils/download_video.dart'; +import 'package:loopin/utils/permissions.dart'; +import 'package:loopin/utils/wxsdk.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import '../../../router/fade_route.dart'; +import './components/popup_reply.dart'; + +class VideoDetailPage extends StatefulWidget { + const VideoDetailPage({super.key}); + + @override + State createState() => _VideoDetailPageState(); +} + +class _VideoDetailPageState extends State { + final ChatController chatController = Get.find(); + + // 播放器controller + late Player player = Player(); + late VideoController videoController = VideoController(player); + + final List subscriptions = []; + // 进度条slider当前阈值 + double sliderValue = 0.0; + bool sliderDraging = false; + late Duration position = Duration.zero; // 当前时长 + late Duration duration = Duration.zero; // 总时长 + + // 视频数据 + late Map videoData = {}; + bool isLoading = true; + String? errorMessage; + + // 视频ID参数 + late String videoId; + + // 评论相关状态 + int commentPage = 1; + final int commentPageSize = 10; + bool isLoadingMoreComments = false; + bool hasMoreComments = true; + List> commentList = []; + String replyingCommentId = ''; // 正在回复的评论ID + String replyingCommentUser = ''; // 正在回复的用户名 + + // 分享列表 + List shareList = [ + {'icon': 'assets/images/share-wx.png', 'label': '好友'}, + {'icon': 'assets/images/share-pyq.png', 'label': '朋友圈'}, + {'icon': 'assets/images/share-link.png', 'label': '复制链接'}, + {'icon': 'assets/images/share-download.png', 'label': '下载'}, + ]; + + @override + void initState() { + super.initState(); + + // 获取路由参数中的videoId + final arguments = Get.arguments; + if (arguments != null && arguments is Map) { + videoId = arguments['videoId']?.toString() ?? ''; + } else { + videoId = ''; + } + + if (videoId.isEmpty) { + setState(() { + isLoading = false; + }); + return; + } + + // 获取视频详情数据 + fetchVideoDetail(); + } + + // 获取视频详情 + Future fetchVideoDetail() async { + setState(() { + isLoading = true; + }); + + try { + final res = await Http.get('${VideoApi.videoDetailApi}${videoId}'); + + logger.d('视频详情接口返回: ${json.encode(res)}'); + + if (res['code'] == 200 && res['data'] != null) { + setState(() { + videoData = Map.from(res['data']); + isLoading = false; + }); + + // 初始化播放器并自动播放 + if (videoData['url'] != null && videoData['url'].toString().isNotEmpty) { + await player.open( + Media(videoData['url']), + play: true, // 自动播放 + ); + player.setPlaylistMode(PlaylistMode.loop); // 循环播放 + } + } else { + setState(() { + isLoading = false; + }); + } + } catch (e) { + logger.e('获取视频详情异常: $e'); + setState(() { + isLoading = false; + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + // 监听视频时长 + player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + // 监听视频播放进度 + player.stream.position.listen((event) { + setState(() { + position = event; + if (position > Duration.zero && !sliderDraging) { + // 设置视频播放位置 + sliderValue = (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0); + } + }); + }), + ], + ); + } + } + + @override + void dispose() { + player.dispose(); + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + // 取消喜欢视频 + Future doUnLikeVideo() async { + logger.d('点击了取消点赞按钮'); + try { + final res = await Http.post(VideoApi.unlike, data: {'id': videoData['id']}); + final resCode = res['code']; + if (resCode == 200) { + setState(() { + videoData['doILikeThisVlog'] = false; + videoData['likeCounts'] = (videoData['likeCounts'] ?? 1) - 1; + }); + MyToast().tip( + title: '已取消点赞', + position: 'center', + type: 'success', + ); + } + } catch (e) { + logger.i('点击取消喜欢按钮报错: $e'); + MyToast().tip( + title: '操作失败', + position: 'center', + type: 'error', + ); + } + } + + // 点击喜欢视频 + Future doLikeVideo() async { + try { + final res = await Http.post(VideoApi.like, data: {'id': videoData['id']}); + final resCode = res['code']; + if (resCode == 200) { + setState(() { + videoData['doILikeThisVlog'] = true; + videoData['likeCounts'] = (videoData['likeCounts'] ?? 0) + 1; + }); + MyToast().tip( + title: '点赞成功', + position: 'center', + type: 'success', + ); + } + logger.i('点赞返回信息----------->: $res'); + } catch (e) { + logger.i('点击喜欢按钮报错: $e'); + MyToast().tip( + title: '点赞失败', + position: 'center', + type: 'error', + ); + } + } + + // 关注用户 + Future followUser() async { + try { + final vlogerId = videoData['vlogerId'] ?? videoData['memberId']; + final res = await ImService.instance.followUser(userIDList: [vlogerId]); + if (res.success) { + setState(() { + videoData['doIFollowVloger'] = true; + }); + MyToast().tip( + title: '关注成功', + position: 'center', + type: 'success', + ); + } else { + MyToast().tip( + title: '关注失败', + position: 'center', + type: 'error', + ); + } + } catch (e) { + logger.e('关注用户异常: $e'); + MyToast().tip( + title: '关注失败', + position: 'center', + type: 'error', + ); + } + } + + // 取消关注用户 + Future unfollowUser() async { + try { + final vlogerId = videoData['vlogerId'] ?? videoData['memberId']; + + } catch (e) { + logger.e('取消关注用户异常: $e'); + MyToast().tip( + title: '操作失败', + position: 'center', + type: 'error', + ); + } + } + + // 评论弹框 + void handleComment() { + final videoId = videoData['id']; + logger.i('点击了评论按钮$videoId'); + + showModalBottomSheet( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), + showDragHandle: false, + clipBehavior: Clip.antiAlias, + isScrollControlled: true, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 3 / 4, + ), + context: context, + builder: (context) { + return CommentBottomSheet( + videoId: videoId, + onCommentCountChanged: (newCount) { + // 更新视频的评论数量 + setState(() { + videoData['commentsCounts'] = newCount; + }); + }); + }, + ); + } + + // 分享弹框 + void handleShare() { + if (chatController.chatList.isNotEmpty) { + chatController.getConversationList(); + } + showModalBottomSheet( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(15.0)), + ), + clipBehavior: Clip.antiAlias, + context: context, + isScrollControlled: true, + builder: (context) { + return Material( + color: Colors.white, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 分享列表 + SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: shareList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + onTap: () => handleShareClick(index), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('${shareList[index]['icon']}', width: 48.0), + SizedBox(height: 5), + Text( + '${shareList[index]['label']}', + style: TextStyle(fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ), + + // 会话列表 + if (chatController.chatList.where((item) => item.conversation.conversationGroupList?.isEmpty == true).isNotEmpty) + SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: chatController.chatList.where((item) => item.conversation.conversationGroupList?.isEmpty == true).length, + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + final filteredList = chatController.chatList.where((item) => item.conversation.conversationGroupList?.isEmpty == true).toList(); + return GestureDetector( + onTap: () => handlCoverClick(filteredList[index].conversation), + child: Container( + width: 64, + margin: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + NetworkOrAssetImage( + imageUrl: filteredList[index].faceUrl, + width: 48.0, + height: 48.0, + ), + const SizedBox(height: 5), + Text( + filteredList[index].conversation.showName ?? '', + style: const TextStyle(fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ), + + // 取消按钮 + SafeArea( + top: false, + child: InkWell( + onTap: () => Get.back(), + child: Container( + alignment: Alignment.center, + width: double.infinity, + height: 50.0, + color: Colors.grey[50], + child: Text( + '取消', + style: TextStyle(color: Colors.black87), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + void handleShareClick(int index) { + final videoUrl = videoData['url']; + final description = videoData['title'] ?? '快来看看这个视频'; + var httpPrefix = 'http://43.143.227.203/adv'; + + if (index == 0) { + // 分享好友 + Wxsdk.shareToFriend(title: '快来看看这个视频', description: description, webpageUrl: '$httpPrefix/video-detail?id=${videoData['id']}'); + } else if (index == 1) { + // 分享到朋友圈 + Wxsdk.shareToTimeline(title: '快来看看这个视频', webpageUrl: '$httpPrefix/video-detail?id=${videoData['id']}'); + } else if (index == 2) { + // 复制链接到剪切板 + copyToClipboard('$httpPrefix/video-detail?id=${videoData['id']}'); + } else if (index == 3) { + // 下载视频到本地 + _downloadVideoWithDio(videoUrl, description); + } + } + + // 复制链接到剪贴板 + void copyToClipboard(String text) async { + try { + await Clipboard.setData(ClipboardData(text: text)); + MyToast().tip( + title: '链接已复制到剪贴板', + position: 'center', + type: 'success', + ); + } catch (e) { + MyToast().tip( + title: '复制失败', + position: 'center', + type: 'error', + ); + } + } + + // 下载视频 + Future _downloadVideoWithDio(String videoUrl, String fileName) async { + try { + // 请求存储权限 + var status = await Permissions.requestStoragePermission(); + if (!status) { + MyToast().tip( + title: '需要存储权限才能下载视频', + position: 'center', + type: 'error', + ); + return; + } + await DownloadManager.downloadFile( + url: videoUrl, + fileName: '$fileName.mp4', + onProgress: (progress) { + print("下载进度: $progress%"); + }, + onComplete: (filePath) { + MyToast().tip( + title: '下载完成', + position: 'center', + type: 'success', + ); + }, + onError: (error) { + MyToast().tip( + title: '下载失败: $error', + position: 'center', + type: 'error', + ); + }, + ); + } catch (e) { + print("下载视频失败: $e"); + MyToast().tip( + title: '下载失败', + position: 'center', + type: 'error', + ); + } + } + + void handlCoverClick(V2TimConversation conv) async { + // 发送VideoMsg,获取当前视频信息 + final userId = conv.userID; + final String url = videoData['url']; + final img = videoData['firstFrameImg']; + final width = videoData['width']; + final height = videoData['height']; + final makeJson = jsonEncode({ + "width": width, + "height": height, + "imgUrl": img, + "videoUrl": url, + }); + final res = await IMMessage().createCustomMessage( + data: makeJson, + ); + if (res.success) { + final sendRes = await IMMessage().sendMessage(msg: res.data!.messageInfo!, toUserID: userId, cloudCustomData: SummaryType.shareVideo); + if (sendRes.success) { + MyToast().tip( + title: '分享成功', + position: 'center', + type: 'success', + ); + Get.back(); + } else { + logger.e(res.desc); + MyToast().tip( + title: '分享失败', + position: 'center', + type: 'error', + ); + } + } else { + logger.e(res.desc); + MyToast().tip( + title: '分享失败', + position: 'center', + type: 'error', + ); + } + } + + @override + Widget build(BuildContext context) { + if (isLoading) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ); + } + + final videoWidth = videoData['width'] ?? 1; + final videoHeight = videoData['height'] ?? 1; + final isHorizontal = videoWidth > videoHeight; + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: Stack( + children: [ + // 视频区域 + Positioned.fill( + child: GestureDetector( + child: Stack( + children: [ + Visibility( + visible: position > Duration.zero, + child: Video( + controller: videoController, + fit: isHorizontal ? BoxFit.contain : BoxFit.cover, + controls: NoVideoControls, + ), + ), + AnimatedOpacity( + opacity: position > Duration(milliseconds: 100) ? 0.0 : 1.0, + duration: Duration(milliseconds: 50), + child: Image.network( + videoData['firstFrameImg'] ?? 'https://wuzhongjie.com.cn/download/logo.png', + fit: isHorizontal ? BoxFit.contain : BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + StreamBuilder( + stream: player.stream.playing, + builder: (context, playing) { + return Visibility( + visible: playing.data == false, + child: Center( + child: IconButton( + padding: EdgeInsets.zero, + onPressed: () { + player.playOrPause(); + }, + icon: Icon( + playing.data == true ? Icons.pause : Icons.play_arrow_rounded, + color: Colors.white60, + size: 80, + ), + style: ButtonStyle(backgroundColor: WidgetStateProperty.all(Colors.black.withAlpha(15))), + ), + ), + ); + }, + ), + ], + ), + onTap: () { + player.playOrPause(); + }, + ), + ), + + // 右侧操作栏 + Positioned( + bottom: 100.0, + right: 6.0, + child: Column( + spacing: 15.0, + children: [ + Stack( + children: [ + SizedBox( + height: 55.0, + width: 48.0, + child: GestureDetector( + onTap: () async { + player.pause(); + // 跳转到 Vloger 页面并等待返回结果 + final result = await Get.toNamed('/vloger', arguments: videoData); + if (result != null) { + // 处理返回的参数 + player.play(); + setState(() { + videoData['doIFollowVloger'] = result['followStatus']; + }); + } + }, + child: UnconstrainedBox( + alignment: Alignment.topCenter, + child: Container( + height: 48.0, + width: 48.0, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2.0), + borderRadius: BorderRadius.circular(100.0), + ), + child: ClipOval( + child: NetworkOrAssetImage( + imageUrl: videoData['vlogerFace'] ?? videoData['commentUserFace'], + ), + ), + ), + ), + ), + ), + Positioned( + bottom: 0, + left: 15.0, + child: InkWell( + child: Container( + height: 18.0, + width: 18.0, + decoration: BoxDecoration( + color: videoData['doIFollowVloger'] == true ? Colors.white : Color(0xFFFF5000), + borderRadius: BorderRadius.circular(100.0), + ), + child: Icon( + videoData['doIFollowVloger'] == true ? Icons.check : Icons.add, + color: videoData['doIFollowVloger'] == true ? Color(0xFFFF5000) : Colors.white, + size: 14.0, + ), + ), + onTap: () async { + if (videoData['doIFollowVloger'] == true) { + await unfollowUser(); + } else { + await followUser(); + } + }, + ), + ), + ], + ), + GestureDetector( + child: Column( + children: [ + SvgPicture.asset( + 'assets/images/svg/heart.svg', + colorFilter: ColorFilter.mode(videoData['doILikeThisVlog'] == true ? Color(0xFFFF5000) : Colors.white, BlendMode.srcIn), + height: 40.0, + width: 40.0, + ), + Text( + '${videoData['likeCounts'] ?? 0}', + style: TextStyle(color: Colors.white, fontSize: 12.0), + ), + ], + ), + onTap: () { + if (videoData['doILikeThisVlog'] == true) { + doUnLikeVideo(); + } else { + doLikeVideo(); + } + }, + ), + GestureDetector( + child: Column( + children: [ + SvgPicture.asset( + 'assets/images/svg/reply.svg', + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), + height: 40.0, + width: 40.0, + ), + Text( + '${videoData['commentsCounts'] ?? 0}', + style: TextStyle(color: Colors.white, fontSize: 12.0), + ), + ], + ), + onTap: () { + handleComment(); + }, + ), + GestureDetector( + child: Column( + children: [ + SvgPicture.asset( + 'assets/images/svg/share.svg', + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), + height: 40.0, + width: 40.0, + ), + ], + ), + onTap: () { + handleShare(); + }, + ), + GestureDetector( + child: Column( + children: [ + SvgPicture.asset( + 'assets/images/svg/report.svg', + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), + height: 40.0, + width: 40.0, + ), + ], + ), + onTap: () async { + player.pause(); + // 跳转到举报页面并等待返回结果 + final result = await Get.toNamed('/report', arguments: videoData); + if (result != null) { + player.play(); + } + }, + ), + ], + ), + ), + + // 底部信息区域 + Positioned( + bottom: 15.0, + left: 10.0, + right: 80.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '@${videoData['vlogerNickname'] ?? videoData['commentUserNickname'] ?? '未知用户'}', + style: const TextStyle(color: Colors.white, fontSize: 16.0), + ), + LayoutBuilder( + builder: (context, constraints) { + final text = videoData['title'] ?? '未知'; + final span = TextSpan( + text: text, + style: const TextStyle(color: Colors.white, fontSize: 14.0), + ); + final tp = TextPainter( + text: span, + maxLines: 3, + textDirection: TextDirection.ltr, + ); + tp.layout(maxWidth: constraints.maxWidth); + final isOverflow = tp.didExceedMaxLines; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + maxLines: videoData['expanded'] ?? false ? null : 3, + overflow: videoData['expanded'] ?? false ? TextOverflow.visible : TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 14.0), + ), + if (isOverflow) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: GestureDetector( + onTap: () { + setState(() { + videoData['expanded'] = !(videoData['expanded'] ?? false); + }); + }, + child: Text( + videoData['expanded'] ?? false ? '收起' : '展开更多', + textAlign: TextAlign.right, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + + // 进度条 + Positioned( + bottom: 0.0, + left: 6.0, + right: 6.0, + child: Visibility( + visible: position > Duration.zero, + child: Listener( + child: SliderTheme( + data: SliderThemeData( + trackHeight: sliderDraging ? 6.0 : 2.0, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 4.0), + overlayShape: RoundSliderOverlayShape(overlayRadius: 0), + inactiveTrackColor: Colors.white24, + activeTrackColor: Colors.white, + thumbColor: Colors.white, + overlayColor: Colors.transparent, + ), + child: Slider( + value: sliderValue, + onChanged: (value) async { + setState(() { + sliderValue = value; + }); + await player.seek(duration * value.clamp(0.0, 1.0)); + }, + onChangeEnd: (value) async { + setState(() { + sliderDraging = false; + }); + if (!player.state.playing) { + await player.play(); + } + }, + ), + ), + onPointerMove: (e) { + setState(() { + sliderDraging = true; + }); + }, + ), + ), + ), + + // 时间显示 + Positioned( + bottom: 100.0, + left: 10.0, + right: 10.0, + child: Visibility( + visible: sliderDraging, + child: DefaultTextStyle( + style: TextStyle(color: Colors.white54, fontSize: 18.0, fontFamily: 'Arial'), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8.0, + children: [ + Text(position.label(reference: duration), style: TextStyle(color: Colors.white)), + Text('/', style: TextStyle(fontSize: 14.0)), + Text(duration.label(reference: duration)), + ], + ), + )), + ), + ], + ), + ); + } +} +class CommentBottomSheet extends StatefulWidget { + final String videoId; + final Function(int) onCommentCountChanged; // 新增回调函数 + + const CommentBottomSheet({ + super.key, + required this.videoId, + required this.onCommentCountChanged, // 接收回调 + }); + + @override + _CommentBottomSheetState createState() => _CommentBottomSheetState(); +} + +class _CommentBottomSheetState extends State { + // 评论相关状态(移动到内部) + int commentPage = 1; + final int commentPageSize = 10; + bool isLoadingMoreComments = false; + bool hasMoreComments = true; + List> commentList = []; + String replyingCommentId = ''; + String replyingCommentUser = ''; + + // 新增:子评论相关状态 + Map expandedReplies = {}; // 存储已展开的回复 {commentId: true/false} + Map>> replyData = {}; // 存储子评论数据 {commentId: [replies]} + Map replyPage = {}; // 子评论分页 + Map isLoadingReplies = {}; // 子评论加载状态 + + @override + void initState() { + super.initState(); + // 初始化时加载评论 + fetchComments(false); + } + + Future fetchComments(bool loadMore) async { + if (isLoadingMoreComments && !loadMore) return; + + setState(() { + isLoadingMoreComments = true; + }); + + if (!loadMore) { + commentPage = 1; + hasMoreComments = true; + commentList.clear(); + } + logger.d('入参vlogId-------------------->: ${widget.videoId}'); + try { + final res = await Http.post(VideoApi.videoCommentList, data: { + 'vlogId': widget.videoId, + 'current': commentPage, + 'size': commentPageSize, + }); + + logger.d('评论接口列表返回: ${json.encode(res)}'); + + if (res['code'] == 200 && res['data'] != null) { + final data = res['data']; + final List> newComments = List>.from(data['records'] ?? []); + final int total = data['total'] ?? 0; + + setState(() { + if (loadMore) { + commentList.addAll(newComments); + } else { + commentList = newComments; + } + hasMoreComments = commentList.length < total; + commentPage++; + }); + widget.onCommentCountChanged(total); + logger.d('成功加载 ${newComments.length} 条评论,总共 $total 条'); + } + } catch (e) { + logger.e('获取评论异常: $e'); + } finally { + setState(() { + isLoadingMoreComments = false; + }); + } + } + + Future postComment(String content, {String parentCommentId = ''}) async { + try { + final res = await Http.post(VideoApi.doVideoComment, data: { + 'vlogId': widget.videoId, + 'content': content, + 'fatherCommentId': parentCommentId.isNotEmpty ? parentCommentId : null, + }); + + if (res['code'] == 200) { + // 如果是回复,刷新对应的子评论 + if (parentCommentId.isNotEmpty) { + fetchReplies(parentCommentId, false); + // 更新主评论的子评论数量 + setState(() { + final comment = commentList.firstWhere((c) => c['id'] == parentCommentId, orElse: () => {}); + if (comment.isNotEmpty) { + comment['childCount'] = (comment['childCount'] ?? 0) + 1; + } + }); + } else { + // 如果是新评论,刷新整个列表 + fetchComments(false); + } + widget.onCommentCountChanged(commentList.length + 1); + MyToast().tip( + title: '评论成功', + position: 'center', + type: 'success', + ); + } + } catch (e) { + logger.i('发布评论失败: $e'); + MyToast().tip( + title: '评论失败', + position: 'center', + type: 'error', + ); + } + } + + // 获取二级评论,复用一级评论的接口,修改传参 + Future fetchReplies(String commentId, bool loadMore) async { + if (isLoadingReplies[commentId] == true && !loadMore) return; + + setState(() { + isLoadingReplies[commentId] = true; + }); + + if (!loadMore) { + replyPage[commentId] = 1; + replyData[commentId] = []; + } + + try { + final res = await Http.post(VideoApi.videoCommentList, data: { + 'fatherCommentId': commentId, + 'current': replyPage[commentId], + 'size': commentPageSize, + }); + + if (res['code'] == 200 && res['data'] != null) { + final data = res['data']; + final List> newReplies = List>.from(data['records'] ?? []); + + setState(() { + if (loadMore) { + replyData[commentId]!.addAll(newReplies); + } else { + replyData[commentId] = newReplies; + } + replyPage[commentId] = replyPage[commentId]! + 1; + }); + } + } catch (e) { + logger.e('获取子评论异常: $e'); + } finally { + setState(() { + isLoadingReplies[commentId] = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + child: Column( + children: [ + Container( + padding: EdgeInsets.fromLTRB(15.0, 10.0, 10.0, 5.0), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFFAFAFA)))), + child: Column( + spacing: 10.0, + children: [ + Row( + children: [ + /* Expanded( + child: Text.rich(TextSpan(children: [ + TextSpan( + text: '大家都在搜: ', + style: TextStyle(color: Colors.grey), + ), + TextSpan( + text: '黑神话-悟空', + style: TextStyle(color: const Color(0xFF496D80)), + ), + ]))),*/ + GestureDetector( + child: Container( + height: 22.0, + width: 22.0, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(100.0), + ), + child: UnconstrainedBox(child: Icon(Icons.close, color: Colors.black45, size: 14.0))), + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + Text( + '${commentList.length}条评论', + style: TextStyle(fontSize: 12.0, fontWeight: FontWeight.w600), + ), + ], + ), + ), + Expanded( + child: NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent && !isLoadingMoreComments && hasMoreComments) { + fetchComments(true); + } + return false; + }, + child: commentList.isEmpty && !isLoadingMoreComments + ? Center( + child: Text( + '暂无评论', + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + physics: BouncingScrollPhysics(), + itemCount: commentList.length + (hasMoreComments ? 1 : 0), + itemBuilder: (context, index) { + if (index == commentList.length) { + return Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: isLoadingMoreComments ? CircularProgressIndicator() : Text('没有更多评论了'), + ), + ); + } + + final comment = commentList[index]; + final hasReplies = comment['childCount'] > 0; + final isExpanded = expandedReplies[comment['id']] == true; + final replies = replyData[comment['id']] ?? []; + + return Column( + children: [ + // 主评论 + ListTile( + isThreeLine: true, + leading: ClipRRect( + borderRadius: BorderRadius.circular(50.0), + child: NetworkOrAssetImage( + imageUrl: comment['commentUserFace'] ?? 'assets/images/avatar/default.png', + width: 30.0, + height: 30.0, + fit: BoxFit.cover, + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + comment['commentUserNickname'] ?? '未知用户', + style: TextStyle( + color: Colors.grey, + fontSize: 12.0, + ), + ), + ), + SizedBox(width: 20.0), + GestureDetector( + onTap: () { + setState(() { + replyingCommentId = comment['id']; + replyingCommentUser = comment['commentUserNickname'] ?? '未知用户'; + }); + }, + child: Icon( + Icons.reply, + color: Colors.black54, + size: 16.0, + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: EdgeInsets.symmetric(vertical: 5.0), + child: Text( + comment['content'] ?? '', + style: TextStyle( + fontSize: 14.0, + ), + ), + ), + if (hasReplies) + GestureDetector( + onTap: () { + setState(() { + expandedReplies[comment['id']] = !isExpanded; + if (expandedReplies[comment['id']] == true && + (replyData[comment['id']] == null || replyData[comment['id']]!.isEmpty)) { + fetchReplies(comment['id'], false); + } + }); + }, + child: Container( + margin: EdgeInsets.only(right: 15.0), + padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 3.0), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(20.0), + ), + child: Row(children: [ + Text( + '${comment['childCount']}回复', + style: TextStyle(fontSize: 12.0), + ), + Icon( + isExpanded ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 16.0, + ) + ]), + ), + ), + Text( + comment['createTime']?.toString().substring(0, 10) ?? '', + style: TextStyle(color: Colors.grey, fontSize: 12.0), + ), + ], + ), + ), + + // 子评论列表 + if (isExpanded && replies.isNotEmpty) + Padding( + padding: EdgeInsets.only(left: 40.0), + child: Column( + children: [ + ...replies.map((reply) => ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(25.0), + child: NetworkOrAssetImage( + imageUrl: reply['commentUserFace'] ?? 'assets/images/avatar/default.png', + width: 25.0, + height: 25.0, + fit: BoxFit.cover, + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + reply['commentUserNickname'] ?? '未知用户', + style: TextStyle( + color: Colors.grey, + fontSize: 11.0, + ), + ), + ), + GestureDetector( + onTap: () { + setState(() { + replyingCommentId = comment['id']; + replyingCommentUser = reply['commentUserNickname'] ?? '未知用户'; + }); + }, + child: Icon( + Icons.reply, + color: Colors.black54, + size: 14.0, + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: EdgeInsets.symmetric(vertical: 3.0), + child: Text( + reply['content'] ?? '', + style: TextStyle(fontSize: 13.0), + ), + ), + Text( + reply['createTime']?.toString().substring(0, 10) ?? '', + style: TextStyle(color: Colors.grey, fontSize: 11.0), + ), + ], + ), + )), + + // 加载更多子评论按钮 + if (replies.length < comment['childCount']) + Center( + child: TextButton( + onPressed: () => fetchReplies(comment['id'], true), + child: isLoadingReplies[comment['id']] == true ? CircularProgressIndicator() : Text('加载更多回复'), + ), + ), + ], + ), + ), + + Divider(height: 1, color: Colors.grey[200]), + ], + ); + }, + ), + ), + ), + // 在回复输入区域显示更详细的信息 + if (replyingCommentId.isNotEmpty) + Container( + padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + color: Colors.grey[100], + child: Row( + children: [ + Expanded( + child: Text( + '回复 @$replyingCommentUser', + style: TextStyle(fontSize: 12.0, color: Colors.blue), + overflow: TextOverflow.ellipsis, + ), + ), + GestureDetector( + onTap: () { + setState(() { + replyingCommentId = ''; + replyingCommentUser = ''; + }); + }, + child: Icon(Icons.close, size: 16.0), + ), + ], + ), + ), + GestureDetector( + child: Container( + margin: EdgeInsets.all(10.0), + height: 40.0, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(30.0), + ), + child: Row( + children: [ + SizedBox(width: 15.0), + Icon( + Icons.edit_note, + color: Colors.black54, + size: 16.0, + ), + SizedBox(width: 5.0), + Text( + replyingCommentId.isNotEmpty ? '回复 @$replyingCommentUser' : '说点什么...', + style: TextStyle(color: Colors.black54, fontSize: 14.0), + ), + ], + ), + ), + onTap: () { + Navigator.push( + context, + FadeRoute( + child: PopupReply( + hintText: replyingCommentId.isNotEmpty ? '回复 @$replyingCommentUser' : '说点什么...', + onChanged: (value) { + debugPrint('评论内容: $value'); + }, + onSubmitted: (value) { + if (value.isNotEmpty) { + postComment(value, parentCommentId: replyingCommentId); + setState(() { + replyingCommentId = ''; + replyingCommentUser = ''; + }); + Navigator.pop(context); + } + }, + ))); + }, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/router/index.dart b/lib/router/index.dart index 893b032..1149d8b 100644 --- a/lib/router/index.dart +++ b/lib/router/index.dart @@ -18,6 +18,7 @@ import 'package:loopin/pages/my/vloger.dart'; import 'package:loopin/pages/video/report.dart'; import 'package:loopin/pages/search/index.dart'; import 'package:loopin/pages/search/search-result.dart'; +import 'package:loopin/pages/video/commonVideo.dart'; import '../layouts/index.dart'; /* 引入路由页面 */ @@ -40,6 +41,7 @@ final Map routes = { '/order/detail': const OrderDetail(), '/vloger': const Vloger(), '/report': const ReportPage(), + '/videoDetail': const VideoDetailPage(), '/search': const SearchPage(), '/search-result': const SearchResultPage(), //settins diff --git a/lib/utils/index.dart b/lib/utils/index.dart index f0871fb..45fdcdd 100644 --- a/lib/utils/index.dart +++ b/lib/utils/index.dart @@ -199,4 +199,13 @@ class Utils { const weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]; return weekdays[(weekday - 1) % 7]; } + // 格式化点赞数量 + String formatLikeCount(int count) { + if (count >= 10000) { + return '${(count / 10000).toStringAsFixed(1)}w'; + } else if (count >= 1000) { + return '${(count / 1000).toStringAsFixed(1)}k'; + } + return count.toString(); + } }