diff --git a/lib/models/video/reply/data.dart b/lib/models/video/reply/data.dart index 3b94a008..cc419777 100644 --- a/lib/models/video/reply/data.dart +++ b/lib/models/video/reply/data.dart @@ -22,10 +22,12 @@ class ReplyData { ReplyData.fromJson(Map json) { page = ReplyPage.fromJson(json['page']); config = ReplyConfig.fromJson(json['config']); - replies = json['replies'] - .map( - (item) => ReplyItemModel.fromJson(item, json['upper']['mid'])) - .toList(); + replies = json['replies'] != null + ? json['replies'] + .map( + (item) => ReplyItemModel.fromJson(item, json['upper']['mid'])) + .toList() + : []; topReplies = json['top_replies'] != null ? json['top_replies'] .map((item) => ReplyItemModel.fromJson( diff --git a/lib/pages/video/detail/reply/controller.dart b/lib/pages/video/detail/reply/controller.dart index a823778f..23447a37 100644 --- a/lib/pages/video/detail/reply/controller.dart +++ b/lib/pages/video/detail/reply/controller.dart @@ -1,22 +1,62 @@ +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 VideoReplyController extends GetxController { + final ScrollController scrollController = ScrollController(); // 视频aid String aid = Get.parameters['aid']!; + RxList replyList = [ReplyItemModel()].obs; + // 当前页 + int currentPage = 0; + bool isLoadingMore = false; + bool noMore = false; - @override - void onInit() { - super.onInit(); - queryReplyList(); - } - - Future queryReplyList() async { - var res = await ReplyHttp.replyList(oid: aid, pageNum: 1, type: 1); + Future queryReplyList({type = 'init'}) async { + isLoadingMore = true; + var res = + await ReplyHttp.replyList(oid: aid, pageNum: currentPage + 1, type: 1); if (res['status']) { res['data'] = ReplyData.fromJson(res['data']); + if (res['data'].replies.isNotEmpty) { + currentPage = currentPage + 1; + noMore = false; + } else { + if (currentPage == 0) { + } else { + noMore = 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; } + + // 上拉加载 + Future onLoad() async { + queryReplyList(type: 'onLoad'); + } } diff --git a/lib/pages/video/detail/reply/view.dart b/lib/pages/video/detail/reply/view.dart index 67341100..b1ce3fad 100644 --- a/lib/pages/video/detail/reply/view.dart +++ b/lib/pages/video/detail/reply/view.dart @@ -17,64 +17,95 @@ class _VideoReplyPanelState extends State with AutomaticKeepAliveClientMixin { final VideoReplyController _videoReplyController = Get.put(VideoReplyController(), tag: Get.arguments['heroTag']); - + // List? replyList; + Future? _futureBuilderFuture; // 添加页面缓存 @override bool get wantKeepAlive => true; @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _videoReplyController.queryReplyList(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.data['status']) { - List replies = snapshot.data['data'].replies; - // 添加置顶回复 - if (snapshot.data['data'].upper.top != null) { - bool flag = false; - for (var i = 0; - i < snapshot.data['data'].topReplies.length; - i++) { - if (snapshot.data['data'].topReplies[i].rpid == - snapshot.data['data'].upper.top.rpid) { - flag = true; - } - } - if (!flag) { - replies.insert(0, snapshot.data['data'].upper.top); - } - } + void initState() { + super.initState(); - replies.insertAll(0, snapshot.data['data'].topReplies); - // 请求成功 - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (index == replies.length) { - return SizedBox(height: MediaQuery.of(context).padding.bottom); - } else { - return ReplyItem( - replyItem: replies[index], - isUp: - replies[index].mid == snapshot.data['data'].upper.mid); - } - }, childCount: replies.length + 1)); - } else { - // 请求错误 - return HttpError( - errMsg: snapshot.data['msg'], - fn: () => setState(() {}), - ); + _futureBuilderFuture = _videoReplyController.queryReplyList(); + _videoReplyController.scrollController.addListener( + () { + if (_videoReplyController.scrollController.position.pixels >= + _videoReplyController.scrollController.position.maxScrollExtent - + 300) { + if (!_videoReplyController.isLoadingMore) { + _videoReplyController.onLoad(); } - } else { - // 骨架屏 - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - return const VideoCardHSkeleton(); - }, childCount: 5), - ); } }, ); } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () async { + setState(() {}); + _videoReplyController.currentPage = 0; + return await _videoReplyController.queryReplyList(); + }, + child: CustomScrollView( + controller: _videoReplyController.scrollController, + key: const PageStorageKey('评论'), + slivers: [ + FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + // 请求成功 + return Obx( + () => SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == _videoReplyController.replyList.length) { + return Container( + padding: EdgeInsets.only( + bottom: + MediaQuery.of(context).padding.bottom), + height: + MediaQuery.of(context).padding.bottom + 60, + child: Center( + child: Text(_videoReplyController.noMore + ? '没有更多了' + : '加载中'), + ), + ); + } else { + return ReplyItem( + replyItem: _videoReplyController.replyList[index], + ); + } + }, + childCount: _videoReplyController.replyList.length + 1, + ), + ), + ); + } else { + // 请求错误 + return HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ); + } + } else { + // 骨架屏 + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return const VideoCardHSkeleton(); + }, childCount: 5), + ); + } + }, + ) + ], + ), + ); + } } diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index 27c22287..b5b78747 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -5,9 +5,8 @@ import 'package:pilipala/models/video/reply/item.dart'; import 'package:pilipala/utils/utils.dart'; class ReplyItem extends StatelessWidget { - ReplyItem({super.key, this.replyItem, required this.isUp}); + ReplyItem({super.key, this.replyItem}); ReplyItemModel? replyItem; - bool isUp = false; @override Widget build(BuildContext context) { @@ -45,51 +44,46 @@ class ReplyItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // 头像、昵称 - Row( - // 两端对齐 - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - GestureDetector( - // onTap: () => - // Get.toNamed('/member/${reply.userName}', parameters: { - // 'memberAvatar': reply.avatar, - // 'heroTag': reply.userName + heroTag, - // }), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - lfAvtar(context), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, + GestureDetector( + // onTap: () => + // Get.toNamed('/member/${reply.userName}', parameters: { + // 'memberAvatar': reply.avatar, + // 'heroTag': reply.userName + heroTag, + // }), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + lfAvtar(context), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Row( - children: [ - Text( - replyItem!.member!.uname!, - style: Theme.of(context) - .textTheme - .titleSmall! - .copyWith( - color: replyItem!.isUp! - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline, - ), - ), - const SizedBox(width: 6), - Image.asset( - 'assets/images/lv/lv${replyItem!.member!.level}.png', - height: 13, - ), - ], + Text( + replyItem!.member!.uname!, + style: TextStyle( + color: replyItem!.isUp! + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + fontSize: + Theme.of(context).textTheme.titleSmall!.fontSize, + ), ), + const SizedBox(width: 6), + Image.asset( + 'assets/images/lv/lv${replyItem!.member!.level}.png', + height: 11, + ), + const SizedBox(width: 6), + if (replyItem!.isUp!) UpTag() ], - ) + ), ], - ), - ), - ], + ) + ], + ), ), // title Container( @@ -102,6 +96,8 @@ class ReplyItem extends StatelessWidget { style: const TextStyle(height: 1.65), TextSpan( children: [ + if (replyItem!.isTop!) + WidgetSpan(child: UpTag(tagText: '置顶')), buildContent(context, replyItem!.content!), ], ), @@ -136,26 +132,9 @@ class ReplyItem extends StatelessWidget { .labelMedium! .copyWith(color: Theme.of(context).colorScheme.outline), ), - if (replyItem!.isTop!) ...[ - Text( - ' • 置顶', - style: TextStyle( - fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - if (replyControl!.isUpTop!) ...[ - Text( - ' • 超赞', - style: TextStyle( - fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, - color: Theme.of(context).colorScheme.primary, - ), - ), - // const SizedBox(width: 4), - ], const Spacer(), + if (replyControl!.isUpTop!) + Icon(Icons.favorite, color: Colors.red[400], size: 18), SizedBox( height: 35, child: TextButton( @@ -202,7 +181,7 @@ class ReplyItemRow extends StatelessWidget { return Container( margin: const EdgeInsets.only(left: 42, right: 4, top: 0), child: Material( - color: Theme.of(context).colorScheme.onInverseSurface.withOpacity(0.7), + color: Theme.of(context).colorScheme.onInverseSurface, borderRadius: BorderRadius.circular(6), clipBehavior: Clip.hardEdge, animationDuration: Duration.zero, @@ -245,21 +224,15 @@ class ReplyItemRow extends StatelessWidget { child: Padding( padding: EdgeInsets.fromLTRB(8, index == 0 ? 8 : 4, 8, 4), child: Text.rich( - overflow: TextOverflow.ellipsis, - maxLines: 2, + overflow: extraRow == 1 + ? TextOverflow.ellipsis + : TextOverflow.visible, + maxLines: extraRow == 1 ? 2 : null, TextSpan( children: [ if (replies![index].isUp) - TextSpan( - text: 'UP • ', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: Theme.of(context) - .textTheme - .labelMedium! - .fontSize, - color: Theme.of(context).colorScheme.primary, - ), + WidgetSpan( + child: UpTag(), ), TextSpan( text: replies![index].member.uname + ' ', @@ -417,3 +390,31 @@ InlineSpan buildContent(BuildContext context, content) { // spanChilds.add(TextSpan(text: matchMember)); return TextSpan(children: spanChilds); } + +class UpTag extends StatelessWidget { + String? tagText; + UpTag({super.key, this.tagText = 'UP'}); + + @override + Widget build(BuildContext context) { + return Container( + width: tagText == 'UP' ? 28 : 38, + height: tagText == 'UP' ? 17 : 19, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + // color: Theme.of(context).colorScheme.primary, + border: Border.all(color: Theme.of(context).colorScheme.primary)), + margin: const EdgeInsets.only(right: 4), + // padding: const EdgeInsets.symmetric(vertical: 0.5, horizontal: 4), + child: Center( + child: Text( + tagText!, + style: TextStyle( + fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + } +} diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index ed2e1047..1ff41764 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -1,3 +1,4 @@ +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:get/get.dart'; import 'package:flutter/material.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; @@ -19,6 +20,10 @@ class _VideoDetailPageState extends State { @override Widget build(BuildContext context) { + final double statusBarHeight = MediaQuery.of(context).padding.top; + final double pinnedHeaderHeight = statusBarHeight + + kToolbarHeight + + MediaQuery.of(context).size.width * 9 / 16; return DefaultTabController( initialIndex: videoDetailController.tabInitialIndex, length: videoDetailController.tabs.length, // tab的数量. @@ -26,126 +31,115 @@ class _VideoDetailPageState extends State { top: false, bottom: false, child: Scaffold( - body: NestedScrollView( + body: ExtendedNestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ - SliverOverlapAbsorber( - handle: - NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: SliverAppBar( - title: const Text("视频详情"), - // floating: true, - // snap: true, - pinned: true, - elevation: 0, - scrolledUnderElevation: 0, - forceElevated: innerBoxIsScrolled, - expandedHeight: MediaQuery.of(context).size.width * 9 / 16, - collapsedHeight: MediaQuery.of(context).size.width * 9 / 16, - toolbarHeight: kToolbarHeight, - flexibleSpace: FlexibleSpaceBar( - background: Padding( - padding: EdgeInsets.only( - bottom: 50, - 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, - ), - ); - }, - ), - ), - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(50.0), - child: Container( - height: 50, - 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( - splashBorderRadius: BorderRadius.circular(6), - dividerColor: Colors.transparent, - tabs: videoDetailController.tabs - .map((String name) => Tab(text: name)) - .toList(), - ), - ), + 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, - // ), - // ), - ], - ), + ); + }, ), ), ), ), ]; }, - body: TabBarView( + pinnedHeaderSliverHeightBuilder: () { + return pinnedHeaderHeight; + }, + onlyOneScrollInBody: true, + body: Column( children: [ - Builder(builder: (context) { - return CustomScrollView( - key: const PageStorageKey('简介'), - slivers: [ - SliverOverlapInjector( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor( - context), + Container( + height: 50, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withOpacity(0.1), ), - const VideoIntroPanel(), - SliverPadding( - padding: const EdgeInsets.only(top: 8, bottom: 5), - sliver: SliverToBoxAdapter( - child: Divider( - height: 1, - 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( + splashBorderRadius: BorderRadius.circular(6), + dividerColor: Colors.transparent, + tabs: videoDetailController.tabs + .map((String name) => Tab(text: name)) + .toList(), ), ), ), - const RelatedVideoPanel(), + // 弹幕开关 + // const Spacer(), + // Flexible( + // flex: 2, + // child: Container( + // height: 50, + // ), + // ), ], - ); - }), - Builder(builder: (context) { - return CustomScrollView( - key: const PageStorageKey('评论'), - slivers: [ - SliverOverlapInjector( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor( - context), + ), + ), + Expanded( + child: TabBarView( + children: [ + Builder( + builder: (context) { + return CustomScrollView( + key: const PageStorageKey('简介'), + slivers: [ + const VideoIntroPanel(), + SliverPadding( + padding: + const EdgeInsets.only(top: 8, bottom: 5), + sliver: SliverToBoxAdapter( + child: Divider( + height: 1, + color: Theme.of(context) + .dividerColor + .withOpacity(0.1), + ), + ), + ), + const RelatedVideoPanel(), + ], + ); + }, ), const VideoReplyPanel() ], - ); - }) + ), + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index 2a6cbf57..24787131 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.3" + extended_nested_scroll_view: + dependency: "direct main" + description: + name: extended_nested_scroll_view + sha256: fc55b8f7e2c78701320d7eccda3b256387290b8498f0363d8ffd6f16760949d7 + url: "https://pub.dev" + source: hosted + version: "6.0.0" fake_async: dependency: transitive description: @@ -509,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: "15c54a459ec2c17b4705450483f3d5a2858e733aee893dcee9d75fd04814940d" + url: "https://pub.dev" + source: hosted + version: "0.3.3" win32: dependency: transitive description: @@ -535,4 +551,4 @@ packages: version: "6.2.2" sdks: dart: ">=2.19.6 <3.0.0" - flutter: ">=3.4.0-17.0.pre" + flutter: ">=3.7.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7eaab185..45b193f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,8 @@ dependencies: # 存储 path_provider: ^2.0.14 + extended_nested_scroll_view: ^6.0.0 + dev_dependencies: flutter_test: sdk: flutter