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';
// 发表评论
// 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';

View File

@ -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 发送平台标识 非必要 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.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<String, dynamic> 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()
: [];
}
}

View File

@ -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']);
}
}
}

View File

@ -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<VideoReplyPanel>
with AutomaticKeepAliveClientMixin {
with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
late VideoReplyController _videoReplyController;
late AnimationController fabAnimationCtr;
late AnimationController replyAnimationCtl;
// List<ReplyItemModel>? replyList;
Future? _futureBuilderFuture;
bool _isFabVisible = true;
String replyLevel = '1';
// 添加页面缓存
@override
bool get wantKeepAlive => true;
@ -35,16 +43,28 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
@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<VideoReplyPanel>
_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<VideoReplyPanel>
_videoReplyController.currentPage = 0;
return await _videoReplyController.queryReplyList();
},
child: CustomScrollView(
controller: _videoReplyController.scrollController,
key: const PageStorageKey<String>('评论'),
slivers: <Widget>[
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<String>('评论'),
slivers: <Widget>[
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<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';
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<InlineSpan> 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,