feat: 评论

This commit is contained in:
guozhigq
2023-05-15 07:32:39 +08:00
parent 7ae1716f44
commit 351cc0a850
7 changed files with 380 additions and 66 deletions

View File

@ -91,6 +91,10 @@ class Api {
// 楼中楼 // 楼中楼
static const String replyReplyList = '/x/v2/reply/reply'; 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 // https://api.bilibili.com/x/relation/stat?vmid=697166795
static const String userStat = '/x/relation/stat'; static const String userStat = '/x/relation/stat';

View File

@ -1,5 +1,8 @@
import 'dart:developer';
import 'package:pilipala/http/api.dart'; import 'package:pilipala/http/api.dart';
import 'package:pilipala/http/init.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_hot_video_item.dart';
import 'package:pilipala/models/model_rec_video_item.dart'; import 'package:pilipala/models/model_rec_video_item.dart';
import 'package:pilipala/models/user/fav_folder.dart'; import 'package:pilipala/models/user/fav_folder.dart';
@ -183,4 +186,42 @@ class VideoHttp {
return {'status': false, 'data': []}; return {'status': false, 'data': []};
} }
} }
// 发表评论 replyAdd
// type num 评论区类型代码 必要 类型代码见表
// oid num 目标评论区id 必要
// root num 根评论rpid 非必要 二级评论以上使用
// parent num 父评论rpid 非必要 二级评论同根评论id 大于二级评论为要回复的评论id
// message str 发送评论内容 必要 最大1000字符
// plat num 发送平台标识 非必要 1web端 2安卓客户端 3ios客户端 4wp客户端
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': []};
}
}
} }

View File

@ -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,
}

View File

@ -30,6 +30,7 @@ class ReplyItemModel {
this.replyControl, this.replyControl,
this.isUp, this.isUp,
this.isTop, this.isTop,
this.cardLabel,
}); });
int? rpid; int? rpid;
@ -59,6 +60,7 @@ class ReplyItemModel {
ReplyControl? replyControl; ReplyControl? replyControl;
bool? isUp; bool? isUp;
bool? isTop = false; bool? isTop = false;
List? cardLabel;
ReplyItemModel.fromJson(Map<String, dynamic> json, upperMid, ReplyItemModel.fromJson(Map<String, dynamic> json, upperMid,
{isTopStatus = false}) { {isTopStatus = false}) {
@ -95,6 +97,9 @@ class ReplyItemModel {
: ReplyControl.fromJson(json['reply_control']); : ReplyControl.fromJson(json['reply_control']);
isUp = upperMid.toString() == json['member']['mid']; isUp = upperMid.toString() == json['member']['mid'];
isTop = isTopStatus; isTop = isTopStatus;
cardLabel = json['card_label'] != null
? json['card_label'].map((e) => e['text_content']).toList()
: [];
} }
} }

View File

@ -1,8 +1,11 @@
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:pilipala/http/reply.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/data.dart';
import 'package:pilipala/models/video/reply/item.dart'; import 'package:pilipala/models/video/reply/item.dart';
@ -10,7 +13,7 @@ class VideoReplyController extends GetxController {
VideoReplyController( VideoReplyController(
this.aid, this.aid,
this.rpid, this.rpid,
this.level, this.level
); );
final ScrollController scrollController = ScrollController(); final ScrollController scrollController = ScrollController();
// 视频aid 请求时使用的oid // 视频aid 请求时使用的oid
@ -24,6 +27,15 @@ class VideoReplyController extends GetxController {
int currentPage = 0; int currentPage = 0;
bool isLoadingMore = false; bool isLoadingMore = false;
RxBool noMore = false.obs; RxBool noMore = false.obs;
RxBool autoFocus = false.obs;
// 当前回复的回复
ReplyItemModel? currentReplyItem;
// 回复来源
String replySource = 'main';
// 根评论 id 回复楼中楼回复使用
int? rPid;
// 默认回复主楼
String replyLevel = '0';
Future queryReplyList({type = 'init'}) async { Future queryReplyList({type = 'init'}) async {
isLoadingMore = true; isLoadingMore = true;
@ -77,4 +89,29 @@ class VideoReplyController extends GetxController {
Future onLoad() async { Future onLoad() async {
queryReplyList(type: 'onLoad'); 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']);
}
}
} }

View File

@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:pilipala/common/skeleton/video_card_h.dart'; import 'package:pilipala/common/skeleton/video_card_h.dart';
import 'package:pilipala/common/skeleton/video_reply.dart'; import 'package:pilipala/common/skeleton/video_reply.dart';
@ -10,11 +13,11 @@ import 'widgets/reply_item.dart';
class VideoReplyPanel extends StatefulWidget { class VideoReplyPanel extends StatefulWidget {
int oid; int oid;
int rpid; int rpid;
String level; String? level;
VideoReplyPanel({ VideoReplyPanel({
this.oid = 0, this.oid = 0,
this.rpid = 0, this.rpid = 0,
this.level = '', this.level,
super.key, super.key,
}); });
@ -23,11 +26,16 @@ class VideoReplyPanel extends StatefulWidget {
} }
class _VideoReplyPanelState extends State<VideoReplyPanel> class _VideoReplyPanelState extends State<VideoReplyPanel>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
late VideoReplyController _videoReplyController; late VideoReplyController _videoReplyController;
late AnimationController fabAnimationCtr;
late AnimationController replyAnimationCtl;
// List<ReplyItemModel>? replyList; // List<ReplyItemModel>? replyList;
Future? _futureBuilderFuture; Future? _futureBuilderFuture;
bool _isFabVisible = true;
String replyLevel = '1';
// 添加页面缓存 // 添加页面缓存
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@ -35,16 +43,28 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.level == '2') { replyLevel = widget.level ?? '1';
if (widget.level != null && widget.level == '2') {
_videoReplyController = Get.put( _videoReplyController = Get.put(
VideoReplyController( VideoReplyController(
widget.oid.toString(), widget.rpid.toString(), '2'), widget.oid.toString(), widget.rpid.toString(), '2'),
tag: widget.rpid.toString()); tag: widget.rpid.toString());
_videoReplyController.rPid = widget.rpid;
} else { } else {
_videoReplyController = Get.put( _videoReplyController = Get.put(
VideoReplyController(Get.parameters['aid']!, '', '1'), VideoReplyController(Get.parameters['aid']!, '', '1'),
tag: Get.arguments['heroTag']); 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(); _futureBuilderFuture = _videoReplyController.queryReplyList();
_videoReplyController.scrollController.addListener( _videoReplyController.scrollController.addListener(
@ -56,10 +76,55 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
_videoReplyController.onLoad(); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RefreshIndicator( return RefreshIndicator(
@ -68,63 +133,147 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
_videoReplyController.currentPage = 0; _videoReplyController.currentPage = 0;
return await _videoReplyController.queryReplyList(); return await _videoReplyController.queryReplyList();
}, },
child: CustomScrollView( child: Scaffold(
controller: _videoReplyController.scrollController, resizeToAvoidBottomInset: false,
key: const PageStorageKey<String>('评论'), body: Stack(
slivers: <Widget>[ children: [
FutureBuilder( CustomScrollView(
future: _futureBuilderFuture, controller: _videoReplyController.scrollController,
builder: (context, snapshot) { key: const PageStorageKey<String>('评论'),
if (snapshot.connectionState == ConnectionState.done) { slivers: <Widget>[
Map data = snapshot.data as Map; FutureBuilder(
if (data['status']) { future: _futureBuilderFuture,
// 请求成功 builder: (context, snapshot) {
return Obx( if (snapshot.connectionState == ConnectionState.done) {
() => SliverList( Map data = snapshot.data as Map;
delegate: SliverChildBuilderDelegate( if (data['status']) {
(context, index) { // 请求成功
if (index == _videoReplyController.replyList.length) { return Obx(
return Container( () => SliverList(
padding: EdgeInsets.only( delegate: SliverChildBuilderDelegate(
bottom: (context, index) {
MediaQuery.of(context).padding.bottom), if (index ==
height: _videoReplyController.replyList.length) {
MediaQuery.of(context).padding.bottom + 60, return Container(
child: Center( padding: EdgeInsets.only(
child: Obx(() => Text( bottom: MediaQuery.of(context)
_videoReplyController.noMore.value .padding
? '没有更多了' .bottom),
: '加载中')), height:
), MediaQuery.of(context).padding.bottom +
); 60,
} else { child: Center(
return ReplyItem( child: Obx(() => Text(
replyItem: _videoReplyController.replyList[index], _videoReplyController.noMore.value
); ? '没有更多了'
} : '加载中')),
}, ),
childCount: _videoReplyController.replyList.length + 1, );
} 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<Offset>(
// 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<Offset>(
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),
);
}
},
)
],
), ),
); );
} }

View File

@ -8,8 +8,10 @@ import 'package:pilipala/pages/video/detail/reply/index.dart';
import 'package:pilipala/utils/utils.dart'; import 'package:pilipala/utils/utils.dart';
class ReplyItem extends StatelessWidget { class ReplyItem extends StatelessWidget {
ReplyItem({super.key, this.replyItem}); ReplyItem({super.key, this.replyItem, this.weakUpReply, this.replyLevel});
ReplyItemModel? replyItem; ReplyItemModel? replyItem;
Function? weakUpReply;
String? replyLevel;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -173,6 +175,7 @@ class ReplyItem extends StatelessWidget {
), ),
// 操作区域 // 操作区域
bottonAction(context, replyItem!.replyControl), bottonAction(context, replyItem!.replyControl),
const SizedBox(height: 3),
if (replyItem!.replies!.isNotEmpty) ...[ if (replyItem!.replies!.isNotEmpty) ...[
Padding( Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12), padding: const EdgeInsets.only(top: 2, bottom: 12),
@ -193,6 +196,15 @@ class ReplyItem extends StatelessWidget {
return Row( return Row(
children: [ children: [
const SizedBox(width: 48), 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( Text(
Utils.dateFormat(replyItem!.ctime), Utils.dateFormat(replyItem!.ctime),
style: Theme.of(context) style: Theme.of(context)
@ -210,10 +222,22 @@ class ReplyItem extends StatelessWidget {
.copyWith(color: Theme.of(context).colorScheme.outline), .copyWith(color: Theme.of(context).colorScheme.outline),
), ),
const Spacer(), const Spacer(),
if (replyControl!.isUpTop!) if (replyItem!.upAction!.like!)
Icon(Icons.favorite, color: Colors.red[400], size: 18), Icon(Icons.favorite, color: Colors.red[400], size: 18),
SizedBox( 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: TextButton(
child: Row( child: Row(
children: [ children: [
@ -314,6 +338,10 @@ class ReplyItemRow extends StatelessWidget {
: TextOverflow.visible, : TextOverflow.visible,
maxLines: extraRow == 1 ? 2 : null, maxLines: extraRow == 1 ? 2 : null,
TextSpan( TextSpan(
recognizer: TapGestureRecognizer()
..onTap = () {
replyReply(context);
},
children: [ children: [
TextSpan( TextSpan(
text: replies![index].member.uname + ' ', text: replies![index].member.uname + ' ',
@ -395,7 +423,11 @@ InlineSpan buildContent(BuildContext context, content) {
content.jumpUrl.isEmpty && content.jumpUrl.isEmpty &&
content.vote.isEmpty && content.vote.isEmpty &&
content.pictures.isEmpty) { content.pictures.isEmpty) {
return TextSpan(text: content.message); return TextSpan(text: content.message,
recognizer: TapGestureRecognizer()
..onTap = ()=> {
print('点击')
},);
} }
List<InlineSpan> spanChilds = []; List<InlineSpan> spanChilds = [];
// 匹配表情 // 匹配表情
@ -635,7 +667,7 @@ class UpTag extends StatelessWidget {
Color primary = Theme.of(context).colorScheme.primary; Color primary = Theme.of(context).colorScheme.primary;
return Container( return Container(
width: 24, width: 24,
height: 15, height: 14,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
color: tagText == 'UP' ? primary : null, color: tagText == 'UP' ? primary : null,
@ -645,7 +677,7 @@ class UpTag extends StatelessWidget {
child: Text( child: Text(
tagText!, tagText!,
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 9,
color: tagText == 'UP' color: tagText == 'UP'
? Theme.of(context).colorScheme.onPrimary ? Theme.of(context).colorScheme.onPrimary
: primary, : primary,