From 51c4a082acc249c0bb4680315716523c7c8a6b17 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 21 May 2023 16:19:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA+=E5=85=B3=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 7 + lib/http/reply.dart | 3 +- lib/http/video.dart | 31 ++- lib/main.dart | 9 + lib/pages/video/detail/controller.dart | 7 + .../video/detail/introduction/controller.dart | 73 ++++++ lib/pages/video/detail/introduction/view.dart | 8 +- lib/pages/video/detail/reply/controller.dart | 44 ++-- lib/pages/video/detail/reply/view.dart | 82 ++---- .../detail/reply/widgets/reply_item.dart | 126 ++++----- lib/pages/video/detail/replyNew/index.dart | 3 + lib/pages/video/detail/replyNew/view.dart | 220 ++++++++++++++++ .../video/detail/replyReply/controller.dart | 87 ++++++ lib/pages/video/detail/replyReply/index.dart | 4 + lib/pages/video/detail/replyReply/view.dart | 187 +++++++++++++ lib/pages/video/detail/view.dart | 248 +++++++++++------- pubspec.lock | 13 + pubspec.yaml | 3 +- 18 files changed, 897 insertions(+), 258 deletions(-) diff --git a/lib/http/api.dart b/lib/http/api.dart index 5c772654..9684f337 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -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'; diff --git a/lib/http/reply.dart b/lib/http/reply.dart index e9a609d2..2e976994 100644 --- a/lib/http/reply.dart +++ b/lib/http/reply.dart @@ -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'], }; } } diff --git a/lib/http/video.dart b/lib/http/video.dart index cf46d5a4..994d8e09 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -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': []}; + } + } } diff --git a/lib/main.dart b/lib/main.dart index f853f4d7..1b8fa2f6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(), diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index 744c6c74..e3e53c2f 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -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(); diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index faf7cfb6..b1532ae2 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -42,6 +42,8 @@ class VideoIntroController extends GetxController { Rx 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('确认'), + ) + ], + ); + }, + ); + + } } diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart index 843126ed..4a5dccf5 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -380,9 +380,11 @@ class _VideoInfoState extends State 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(), ), ), ), diff --git a/lib/pages/video/detail/reply/controller.dart b/lib/pages/video/detail/reply/controller.dart index 267b0354..c2c5d3aa 100644 --- a/lib/pages/video/detail/reply/controller.dart +++ b/lib/pages/video/detail/reply/controller.dart @@ -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 replies = res['data'].replies; // 添加置顶回复 @@ -96,21 +93,26 @@ 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']){ + if (result['status']) { SmartDialog.showToast(result['data']['success_toast']); - }else{ + } else { SmartDialog.showToast(result['message']); } } diff --git a/lib/pages/video/detail/reply/view.dart b/lib/pages/video/detail/reply/view.dart index 89aa090f..1e5ffd22 100644 --- a/lib/pages/video/detail/reply/view.dart +++ b/lib/pages/video/detail/reply/view.dart @@ -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 with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { late VideoReplyController _videoReplyController; late AnimationController fabAnimationCtr; - late AnimationController replyAnimationCtl; // List? replyList; Future? _futureBuilderFuture; @@ -55,16 +55,9 @@ class _VideoReplyPanelState extends State 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 } }, ); + fabAnimationCtr.forward(); } void _showFab() { @@ -112,14 +106,12 @@ class _VideoReplyPanelState extends State _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 60, child: Center( child: Obx(() => Text( - _videoReplyController.noMore.value - ? '没有更多了' - : '加载中')), + _videoReplyController.noMore.value)), ), ); } else { @@ -211,9 +201,9 @@ class _VideoReplyPanelState extends State right: 14, child: SlideTransition( position: Tween( - // 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 )), 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( - 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('发送'), - ) - ], - ), - ), - ), - ), - ), - ), ], ), ), diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index d063cd5a..1eed5827 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -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, ), ), ], @@ -225,17 +228,32 @@ class ReplyItem extends StatelessWidget { if (replyItem!.upAction!.like!) Icon(Icons.favorite, color: Colors.red[400], size: 18), SizedBox( - 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), - )), + height: 28, + width: 42, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + 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(tag: Get.arguments['heroTag']) + .oid + .value = replies!.first.oid; + Get.find(tag: Get.arguments['heroTag']) + .fRpid + .value = f_rpid!; + Get.find(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 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 ''; diff --git a/lib/pages/video/detail/replyNew/index.dart b/lib/pages/video/detail/replyNew/index.dart index e69de29b..c8bbc951 100644 --- a/lib/pages/video/detail/replyNew/index.dart +++ b/lib/pages/video/detail/replyNew/index.dart @@ -0,0 +1,3 @@ +library video_reply_new; + +export './view.dart'; \ No newline at end of file diff --git a/lib/pages/video/detail/replyNew/view.dart b/lib/pages/video/detail/replyNew/view.dart index e69de29b..9b7832af 100644 --- a/lib/pages/video/detail/replyNew/view.dart +++ b/lib/pages/video/detail/replyNew/view.dart @@ -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 createState() => _VideoReplyNewDialogState(); +} + +class _VideoReplyNewDialogState extends State + with WidgetsBindingObserver { + final TextEditingController _replyContentController = TextEditingController(); + final FocusNode replyContentFocusNode = FocusNode(); + final GlobalKey _formKey = GlobalKey(); + 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(); + }); + } +} diff --git a/lib/pages/video/detail/replyReply/controller.dart b/lib/pages/video/detail/replyReply/controller.dart index e69de29b..b36c28c4 100644 --- a/lib/pages/video/detail/replyReply/controller.dart +++ b/lib/pages/video/detail/replyReply/controller.dart @@ -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 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 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(); + } +} \ No newline at end of file diff --git a/lib/pages/video/detail/replyReply/index.dart b/lib/pages/video/detail/replyReply/index.dart index e69de29b..1040b9ee 100644 --- a/lib/pages/video/detail/replyReply/index.dart +++ b/lib/pages/video/detail/replyReply/index.dart @@ -0,0 +1,4 @@ +library video_reply_reply_panel; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/video/detail/replyReply/view.dart b/lib/pages/video/detail/replyReply/view.dart index e69de29b..db717afc 100644 --- a/lib/pages/video/detail/replyReply/view.dart +++ b/lib/pages/video/detail/replyReply/view.dart @@ -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 createState() => _VideoReplyReplyPanelState(); +} + +class _VideoReplyReplyPanelState extends State { + 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: [ + 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), + ); + } + }, + ) + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index 3265ea24..ea71b7fc 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -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 createState() => _VideoDetailPageState(); } -class _VideoDetailPageState extends State { +class _VideoDetailPageState extends State + 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,106 +56,138 @@ class _VideoDetailPageState extends State { child: SafeArea( top: false, bottom: false, - child: Scaffold( - body: ExtendedNestedScrollView( - headerSliverBuilder: - (BuildContext context, bool innerBoxIsScrolled) { - return [ - SliverAppBar( - title: const Text("视频详情"), - pinned: true, - elevation: 0, - scrolledUnderElevation: 0, - forceElevated: innerBoxIsScrolled, - expandedHeight: MediaQuery.of(context).size.width * 9 / 16, - collapsedHeight: MediaQuery.of(context).size.width * 9 / 16, - flexibleSpace: FlexibleSpaceBar( - background: Padding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top), - child: LayoutBuilder( - builder: (context, boxConstraints) { - double maxWidth = boxConstraints.maxWidth; - double maxHeight = boxConstraints.maxHeight; - double PR = MediaQuery.of(context).devicePixelRatio; - 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(), + child: Stack( + children: [ + Scaffold( + body: ExtendedNestedScrollView( + headerSliverBuilder: + (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverAppBar( + title: const Text("视频详情"), + pinned: true, + elevation: 0, + scrolledUnderElevation: 0, + forceElevated: innerBoxIsScrolled, + expandedHeight: + MediaQuery.of(context).size.width * 9 / 16, + collapsedHeight: + MediaQuery.of(context).size.width * 9 / 16, + flexibleSpace: FlexibleSpaceBar( + background: Padding( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top), + child: LayoutBuilder( + builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + double PR = + MediaQuery.of(context).devicePixelRatio; + return Hero( + tag: videoDetailController.heroTag, + child: NetworkImgLayer( + src: videoDetailController.videoItem['pic'], + width: maxWidth, + height: maxHeight, + ), + ); + }, ), ), ), - // 弹幕开关 - // const Spacer(), - // Flexible( - // flex: 2, - // child: Container( - // height: 50, - // ), - // ), - ], - ), - ), - Expanded( - child: TabBarView( - children: [ - Builder( - builder: (context) { - return const CustomScrollView( - key: PageStorageKey('简介'), - slivers: [ - VideoIntroPanel(), - RelatedVideoPanel(), - ], - ); - }, + ), + ]; + }, + 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), + ), + ), ), - 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('简介'), + slivers: [ + VideoIntroPanel(), + RelatedVideoPanel(), + ], + ); + }, + ), + VideoReplyPanel() + ], + ), + ), + ], ), - ], + ), ), - ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 2), + end: const Offset(0, 0), + ).animate(CurvedAnimation( + parent: replyAnimationCtl, + curve: Curves.easeInOut, + )), + child: Obx( + () => videoDetailController.fRpid.value != 0 + ? VideoReplyReplyPanel( + oid: videoDetailController.oid.value, + rpid: videoDetailController.fRpid.value, + closePanel: hiddenReplyReplyPanel, + firstFloor: videoDetailController.firstFloor, + ) + : const SizedBox(), + ), + ), + ), + ], ), ), ); diff --git a/pubspec.lock b/pubspec.lock index e621ffaf..3fc4304b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 8094c95d..ac13cef2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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