From 351cc0a850d479d6a996e65bc998c820d0f1d72d Mon Sep 17 00:00:00 2001 From: guozhigq Date: Mon, 15 May 2023 07:32:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 4 + lib/http/video.dart | 41 +++ lib/models/common/reply_type.dart | 46 +++ lib/models/video/reply/item.dart | 5 + lib/pages/video/detail/reply/controller.dart | 39 ++- lib/pages/video/detail/reply/view.dart | 267 ++++++++++++++---- .../detail/reply/widgets/reply_item.dart | 44 ++- 7 files changed, 380 insertions(+), 66 deletions(-) create mode 100644 lib/models/common/reply_type.dart diff --git a/lib/http/api.dart b/lib/http/api.dart index 94efb69a..b8c5c47c 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -91,6 +91,10 @@ class Api { // 楼中楼 static const String replyReplyList = '/x/v2/reply/reply'; + // 发表评论 + // https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/comment/action.md + static const String replyAdd = '/x/v2/reply/add'; + // 用户(被)关注数、投稿数 // https://api.bilibili.com/x/relation/stat?vmid=697166795 static const String userStat = '/x/relation/stat'; diff --git a/lib/http/video.dart b/lib/http/video.dart index b1e4698c..1e6bbec9 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -1,5 +1,8 @@ +import 'dart:developer'; + import 'package:pilipala/http/api.dart'; import 'package:pilipala/http/init.dart'; +import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/models/model_hot_video_item.dart'; import 'package:pilipala/models/model_rec_video_item.dart'; import 'package:pilipala/models/user/fav_folder.dart'; @@ -183,4 +186,42 @@ class VideoHttp { return {'status': false, 'data': []}; } } + + // 发表评论 replyAdd + + // type num 评论区类型代码 必要 类型代码见表 + // oid num 目标评论区id 必要 + // root num 根评论rpid 非必要 二级评论以上使用 + // parent num 父评论rpid 非必要 二级评论同根评论id 大于二级评论为要回复的评论id + // message str 发送评论内容 必要 最大1000字符 + // plat num 发送平台标识 非必要 1:web端 2:安卓客户端 3:ios客户端 4:wp客户端 + static Future replyAdd({ + required ReplyType type, + required int oid, + required String message, + int? root, + int? parent, + }) async { + if(message == ''){ + return {'status': false, 'data': [], 'msg': '请输入评论内容'}; + } + print('root:$root'); + print('parent: $parent'); + + var res = await Request() + .post(Api.replyAdd, queryParameters: { + 'type': type.index, + 'oid': oid, + 'root': root ?? '', + 'parent': parent == null || parent == 0 ? '' : parent, + 'message': message, + 'csrf': await Request.getCsrf(), + }); + log(res.toString()); + if (res.data['code'] == 0) { + return {'status': true, 'data': res.data['data']}; + } else { + return {'status': false, 'data': []}; + } + } } diff --git a/lib/models/common/reply_type.dart b/lib/models/common/reply_type.dart new file mode 100644 index 00000000..a6e8bdb1 --- /dev/null +++ b/lib/models/common/reply_type.dart @@ -0,0 +1,46 @@ +enum ReplyType { + unset, + // 视频 + video, + // 话题 + topic, + // 活动 + activity, + // 小视频 + videoS, + // 小黑屋封禁信息 + blockMsg, + // 公告信息 + publicMsg, + // 直播活动 + liveActivity, + // 活动稿件 + activityFile, + // 直播公告 + livePublic, + // 相簿 + album, + // 专栏 + column, + // 票务 + ticket, + // 音频 + audio, + + // 点评 + comment, + // 动态 + dynamics, + // 播单 + playList, + // 音乐播单 + musicPlayList, + // 漫画 + comics1, + // 漫画 + comics2, + // 漫画 + comics3, + // 课程 + course, +} diff --git a/lib/models/video/reply/item.dart b/lib/models/video/reply/item.dart index a1e96561..3ae811ec 100644 --- a/lib/models/video/reply/item.dart +++ b/lib/models/video/reply/item.dart @@ -30,6 +30,7 @@ class ReplyItemModel { this.replyControl, this.isUp, this.isTop, + this.cardLabel, }); int? rpid; @@ -59,6 +60,7 @@ class ReplyItemModel { ReplyControl? replyControl; bool? isUp; bool? isTop = false; + List? cardLabel; ReplyItemModel.fromJson(Map json, upperMid, {isTopStatus = false}) { @@ -95,6 +97,9 @@ class ReplyItemModel { : ReplyControl.fromJson(json['reply_control']); isUp = upperMid.toString() == json['member']['mid']; isTop = isTopStatus; + cardLabel = json['card_label'] != null + ? json['card_label'].map((e) => e['text_content']).toList() + : []; } } diff --git a/lib/pages/video/detail/reply/controller.dart b/lib/pages/video/detail/reply/controller.dart index 6e813fc8..267b0354 100644 --- a/lib/pages/video/detail/reply/controller.dart +++ b/lib/pages/video/detail/reply/controller.dart @@ -1,8 +1,11 @@ import 'dart:developer'; import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/http/reply.dart'; +import 'package:pilipala/http/video.dart'; +import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/models/video/reply/data.dart'; import 'package:pilipala/models/video/reply/item.dart'; @@ -10,7 +13,7 @@ class VideoReplyController extends GetxController { VideoReplyController( this.aid, this.rpid, - this.level, + this.level ); final ScrollController scrollController = ScrollController(); // 视频aid 请求时使用的oid @@ -24,6 +27,15 @@ class VideoReplyController extends GetxController { int currentPage = 0; bool isLoadingMore = false; RxBool noMore = false.obs; + RxBool autoFocus = false.obs; + // 当前回复的回复 + ReplyItemModel? currentReplyItem; + // 回复来源 + String replySource = 'main'; + // 根评论 id 回复楼中楼回复使用 + int? rPid; + // 默认回复主楼 + String replyLevel = '0'; Future queryReplyList({type = 'init'}) async { isLoadingMore = true; @@ -77,4 +89,29 @@ class VideoReplyController extends GetxController { Future onLoad() async { queryReplyList(type: 'onLoad'); } + + wakeUpReply() { + autoFocus.value = true; + } + + // 发表评论 + Future submitReplyAdd() async { + print('replyLevel: $replyLevel'); + // print('rpid: $rpid'); + // print('currentReplyItem!.rpid: ${currentReplyItem!.rpid}'); + + + var result = await VideoHttp.replyAdd( + type: ReplyType.video, + oid: int.parse(aid!), + root: replyLevel == '0' ? 0 : replyLevel == '1' ? currentReplyItem!.rpid : rPid, + parent: replyLevel == '0' ? 0 : replyLevel == '1' ? currentReplyItem!.rpid : currentReplyItem!.rpid, + message: replyLevel == '2' ? ' 回复 @${currentReplyItem!.member!.uname!} : 2楼31' : '2楼31', + ); + if(result['status']){ + SmartDialog.showToast(result['data']['success_toast']); + }else{ + SmartDialog.showToast(result['message']); + } + } } diff --git a/lib/pages/video/detail/reply/view.dart b/lib/pages/video/detail/reply/view.dart index 0f8cae68..89aa090f 100644 --- a/lib/pages/video/detail/reply/view.dart +++ b/lib/pages/video/detail/reply/view.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/skeleton/video_card_h.dart'; import 'package:pilipala/common/skeleton/video_reply.dart'; @@ -10,11 +13,11 @@ import 'widgets/reply_item.dart'; class VideoReplyPanel extends StatefulWidget { int oid; int rpid; - String level; + String? level; VideoReplyPanel({ this.oid = 0, this.rpid = 0, - this.level = '', + this.level, super.key, }); @@ -23,11 +26,16 @@ class VideoReplyPanel extends StatefulWidget { } class _VideoReplyPanelState extends State - with AutomaticKeepAliveClientMixin { + with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { late VideoReplyController _videoReplyController; + late AnimationController fabAnimationCtr; + late AnimationController replyAnimationCtl; // List? replyList; Future? _futureBuilderFuture; + bool _isFabVisible = true; + String replyLevel = '1'; + // 添加页面缓存 @override bool get wantKeepAlive => true; @@ -35,16 +43,28 @@ class _VideoReplyPanelState extends State @override void initState() { super.initState(); - if (widget.level == '2') { + replyLevel = widget.level ?? '1'; + if (widget.level != null && widget.level == '2') { _videoReplyController = Get.put( VideoReplyController( widget.oid.toString(), widget.rpid.toString(), '2'), tag: widget.rpid.toString()); + _videoReplyController.rPid = widget.rpid; } else { _videoReplyController = Get.put( VideoReplyController(Get.parameters['aid']!, '', '1'), tag: Get.arguments['heroTag']); } + // if(replyLevel != ''){ + // _videoReplyController.replyLevel = replyLevel; + // } + print( + '_videoReplyController.replyLevel: ${_videoReplyController.replyLevel}'); + + fabAnimationCtr = AnimationController( + vsync: this, duration: const Duration(milliseconds: 300)); + replyAnimationCtl = AnimationController( + vsync: this, duration: const Duration(milliseconds: 500)); _futureBuilderFuture = _videoReplyController.queryReplyList(); _videoReplyController.scrollController.addListener( @@ -56,10 +76,55 @@ class _VideoReplyPanelState extends State _videoReplyController.onLoad(); } } + + final ScrollDirection direction = + _videoReplyController.scrollController.position.userScrollDirection; + if (direction == ScrollDirection.forward) { + _showFab(); + } else if (direction == ScrollDirection.reverse) { + _hideFab(); + } }, ); } + void _showFab() { + if (!_isFabVisible) { + _isFabVisible = true; + fabAnimationCtr.forward(); + } + } + + void _hideFab() { + if (_isFabVisible) { + _isFabVisible = false; + fabAnimationCtr.reverse(); + } + } + + void _showReply(source, {ReplyItemModel? replyItem, replyLevel}) async { + // source main 直接回复 floor 楼中楼回复 + if (source == 'floor') { + _videoReplyController.currentReplyItem = replyItem; + _videoReplyController.replySource = source; + _videoReplyController.replyLevel = replyLevel ?? '1'; + } else { + _videoReplyController.replyLevel = '0'; + } + + replyAnimationCtl.forward(); + await Future.delayed(const Duration(microseconds: 100)); + _videoReplyController.wakeUpReply(); + } + + @override + void dispose() { + // TODO: implement dispose + super.dispose(); + fabAnimationCtr.dispose(); + _videoReplyController.scrollController.dispose(); + } + @override Widget build(BuildContext context) { return RefreshIndicator( @@ -68,63 +133,147 @@ class _VideoReplyPanelState extends State _videoReplyController.currentPage = 0; return await _videoReplyController.queryReplyList(); }, - child: CustomScrollView( - controller: _videoReplyController.scrollController, - key: const PageStorageKey('评论'), - slivers: [ - FutureBuilder( - future: _futureBuilderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data as Map; - if (data['status']) { - // 请求成功 - return Obx( - () => SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index == _videoReplyController.replyList.length) { - return Container( - padding: EdgeInsets.only( - bottom: - MediaQuery.of(context).padding.bottom), - height: - MediaQuery.of(context).padding.bottom + 60, - child: Center( - child: Obx(() => Text( - _videoReplyController.noMore.value - ? '没有更多了' - : '加载中')), - ), - ); - } else { - return ReplyItem( - replyItem: _videoReplyController.replyList[index], - ); - } - }, - childCount: _videoReplyController.replyList.length + 1, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Stack( + children: [ + CustomScrollView( + controller: _videoReplyController.scrollController, + key: const PageStorageKey('评论'), + slivers: [ + FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + // 请求成功 + return Obx( + () => SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == + _videoReplyController.replyList.length) { + return Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context) + .padding + .bottom), + height: + MediaQuery.of(context).padding.bottom + + 60, + child: Center( + child: Obx(() => Text( + _videoReplyController.noMore.value + ? '没有更多了' + : '加载中')), + ), + ); + } else { + return ReplyItem( + replyItem: _videoReplyController + .replyList[index], + weakUpReply: (replyItem, replyLevel) => + _showReply( + 'floor', + replyItem: replyItem, + replyLevel: replyLevel, + ), + replyLevel: replyLevel); + } + }, + childCount: + _videoReplyController.replyList.length + 1, + ), + ), + ); + } else { + // 请求错误 + return HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ); + } + } else { + // 骨架屏 + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return const VideoReplySkeleton(); + }, childCount: 5), + ); + } + }, + ) + ], + ), + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 14, + right: 14, + child: SlideTransition( + position: Tween( + // begin: const Offset(0, 2), + // 评论内容为空/不足一屏 + begin: const Offset(0, 0), + end: const Offset(0, 0), + ).animate(CurvedAnimation( + parent: fabAnimationCtr, + curve: Curves.easeInOut, + )), + child: FloatingActionButton( + heroTag: null, + onPressed: () => _showReply('main'), + tooltip: '发表评论', + child: const Icon(Icons.reply), + ), + ), + ), + Obx( + () => Positioned( + bottom: 0, + left: 0, + right: 0, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 2), + end: const Offset(0, 0), + ).animate(CurvedAnimation( + parent: replyAnimationCtl, + curve: Curves.easeInOut, + )), + child: Container( + height: 100 + MediaQuery.of(context).padding.bottom, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom), + color: Theme.of(context).colorScheme.surfaceVariant, + child: Padding( + padding: const EdgeInsets.only(left: 14, right: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Visibility( + visible: _videoReplyController.autoFocus.value, + child: const TextField( + autofocus: true, + maxLines: null, + decoration: InputDecoration( + hintText: "友善评论", border: InputBorder.none), + ), + ), + TextButton( + onPressed: () => + _videoReplyController.submitReplyAdd(), + child: const Text('发送'), + ) + ], ), ), - ); - } else { - // 请求错误 - return HttpError( - errMsg: data['msg'], - fn: () => setState(() {}), - ); - } - } else { - // 骨架屏 - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - return const VideoReplySkeleton(); - }, childCount: 5), - ); - } - }, - ) - ], + ), + ), + ), + ), + ], + ), ), ); } diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index f920da95..d063cd5a 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -8,8 +8,10 @@ import 'package:pilipala/pages/video/detail/reply/index.dart'; import 'package:pilipala/utils/utils.dart'; class ReplyItem extends StatelessWidget { - ReplyItem({super.key, this.replyItem}); + ReplyItem({super.key, this.replyItem, this.weakUpReply, this.replyLevel}); ReplyItemModel? replyItem; + Function? weakUpReply; + String? replyLevel; @override Widget build(BuildContext context) { @@ -173,6 +175,7 @@ class ReplyItem extends StatelessWidget { ), // 操作区域 bottonAction(context, replyItem!.replyControl), + const SizedBox(height: 3), if (replyItem!.replies!.isNotEmpty) ...[ Padding( padding: const EdgeInsets.only(top: 2, bottom: 12), @@ -193,6 +196,15 @@ class ReplyItem extends StatelessWidget { return Row( children: [ const SizedBox(width: 48), + if (replyItem!.cardLabel!.isNotEmpty && + replyItem!.cardLabel!.contains('热评')) + Text( + '热评 • ', + style: Theme.of(context) + .textTheme + .labelMedium! + .copyWith(color: Theme.of(context).colorScheme.primary), + ), Text( Utils.dateFormat(replyItem!.ctime), style: Theme.of(context) @@ -210,10 +222,22 @@ class ReplyItem extends StatelessWidget { .copyWith(color: Theme.of(context).colorScheme.outline), ), const Spacer(), - if (replyControl!.isUpTop!) + if (replyItem!.upAction!.like!) Icon(Icons.favorite, color: Colors.red[400], size: 18), SizedBox( - height: 35, + height: 28, + width: 42, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + child: Text('回复', style: Theme.of(context) + .textTheme + .labelMedium), + onPressed: () => weakUpReply!(replyItem, replyLevel), + )), + SizedBox( + height: 32, child: TextButton( child: Row( children: [ @@ -314,6 +338,10 @@ class ReplyItemRow extends StatelessWidget { : TextOverflow.visible, maxLines: extraRow == 1 ? 2 : null, TextSpan( + recognizer: TapGestureRecognizer() + ..onTap = () { + replyReply(context); + }, children: [ TextSpan( text: replies![index].member.uname + ' ', @@ -395,7 +423,11 @@ InlineSpan buildContent(BuildContext context, content) { content.jumpUrl.isEmpty && content.vote.isEmpty && content.pictures.isEmpty) { - return TextSpan(text: content.message); + return TextSpan(text: content.message, + recognizer: TapGestureRecognizer() + ..onTap = ()=> { + print('点击') + },); } List spanChilds = []; // 匹配表情 @@ -635,7 +667,7 @@ class UpTag extends StatelessWidget { Color primary = Theme.of(context).colorScheme.primary; return Container( width: 24, - height: 15, + height: 14, decoration: BoxDecoration( borderRadius: BorderRadius.circular(3), color: tagText == 'UP' ? primary : null, @@ -645,7 +677,7 @@ class UpTag extends StatelessWidget { child: Text( tagText!, style: TextStyle( - fontSize: 10, + fontSize: 9, color: tagText == 'UP' ? Theme.of(context).colorScheme.onPrimary : primary,