import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart'; import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; import 'package:PiliPlus/grpc/app/main/community/reply/v1/reply.pb.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/video/reply/item.dart'; import 'package:PiliPlus/pages/video/detail/reply/widgets/reply_item.dart'; import 'package:PiliPlus/pages/video/detail/reply/widgets/reply_item_grpc.dart'; import 'package:PiliPlus/pages/video/detail/reply_new/reply_page.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/global_data.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:PiliPlus/common/skeleton/video_reply.dart'; import 'package:PiliPlus/common/widgets/http_error.dart'; import 'package:PiliPlus/models/common/reply_type.dart'; import 'package:get/get_navigation/src/dialog/dialog_route.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'controller.dart'; class VideoReplyReplyPanel extends StatefulWidget { const VideoReplyReplyPanel({ super.key, this.id, this.oid, this.rpid, this.dialog, this.firstFloor, this.source, this.replyType, this.isDialogue = false, this.isTop = false, this.onViewImage, this.onDismissed, }); final int? id; final int? oid; final int? rpid; final int? dialog; final dynamic firstFloor; final String? source; final ReplyType? replyType; final bool isDialogue; final bool isTop; final VoidCallback? onViewImage; final ValueChanged? onDismissed; @override State createState() => _VideoReplyReplyPanelState(); } class _VideoReplyReplyPanelState extends State { late VideoReplyReplyController _videoReplyReplyController; late final _savedReplies = {}; late final itemPositionsListener = ItemPositionsListener.create(); late final _key = GlobalKey(); dynamic get firstFloor => widget.firstFloor ?? _videoReplyReplyController.firstFloor; bool get _horizontalPreview => context.orientation == Orientation.landscape && _videoReplyReplyController.horizontalPreview; @override void initState() { super.initState(); _videoReplyReplyController = Get.put( VideoReplyReplyController( hasRoot: widget.firstFloor != null, id: widget.id, oid: widget.oid, rpid: widget.rpid, dialog: widget.dialog, replyType: widget.replyType!, isDialogue: widget.isDialogue, ), tag: '${widget.rpid}${widget.dialog}${widget.isDialogue}', ); } @override void dispose() { _videoReplyReplyController.controller?.stop(); _videoReplyReplyController.controller?.dispose(); _videoReplyReplyController.controller = null; Get.delete( tag: '${widget.rpid}${widget.dialog}${widget.isDialogue}', ); super.dispose(); } Widget get _header => firstFloor == null ? _sortWidget : ValueListenableBuilder>( valueListenable: itemPositionsListener.itemPositions, builder: (context, positions, child) { int min = -1; if (positions.isNotEmpty) { min = positions .where( (ItemPosition position) => position.itemTrailingEdge > 0) .reduce((ItemPosition min, ItemPosition position) => position.itemTrailingEdge < min.itemTrailingEdge ? position : min) .index; } return min >= 2 ? _sortWidget : const SizedBox.shrink(); }, ); @override Widget build(BuildContext context) { return Scaffold( key: _key, resizeToAvoidBottomInset: false, body: Container( // height: widget.source == 'videoDetail' // ? Utils.getSheetHeight(context) // : null, color: Theme.of(context).colorScheme.surface, child: Column( children: [ if (widget.source == 'videoDetail') Container( height: 45, padding: const EdgeInsets.only(left: 12, right: 2), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(widget.isDialogue ? '对话列表' : '评论详情'), IconButton( tooltip: '关闭', icon: const Icon(Icons.close, size: 20), onPressed: Get.back, ), ], ), ), Divider( height: 1, color: Theme.of(context).dividerColor.withOpacity(0.1), ), Expanded( child: refreshIndicator( onRefresh: () async { await _videoReplyReplyController.onRefresh(); }, child: Obx( () => Stack( children: [ ScrollablePositionedList.builder( itemPositionsListener: itemPositionsListener, itemCount: _itemCount( _videoReplyReplyController.loadingState.value), itemScrollController: _videoReplyReplyController.itemScrollCtr, physics: const AlwaysScrollableScrollPhysics(), itemBuilder: (context, index) { if (widget.isDialogue) { return _buildBody( _videoReplyReplyController.loadingState.value, index); } else if (firstFloor != null) { if (index == 0) { return GlobalData().grpcReply ? ReplyItemGrpc( replyItem: firstFloor, replyLevel: '2', showReplyRow: false, replyType: widget.replyType, needDivider: false, onReply: () { _onReply(firstFloor, -1); }, upMid: _videoReplyReplyController.upMid, isTop: widget.isTop, onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, callback: _getImageCallback, ) : ReplyItem( replyItem: firstFloor, replyLevel: '2', showReplyRow: false, replyType: widget.replyType, needDivider: false, onReply: () { _onReply(firstFloor, -1); }, onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, callback: _getImageCallback, ); } else if (index == 1) { return Divider( height: 20, color: Theme.of(context) .dividerColor .withOpacity(0.1), thickness: 6, ); } else if (index == 2) { return _sortWidget; } else { return _buildBody( _videoReplyReplyController.loadingState.value, index - 3); } } else { if (index == 0) { return _sortWidget; } else { return _buildBody( _videoReplyReplyController.loadingState.value, index - 1); } } }, ), if (!widget.isDialogue && _videoReplyReplyController.loadingState.value is Success) _header, ], ), ), ), ), ], ), ), ); } Widget get _sortWidget => Container( height: 40, padding: const EdgeInsets.fromLTRB(12, 0, 6, 0), color: Theme.of(context).colorScheme.surface, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Obx( () => _videoReplyReplyController.count.value != -1 ? Text( '相关回复共${_videoReplyReplyController.count.value}条', style: const TextStyle(fontSize: 13), ) : const SizedBox.shrink(), ), if (GlobalData().grpcReply) SizedBox( height: 35, child: TextButton.icon( onPressed: () => _videoReplyReplyController.queryBySort(), icon: Icon( Icons.sort, size: 16, color: Theme.of(context).colorScheme.secondary, ), label: Obx( () => Text( _videoReplyReplyController.mode.value == Mode.MAIN_LIST_HOT ? '按热度' : '按时间', style: TextStyle( fontSize: 13, color: Theme.of(context).colorScheme.secondary, ), ), ), ), ) ], ), ); get _getImageCallback => _horizontalPreview ? (imgList, index) { _key.currentState?.showBottomSheet( (context) { return InteractiveviewerGallery( sources: imgList, initIndex: index, setStatusBar: false, ); }, enableDrag: false, elevation: 0, backgroundColor: Colors.transparent, ); } : null; void _onReply(dynamic item, int index) { dynamic oid = item?.oid.toInt(); dynamic root = GlobalData().grpcReply ? item?.id.toInt() : item?.rpid; dynamic key = oid + root; Navigator.of(context) .push( GetDialogRoute( pageBuilder: (buildContext, animation, secondaryAnimation) { return ReplyPage( oid: oid, root: root, parent: root, replyType: widget.replyType, replyItem: item, savedReply: _savedReplies[key], onSaveReply: (reply) { _savedReplies[key] = reply; }, ); }, transitionDuration: const Duration(milliseconds: 500), transitionBuilder: (context, animation, secondaryAnimation, child) { const begin = Offset(0.0, 1.0); const end = Offset.zero; const curve = Curves.linear; var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); return SlideTransition( position: animation.drive(tween), child: child, ); }, ), ) .then((res) { if (res != null) { _savedReplies[key] = null; if (GlobalData().grpcReply) { ReplyInfo replyInfo = Utils.replyCast(res); List list = _videoReplyReplyController.loadingState.value is Success ? (_videoReplyReplyController.loadingState.value as Success) .response : []; list.insert(index + 1, replyInfo); _videoReplyReplyController.count.value += 1; _videoReplyReplyController.loadingState.value = LoadingState.success(list); } else { List list = _videoReplyReplyController.loadingState.value is Success ? (_videoReplyReplyController.loadingState.value as Success) .response : []; list.insert(index + 1, ReplyItemModel.fromJson(res, '')); _videoReplyReplyController.count.value += 1; _videoReplyReplyController.loadingState.value = LoadingState.success(list); } } }); } Widget _buildBody(LoadingState loadingState, int index) { return switch (loadingState) { Loading() => CustomScrollView( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), slivers: [ SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return const VideoReplySkeleton(); }, childCount: 8, ), ) ], ), Success() => () { if (index == loadingState.response.length) { _videoReplyReplyController.onLoadMore(); return Container( alignment: Alignment.center, padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom), height: MediaQuery.of(context).padding.bottom + 100, child: Text( _videoReplyReplyController.isEnd.not ? '加载中...' : loadingState.response.isEmpty ? '还没有评论' : '没有更多了', style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.outline, ), ), ); } else { return _videoReplyReplyController.index == index ? AnimatedBuilder( animation: _videoReplyReplyController.colorAnimation!, builder: (context, child) { return ColoredBox( color: _videoReplyReplyController .colorAnimation?.value ?? Theme.of(Get.context!).colorScheme.onInverseSurface, child: _replyItem(loadingState.response[index], index), ); }, ) : _replyItem(loadingState.response[index], index); } }(), Error() => CustomScrollView( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), slivers: [ HttpError( errMsg: loadingState.errMsg, callback: _videoReplyReplyController.onReload, extraWidget: GlobalData().grpcReply ? FilledButton.tonal( onPressed: () { GlobalData().grpcReply = false; _videoReplyReplyController.onReload(); }, style: ButtonStyle( backgroundColor: WidgetStateProperty.resolveWith((states) { return Theme.of(context) .colorScheme .primary .withAlpha(20); }), ), child: Text( '暂时关闭gRPC加载评论', style: TextStyle( color: Theme.of(context).colorScheme.primary), ), ) : null, ) ], ), LoadingState() => throw UnimplementedError(), }; } Widget _replyItem(replyItem, index) { return GlobalData().grpcReply ? ReplyItemGrpc( replyItem: replyItem, replyLevel: widget.isDialogue ? '3' : '2', showReplyRow: false, replyType: widget.replyType, onReply: () { _onReply(replyItem, index); }, onDelete: (rpid, frpid) { List list = (_videoReplyReplyController.loadingState.value as Success) .response; list = list.where((item) => item.id != rpid).toList(); _videoReplyReplyController.count.value -= 1; _videoReplyReplyController.loadingState.value = LoadingState.success(list); }, upMid: _videoReplyReplyController.upMid, showDialogue: () { _key.currentState?.showBottomSheet( (context) => VideoReplyReplyPanel( oid: replyItem.oid.toInt(), rpid: replyItem.root.toInt(), dialog: replyItem.dialog.toInt(), replyType: widget.replyType, source: 'videoDetail', isDialogue: true, ), ); }, onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, callback: _getImageCallback, ) : ReplyItem( replyItem: replyItem, replyLevel: '2', showReplyRow: false, replyType: widget.replyType, onReply: () { _onReply(replyItem, index); }, onDelete: (rpid, frpid) { List list = (_videoReplyReplyController.loadingState.value as Success) .response; list.removeWhere((item) => item.rpid == rpid); _videoReplyReplyController.count.value -= 1; _videoReplyReplyController.loadingState.value = LoadingState.success(list); }, onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, callback: _getImageCallback, ); } int _itemCount(LoadingState loadingState) { if (widget.isDialogue) { return (loadingState is Success ? loadingState.response.length : 0) + 1; } int itemCount = 0; if (firstFloor != null) { itemCount = 2; } if (loadingState is Success) { return loadingState.response.length + itemCount + 2; } else { return itemCount + 2; } } }