feat: 评论+关注

This commit is contained in:
guozhigq
2023-05-21 16:19:11 +08:00
parent e384486ab6
commit 51c4a082ac
18 changed files with 897 additions and 258 deletions

View File

@ -1,5 +1,6 @@
class Api { class Api {
// 推荐视频 // 推荐视频
// http://app.bilibili.com/x/v2/feed/index
static const String recommendList = '/x/web-interface/index/top/feed/rcmd'; static const String recommendList = '/x/web-interface/index/top/feed/rcmd';
// 热门视频 // 热门视频
@ -85,6 +86,12 @@ class Api {
// 视频详情页 相关视频 // 视频详情页 相关视频
static const String relatedList = '/x/web-interface/archive/related'; static const String relatedList = '/x/web-interface/archive/related';
// 查询用户与自己关系_仅查关注
static const String hasFollow = '/x/relation';
// 操作用户关系
static const String relationMod = '/x/relation/modify';
// 评论列表 // 评论列表
static const String replyList = '/x/v2/reply'; static const String replyList = '/x/v2/reply';

View File

@ -25,11 +25,12 @@ class ReplyHttp {
-404: '无此项', -404: '无此项',
12002: '评论区已关闭', 12002: '评论区已关闭',
12009: '评论主体的type不合法', 12009: '评论主体的type不合法',
12061: 'UP主已关闭评论区',
}; };
return { return {
'status': false, 'status': false,
'date': [], 'date': [],
'msg': errMap[res.data['code']] ?? '请求异常', 'msg': errMap[res.data['code']] ?? res.data['message'],
}; };
} }
} }

View File

@ -205,13 +205,10 @@ class VideoHttp {
if (message == '') { if (message == '') {
return {'status': false, 'data': [], 'msg': '请输入评论内容'}; return {'status': false, 'data': [], 'msg': '请输入评论内容'};
} }
print('root:$root');
print('parent: $parent');
var res = await Request().post(Api.replyAdd, queryParameters: { var res = await Request().post(Api.replyAdd, queryParameters: {
'type': type.index, 'type': type.index,
'oid': oid, 'oid': oid,
'root': root ?? '', 'root': root == null || root == 0 ? '' : root,
'parent': parent == null || parent == 0 ? '' : parent, 'parent': parent == null || parent == 0 ? '' : parent,
'message': message, 'message': message,
'csrf': await Request.getCsrf(), 'csrf': await Request.getCsrf(),
@ -223,4 +220,30 @@ class VideoHttp {
return {'status': false, 'data': []}; return {'status': false, 'data': []};
} }
} }
// 查询是否关注up
static Future hasFollow({required int mid}) async {
var res = await Request().get(Api.hasFollow, data: {'fid': mid});
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': true, 'data': []};
}
}
// 操作用户关系
static Future relationMod(
{required int mid, required int act, required int reSrc}) async {
var res = await Request().post(Api.relationMod, queryParameters: {
'fid': mid,
'act': act,
're_src': reSrc,
'csrf': await Request.getCsrf(),
});
if (res.data['code'] == 0) {
return {'status': true, 'data': res.data['data']};
} else {
return {'status': true, 'data': []};
}
}
} }

View File

@ -1,3 +1,4 @@
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -41,6 +42,14 @@ class MyApp extends StatelessWidget {
), ),
useMaterial3: true, useMaterial3: true,
), ),
localizationsDelegates: const [
GlobalCupertinoLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale("zh", "CN"),
supportedLocales: const [Locale("zh", "CN"), Locale("en", "US")],
fallbackLocale: const Locale("zh", "CN"),
getPages: Routes.getPages, getPages: Routes.getPages,
home: const MainApp(), home: const MainApp(),
builder: FlutterSmartDialog.init(), builder: FlutterSmartDialog.init(),

View File

@ -1,4 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:pilipala/models/video/reply/item.dart';
class VideoDetailController extends GetxController { class VideoDetailController extends GetxController {
int tabInitialIndex = 0; int tabInitialIndex = 0;
@ -19,6 +20,12 @@ class VideoDetailController extends GetxController {
String heroTag = ''; String heroTag = '';
RxInt oid = 0.obs;
// 评论id 请求楼中楼评论使用
RxInt fRpid = 0.obs;
ReplyItemModel? firstFloor;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();

View File

@ -42,6 +42,8 @@ class VideoIntroController extends GetxController {
Rx<FavFolderData> favFolderData = FavFolderData().obs; Rx<FavFolderData> favFolderData = FavFolderData().obs;
List addMediaIdsNew = []; List addMediaIdsNew = [];
List delMediaIdsNew = []; List delMediaIdsNew = [];
// 关注状态 默认未关注
RxMap followStatus = {}.obs;
@override @override
void onInit() { void onInit() {
@ -82,6 +84,8 @@ class VideoIntroController extends GetxController {
queryHasCoinVideo(); queryHasCoinVideo();
// 获取收藏状态 // 获取收藏状态
queryHasFavVideo(); queryHasFavVideo();
//
queryFollowStatus();
} }
return result; return result;
@ -228,4 +232,73 @@ class VideoIntroController extends GetxController {
favFolderData.value.list = datalist; favFolderData.value.list = datalist;
favFolderData.refresh(); favFolderData.refresh();
} }
// 查询关注状态
Future queryFollowStatus() async {
var result = await VideoHttp.hasFollow(mid: videoDetail.value.owner!.mid!);
if (result['status']) {
followStatus.value = result['data'];
}
return result;
}
// 关注/取关up
Future actionRelationMod() async{
int currentStatus = followStatus['attribute'];
print(currentStatus);
int actionStatus = 0;
switch(currentStatus) {
case 0:
actionStatus = 1;
break;
case 2:
actionStatus = 2;
break;
default:
actionStatus = 0;
break;
}
SmartDialog.show(
useSystem: true,
animationType: SmartAnimationType.centerFade_otherSlide,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('提示'),
content: Text(currentStatus == 0 ? '关注UP主?' : '取消关注UP主?'),
actions: [
TextButton(
onPressed: () => SmartDialog.dismiss(),
child: const Text('点错了')),
TextButton(
onPressed: () async {
var result = await VideoHttp.relationMod(
mid: videoDetail.value.owner!.mid!,
act: actionStatus,
reSrc: 14,
);
if (result['status']) {
switch(currentStatus) {
case 0:
actionStatus = 2;
break;
case 2:
actionStatus = 0;
break;
default:
actionStatus = 0;
break;
}
followStatus['attribute'] = actionStatus;
followStatus.refresh();
}
SmartDialog.dismiss();
},
child: const Text('确认'),
)
],
);
},
);
}
} }

View File

@ -380,9 +380,11 @@ class _VideoInfoState extends State<VideoInfo> with TickerProviderStateMixin {
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
child: SizedBox( child: SizedBox(
height: 36, height: 36,
child: ElevatedButton( child: Obx(()=>
onPressed: () {}, videoIntroController.followStatus.isNotEmpty ? ElevatedButton(
child: const Text('关注'), onPressed: () => videoIntroController.actionRelationMod(),
child: Text(videoIntroController.followStatus['attribute'] == 0 ? '关注' : '已关注'),
) : const SizedBox(),
), ),
), ),
), ),

View File

@ -10,11 +10,7 @@ import 'package:pilipala/models/video/reply/data.dart';
import 'package:pilipala/models/video/reply/item.dart'; import 'package:pilipala/models/video/reply/item.dart';
class VideoReplyController extends GetxController { class VideoReplyController extends GetxController {
VideoReplyController( VideoReplyController(this.aid, this.rpid, this.level);
this.aid,
this.rpid,
this.level
);
final ScrollController scrollController = ScrollController(); final ScrollController scrollController = ScrollController();
// 视频aid 请求时使用的oid // 视频aid 请求时使用的oid
String? aid; String? aid;
@ -26,7 +22,7 @@ class VideoReplyController extends GetxController {
// 当前页 // 当前页
int currentPage = 0; int currentPage = 0;
bool isLoadingMore = false; bool isLoadingMore = false;
RxBool noMore = false.obs; RxString noMore = ''.obs;
RxBool autoFocus = false.obs; RxBool autoFocus = false.obs;
// 当前回复的回复 // 当前回复的回复
ReplyItemModel? currentReplyItem; ReplyItemModel? currentReplyItem;
@ -48,17 +44,18 @@ class VideoReplyController extends GetxController {
res['data'] = ReplyData.fromJson(res['data']); res['data'] = ReplyData.fromJson(res['data']);
if (res['data'].replies.isNotEmpty) { if (res['data'].replies.isNotEmpty) {
currentPage = currentPage + 1; currentPage = currentPage + 1;
noMore.value = false; noMore.value = '加载中';
if(res['data'].page.count == res['data'].page.acount){
noMore.value = '没有更多了';
}
} else { } else {
if (currentPage == 0) { if (currentPage == 0) {
noMore.value = '还没有评论';
} else { } else {
noMore.value = true; noMore.value = '没有更多了';
return; return;
} }
} }
if (res['data'].replies.length >= res['data'].page.count) {
noMore.value = true;
}
if (type == 'init') { if (type == 'init') {
List<ReplyItemModel> replies = res['data'].replies; List<ReplyItemModel> replies = res['data'].replies;
// 添加置顶回复 // 添加置顶回复
@ -96,21 +93,26 @@ class VideoReplyController extends GetxController {
// 发表评论 // 发表评论
Future submitReplyAdd() async { Future submitReplyAdd() async {
print('replyLevel: $replyLevel');
// print('rpid: $rpid');
// print('currentReplyItem!.rpid: ${currentReplyItem!.rpid}');
var result = await VideoHttp.replyAdd( var result = await VideoHttp.replyAdd(
type: ReplyType.video, type: ReplyType.video,
oid: int.parse(aid!), oid: int.parse(aid!),
root: replyLevel == '0' ? 0 : replyLevel == '1' ? currentReplyItem!.rpid : rPid, root: replyLevel == '0'
parent: replyLevel == '0' ? 0 : replyLevel == '1' ? currentReplyItem!.rpid : currentReplyItem!.rpid, ? 0
message: replyLevel == '2' ? ' 回复 @${currentReplyItem!.member!.uname!} : 2楼31' : '2楼31', : 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']){ if (result['status']) {
SmartDialog.showToast(result['data']['success_toast']); SmartDialog.showToast(result['data']['success_toast']);
}else{ } else {
SmartDialog.showToast(result['message']); SmartDialog.showToast(result['message']);
} }
} }

View File

@ -3,10 +3,10 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.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_reply.dart'; import 'package:pilipala/common/skeleton/video_reply.dart';
import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/models/video/reply/item.dart'; import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/video/detail/replyNew/index.dart';
import 'controller.dart'; import 'controller.dart';
import 'widgets/reply_item.dart'; import 'widgets/reply_item.dart';
@ -14,6 +14,7 @@ class VideoReplyPanel extends StatefulWidget {
int oid; int oid;
int rpid; int rpid;
String? level; String? level;
Key? key;
VideoReplyPanel({ VideoReplyPanel({
this.oid = 0, this.oid = 0,
this.rpid = 0, this.rpid = 0,
@ -29,7 +30,6 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
late VideoReplyController _videoReplyController; late VideoReplyController _videoReplyController;
late AnimationController fabAnimationCtr; late AnimationController fabAnimationCtr;
late AnimationController replyAnimationCtl;
// List<ReplyItemModel>? replyList; // List<ReplyItemModel>? replyList;
Future? _futureBuilderFuture; Future? _futureBuilderFuture;
@ -55,16 +55,9 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
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( fabAnimationCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300)); 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(
@ -86,6 +79,7 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
} }
}, },
); );
fabAnimationCtr.forward();
} }
void _showFab() { void _showFab() {
@ -112,14 +106,12 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
_videoReplyController.replyLevel = '0'; _videoReplyController.replyLevel = '0';
} }
replyAnimationCtl.forward();
await Future.delayed(const Duration(microseconds: 100)); await Future.delayed(const Duration(microseconds: 100));
_videoReplyController.wakeUpReply(); _videoReplyController.wakeUpReply();
} }
@override @override
void dispose() { void dispose() {
// TODO: implement dispose
super.dispose(); super.dispose();
fabAnimationCtr.dispose(); fabAnimationCtr.dispose();
_videoReplyController.scrollController.dispose(); _videoReplyController.scrollController.dispose();
@ -164,9 +156,7 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
60, 60,
child: Center( child: Center(
child: Obx(() => Text( child: Obx(() => Text(
_videoReplyController.noMore.value _videoReplyController.noMore.value)),
? '没有更多了'
: '加载中')),
), ),
); );
} else { } else {
@ -211,9 +201,9 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
right: 14, right: 14,
child: SlideTransition( child: SlideTransition(
position: Tween<Offset>( position: Tween<Offset>(
// begin: const Offset(0, 2), begin: const Offset(0, 2),
// 评论内容为空/不足一屏 // 评论内容为空/不足一屏
begin: const Offset(0, 0), // begin: const Offset(0, 0),
end: const Offset(0, 0), end: const Offset(0, 0),
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: fabAnimationCtr, parent: fabAnimationCtr,
@ -221,57 +211,25 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
)), )),
child: FloatingActionButton( child: FloatingActionButton(
heroTag: null, heroTag: null,
onPressed: () => _showReply('main'), onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (builder) {
return VideoReplyNewDialog(
replyLevel: '0',
oid: int.parse(Get.parameters['aid']!),
root: 0,
parent: 0,
);
},
).then((value) => {print('close ModalBottomSheet')});
},
tooltip: '发表评论', tooltip: '发表评论',
child: const Icon(Icons.reply), 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('发送'),
)
],
),
),
),
),
),
),
], ],
), ),
), ),

View File

@ -4,7 +4,9 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/video/reply/item.dart'; import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/video/detail/controller.dart';
import 'package:pilipala/pages/video/detail/reply/index.dart'; import 'package:pilipala/pages/video/detail/reply/index.dart';
import 'package:pilipala/pages/video/detail/replyNew/index.dart';
import 'package:pilipala/utils/utils.dart'; import 'package:pilipala/utils/utils.dart';
class ReplyItem extends StatelessWidget { class ReplyItem extends StatelessWidget {
@ -176,13 +178,14 @@ class ReplyItem extends StatelessWidget {
// 操作区域 // 操作区域
bottonAction(context, replyItem!.replyControl), bottonAction(context, replyItem!.replyControl),
const SizedBox(height: 3), const SizedBox(height: 3),
if (replyItem!.replies!.isNotEmpty) ...[ if (replyItem!.replies!.isNotEmpty && replyLevel != '2') ...[
Padding( Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12), padding: const EdgeInsets.only(top: 2, bottom: 12),
child: ReplyItemRow( child: ReplyItemRow(
replies: replyItem!.replies, replies: replyItem!.replies,
replyControl: replyItem!.replyControl, replyControl: replyItem!.replyControl,
f_rpid: replyItem!.rpid, f_rpid: replyItem!.rpid,
replyItem: replyItem,
), ),
), ),
], ],
@ -225,17 +228,32 @@ class ReplyItem extends StatelessWidget {
if (replyItem!.upAction!.like!) 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: 28, height: 28,
width: 42, width: 42,
child: TextButton( child: TextButton(
style: ButtonStyle( style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.zero), padding: MaterialStateProperty.all(EdgeInsets.zero),
), ),
child: Text('回复', style: Theme.of(context) child: Text('回复', style: Theme.of(context).textTheme.labelMedium),
.textTheme onPressed: () {
.labelMedium), showModalBottomSheet(
onPressed: () => weakUpReply!(replyItem, replyLevel), context: context,
)), isScrollControlled: true,
builder: (builder) {
print('🌹: ${replyItem!.rpid}');
return VideoReplyNewDialog(
replyLevel: replyLevel,
oid: replyItem!.oid,
root: replyItem!.rpid,
parent: replyItem!.rpid,
);
},
).then((value) => {
print('showModalBottomSheet')
});
},
),
),
SizedBox( SizedBox(
height: 32, height: 32,
child: TextButton( child: TextButton(
@ -272,10 +290,12 @@ class ReplyItemRow extends StatelessWidget {
this.replies, this.replies,
this.replyControl, this.replyControl,
this.f_rpid, this.f_rpid,
this.replyItem
}); });
List? replies; List? replies;
ReplyControl? replyControl; ReplyControl? replyControl;
int? f_rpid; int? f_rpid;
ReplyItemModel? replyItem;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -297,7 +317,7 @@ class ReplyItemRow extends StatelessWidget {
if (extraRow == 1 && index == replies!.length) { if (extraRow == 1 && index == replies!.length) {
// 有楼中楼回复,在最后显示 // 有楼中楼回复,在最后显示
return InkWell( return InkWell(
onTap: () => replyReply(context), onTap: () => replyReply(replyItem),
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8), const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
@ -323,7 +343,7 @@ class ReplyItemRow extends StatelessWidget {
); );
} else { } else {
return InkWell( return InkWell(
onTap: () {}, onTap: () => replyReply(replyItem),
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
8, 8,
@ -338,10 +358,6 @@ 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 + ' ',
@ -374,46 +390,15 @@ class ReplyItemRow extends StatelessWidget {
); );
} }
void replyReply(context) { void replyReply(replyItem) {
Get.bottomSheet( // replyItem 楼主评论
barrierColor: Colors.transparent, Get.find<VideoDetailController>(tag: Get.arguments['heroTag'])
useRootNavigator: true, .oid
isScrollControlled: true, .value = replies!.first.oid;
Container( Get.find<VideoDetailController>(tag: Get.arguments['heroTag'])
height: Get.size.height - Get.size.width * 9 / 16 - 45, .fRpid
color: Theme.of(context).colorScheme.background, .value = f_rpid!;
child: Column( Get.find<VideoDetailController>(tag: Get.arguments['heroTag']).firstFloor = replyItem;
children: [
AppBar(
automaticallyImplyLeading: false,
centerTitle: false,
elevation: 1,
title: Text(
'评论详情',
style: Theme.of(context).textTheme.titleMedium,
),
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () async {
Get.back();
},
)
],
),
Expanded(
child: VideoReplyPanel(
oid: replies!.first.oid,
rpid: f_rpid!,
level: '2',
),
)
],
),
),
persistent: false,
backgroundColor: Theme.of(context).bottomSheetTheme.backgroundColor,
);
} }
} }
@ -423,11 +408,10 @@ 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(
recognizer: TapGestureRecognizer() text: content.message,
..onTap = ()=> { // recognizer: TapGestureRecognizer()..onTap = () => {print('点击')},
print('点击') );
},);
} }
List<InlineSpan> spanChilds = []; List<InlineSpan> spanChilds = [];
// 匹配表情 // 匹配表情
@ -505,10 +489,10 @@ InlineSpan buildContent(BuildContext context, content) {
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
recognizer: TapGestureRecognizer() // recognizer: TapGestureRecognizer()
..onTap = () => { // ..onTap = () => {
print('Url 点击'), // print('Url 点击'),
}, // },
), ),
); );
spanChilds.add( spanChilds.add(
@ -540,10 +524,10 @@ InlineSpan buildContent(BuildContext context, content) {
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
recognizer: TapGestureRecognizer() // recognizer: TapGestureRecognizer()
..onTap = () => { // ..onTap = () => {
print('time 点击'), // print('time 点击'),
}, // },
), ),
); );
return ''; return '';

View File

@ -0,0 +1,3 @@
library video_reply_new;
export './view.dart';

View File

@ -0,0 +1,220 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/video.dart';
import 'package:pilipala/models/common/reply_type.dart';
class VideoReplyNewDialog extends StatefulWidget {
int? oid;
int? root;
String? replyLevel;
int? parent;
VideoReplyNewDialog({
this.oid,
this.root,
this.replyLevel,
this.parent,
});
@override
State<VideoReplyNewDialog> createState() => _VideoReplyNewDialogState();
}
class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
with WidgetsBindingObserver {
final TextEditingController _replyContentController = TextEditingController();
final FocusNode replyContentFocusNode = FocusNode();
final GlobalKey _formKey = GlobalKey<FormState>();
double _keyboardHeight = 0.0; // 键盘高度
final _debouncer = Debouncer(milliseconds: 100); // 设置延迟时间
bool ableClean = false;
bool autoFocus = false;
Timer? timer;
@override
void initState() {
// TODO: implement initState
super.initState();
// 监听输入框聚焦
// replyContentFocusNode.addListener(_onFocus);
_replyContentController.addListener(_printLatestValue);
// 界面观察者 必须
WidgetsBinding.instance.addObserver(this);
// 自动聚焦
_autoFocus();
}
_autoFocus() async {
await Future.delayed(const Duration(milliseconds: 300));
FocusScope.of(context).requestFocus(replyContentFocusNode);
}
_printLatestValue() {
setState(() {
ableClean = _replyContentController.text != '';
});
}
Future submitReplyAdd() async {
String message = _replyContentController.text;
print(widget.oid);
var result = await VideoHttp.replyAdd(
type: ReplyType.video,
oid: widget.oid!,
root: widget.root!,
parent: widget.parent!,
message: message,
);
if (result['status']) {
SmartDialog.showToast(result['data']['success_toast']);
} else {
}
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
WidgetsBinding.instance.addPostFrameCallback((_) {
// 键盘高度
final viewInsets = EdgeInsets.fromWindowPadding(
WidgetsBinding.instance.window.viewInsets,
WidgetsBinding.instance.window.devicePixelRatio);
_debouncer.run(() {
if (mounted) {
setState(() {
_keyboardHeight =
_keyboardHeight == 0.0 ? viewInsets.bottom : _keyboardHeight;
});
}
});
});
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height - MediaQuery.of(context).size.width * 9 / 16 - 48,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
color: Theme.of(context).colorScheme.background),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 55,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SizedBox(width: 20),
Text('发表评论', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
IconButton(
onPressed: () => Get.back(),
icon: const Icon(Icons.close),
),
const SizedBox(width: 12),
],
),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
Expanded(
child: Container(
padding: const EdgeInsets.only(
top: 12, right: 15, left: 15, bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(16),
),
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: TextField(
controller: _replyContentController,
minLines: 1,
maxLines: null,
autofocus: false,
focusNode: replyContentFocusNode,
decoration: const InputDecoration(
hintText: "输入回复内容", border: InputBorder.none),
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
Container(
height: 52,
padding: const EdgeInsets.only(left: 12, right: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: () {
FocusScope.of(context)
.requestFocus(replyContentFocusNode);
},
icon: Icon(Icons.keyboard,
size: 22,
color: Theme.of(context).colorScheme.onBackground),
highlightColor:
Theme.of(context).colorScheme.onInverseSurface,
style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.zero),
backgroundColor:
MaterialStateProperty.resolveWith((states) {
return Theme.of(context).highlightColor;
}),
)),
),
const Spacer(),
TextButton(onPressed: () => submitReplyAdd(), child: const Text('发送'))
],
),
),
AnimatedSize(
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 500),
child: SizedBox(
width: double.infinity,
height: _keyboardHeight,
),
),
],
),
);
}
}
typedef void DebounceCallback();
class Debouncer {
DebounceCallback? callback;
final int? milliseconds;
Timer? _timer;
Debouncer({this.milliseconds});
run(DebounceCallback callback) {
if (_timer != null) {
_timer!.cancel();
}
_timer = Timer(Duration(milliseconds: milliseconds!), () {
callback();
});
}
}

View File

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/reply.dart';
import 'package:pilipala/models/video/reply/data.dart';
import 'package:pilipala/models/video/reply/item.dart';
class VideoReplyReplyController extends GetxController {
VideoReplyReplyController(this.aid, this.rpid);
final ScrollController scrollController = ScrollController();
// 视频aid 请求时使用的oid
String? aid;
// rpid 请求楼中楼回复
String? rpid;
RxList<ReplyItemModel> replyList = [ReplyItemModel()].obs;
// 当前页
int currentPage = 0;
bool isLoadingMore = false;
RxBool noMore = false.obs;
// 当前回复的回复
ReplyItemModel? currentReplyItem;
// 根评论 id 回复楼中楼回复使用
int? rPid;
// 默认回复主楼
String replyLevel = '0';
@override
void onInit() {
super.onInit();
currentPage = 0;
}
// 上拉加载
Future onLoad() async {
queryReplyList(type: 'onLoad');
}
Future queryReplyList({type = 'init'}) async {
isLoadingMore = true;
var res = await ReplyHttp.replyReplyList(
oid: aid!, root: rpid!, pageNum: currentPage + 1, type: 1);
if (res['status']) {
res['data'] = ReplyData.fromJson(res['data']);
if (res['data'].replies.isNotEmpty) {
currentPage = currentPage + 1;
noMore.value = false;
} else {
if (currentPage == 0) {
} else {
noMore.value = true;
return;
}
}
if (res['data'].replies.length >= res['data'].page.count) {
noMore.value = true;
}
if (type == 'init') {
List<ReplyItemModel> replies = res['data'].replies;
// 添加置顶回复
// if (res['data'].upper.top != null) {
// bool flag = false;
// for (var i = 0; i < res['data'].topReplies.length; i++) {
// if (res['data'].topReplies[i].rpid == res['data'].upper.top.rpid) {
// flag = true;
// }
// }
// if (!flag) {
// replies.insert(0, res['data'].upper.top);
// }
// }
replies.insertAll(0, res['data'].topReplies);
res['data'].replies = replies;
replyList.value = res['data'].replies!;
} else {
replyList.addAll(res['data'].replies!);
res['data'].replies.addAll(replyList);
}
}
isLoadingMore = false;
return res;
}
@override
void onClose() {
currentPage = 0;
super.onClose();
}
}

View File

@ -0,0 +1,4 @@
library video_reply_reply_panel;
export './controller.dart';
export './view.dart';

View File

@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/skeleton/video_reply.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/video/detail/reply/widgets/reply_item.dart';
import 'controller.dart';
class VideoReplyReplyPanel extends StatefulWidget {
int? oid;
int? rpid;
Function? closePanel;
ReplyItemModel? firstFloor;
VideoReplyReplyPanel({
this.oid,
this.rpid,
this.closePanel,
this.firstFloor,
super.key,
});
@override
State<VideoReplyReplyPanel> createState() => _VideoReplyReplyPanelState();
}
class _VideoReplyReplyPanelState extends State<VideoReplyReplyPanel> {
late VideoReplyReplyController _videoReplyReplyController;
late AnimationController replyAnimationCtl;
@override
void initState() {
_videoReplyReplyController = Get.put(
VideoReplyReplyController(
widget.oid.toString(), widget.rpid.toString()),
tag: widget.rpid.toString());
super.initState();
// 上拉加载更多
_videoReplyReplyController.scrollController.addListener(
() {
if (_videoReplyReplyController.scrollController.position.pixels >=
_videoReplyReplyController
.scrollController.position.maxScrollExtent -
300) {
if (!_videoReplyReplyController.isLoadingMore) {
_videoReplyReplyController.onLoad();
}
}
},
);
}
@override
void dispose() {
// _videoReplyReplyController.scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: Get.size.height - Get.size.width * 9 / 16 - 48,
color: Theme.of(context).colorScheme.background,
child: Column(
children: [
Container(
height: 45,
padding: const EdgeInsets.only(left: 14, right: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'评论详情',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_videoReplyReplyController.currentPage = 0;
_videoReplyReplyController.rPid = 0;
widget.closePanel!();
},
),
],
),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
Expanded(
child: RefreshIndicator(
onRefresh: () async {
setState(() {});
_videoReplyReplyController.currentPage = 0;
return await _videoReplyReplyController.queryReplyList();
},
child: CustomScrollView(
controller: _videoReplyReplyController.scrollController,
slivers: <Widget>[
if (widget.firstFloor != null) ...[
SliverToBoxAdapter(
child: ReplyItem(
replyItem: widget.firstFloor,
replyLevel: '2',
),
),
SliverToBoxAdapter(
child: Divider(
height: 30,
color: Theme.of(context).dividerColor.withOpacity(0.1),
thickness: 6,
),
),
],
FutureBuilder(
future: _videoReplyReplyController.queryReplyList(),
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 ==
_videoReplyReplyController
.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(
_videoReplyReplyController
.noMore.value
? '没有更多了'
: '加载中')),
),
);
} else {
return ReplyItem(
replyItem: _videoReplyReplyController
.replyList[index],
);
}
},
childCount: _videoReplyReplyController
.replyList.length +
1,
),
),
);
} else {
// 请求错误
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return SliverList(
delegate:
SliverChildBuilderDelegate((context, index) {
return const VideoReplySkeleton();
}, childCount: 5),
);
}
},
)
],
),
),
),
],
),
);
}
}

View File

@ -6,6 +6,7 @@ import 'package:pilipala/pages/video/detail/reply/index.dart';
import 'package:pilipala/pages/video/detail/controller.dart'; import 'package:pilipala/pages/video/detail/controller.dart';
import 'package:pilipala/pages/video/detail/introduction/index.dart'; import 'package:pilipala/pages/video/detail/introduction/index.dart';
import 'package:pilipala/pages/video/detail/related/index.dart'; import 'package:pilipala/pages/video/detail/related/index.dart';
import 'package:pilipala/pages/video/detail/replyReply/index.dart';
class VideoDetailPage extends StatefulWidget { class VideoDetailPage extends StatefulWidget {
const VideoDetailPage({Key? key}) : super(key: key); const VideoDetailPage({Key? key}) : super(key: key);
@ -14,9 +15,34 @@ class VideoDetailPage extends StatefulWidget {
State<VideoDetailPage> createState() => _VideoDetailPageState(); State<VideoDetailPage> createState() => _VideoDetailPageState();
} }
class _VideoDetailPageState extends State<VideoDetailPage> { class _VideoDetailPageState extends State<VideoDetailPage>
with TickerProviderStateMixin {
final VideoDetailController videoDetailController = final VideoDetailController videoDetailController =
Get.put(VideoDetailController(), tag: Get.arguments['heroTag']); Get.put(VideoDetailController(), tag: Get.arguments['heroTag']);
late AnimationController replyAnimationCtl;
@override
void initState() {
super.initState();
replyAnimationCtl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));
videoDetailController.fRpid.listen((p0) {
if (p0 != 0) {
showReplyReplyPanel();
}
});
}
showReplyReplyPanel() {
replyAnimationCtl.forward();
}
hiddenReplyReplyPanel() {
replyAnimationCtl.reverse().then((value) {
videoDetailController.fRpid.value = 0;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -30,106 +56,138 @@ class _VideoDetailPageState extends State<VideoDetailPage> {
child: SafeArea( child: SafeArea(
top: false, top: false,
bottom: false, bottom: false,
child: Scaffold( child: Stack(
body: ExtendedNestedScrollView( children: [
headerSliverBuilder: Scaffold(
(BuildContext context, bool innerBoxIsScrolled) { body: ExtendedNestedScrollView(
return <Widget>[ headerSliverBuilder:
SliverAppBar( (BuildContext context, bool innerBoxIsScrolled) {
title: const Text("视频详情"), return <Widget>[
pinned: true, SliverAppBar(
elevation: 0, title: const Text("视频详情"),
scrolledUnderElevation: 0, pinned: true,
forceElevated: innerBoxIsScrolled, elevation: 0,
expandedHeight: MediaQuery.of(context).size.width * 9 / 16, scrolledUnderElevation: 0,
collapsedHeight: MediaQuery.of(context).size.width * 9 / 16, forceElevated: innerBoxIsScrolled,
flexibleSpace: FlexibleSpaceBar( expandedHeight:
background: Padding( MediaQuery.of(context).size.width * 9 / 16,
padding: EdgeInsets.only( collapsedHeight:
top: MediaQuery.of(context).padding.top), MediaQuery.of(context).size.width * 9 / 16,
child: LayoutBuilder( flexibleSpace: FlexibleSpaceBar(
builder: (context, boxConstraints) { background: Padding(
double maxWidth = boxConstraints.maxWidth; padding: EdgeInsets.only(
double maxHeight = boxConstraints.maxHeight; top: MediaQuery.of(context).padding.top),
double PR = MediaQuery.of(context).devicePixelRatio; child: LayoutBuilder(
return Hero( builder: (context, boxConstraints) {
tag: videoDetailController.heroTag, double maxWidth = boxConstraints.maxWidth;
child: NetworkImgLayer( double maxHeight = boxConstraints.maxHeight;
src: videoDetailController.videoItem['pic'], double PR =
width: maxWidth, MediaQuery.of(context).devicePixelRatio;
height: maxHeight, return Hero(
), tag: videoDetailController.heroTag,
); child: NetworkImgLayer(
}, src: videoDetailController.videoItem['pic'],
), width: maxWidth,
), height: maxHeight,
), ),
), );
]; },
},
pinnedHeaderSliverHeightBuilder: () {
return pinnedHeaderHeight;
},
onlyOneScrollInBody: true,
body: Column(
children: [
Container(
height: 45,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Container(
width: 280,
margin: const EdgeInsets.only(left: 20),
child: Obx(
() => TabBar(
dividerColor: Colors.transparent,
tabs: videoDetailController.tabs
.map((String name) => Tab(text: name))
.toList(),
), ),
), ),
), ),
// 弹幕开关 ),
// const Spacer(), ];
// Flexible( },
// flex: 2, pinnedHeaderSliverHeightBuilder: () {
// child: Container( return pinnedHeaderHeight;
// height: 50, },
// ), onlyOneScrollInBody: true,
// ), body: Column(
], children: [
), Container(
), height: 45,
Expanded( decoration: BoxDecoration(
child: TabBarView( border: Border(
children: [ bottom: BorderSide(
Builder( color:
builder: (context) { Theme.of(context).dividerColor.withOpacity(0.1),
return const CustomScrollView( ),
key: PageStorageKey<String>('简介'), ),
slivers: <Widget>[
VideoIntroPanel(),
RelatedVideoPanel(),
],
);
},
), ),
VideoReplyPanel() child: Row(
], mainAxisAlignment: MainAxisAlignment.center,
), mainAxisSize: MainAxisSize.max,
children: [
Container(
width: 280,
margin: const EdgeInsets.only(left: 20),
child: Obx(
() => TabBar(
dividerColor: Colors.transparent,
tabs: videoDetailController.tabs
.map((String name) => Tab(text: name))
.toList(),
),
),
),
// 弹幕开关
// const Spacer(),
// Flexible(
// flex: 2,
// child: Container(
// height: 50,
// ),
// ),
],
),
),
Expanded(
child: TabBarView(
children: [
Builder(
builder: (context) {
return const CustomScrollView(
key: PageStorageKey<String>('简介'),
slivers: <Widget>[
VideoIntroPanel(),
RelatedVideoPanel(),
],
);
},
),
VideoReplyPanel()
],
),
),
],
), ),
], ),
), ),
), 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: Obx(
() => videoDetailController.fRpid.value != 0
? VideoReplyReplyPanel(
oid: videoDetailController.oid.value,
rpid: videoDetailController.fRpid.value,
closePanel: hiddenReplyReplyPanel,
firstFloor: videoDetailController.firstFloor,
)
: const SizedBox(),
),
),
),
],
), ),
), ),
); );

View File

@ -374,6 +374,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_smart_dialog: flutter_smart_dialog:
dependency: "direct main" dependency: "direct main"
description: description:
@ -504,6 +509,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.7.1" version: "1.7.1"
intl:
dependency: transitive
description:
name: intl
sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91"
url: "https://pub.dev"
source: hosted
version: "0.17.0"
io: io:
dependency: transitive dependency: transitive
description: description:

View File

@ -30,7 +30,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2