import 'dart:math'; import 'package:PiliPlus/common/skeleton/video_reply.dart'; import 'package:PiliPlus/common/widgets/badge.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/image/network_img_layer.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/loading_state.dart'; import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/models/dynamics/result.dart' show DynamicStat; import 'package:PiliPlus/pages/article/controller.dart'; import 'package:PiliPlus/pages/article/widgets/article_ops.dart'; import 'package:PiliPlus/pages/article/widgets/html_render.dart'; import 'package:PiliPlus/pages/article/widgets/opus_content.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/date_util.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/image_util.dart'; import 'package:PiliPlus/utils/num_util.dart'; import 'package:PiliPlus/utils/page_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:cached_network_image/cached_network_image.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:html/parser.dart' as parser; class ArticlePage extends StatefulWidget { const ArticlePage({super.key}); @override State createState() => _ArticlePageState(); } class _ArticlePageState extends State with TickerProviderStateMixin { final ArticleController _articleCtr = Get.put( ArticleController(), tag: Utils.generateRandomString(8), ); bool _isFabVisible = true; bool? _imageStatus; late final AnimationController fabAnimationCtr; late final Animation _anim; late final List _ratio = Pref.dynamicDetailRatio; bool get _horizontalPreview => context.orientation == Orientation.landscape && _articleCtr.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(); _articleCtr.scrollController.addListener(listener); } @override void dispose() { fabAnimationCtr.dispose(); _articleCtr.scrollController.removeListener(listener); super.dispose(); } @override void didChangeDependencies() { super.didChangeDependencies(); WidgetsBinding.instance.addPostFrameCallback((_) { if (_articleCtr.scrollController.hasClients) { _articleCtr.showTitle.value = _articleCtr.scrollController.positions.last.pixels >= 45; } }); } void listener() { _articleCtr.showTitle.value = _articleCtr.scrollController.positions.last.pixels >= 45; final ScrollDirection direction1 = _articleCtr.scrollController.positions.first.userScrollDirection; late final ScrollDirection direction2 = _articleCtr.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(); } } 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: _articleCtr.commentType, firstFloor: replyItem, onDispose: onDispose, ), ), ); if (this.context.orientation == Orientation.portrait) { Get.to( replyReplyPage, routeName: 'htmlRender-Copy', arguments: { 'id': _articleCtr.id, }, ); } 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: 'htmlRender-Copy', arguments: { 'id': _articleCtr.id, }, ); } } }); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( resizeToAvoidBottomInset: false, appBar: _buildAppBar, body: SafeArea( top: false, bottom: false, child: Stack( clipBehavior: Clip.none, children: [ SafeArea( top: false, bottom: false, child: Builder( builder: (context) { final isPortrait = context.orientation == Orientation.portrait; double padding = max(context.width / 2 - Grid.smallCardWidth, 0); if (isPortrait) { return LayoutBuilder(builder: (context, constraints) { final maxWidth = constraints.maxWidth - 2 * padding - 24; return Padding( padding: EdgeInsets.symmetric(horizontal: padding), child: CustomScrollView( controller: _articleCtr.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ _buildContent(theme, maxWidth), SliverToBoxAdapter( child: Divider( thickness: 8, color: theme.dividerColor.withValues(alpha: 0.05), ), ), _buildReplyHeader(theme), Obx(() => _buildReplyList( theme, _articleCtr.loadingState.value)), ], ), ); }); } else { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: _ratio[0].toInt(), child: LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth - padding / 4 - 24; return CustomScrollView( controller: _articleCtr.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( padding: EdgeInsets.only( left: padding / 4, bottom: MediaQuery.paddingOf(context).bottom + 80, ), sliver: _buildContent(theme, maxWidth), ), ], ); }, ), ), VerticalDivider( thickness: 8, color: theme.dividerColor.withValues(alpha: 0.05), ), Expanded( flex: _ratio[1].toInt(), child: Scaffold( key: _key, backgroundColor: Colors.transparent, body: refreshIndicator( onRefresh: _articleCtr.onRefresh, child: Padding( padding: EdgeInsets.only(right: padding / 4), child: CustomScrollView( controller: _articleCtr.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ _buildReplyHeader(theme), Obx(() => _buildReplyList( theme, _articleCtr.loadingState.value)), ], ), ), ), ), ), ], ); } }, ), ), _buildBottom(theme), ], ), ), ); } Widget _buildContent(ThemeData theme, double maxWidth) => SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), sliver: Obx( () { if (_articleCtr.isLoaded.value) { late Widget content; if (_articleCtr.opus != null) { if (kDebugMode) debugPrint('json page'); content = OpusContent( opus: _articleCtr.opus!, callback: _getImageCallback, maxWidth: maxWidth, ); } else if (_articleCtr.opusData?.modules.moduleBlocked != null) { if (kDebugMode) debugPrint('moduleBlocked'); final moduleBlocked = _articleCtr.opusData!.modules.moduleBlocked!; content = SliverToBoxAdapter( child: moduleBlockedItem(theme, moduleBlocked, maxWidth), ); } else if (_articleCtr.articleData?.content != null) { if (_articleCtr.articleData?.type == 3) { // json return ArticleOpus(ops: _articleCtr.articleData?.ops); } if (kDebugMode) debugPrint('html page'); final res = parser.parse(_articleCtr.articleData!.content!); if (res.body!.children.isEmpty) { content = SliverToBoxAdapter( child: htmlRender( context: context, html: _articleCtr.articleData!.content!, maxWidth: maxWidth, callback: _getImageCallback, ), ); } else { content = SliverList.separated( itemCount: res.body!.children.length, itemBuilder: (context, index) { return htmlRender( context: context, element: res.body!.children[index], maxWidth: maxWidth, callback: _getImageCallback, ); }, separatorBuilder: (context, index) => const SizedBox(height: 10), ); } } else { content = const SliverToBoxAdapter(child: Text('NULL')); } int? pubTime = _articleCtr.opusData?.modules.moduleAuthor?.pubTs ?? _articleCtr.articleData?.publishTime; return SliverMainAxisGroup( slivers: [ if (_articleCtr.type != 'read' && _articleCtr.opusData?.modules.moduleTop?.display?.album ?.pics?.isNotEmpty == true) SliverToBoxAdapter( child: Builder( builder: (context) { final pics = _articleCtr.opusData!.modules.moduleTop! .display!.album!.pics!; final length = pics.length; final first = pics.first; double height; double paddingRight; if (first.height != null && first.width != null) { final ratio = first.height! / first.width!; height = min(maxWidth * ratio, Get.height * 0.55); paddingRight = (maxWidth - height / ratio) / 2 + 12; } else { height = Get.height * 0.55; paddingRight = 12; } return Stack( clipBehavior: Clip.none, children: [ Container( height: height, width: maxWidth, margin: const EdgeInsets.only(bottom: 10), child: PageView.builder( physics: const ClampingScrollPhysics(), onPageChanged: (value) { _articleCtr.topIndex.value = value; }, itemCount: length, itemBuilder: (context, index) { final pic = pics[index]; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context.imageView( quality: 60, imgList: pics .map( (e) => SourceModel(url: e.url!)) .toList(), initialPage: index, ), child: Hero( tag: pic.url!, child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ Positioned.fill( child: CachedNetworkImage( fit: pic.isLongPic == true ? BoxFit.cover : null, imageUrl: ImageUtil.thumbnailUrl( pic.url, 60), fadeInDuration: const Duration( milliseconds: 120), fadeOutDuration: const Duration( milliseconds: 120), ), ), if (pic.isLongPic == true) PBadge( text: '长图', type: PBadgeType.primary, right: paddingRight, bottom: 12, ), ], ), ), ); }, ), ), Obx( () => PBadge( top: 12, right: paddingRight, type: PBadgeType.gray, text: '${_articleCtr.topIndex.value + 1}/$length'), ), ], ); }, ), ), if (_articleCtr.summary.title != null) SliverToBoxAdapter( child: Text( _articleCtr.summary.title!, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.bold, ), ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: GestureDetector( onTap: () => Get.toNamed( '/member?mid=${_articleCtr.summary.author?.mid}'), child: Row( children: [ NetworkImgLayer( width: 40, height: 40, type: ImageType.avatar, src: _articleCtr.summary.author?.face, ), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _articleCtr.summary.author?.name ?? '', style: TextStyle( fontSize: theme.textTheme.titleSmall!.fontSize, ), ), if (pubTime != null) Text( DateUtil.format(pubTime), style: TextStyle( color: theme.colorScheme.outline, fontSize: theme.textTheme.labelSmall!.fontSize, ), ), ], ), ], ), ), ), ), if (_articleCtr.type != 'read' && _articleCtr.opusData?.modules.moduleCollection != null) SliverToBoxAdapter( child: opusCollection( theme, _articleCtr.opusData!.modules.moduleCollection!, ), ), content, ], ); } return const SliverToBoxAdapter(); }, ), ); Widget _buildReplyList( ThemeData theme, LoadingState?> loadingState) { return switch (loadingState) { Loading() => SliverList.builder( itemCount: 12, itemBuilder: (context, index) { return const VideoReplySkeleton(); }, ), Success(:var response) => response?.isNotEmpty == true ? SliverList.builder( itemCount: response!.length + 1, itemBuilder: (context, index) { if (index == response.length) { _articleCtr.onLoadMore(); return Container( alignment: Alignment.center, margin: EdgeInsets.only( bottom: MediaQuery.paddingOf(context).bottom), height: 125, child: Text( _articleCtr.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) => _articleCtr.onReply( context, replyItem: replyItem, ), onDelete: (item, subIndex) => _articleCtr.onRemove(index, item, subIndex), upMid: _articleCtr.upMid, callback: _getImageCallback, onCheckReply: (item) => _articleCtr.onCheckReply(item, isManual: true), onToggleTop: (item) => _articleCtr.onToggleTop( item, index, _articleCtr.commentId, _articleCtr.commentType, ), ); } }, ) : HttpError(onReload: _articleCtr.onReload), Error(:var errMsg) => HttpError( errMsg: errMsg, onReload: _articleCtr.onReload, ), }; } Widget _buildReplyHeader(ThemeData theme) { return SliverPersistentHeader( pinned: true, delegate: CustomSliverPersistentHeaderDelegate( extent: 40, bgColor: theme.colorScheme.surface, child: Container( height: 45, padding: const EdgeInsets.only(left: 12, right: 6), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Obx(() => Text( '${_articleCtr.count.value == -1 ? 0 : NumUtil.numFormat(_articleCtr.count.value)}条回复')), SizedBox( height: 35, child: TextButton.icon( onPressed: _articleCtr.queryBySort, icon: const Icon(Icons.sort, size: 16), label: Obx( () => Text( _articleCtr.sortType.value.label, style: const TextStyle(fontSize: 13), ), ), ), ) ], ), ), ), ); } PreferredSizeWidget get _buildAppBar => AppBar( title: Obx(() { if (_articleCtr.isLoaded.value && _articleCtr.showTitle.value) { return Text(_articleCtr.summary.title ?? ''); } return const SizedBox.shrink(); }), actions: [ const SizedBox(width: 4), if (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), ), ), IconButton( tooltip: '浏览器打开', onPressed: () => PageUtils.inAppWebview(_articleCtr.url), icon: const Icon(Icons.open_in_browser_outlined, size: 19), ), PopupMenuButton( icon: const Icon(Icons.more_vert, size: 19), itemBuilder: (BuildContext context) => [ PopupMenuItem( onTap: () => Utils.shareText(_articleCtr.url), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.share_outlined, size: 19), SizedBox(width: 10), Text('分享'), ], ), ), PopupMenuItem( onTap: () => Utils.copyText(_articleCtr.url), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.copy_rounded, size: 19), SizedBox(width: 10), Text('复制链接'), ], ), ), if (_articleCtr.commentType == 12 && _articleCtr.stats.value != null && _articleCtr.opusData?.modules.moduleBlocked == null) PopupMenuItem( onTap: () async { try { if (_articleCtr.summary.cover == null) { if (!await _articleCtr.getArticleInfo(true)) { return; } } if (mounted) { PageUtils.pmShare( this.context, content: { "id": _articleCtr.commentId, "title": "- 哔哩哔哩专栏", "headline": _articleCtr.summary.title!, // throw "source": 6, "thumb": _articleCtr.summary.cover!, "author": _articleCtr.summary.author!.name, "author_id": _articleCtr.summary.author!.mid.toString(), }, ); } } catch (e) { SmartDialog.showToast(e.toString()); } }, child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.forward_to_inbox, size: 19), SizedBox(width: 10), Text('分享至消息'), ], ), ), ], ), const SizedBox(width: 6) ], ); Widget _buildBottom(ThemeData theme) => Positioned( left: 0, bottom: 0, right: 0, child: SlideTransition( position: _anim, child: Builder( builder: (context) { Widget button() => FloatingActionButton( heroTag: null, onPressed: () { feedBack(); _articleCtr.onReply( context, oid: _articleCtr.commentId, replyType: _articleCtr.commentType, ); }, tooltip: '评论动态', child: const Icon(Icons.reply), ); return !_articleCtr.showDynActionBar ? Align( alignment: Alignment.bottomRight, child: Padding( padding: EdgeInsets.only( right: 14, bottom: MediaQuery.paddingOf(context).bottom + 14, ), child: button(), ), ) : Obx(() { Widget textIconButton({ required IconData icon, required String text, required DynamicStat? stat, required VoidCallback callback, IconData? activitedIcon, }) { final show = stat?.status == true; final color = show ? theme.colorScheme.primary : theme.colorScheme.outline; return TextButton.icon( onPressed: callback, icon: Icon( stat?.status == true ? activitedIcon : icon, size: 16, color: color, semanticLabel: text, ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 15), foregroundColor: theme.colorScheme.outline, ), label: Text(stat?.count != null ? NumUtil.numFormat(stat!.count) : text), ); } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Padding( padding: EdgeInsets.only( right: 14, bottom: 14 + (_articleCtr.stats.value != null ? 0 : MediaQuery.paddingOf(context).bottom), ), child: button(), ), _articleCtr.stats.value != null ? 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) => textIconButton( text: '转发', icon: FontAwesomeIcons .shareFromSquare, stat: _articleCtr .stats.value?.forward, callback: () { if (_articleCtr.opusData == null && _articleCtr.articleData ?.dynIdStr == null) { SmartDialog.showToast( 'err: ${_articleCtr.id}'); return; } showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (context) => RepostPanel( item: _articleCtr.opusData, dynIdStr: _articleCtr .articleData?.dynIdStr, pic: _articleCtr.summary.cover, title: _articleCtr.summary.title, uname: _articleCtr .summary.author?.name, callback: () { int count = _articleCtr .stats .value ?.forward ?.count ?? 0; _articleCtr .stats .value ?.forward ?.count = count + 1; if (btnContext.mounted) { (btnContext as Element?) ?.markNeedsBuild(); } }, ), ); }, ), ), ), Expanded( child: textIconButton( text: '分享', icon: CustomIcon.share_node, stat: null, callback: () => Utils.shareText(_articleCtr.url), )), if (_articleCtr.stats.value != null) Expanded( child: textIconButton( icon: FontAwesomeIcons.star, activitedIcon: FontAwesomeIcons.solidStar, text: '收藏', stat: _articleCtr.stats.value!.favorite, callback: _articleCtr.onFav, )), Expanded( child: Builder( builder: (context) => TextButton.icon( onPressed: _articleCtr.onLike, icon: Icon( _articleCtr.stats.value?.like ?.status == true ? FontAwesomeIcons .solidThumbsUp : FontAwesomeIcons.thumbsUp, size: 16, color: _articleCtr.stats.value ?.like?.status == true ? theme.colorScheme.primary : theme.colorScheme.outline, semanticLabel: _articleCtr .stats .value ?.like ?.status == true ? "已赞" : "点赞", ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 15), foregroundColor: theme.colorScheme.outline, ), label: AnimatedSwitcher( duration: const Duration( milliseconds: 400), transitionBuilder: (Widget child, Animation animation) { return ScaleTransition( scale: animation, child: child); }, child: Text( _articleCtr.stats.value?.like ?.count != null ? NumUtil.numFormat( _articleCtr.stats.value! .like!.count) : '点赞', style: TextStyle( color: _articleCtr.stats.value ?.like?.status == true ? theme .colorScheme.primary : theme .colorScheme.outline, ), ), ), ), ), ), ], ), ) : const SizedBox.shrink(), ], ); }); }, ), ), ); }