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 {
// 推荐视频
// http://app.bilibili.com/x/v2/feed/index
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 hasFollow = '/x/relation';
// 操作用户关系
static const String relationMod = '/x/relation/modify';
// 评论列表
static const String replyList = '/x/v2/reply';

View File

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

View File

@ -205,13 +205,10 @@ class VideoHttp {
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 ?? '',
'root': root == null || root == 0 ? '' : root,
'parent': parent == null || parent == 0 ? '' : parent,
'message': message,
'csrf': await Request.getCsrf(),
@ -223,4 +220,30 @@ class VideoHttp {
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:get/get.dart';
import 'package:flutter/material.dart';
@ -41,6 +42,14 @@ class MyApp extends StatelessWidget {
),
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,
home: const MainApp(),
builder: FlutterSmartDialog.init(),

View File

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

View File

@ -42,6 +42,8 @@ class VideoIntroController extends GetxController {
Rx<FavFolderData> favFolderData = FavFolderData().obs;
List addMediaIdsNew = [];
List delMediaIdsNew = [];
// 关注状态 默认未关注
RxMap followStatus = {}.obs;
@override
void onInit() {
@ -82,6 +84,8 @@ class VideoIntroController extends GetxController {
queryHasCoinVideo();
// 获取收藏状态
queryHasFavVideo();
//
queryFollowStatus();
}
return result;
@ -228,4 +232,73 @@ class VideoIntroController extends GetxController {
favFolderData.value.list = datalist;
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),
child: SizedBox(
height: 36,
child: ElevatedButton(
onPressed: () {},
child: const Text('关注'),
child: Obx(()=>
videoIntroController.followStatus.isNotEmpty ? ElevatedButton(
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';
class VideoReplyController extends GetxController {
VideoReplyController(
this.aid,
this.rpid,
this.level
);
VideoReplyController(this.aid, this.rpid, this.level);
final ScrollController scrollController = ScrollController();
// 视频aid 请求时使用的oid
String? aid;
@ -26,7 +22,7 @@ class VideoReplyController extends GetxController {
// 当前页
int currentPage = 0;
bool isLoadingMore = false;
RxBool noMore = false.obs;
RxString noMore = ''.obs;
RxBool autoFocus = false.obs;
// 当前回复的回复
ReplyItemModel? currentReplyItem;
@ -48,17 +44,18 @@ class VideoReplyController extends GetxController {
res['data'] = ReplyData.fromJson(res['data']);
if (res['data'].replies.isNotEmpty) {
currentPage = currentPage + 1;
noMore.value = false;
noMore.value = '加载中';
if(res['data'].page.count == res['data'].page.acount){
noMore.value = '没有更多了';
}
} else {
if (currentPage == 0) {
noMore.value = '还没有评论';
} else {
noMore.value = true;
noMore.value = '没有更多了';
return;
}
}
if (res['data'].replies.length >= res['data'].page.count) {
noMore.value = true;
}
if (type == 'init') {
List<ReplyItemModel> replies = res['data'].replies;
// 添加置顶回复
@ -96,17 +93,22 @@ class VideoReplyController extends GetxController {
// 发表评论
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',
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']);

View File

@ -3,10 +3,10 @@ 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';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/video/detail/replyNew/index.dart';
import 'controller.dart';
import 'widgets/reply_item.dart';
@ -14,6 +14,7 @@ class VideoReplyPanel extends StatefulWidget {
int oid;
int rpid;
String? level;
Key? key;
VideoReplyPanel({
this.oid = 0,
this.rpid = 0,
@ -29,7 +30,6 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
late VideoReplyController _videoReplyController;
late AnimationController fabAnimationCtr;
late AnimationController replyAnimationCtl;
// List<ReplyItemModel>? replyList;
Future? _futureBuilderFuture;
@ -55,16 +55,9 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
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(
@ -86,6 +79,7 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
}
},
);
fabAnimationCtr.forward();
}
void _showFab() {
@ -112,14 +106,12 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
_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();
@ -164,9 +156,7 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
60,
child: Center(
child: Obx(() => Text(
_videoReplyController.noMore.value
? '没有更多了'
: '加载中')),
_videoReplyController.noMore.value)),
),
);
} else {
@ -211,9 +201,9 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
right: 14,
child: SlideTransition(
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),
).animate(CurvedAnimation(
parent: fabAnimationCtr,
@ -221,57 +211,25 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
)),
child: FloatingActionButton(
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: '发表评论',
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:pilipala/common/widgets/network_img_layer.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/replyNew/index.dart';
import 'package:pilipala/utils/utils.dart';
class ReplyItem extends StatelessWidget {
@ -176,13 +178,14 @@ class ReplyItem extends StatelessWidget {
// 操作区域
bottonAction(context, replyItem!.replyControl),
const SizedBox(height: 3),
if (replyItem!.replies!.isNotEmpty) ...[
if (replyItem!.replies!.isNotEmpty && replyLevel != '2') ...[
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12),
child: ReplyItemRow(
replies: replyItem!.replies,
replyControl: replyItem!.replyControl,
f_rpid: replyItem!.rpid,
replyItem: replyItem,
),
),
],
@ -231,11 +234,26 @@ class ReplyItem extends StatelessWidget {
style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.zero),
),
child: Text('回复', style: Theme.of(context)
.textTheme
.labelMedium),
onPressed: () => weakUpReply!(replyItem, replyLevel),
)),
child: Text('回复', style: Theme.of(context).textTheme.labelMedium),
onPressed: () {
showModalBottomSheet(
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(
height: 32,
child: TextButton(
@ -272,10 +290,12 @@ class ReplyItemRow extends StatelessWidget {
this.replies,
this.replyControl,
this.f_rpid,
this.replyItem
});
List? replies;
ReplyControl? replyControl;
int? f_rpid;
ReplyItemModel? replyItem;
@override
Widget build(BuildContext context) {
@ -297,7 +317,7 @@ class ReplyItemRow extends StatelessWidget {
if (extraRow == 1 && index == replies!.length) {
// 有楼中楼回复,在最后显示
return InkWell(
onTap: () => replyReply(context),
onTap: () => replyReply(replyItem),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
@ -323,7 +343,7 @@ class ReplyItemRow extends StatelessWidget {
);
} else {
return InkWell(
onTap: () {},
onTap: () => replyReply(replyItem),
child: Padding(
padding: EdgeInsets.fromLTRB(
8,
@ -338,10 +358,6 @@ class ReplyItemRow extends StatelessWidget {
: TextOverflow.visible,
maxLines: extraRow == 1 ? 2 : null,
TextSpan(
recognizer: TapGestureRecognizer()
..onTap = () {
replyReply(context);
},
children: [
TextSpan(
text: replies![index].member.uname + ' ',
@ -374,46 +390,15 @@ class ReplyItemRow extends StatelessWidget {
);
}
void replyReply(context) {
Get.bottomSheet(
barrierColor: Colors.transparent,
useRootNavigator: true,
isScrollControlled: true,
Container(
height: Get.size.height - Get.size.width * 9 / 16 - 45,
color: Theme.of(context).colorScheme.background,
child: Column(
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,
);
void replyReply(replyItem) {
// replyItem 楼主评论
Get.find<VideoDetailController>(tag: Get.arguments['heroTag'])
.oid
.value = replies!.first.oid;
Get.find<VideoDetailController>(tag: Get.arguments['heroTag'])
.fRpid
.value = f_rpid!;
Get.find<VideoDetailController>(tag: Get.arguments['heroTag']).firstFloor = replyItem;
}
}
@ -423,11 +408,10 @@ InlineSpan buildContent(BuildContext context, content) {
content.jumpUrl.isEmpty &&
content.vote.isEmpty &&
content.pictures.isEmpty) {
return TextSpan(text: content.message,
recognizer: TapGestureRecognizer()
..onTap = ()=> {
print('点击')
},);
return TextSpan(
text: content.message,
// recognizer: TapGestureRecognizer()..onTap = () => {print('点击')},
);
}
List<InlineSpan> spanChilds = [];
// 匹配表情
@ -505,10 +489,10 @@ InlineSpan buildContent(BuildContext context, content) {
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {
print('Url 点击'),
},
// recognizer: TapGestureRecognizer()
// ..onTap = () => {
// print('Url 点击'),
// },
),
);
spanChilds.add(
@ -540,10 +524,10 @@ InlineSpan buildContent(BuildContext context, content) {
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {
print('time 点击'),
},
// recognizer: TapGestureRecognizer()
// ..onTap = () => {
// print('time 点击'),
// },
),
);
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/introduction/index.dart';
import 'package:pilipala/pages/video/detail/related/index.dart';
import 'package:pilipala/pages/video/detail/replyReply/index.dart';
class VideoDetailPage extends StatefulWidget {
const VideoDetailPage({Key? key}) : super(key: key);
@ -14,9 +15,34 @@ class VideoDetailPage extends StatefulWidget {
State<VideoDetailPage> createState() => _VideoDetailPageState();
}
class _VideoDetailPageState extends State<VideoDetailPage> {
class _VideoDetailPageState extends State<VideoDetailPage>
with TickerProviderStateMixin {
final VideoDetailController videoDetailController =
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
Widget build(BuildContext context) {
@ -30,7 +56,9 @@ class _VideoDetailPageState extends State<VideoDetailPage> {
child: SafeArea(
top: false,
bottom: false,
child: Scaffold(
child: Stack(
children: [
Scaffold(
body: ExtendedNestedScrollView(
headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) {
@ -41,8 +69,10 @@ class _VideoDetailPageState extends State<VideoDetailPage> {
elevation: 0,
scrolledUnderElevation: 0,
forceElevated: innerBoxIsScrolled,
expandedHeight: MediaQuery.of(context).size.width * 9 / 16,
collapsedHeight: MediaQuery.of(context).size.width * 9 / 16,
expandedHeight:
MediaQuery.of(context).size.width * 9 / 16,
collapsedHeight:
MediaQuery.of(context).size.width * 9 / 16,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: EdgeInsets.only(
@ -51,7 +81,8 @@ class _VideoDetailPageState extends State<VideoDetailPage> {
builder: (context, boxConstraints) {
double maxWidth = boxConstraints.maxWidth;
double maxHeight = boxConstraints.maxHeight;
double PR = MediaQuery.of(context).devicePixelRatio;
double PR =
MediaQuery.of(context).devicePixelRatio;
return Hero(
tag: videoDetailController.heroTag,
child: NetworkImgLayer(
@ -78,7 +109,8 @@ class _VideoDetailPageState extends State<VideoDetailPage> {
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.1),
color:
Theme.of(context).dividerColor.withOpacity(0.1),
),
),
),
@ -131,6 +163,32 @@ class _VideoDetailPageState extends State<VideoDetailPage> {
),
),
),
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"
source: hosted
version: "2.0.1"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_smart_dialog:
dependency: "direct main"
description:
@ -504,6 +509,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.7.1"
intl:
dependency: transitive
description:
name: intl
sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91"
url: "https://pub.dev"
source: hosted
version: "0.17.0"
io:
dependency: transitive
description:

View File

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