import 'dart:math'; import 'package:PiliPlus/common/skeleton/video_reply.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/custom_sliver_persistent_header_delegate.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show ReplyInfo; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/dynamics/widgets/author_panel.dart'; import 'package:PiliPlus/pages/dynamics/widgets/dynamic_panel.dart'; import 'package:PiliPlus/pages/dynamics_detail/controller.dart'; import 'package:PiliPlus/pages/dynamics_repost/view.dart'; import 'package:PiliPlus/pages/video/reply/widgets/reply_item_grpc.dart'; import 'package:PiliPlus/pages/video/reply_reply/view.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/num_util.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; class DynamicDetailPage extends StatefulWidget { const DynamicDetailPage({super.key}); @override State createState() => _DynamicDetailPageState(); } class _DynamicDetailPageState extends State with TickerProviderStateMixin { final _controller = Get.put(DynamicDetailController(), tag: Utils.generateRandomString(8)); late final AnimationController _fabAnimationCtr; late final Animation _anim; final RxBool _visibleTitle = false.obs; bool _isFabVisible = true; bool? _imageStatus; late final List _ratio = Pref.dynamicDetailRatio; bool get _horizontalPreview => context.orientation == Orientation.landscape && _controller.horizontalPreview; late final _key = GlobalKey(); Function(dynamic imgList, dynamic index)? get _getImageCallback => _horizontalPreview ? (imgList, index) { _imageStatus = true; bool isFabVisible = _isFabVisible; if (isFabVisible) { _hideFab(); } final ctr = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), )..forward(); PageUtils.onHorizontalPreview( _key, AnimationController( vsync: this, duration: Duration.zero, ), ctr, imgList, index, (value) async { _imageStatus = null; if (isFabVisible) { isFabVisible = false; _showFab(); } if (value == false) { await ctr.reverse(); } try { ctr.dispose(); } catch (_) {} if (value == false) { Get.back(); } }, ); } : null; @override void initState() { super.initState(); _fabAnimationCtr = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); _anim = Tween( begin: const Offset(0, 1), end: Offset.zero, ).animate(CurvedAnimation( parent: _fabAnimationCtr, curve: Curves.easeInOut, )); _fabAnimationCtr.forward(); _controller.scrollController.addListener(listener); } // 查看二级评论 void replyReply(BuildContext context, ReplyInfo replyItem, int? id) { EasyThrottle.throttle('replyReply', const Duration(milliseconds: 500), () { int oid = replyItem.oid.toInt(); int rpid = replyItem.id.toInt(); Widget replyReplyPage({ bool automaticallyImplyLeading = true, VoidCallback? onDispose, }) => Scaffold( appBar: AppBar( title: const Text('评论详情'), titleSpacing: automaticallyImplyLeading ? null : 12, automaticallyImplyLeading: automaticallyImplyLeading, ), body: SafeArea( top: false, bottom: false, child: VideoReplyReplyPanel( enableSlide: false, id: id, oid: oid, rpid: rpid, isVideoDetail: false, replyType: _controller.replyType, firstFloor: replyItem, onDispose: onDispose, ), ), ); if (this.context.orientation == Orientation.portrait) { Get.to( replyReplyPage, routeName: 'dynamicDetail-Copy', arguments: { 'item': _controller.dynItem, }, ); } else { ScaffoldState? scaffoldState = Scaffold.maybeOf(context); if (scaffoldState != null) { bool isFabVisible = _isFabVisible; if (isFabVisible) { _hideFab(); } scaffoldState.showBottomSheet( backgroundColor: Colors.transparent, (context) => MediaQuery.removePadding( context: context, removeLeft: true, child: replyReplyPage( automaticallyImplyLeading: false, onDispose: () { if (isFabVisible && _imageStatus != true) { _showFab(); } }, ), ), ); } else { Get.to( replyReplyPage, routeName: 'dynamicDetail-Copy', arguments: { 'item': _controller.dynItem, }, ); } } }); } @override void didChangeDependencies() { super.didChangeDependencies(); WidgetsBinding.instance.addPostFrameCallback((_) { if (_controller.scrollController.hasClients) { _visibleTitle.value = _controller.scrollController.positions.first.pixels > 55; } }); } void listener() { // 标题 _visibleTitle.value = _controller.scrollController.positions.first.pixels > 55; // fab按钮 final ScrollDirection direction1 = _controller.scrollController.positions.first.userScrollDirection; late final ScrollDirection direction2 = _controller.scrollController.positions.last.userScrollDirection; if (direction1 == ScrollDirection.forward || direction2 == ScrollDirection.forward) { _showFab(); } else if (direction1 == ScrollDirection.reverse || direction2 == ScrollDirection.reverse) { _hideFab(); } } void _showFab() { if (!_isFabVisible) { _isFabVisible = true; _fabAnimationCtr.forward(); } } void _hideFab() { if (_isFabVisible) { _isFabVisible = false; _fabAnimationCtr.reverse(); } } @override void dispose() { _fabAnimationCtr.dispose(); _controller.scrollController.removeListener(listener); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( title: Padding( padding: const EdgeInsets.only(right: 12), child: Obx( () { return AnimatedOpacity( opacity: _visibleTitle.value ? 1 : 0, duration: const Duration(milliseconds: 300), child: IgnorePointer( ignoring: !_visibleTitle.value, child: AuthorPanel( item: _controller.dynItem, isDetail: true, ), ), ); }, ), ), actions: context.orientation == Orientation.landscape ? [ IconButton( tooltip: '页面比例调节', onPressed: () => showDialog( context: context, builder: (context) => Align( alignment: Alignment.topRight, child: Container( margin: const EdgeInsets.only( top: 56, right: 16, ), width: context.width / 4, height: 32, child: Builder( builder: (context) => Slider( min: 1, max: 100, value: _ratio.first, onChanged: (value) { if (value >= 10 && value <= 90) { _ratio[0] = value.toPrecision(2); _ratio[1] = 100 - value; GStorage.setting.put( SettingBoxKey.dynamicDetailRatio, _ratio, ); (context as Element).markNeedsBuild(); setState(() {}); } }, ), ), ), ), ), icon: Transform.rotate( angle: pi / 2, child: const Icon(Icons.splitscreen, size: 19), ), ), const SizedBox(width: 16), ] : null, ), body: SafeArea( top: false, bottom: false, child: context.orientation == Orientation.portrait ? refreshIndicator( onRefresh: _controller.onRefresh, child: _buildBody(context.orientation, theme), ) : _buildBody(context.orientation, theme), ), ); } Widget _buildBody(Orientation orientation, ThemeData theme) => Stack( clipBehavior: Clip.none, children: [ Builder( builder: (context) { double padding = max(context.width / 2 - Grid.smallCardWidth, 0); if (orientation == Orientation.portrait) { return CustomScrollView( controller: _controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter( child: DynamicPanel( item: _controller.dynItem, isDetail: true, callback: _getImageCallback, ), ), replyPersistentHeader(theme), Obx( () => replyList(theme, _controller.loadingState.value), ), ] .map((e) => SliverPadding( padding: EdgeInsets.symmetric(horizontal: padding), sliver: e)) .toList(), ); } else { return Row( children: [ Expanded( flex: _ratio[0].toInt(), child: CustomScrollView( controller: _controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( padding: EdgeInsets.only( left: padding / 4, bottom: MediaQuery.paddingOf(context).bottom + 80, ), sliver: SliverToBoxAdapter( child: DynamicPanel( item: _controller.dynItem, isDetail: true, callback: _getImageCallback, ), ), ), ], ), ), Expanded( flex: _ratio[1].toInt(), child: Scaffold( key: _key, backgroundColor: Colors.transparent, body: refreshIndicator( onRefresh: _controller.onRefresh, child: CustomScrollView( controller: _controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( padding: EdgeInsets.only(right: padding / 4), sliver: replyPersistentHeader(theme), ), SliverPadding( padding: EdgeInsets.only(right: padding / 4), sliver: Obx( () => replyList( theme, _controller.loadingState.value), ), ), ], ), ), ), ), ], ); } }, ), Positioned( left: 0, right: 0, bottom: 0, child: SlideTransition( position: _anim, child: Builder( builder: (context) { Widget button() => FloatingActionButton( heroTag: null, onPressed: () { feedBack(); _controller.onReply( context, oid: _controller.oid, replyType: _controller.replyType, ); }, tooltip: '评论动态', child: const Icon(Icons.reply), ); return !_controller.showDynActionBar ? Align( alignment: Alignment.bottomRight, child: Padding( padding: EdgeInsets.only( right: 14, bottom: MediaQuery.paddingOf(context).bottom + 14, ), child: button(), ), ) : Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Padding( padding: const EdgeInsets.only(right: 14, bottom: 14), child: button(), ), Container( decoration: BoxDecoration( color: theme.colorScheme.surface, border: Border( top: BorderSide( color: theme.colorScheme.outline .withValues(alpha: 0.08), ), ), ), padding: EdgeInsets.only( bottom: MediaQuery.paddingOf(context).bottom), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Expanded( child: Builder( builder: (btnContext) => TextButton.icon( onPressed: () => showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (context) => RepostPanel( item: _controller.dynItem, callback: () { int count = _controller .dynItem .modules .moduleStat ?.forward ?.count ?? 0; _controller.dynItem.modules .moduleStat ??= ModuleStatModel(); _controller .dynItem .modules .moduleStat ?.forward ??= DynamicStat(); _controller .dynItem .modules .moduleStat! .forward! .count = count + 1; if (btnContext.mounted) { (btnContext as Element?) ?.markNeedsBuild(); } }, ), ), icon: Icon( FontAwesomeIcons.shareFromSquare, size: 16, color: theme.colorScheme.outline, semanticLabel: "转发", ), style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB( 15, 0, 15, 0), foregroundColor: theme.colorScheme.outline, ), label: Text( _controller.dynItem.modules.moduleStat ?.forward?.count != null ? NumUtil.numFormat(_controller .dynItem .modules .moduleStat! .forward! .count) : '转发', ), ), ), ), Expanded( child: TextButton.icon( onPressed: () => Utils.shareText( '${HttpString.dynamicShareBaseUrl}/${_controller.dynItem.idStr}'), icon: Icon( CustomIcon.share_node, size: 16, color: theme.colorScheme.outline, semanticLabel: "分享", ), style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB( 15, 0, 15, 0), foregroundColor: theme.colorScheme.outline, ), label: const Text('分享'), ), ), Expanded( child: Builder( builder: (context) => TextButton.icon( onPressed: () => RequestUtils.onLikeDynamic( _controller.dynItem, () { if (context.mounted) { (context as Element?) ?.markNeedsBuild(); } }, ), icon: Icon( _controller.dynItem.modules.moduleStat ?.like?.status == true ? FontAwesomeIcons.solidThumbsUp : FontAwesomeIcons.thumbsUp, size: 16, color: _controller .dynItem .modules .moduleStat ?.like ?.status == true ? theme.colorScheme.primary : theme.colorScheme.outline, semanticLabel: _controller .dynItem .modules .moduleStat ?.like ?.status == true ? "已赞" : "点赞", ), style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB( 15, 0, 15, 0), foregroundColor: theme.colorScheme.outline, ), label: AnimatedSwitcher( duration: const Duration(milliseconds: 400), transitionBuilder: (Widget child, Animation animation) { return ScaleTransition( scale: animation, child: child); }, child: Text( _controller .dynItem .modules .moduleStat ?.like ?.count != null ? NumUtil.numFormat(_controller .dynItem .modules .moduleStat! .like! .count) : '点赞', style: TextStyle( color: _controller .dynItem .modules .moduleStat ?.like ?.status == true ? theme.colorScheme.primary : theme.colorScheme.outline, ), ), ), ), ), ), ], ), ), ], ); }, ), ), ), ], ); SliverPersistentHeader replyPersistentHeader(ThemeData theme) { return SliverPersistentHeader( delegate: CustomSliverPersistentHeaderDelegate( bgColor: theme.colorScheme.surface, child: Container( height: 45, padding: const EdgeInsets.only(left: 12, right: 6), child: Row( children: [ Obx( () => AnimatedSwitcher( duration: const Duration(milliseconds: 400), transitionBuilder: (Widget child, Animation animation) { return ScaleTransition(scale: animation, child: child); }, child: Text( '${_controller.count.value == -1 ? 0 : NumUtil.numFormat(_controller.count.value)}条回复', key: ValueKey(_controller.count.value), ), ), ), const Spacer(), SizedBox( height: 35, child: TextButton.icon( onPressed: _controller.queryBySort, icon: Icon( Icons.sort, size: 16, color: theme.colorScheme.secondary, ), label: Obx(() => Text( _controller.sortType.value.label, style: TextStyle( fontSize: 13, color: theme.colorScheme.secondary, ), )), ), ) ], ), ), ), pinned: true, ); } Widget replyList( ThemeData theme, LoadingState?> loadingState) { return switch (loadingState) { Loading() => SliverList.builder( itemBuilder: (context, index) { return const VideoReplySkeleton(); }, itemCount: 8, ), Success(:var response) => response?.isNotEmpty == true ? SliverList.builder( itemBuilder: (context, index) { if (index == response.length) { _controller.onLoadMore(); return Container( alignment: Alignment.center, margin: EdgeInsets.only( bottom: MediaQuery.paddingOf(context).bottom), height: 125, child: Text( _controller.isEnd ? '没有更多了' : '加载中...', style: TextStyle( fontSize: 12, color: theme.colorScheme.outline, ), ), ); } else { return ReplyItemGrpc( replyItem: response[index], replyLevel: 1, replyReply: (replyItem, id) => replyReply(context, replyItem, id), onReply: (replyItem) => _controller.onReply( context, replyItem: replyItem, ), onDelete: (item, subIndex) => _controller.onRemove(index, item, subIndex), upMid: _controller.upMid, callback: _getImageCallback, onCheckReply: (item) => _controller.onCheckReply(item, isManual: true), onToggleTop: (item) => _controller.onToggleTop( item, index, _controller.oid, _controller.replyType, ), ); } }, itemCount: response!.length + 1, ) : HttpError( onReload: _controller.onReload, ), Error(:var errMsg) => HttpError( errMsg: errMsg, onReload: _controller.onReload, ), }; } }