diff --git a/lib/pages/article/controller.dart b/lib/pages/article/controller.dart index 150ec801b..6caac6d28 100644 --- a/lib/pages/article/controller.dart +++ b/lib/pages/article/controller.dart @@ -12,27 +12,29 @@ import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/models/model_avatar.dart'; import 'package:PiliPlus/models_new/article/article_info/data.dart'; import 'package:PiliPlus/models_new/article/article_view/data.dart'; -import 'package:PiliPlus/pages/common/reply_controller.dart'; +import 'package:PiliPlus/pages/common/dyn/common_dyn_controller.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/url_utils.dart'; +import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class ArticleController extends ReplyController { +class ArticleController extends CommonDynController { late String id; late String type; late String url; - late int commentType; late int commentId; + @override + int get oid => commentId; + late int commentType; + @override + int get replyType => commentType; final summary = Summary(); - RxBool showTitle = false.obs; - late final RxInt topIndex = 0.obs; - late final horizontalPreview = Pref.horizontalPreview; late final showDynActionBar = Pref.showDynActionBar; @override @@ -186,19 +188,20 @@ class ArticleController extends ReplyController { } Future onFav() async { - bool isFav = stats.value?.favorite?.status == true; + final favorite = stats.value?.favorite; + bool isFav = favorite?.status == true; final res = type == 'read' ? isFav ? await FavHttp.delFavArticle(id: commentId) : await FavHttp.addFavArticle(id: commentId) : await FavHttp.communityAction(opusId: id, action: isFav ? 4 : 3); if (res['status']) { - stats.value?.favorite?.status = !isFav; - var count = stats.value?.favorite?.count ?? 0; + favorite?.status = !isFav; + var count = favorite?.count ?? 0; if (isFav) { - stats.value?.favorite?.count = count - 1; + favorite?.count = count - 1; } else { - stats.value?.favorite?.count = count + 1; + favorite?.count = count + 1; } stats.refresh(); SmartDialog.showToast('${isFav ? '取消' : ''}收藏成功'); @@ -208,18 +211,19 @@ class ArticleController extends ReplyController { } Future onLike() async { - bool isLike = stats.value?.like?.status == true; + final like = stats.value?.like; + bool isLike = like?.status == true; final res = await DynamicsHttp.thumbDynamic( dynamicId: opusData?.idStr ?? articleData?.dynIdStr, up: isLike ? 2 : 1, ); if (res['status']) { - stats.value?.like?.status = !isLike; - int count = stats.value?.like?.count ?? 0; + like?.status = !isLike; + int count = like?.count ?? 0; if (isLike) { - stats.value?.like?.count = count - 1; + like?.count = count - 1; } else { - stats.value?.like?.count = count + 1; + like?.count = count + 1; } stats.refresh(); SmartDialog.showToast(!isLike ? '点赞成功' : '取消赞'); @@ -227,6 +231,22 @@ class ArticleController extends ReplyController { SmartDialog.showToast(res['msg']); } } + + @override + void listener() { + showTitle.value = scrollController.positions.last.pixels >= 45; + final ScrollDirection direction1 = + scrollController.positions.first.userScrollDirection; + late final ScrollDirection direction2 = + scrollController.positions.last.userScrollDirection; + if (direction1 == ScrollDirection.forward || + direction2 == ScrollDirection.forward) { + showFab(); + } else if (direction1 == ScrollDirection.reverse || + direction2 == ScrollDirection.reverse) { + hideFab(); + } + } } class Summary { diff --git a/lib/pages/article/view.dart b/lib/pages/article/view.dart index 62dbed134..6d0e364fd 100644 --- a/lib/pages/article/view.dart +++ b/lib/pages/article/view.dart @@ -3,7 +3,6 @@ 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'; @@ -18,9 +17,9 @@ 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/common/dyn/common_dyn_page.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'; @@ -30,180 +29,41 @@ 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 { +class ArticlePage extends CommonDynPage { const ArticlePage({super.key}); @override State createState() => _ArticlePageState(); } -class _ArticlePageState extends State - with TickerProviderStateMixin { - final ArticleController _articleCtr = Get.put( +class _ArticlePageState extends CommonDynPageState { + @override + final ArticleController controller = Get.put( ArticleController(), tag: Utils.generateRandomString(8), ); - bool _isFabVisible = true; - late final AnimationController fabAnimationCtr; - late final Animation _anim; - - late final List _ratio = Pref.dynamicDetailRatio; - - bool get _horizontalPreview => - _articleCtr.horizontalPreview && - context.orientation == Orientation.landscape; - - late final _key = GlobalKey(); - - late Function(dynamic imgList, dynamic index)? _imageCallback; @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(); - } + dynamic get arguments => { + 'id': controller.id, + }; @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 showBackBtn = true}) => Scaffold( - appBar: AppBar( - toolbarHeight: showBackBtn ? null : 45, - title: const Text('评论详情'), - titleSpacing: showBackBtn ? null : 12, - automaticallyImplyLeading: showBackBtn, - actions: showBackBtn - ? null - : [ - IconButton( - tooltip: '关闭', - icon: const Icon(Icons.close, size: 20), - onPressed: Get.back, - ), - ], - ), - body: SafeArea( - top: false, - bottom: false, - child: VideoReplyReplyPanel( - enableSlide: false, - id: id, - oid: oid, - rpid: rpid, - isVideoDetail: false, - replyType: _articleCtr.commentType, - firstFloor: replyItem, - ), - ), - ); - 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(showBackBtn: false), - ), - ); - } else { - Get.to( - replyReplyPage, - routeName: 'htmlRender-Copy', - arguments: { - 'id': _articleCtr.id, - }, - ); - } + if (controller.scrollController.hasClients) { + controller.showTitle.value = + controller.scrollController.positions.last.pixels >= 45; } }); } @@ -211,17 +71,6 @@ class _ArticlePageState extends State @override Widget build(BuildContext context) { final theme = Theme.of(context); - _imageCallback = _horizontalPreview - ? (imgList, index) { - _hideFab(); - PageUtils.onHorizontalPreview( - _key, - this, - imgList, - index, - ); - } - : null; return Scaffold( resizeToAvoidBottomInset: false, appBar: _buildAppBar, @@ -250,7 +99,7 @@ class _ArticlePageState extends State return Padding( padding: EdgeInsets.symmetric(horizontal: padding), child: CustomScrollView( - controller: _articleCtr.scrollController, + controller: controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ _buildContent(theme, maxWidth), @@ -262,11 +111,11 @@ class _ArticlePageState extends State ), ), ), - _buildReplyHeader(theme), + buildReplyHeader(theme), Obx( () => _buildReplyList( theme, - _articleCtr.loadingState.value, + controller.loadingState.value, ), ), ], @@ -279,13 +128,13 @@ class _ArticlePageState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - flex: _ratio[0].toInt(), + flex: controller.ratio[0].toInt(), child: LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth - padding / 4 - 24; return CustomScrollView( - controller: _articleCtr.scrollController, + controller: controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( @@ -307,24 +156,24 @@ class _ArticlePageState extends State color: theme.dividerColor.withValues(alpha: 0.05), ), Expanded( - flex: _ratio[1].toInt(), + flex: controller.ratio[1].toInt(), child: Scaffold( - key: _key, + key: scaffoldKey, backgroundColor: Colors.transparent, body: refreshIndicator( - onRefresh: _articleCtr.onRefresh, + onRefresh: controller.onRefresh, child: Padding( padding: EdgeInsets.only(right: padding / 4), child: CustomScrollView( - controller: _articleCtr.scrollController, + controller: controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - _buildReplyHeader(theme), + buildReplyHeader(theme), Obx( () => _buildReplyList( theme, - _articleCtr.loadingState.value, + controller.loadingState.value, ), ), ], @@ -350,35 +199,35 @@ class _ArticlePageState extends State padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), sliver: Obx( () { - if (_articleCtr.isLoaded.value) { + if (controller.isLoaded.value) { late Widget content; - if (_articleCtr.opus != null) { + if (controller.opus != null) { if (kDebugMode) debugPrint('json page'); content = OpusContent( - opus: _articleCtr.opus!, - callback: _imageCallback, + opus: controller.opus!, + callback: imageCallback, maxWidth: maxWidth, ); - } else if (_articleCtr.opusData?.modules.moduleBlocked != null) { + } else if (controller.opusData?.modules.moduleBlocked != null) { if (kDebugMode) debugPrint('moduleBlocked'); - final moduleBlocked = _articleCtr.opusData!.modules.moduleBlocked!; + final moduleBlocked = controller.opusData!.modules.moduleBlocked!; content = SliverToBoxAdapter( child: moduleBlockedItem(theme, moduleBlocked, maxWidth), ); - } else if (_articleCtr.articleData?.content != null) { - if (_articleCtr.articleData?.type == 3) { + } else if (controller.articleData?.content != null) { + if (controller.articleData?.type == 3) { // json - return ArticleOpus(ops: _articleCtr.articleData?.ops); + return ArticleOpus(ops: controller.articleData?.ops); } if (kDebugMode) debugPrint('html page'); - final res = parser.parse(_articleCtr.articleData!.content!); + final res = parser.parse(controller.articleData!.content!); if (res.body!.children.isEmpty) { content = SliverToBoxAdapter( child: htmlRender( context: context, - html: _articleCtr.articleData!.content!, + html: controller.articleData!.content!, maxWidth: maxWidth, - callback: _imageCallback, + callback: imageCallback, ), ); } else { @@ -389,7 +238,7 @@ class _ArticlePageState extends State context: context, element: res.body!.children[index], maxWidth: maxWidth, - callback: _imageCallback, + callback: imageCallback, ); }, separatorBuilder: (context, index) => @@ -401,12 +250,12 @@ class _ArticlePageState extends State } int? pubTime = - _articleCtr.opusData?.modules.moduleAuthor?.pubTs ?? - _articleCtr.articleData?.publishTime; + controller.opusData?.modules.moduleAuthor?.pubTs ?? + controller.articleData?.publishTime; return SliverMainAxisGroup( slivers: [ - if (_articleCtr.type != 'read' && - _articleCtr + if (controller.type != 'read' && + controller .opusData ?.modules .moduleTop @@ -418,7 +267,7 @@ class _ArticlePageState extends State SliverToBoxAdapter( child: Builder( builder: (context) { - final pics = _articleCtr + final pics = controller .opusData! .modules .moduleTop! @@ -447,7 +296,7 @@ class _ArticlePageState extends State child: PageView.builder( physics: const ClampingScrollPhysics(), onPageChanged: (value) { - _articleCtr.topIndex.value = value; + controller.topIndex.value = value; }, itemCount: length, itemBuilder: (context, index) { @@ -503,7 +352,7 @@ class _ArticlePageState extends State top: 12, right: paddingRight, type: PBadgeType.gray, - text: '${_articleCtr.topIndex.value + 1}/$length', + text: '${controller.topIndex.value + 1}/$length', ), ), ], @@ -511,10 +360,10 @@ class _ArticlePageState extends State }, ), ), - if (_articleCtr.summary.title != null) + if (controller.summary.title != null) SliverToBoxAdapter( child: Text( - _articleCtr.summary.title!, + controller.summary.title!, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.bold, @@ -526,7 +375,7 @@ class _ArticlePageState extends State padding: const EdgeInsets.symmetric(vertical: 10), child: GestureDetector( onTap: () => Get.toNamed( - '/member?mid=${_articleCtr.summary.author?.mid}', + '/member?mid=${controller.summary.author?.mid}', ), child: Row( children: [ @@ -534,14 +383,14 @@ class _ArticlePageState extends State width: 40, height: 40, type: ImageType.avatar, - src: _articleCtr.summary.author?.face, + src: controller.summary.author?.face, ), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _articleCtr.summary.author?.name ?? '', + controller.summary.author?.name ?? '', style: TextStyle( fontSize: theme.textTheme.titleSmall!.fontSize, ), @@ -562,12 +411,12 @@ class _ArticlePageState extends State ), ), ), - if (_articleCtr.type != 'read' && - _articleCtr.opusData?.modules.moduleCollection != null) + if (controller.type != 'read' && + controller.opusData?.modules.moduleCollection != null) SliverToBoxAdapter( child: opusCollection( theme, - _articleCtr.opusData!.modules.moduleCollection!, + controller.opusData!.modules.moduleCollection!, ), ), content, @@ -597,7 +446,7 @@ class _ArticlePageState extends State itemCount: response!.length + 1, itemBuilder: (context, index) { if (index == response.length) { - _articleCtr.onLoadMore(); + controller.onLoadMore(); return Container( alignment: Alignment.center, margin: EdgeInsets.only( @@ -605,7 +454,7 @@ class _ArticlePageState extends State ), height: 125, child: Text( - _articleCtr.isEnd ? '没有更多了' : '加载中...', + controller.isEnd ? '没有更多了' : '加载中...', style: TextStyle( fontSize: 12, color: theme.colorScheme.outline, @@ -618,75 +467,38 @@ class _ArticlePageState extends State replyLevel: 1, replyReply: (replyItem, id) => replyReply(context, replyItem, id), - onReply: (replyItem) => _articleCtr.onReply( + onReply: (replyItem) => controller.onReply( context, replyItem: replyItem, ), onDelete: (item, subIndex) => - _articleCtr.onRemove(index, item, subIndex), - upMid: _articleCtr.upMid, - callback: _imageCallback, + controller.onRemove(index, item, subIndex), + upMid: controller.upMid, + callback: imageCallback, onCheckReply: (item) => - _articleCtr.onCheckReply(item, isManual: true), - onToggleTop: (item) => _articleCtr.onToggleTop( + controller.onCheckReply(item, isManual: true), + onToggleTop: (item) => controller.onToggleTop( item, index, - _articleCtr.commentId, - _articleCtr.commentType, + controller.commentId, + controller.commentType, ), ); } }, ) - : HttpError(onReload: _articleCtr.onReload), + : HttpError(onReload: controller.onReload), Error(:var errMsg) => HttpError( errMsg: errMsg, - onReload: _articleCtr.onReload, + onReload: controller.onReload, ), }; } - Widget _buildReplyHeader(ThemeData theme) { - return SliverPersistentHeader( - pinned: true, - delegate: CustomSliverPersistentHeaderDelegate( - extent: 45, - 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 ?? ''); + if (controller.isLoaded.value && controller.showTitle.value) { + return Text(controller.summary.title ?? ''); } return const SizedBox.shrink(); }), @@ -710,14 +522,15 @@ class _ArticlePageState extends State builder: (context) => Slider( min: 1, max: 100, - value: _ratio.first, + value: controller.ratio.first, onChanged: (value) { if (value >= 10 && value <= 90) { - _ratio[0] = value.toPrecision(2); - _ratio[1] = 100 - value; + controller.ratio + ..[0] = value.toPrecision(2) + ..[1] = 100 - value; GStorage.setting.put( SettingBoxKey.dynamicDetailRatio, - _ratio, + controller.ratio, ); (context as Element).markNeedsBuild(); setState(() {}); @@ -735,14 +548,14 @@ class _ArticlePageState extends State ), IconButton( tooltip: '浏览器打开', - onPressed: () => PageUtils.inAppWebview(_articleCtr.url), + onPressed: () => PageUtils.inAppWebview(controller.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), + onTap: () => Utils.shareText(controller.url), child: const Row( mainAxisSize: MainAxisSize.min, children: [ @@ -753,7 +566,7 @@ class _ArticlePageState extends State ), ), PopupMenuItem( - onTap: () => Utils.copyText(_articleCtr.url), + onTap: () => Utils.copyText(controller.url), child: const Row( mainAxisSize: MainAxisSize.min, children: [ @@ -763,14 +576,15 @@ class _ArticlePageState extends State ], ), ), - if (_articleCtr.commentType == 12 && - _articleCtr.stats.value != null && - _articleCtr.opusData?.modules.moduleBlocked == null) + if (controller.commentType == 12 && + controller.stats.value != null && + controller.opusData?.modules.moduleBlocked == null) PopupMenuItem( onTap: () async { + final summary = controller.summary; try { - if (_articleCtr.summary.cover == null) { - if (!await _articleCtr.getArticleInfo(true)) { + if (summary.cover == null) { + if (!await controller.getArticleInfo(true)) { return; } } @@ -778,13 +592,13 @@ class _ArticlePageState extends State PageUtils.pmShare( this.context, content: { - "id": _articleCtr.commentId, + "id": controller.commentId, "title": "- 哔哩哔哩专栏", - "headline": _articleCtr.summary.title!, // throw + "headline": summary.title!, // throw "source": 6, - "thumb": _articleCtr.summary.cover!, - "author": _articleCtr.summary.author!.name, - "author_id": _articleCtr.summary.author!.mid.toString(), + "thumb": summary.cover!, + "author": summary.author!.name, + "author_id": summary.author!.mid.toString(), }, ); } @@ -812,268 +626,179 @@ class _ArticlePageState extends State bottom: 0, right: 0, child: SlideTransition( - position: _anim, + position: controller.fabAnim, child: Builder( builder: (context) { Widget button() => FloatingActionButton( heroTag: null, onPressed: () { feedBack(); - _articleCtr.onReply( + controller.onReply( context, - oid: _articleCtr.commentId, - replyType: _articleCtr.commentType, + oid: controller.commentId, + replyType: controller.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), + final bottom = MediaQuery.paddingOf(context).bottom; + if (!controller.showDynActionBar) { + return Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.only(right: 14, bottom: bottom + 14), + child: button(), + ), + ); + } + + late final primary = theme.colorScheme.primary; + late final outline = theme.colorScheme.outline; + late final btnStyle = TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 15), + foregroundColor: outline, + ); + + Widget textIconButton({ + required IconData icon, + required String text, + required DynamicStat? stat, + required VoidCallback onPressed, + IconData? activitedIcon, + }) { + final status = stat?.status == true; + final color = status ? primary : outline; + return TextButton.icon( + onPressed: onPressed, + icon: Icon( + status ? activitedIcon : icon, + size: 16, + color: color, + ), + style: btnStyle, + label: Text( + stat?.count != null ? NumUtil.numFormat(stat!.count) : text, + style: TextStyle(color: color), + ), + ); + } + + return Obx(() { + final stats = controller.stats.value; + + Widget btn = Padding( + padding: EdgeInsets.only( + right: 14, + bottom: 14 + (stats != null ? 0 : bottom), + ), + child: button(), + ); + + if (stats == null) { + return Align( + alignment: Alignment.centerRight, + child: btn, + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + btn, + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.outline.withValues( + alpha: 0.08, ), - 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: bottom), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: Builder( + builder: (btnContext) { + final forward = stats.forward; + return textIconButton( + text: '转发', + icon: FontAwesomeIcons.shareFromSquare, + stat: forward, + onPressed: () { + if (controller.opusData == null && + controller.articleData?.dynIdStr == null) { + SmartDialog.showToast( + 'err: ${controller.id}', + ); + return; + } + final summary = controller.summary; + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => RepostPanel( + item: controller.opusData, + dynIdStr: controller.articleData?.dynIdStr, + pic: summary.cover, + title: summary.title, + uname: summary.author?.name, + callback: () { + if (forward != null) { + int count = forward.count ?? 0; + forward.count = count + 1; + if (btnContext.mounted) { + (btnContext as Element?) + ?.markNeedsBuild(); + } + } + }, ), - ), - ), - 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(), + ); + }, + ); + }, + ), + ), + Expanded( + child: textIconButton( + text: '分享', + icon: CustomIcon.share_node, + stat: null, + onPressed: () => Utils.shareText(controller.url), + ), + ), + Expanded( + child: textIconButton( + icon: FontAwesomeIcons.star, + activitedIcon: FontAwesomeIcons.solidStar, + text: '收藏', + stat: stats.favorite, + onPressed: controller.onFav, + ), + ), + Expanded( + child: textIconButton( + icon: FontAwesomeIcons.thumbsUp, + activitedIcon: FontAwesomeIcons.solidThumbsUp, + text: '点赞', + stat: stats.like, + onPressed: controller.onLike, + ), + ), ], - ); - }); + ), + ), + ], + ); + }); }, ), ), diff --git a/lib/pages/common/dyn/common_dyn_controller.dart b/lib/pages/common/dyn/common_dyn_controller.dart new file mode 100644 index 000000000..32dc635e5 --- /dev/null +++ b/lib/pages/common/dyn/common_dyn_controller.dart @@ -0,0 +1,80 @@ +import 'package:PiliPlus/pages/common/reply_controller.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:get/get.dart'; + +abstract class CommonDynController extends ReplyController + with GetSingleTickerProviderStateMixin { + int get oid; + int get replyType; + + bool _showFab = true; + late final AnimationController fabAnimationCtr; + late final Animation fabAnim; + + final RxBool showTitle = false.obs; + + late final horizontalPreview = Pref.horizontalPreview; + late final List ratio = Pref.dynamicDetailRatio; + + double offsetDy = 1; + + @override + void onInit() { + fabAnimationCtr = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + fabAnim = + Tween( + begin: Offset(0, offsetDy), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: fabAnimationCtr, + curve: Curves.easeInOut, + ), + ); + fabAnimationCtr.forward(); + scrollController.addListener(listener); + super.onInit(); + } + + void listener() { + showTitle.value = scrollController.positions.first.pixels > 55; + + final ScrollDirection direction1 = + scrollController.positions.first.userScrollDirection; + late final ScrollDirection direction2 = + scrollController.positions.last.userScrollDirection; + if (direction1 == ScrollDirection.forward || + direction2 == ScrollDirection.forward) { + showFab(); + } else if (direction1 == ScrollDirection.reverse || + direction2 == ScrollDirection.reverse) { + hideFab(); + } + } + + void showFab() { + if (!_showFab) { + _showFab = true; + fabAnimationCtr.forward(); + } + } + + void hideFab() { + if (_showFab) { + _showFab = false; + fabAnimationCtr.reverse(); + } + } + + @override + void onClose() { + fabAnimationCtr.dispose(); + scrollController.removeListener(listener); + super.onClose(); + } +} diff --git a/lib/pages/common/dyn/common_dyn_page.dart b/lib/pages/common/dyn/common_dyn_page.dart new file mode 100644 index 000000000..66a5f0186 --- /dev/null +++ b/lib/pages/common/dyn/common_dyn_page.dart @@ -0,0 +1,219 @@ +import 'package:PiliPlus/common/skeleton/video_reply.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/grpc/bilibili/main/community/reply/v1.pb.dart' + show ReplyInfo; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/pages/common/dyn/common_dyn_controller.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/num_util.dart'; +import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +abstract class CommonDynPage extends StatefulWidget { + const CommonDynPage({super.key}); +} + +abstract class CommonDynPageState extends State + with TickerProviderStateMixin { + CommonDynController get controller; + + late final scaffoldKey = GlobalKey(); + + bool get horizontalPreview => + context.orientation == Orientation.landscape && + controller.horizontalPreview; + Function(List imgList, int index)? imageCallback; + + dynamic get arguments; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + imageCallback = horizontalPreview + ? (imgList, index) { + controller.hideFab(); + PageUtils.onHorizontalPreview( + scaffoldKey, + this, + imgList, + index, + ); + } + : null; + } + + Widget buildReplyHeader(ThemeData theme) { + final secondary = theme.colorScheme.secondary; + return SliverPersistentHeader( + pinned: true, + delegate: CustomSliverPersistentHeaderDelegate( + extent: 45, + bgColor: theme.colorScheme.surface, + child: Container( + height: 45, + padding: const EdgeInsets.only(left: 12, right: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Obx( + () => Text( + '${controller.count.value == -1 ? 0 : NumUtil.numFormat(controller.count.value)}条回复', + ), + ), + SizedBox( + height: 35, + child: TextButton.icon( + onPressed: controller.queryBySort, + icon: Icon( + Icons.sort, + size: 16, + color: secondary, + ), + label: Obx( + () => Text( + controller.sortType.value.label, + style: TextStyle(fontSize: 13, color: secondary), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget replyList( + 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) { + 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: imageCallback, + onCheckReply: (item) => + controller.onCheckReply(item, isManual: true), + onToggleTop: (item) => controller.onToggleTop( + item, + index, + controller.oid, + controller.replyType, + ), + ); + } + }, + ) + : HttpError(onReload: controller.onReload), + Error(:var errMsg) => HttpError( + errMsg: errMsg, + onReload: controller.onReload, + ), + }; + } + + 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 showBackBtn = true}) => Scaffold( + appBar: AppBar( + toolbarHeight: showBackBtn ? null : 45, + title: const Text('评论详情'), + titleSpacing: showBackBtn ? null : 12, + automaticallyImplyLeading: showBackBtn, + actions: showBackBtn + ? null + : [ + IconButton( + tooltip: '关闭', + icon: const Icon(Icons.close, size: 20), + onPressed: Get.back, + ), + ], + ), + body: SafeArea( + top: false, + bottom: false, + child: VideoReplyReplyPanel( + enableSlide: false, + id: id, + oid: oid, + rpid: rpid, + isVideoDetail: false, + replyType: controller.replyType, + firstFloor: replyItem, + ), + ), + ); + if (this.context.orientation == Orientation.portrait) { + Get.to( + replyReplyPage, + routeName: 'dynamicDetail-Copy', + arguments: arguments, + ); + } else { + ScaffoldState? scaffoldState = Scaffold.maybeOf(context); + if (scaffoldState != null) { + controller.hideFab(); + scaffoldState.showBottomSheet( + backgroundColor: Colors.transparent, + (context) => MediaQuery.removePadding( + context: context, + removeLeft: true, + child: replyReplyPage(showBackBtn: false), + ), + ); + } else { + Get.to( + replyReplyPage, + routeName: 'dynamicDetail-Copy', + arguments: arguments, + ); + } + } + }); + } +} diff --git a/lib/pages/dynamics/widgets/action_panel.dart b/lib/pages/dynamics/widgets/action_panel.dart index 9c8ebb0e1..c59beaf4e 100644 --- a/lib/pages/dynamics/widgets/action_panel.dart +++ b/lib/pages/dynamics/widgets/action_panel.dart @@ -1,173 +1,104 @@ -import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/dynamics_repost/view.dart'; -import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/num_util.dart'; import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:PiliPlus/utils/request_utils.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -class ActionPanel extends StatefulWidget { +class ActionPanel extends StatelessWidget { const ActionPanel({ super.key, required this.item, }); final DynamicItemModel item; - @override - State createState() => _ActionPanelState(); -} - -class _ActionPanelState extends State { - bool isProcessing = false; - Future handleState(Future Function() action) async { - if (!isProcessing) { - isProcessing = true; - await action(); - isProcessing = false; - } - } - - // 动态点赞 - Future onLikeDynamic() async { - feedBack(); - final item = widget.item; - String dynamicId = item.idStr!; - // 1 已点赞 2 不喜欢 0 未操作 - DynamicStat? like = item.modules.moduleStat?.like; - int count = like?.count ?? 0; - bool status = like?.status == true; - int up = status ? 2 : 1; - var res = await DynamicsHttp.thumbDynamic(dynamicId: dynamicId, up: up); - if (res['status']) { - SmartDialog.showToast(!status ? '点赞成功' : '取消赞'); - if (up == 1) { - item.modules.moduleStat?.like - ?..count = count + 1 - ..status = true; - } else { - item.modules.moduleStat?.like - ?..count = count - 1 - ..status = false; - } - if (mounted) { - setState(() {}); - } - } else { - SmartDialog.showToast(res['msg']); - } - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = theme.colorScheme.outline; final primary = theme.colorScheme.primary; final outline = theme.colorScheme.outline; + final moduleStat = item.modules.moduleStat!; + final forward = moduleStat.forward!; + final comment = moduleStat.comment!; + final like = moduleStat.like!; + final btnStyle = TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 15), + foregroundColor: outline, + ); return Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Expanded( - flex: 1, child: TextButton.icon( onPressed: () => showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, - builder: (context) => RepostPanel( - item: widget.item, + builder: (_) => RepostPanel( + item: item, callback: () { - int count = - widget.item.modules.moduleStat?.forward?.count ?? 0; - widget.item.modules.moduleStat!.forward!.count = count + 1; - setState(() {}); + int count = forward.count ?? 0; + forward.count = count + 1; + if (context.mounted) { + (context as Element?)?.markNeedsBuild(); + } }, ), ), icon: Icon( FontAwesomeIcons.shareFromSquare, size: 16, - color: color, + color: outline, semanticLabel: "转发", ), - style: TextButton.styleFrom( - padding: const EdgeInsets.fromLTRB(15, 0, 15, 0), - foregroundColor: outline, - ), + style: btnStyle, label: Text( - widget.item.modules.moduleStat!.forward!.count != null - ? NumUtil.numFormat( - widget.item.modules.moduleStat!.forward!.count, - ) - : '转发', + forward.count != null ? NumUtil.numFormat(forward.count) : '转发', ), ), ), Expanded( - flex: 1, child: TextButton.icon( onPressed: () => - PageUtils.pushDynDetail(widget.item, 1, action: 'comment'), + PageUtils.pushDynDetail(item, 1, action: 'comment'), icon: Icon( FontAwesomeIcons.comment, size: 16, - color: color, + color: outline, semanticLabel: "评论", ), - style: TextButton.styleFrom( - padding: const EdgeInsets.fromLTRB(15, 0, 15, 0), - foregroundColor: outline, - ), + style: btnStyle, label: Text( - widget.item.modules.moduleStat!.comment!.count != null - ? NumUtil.numFormat( - widget.item.modules.moduleStat!.comment!.count, - ) - : '评论', + comment.count != null ? NumUtil.numFormat(comment.count) : '评论', ), ), ), Expanded( - flex: 1, child: TextButton.icon( - onPressed: () => handleState(onLikeDynamic), + onPressed: () => RequestUtils.onLikeDynamic(item, () { + if (context.mounted) { + (context as Element?)?.markNeedsBuild(); + } + }), icon: Icon( - widget.item.modules.moduleStat!.like!.status! + like.status! ? FontAwesomeIcons.solidThumbsUp : FontAwesomeIcons.thumbsUp, size: 16, - color: widget.item.modules.moduleStat!.like!.status! - ? primary - : color, - semanticLabel: widget.item.modules.moduleStat!.like!.status! - ? "已赞" - : "点赞", - ), - style: TextButton.styleFrom( - padding: const EdgeInsets.fromLTRB(15, 0, 15, 0), - foregroundColor: outline, + color: like.status! ? primary : outline, + semanticLabel: like.status! ? "已赞" : "点赞", ), + style: btnStyle, label: AnimatedSwitcher( duration: const Duration(milliseconds: 400), transitionBuilder: (Widget child, Animation animation) { return ScaleTransition(scale: animation, child: child); }, child: Text( - widget.item.modules.moduleStat!.like!.count != null - ? NumUtil.numFormat( - widget.item.modules.moduleStat!.like!.count, - ) - : '点赞', - key: ValueKey( - widget.item.modules.moduleStat!.like!.count?.toString() ?? - '点赞', - ), - style: TextStyle( - color: widget.item.modules.moduleStat!.like!.status! - ? primary - : color, - ), + like.count != null ? NumUtil.numFormat(like.count) : '点赞', + key: ValueKey(like.count?.toString() ?? '点赞'), + style: TextStyle(color: like.status! ? primary : outline), ), ), ), diff --git a/lib/pages/dynamics_detail/controller.dart b/lib/pages/dynamics_detail/controller.dart index d3f750197..fda4f1092 100644 --- a/lib/pages/dynamics_detail/controller.dart +++ b/lib/pages/dynamics_detail/controller.dart @@ -4,18 +4,19 @@ import 'package:PiliPlus/grpc/reply.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; -import 'package:PiliPlus/pages/common/reply_controller.dart'; +import 'package:PiliPlus/pages/common/dyn/common_dyn_controller.dart'; import 'package:PiliPlus/utils/id_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class DynamicDetailController extends ReplyController { +class DynamicDetailController extends CommonDynController { + @override late int oid; + @override late int replyType; late DynamicItemModel dynItem; - late final horizontalPreview = Pref.horizontalPreview; late final showDynActionBar = Pref.showDynActionBar; @override diff --git a/lib/pages/dynamics_detail/view.dart b/lib/pages/dynamics_detail/view.dart index a555a8aaa..61bdf99b0 100644 --- a/lib/pages/dynamics_detail/view.dart +++ b/lib/pages/dynamics_detail/view.dart @@ -1,222 +1,58 @@ 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/common/dyn/common_dyn_page.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 { +class DynamicDetailPage extends CommonDynPage { const DynamicDetailPage({super.key}); @override State createState() => _DynamicDetailPageState(); } -class _DynamicDetailPageState extends State - with TickerProviderStateMixin { - final _controller = Get.put( +class _DynamicDetailPageState extends CommonDynPageState { + @override + final DynamicDetailController controller = Get.put( DynamicDetailController(), tag: Utils.generateRandomString(8), ); - late final AnimationController _fabAnimationCtr; - late final Animation _anim; - - final RxBool _visibleTitle = false.obs; - bool _isFabVisible = true; - - late final List _ratio = Pref.dynamicDetailRatio; - - bool get _horizontalPreview => - context.orientation == Orientation.landscape && - _controller.horizontalPreview; - - late final _key = GlobalKey(); - - late Function(List imgList, int index)? _imageCallback; @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 showBackBtn = true}) => Scaffold( - appBar: AppBar( - toolbarHeight: showBackBtn ? null : 45, - title: const Text('评论详情'), - titleSpacing: showBackBtn ? null : 12, - automaticallyImplyLeading: showBackBtn, - actions: showBackBtn - ? null - : [ - IconButton( - tooltip: '关闭', - icon: const Icon(Icons.close, size: 20), - onPressed: Get.back, - ), - ], - ), - body: SafeArea( - top: false, - bottom: false, - child: VideoReplyReplyPanel( - enableSlide: false, - id: id, - oid: oid, - rpid: rpid, - isVideoDetail: false, - replyType: _controller.replyType, - firstFloor: replyItem, - ), - ), - ); - 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(showBackBtn: false), - ), - ); - } else { - Get.to( - replyReplyPage, - routeName: 'dynamicDetail-Copy', - arguments: { - 'item': _controller.dynItem, - }, - ); - } - } - }); - } + dynamic get arguments => { + 'item': controller.dynItem, + }; @override void didChangeDependencies() { super.didChangeDependencies(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (_controller.scrollController.hasClients) { - _visibleTitle.value = - _controller.scrollController.positions.first.pixels > 55; + if (controller.scrollController.hasClients) { + controller.showTitle.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); - _imageCallback = _horizontalPreview - ? (imgList, index) { - _hideFab(); - PageUtils.onHorizontalPreview( - _key, - this, - imgList, - index, - ); - } - : null; return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( @@ -225,12 +61,12 @@ class _DynamicDetailPageState extends State child: Obx( () { return AnimatedOpacity( - opacity: _visibleTitle.value ? 1 : 0, + opacity: controller.showTitle.value ? 1 : 0, duration: const Duration(milliseconds: 300), child: IgnorePointer( - ignoring: !_visibleTitle.value, + ignoring: !controller.showTitle.value, child: AuthorPanel( - item: _controller.dynItem, + item: controller.dynItem, isDetail: true, ), ), @@ -257,14 +93,15 @@ class _DynamicDetailPageState extends State builder: (context) => Slider( min: 1, max: 100, - value: _ratio.first, + value: controller.ratio.first, onChanged: (value) { if (value >= 10 && value <= 90) { - _ratio[0] = value.toPrecision(2); - _ratio[1] = 100 - value; + controller.ratio + ..[0] = value.toPrecision(2) + ..[1] = 100 - value; GStorage.setting.put( SettingBoxKey.dynamicDetailRatio, - _ratio, + controller.ratio, ); (context as Element).markNeedsBuild(); setState(() {}); @@ -289,7 +126,7 @@ class _DynamicDetailPageState extends State bottom: false, child: context.orientation == Orientation.portrait ? refreshIndicator( - onRefresh: _controller.onRefresh, + onRefresh: controller.onRefresh, child: _buildBody(context.orientation, theme), ) : _buildBody(context.orientation, theme), @@ -305,21 +142,20 @@ class _DynamicDetailPageState extends State double padding = max(context.width / 2 - Grid.smallCardWidth, 0); if (orientation == Orientation.portrait) { return CustomScrollView( - controller: _controller.scrollController, + controller: controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter( child: DynamicPanel( - item: _controller.dynItem, + item: controller.dynItem, isDetail: true, - callback: _imageCallback, + callback: imageCallback, ), ), - replyPersistentHeader(theme), + buildReplyHeader(theme), Obx( - () => - replyList(theme, _controller.loadingState.value), + () => replyList(theme, controller.loadingState.value), ), ] .map( @@ -334,9 +170,9 @@ class _DynamicDetailPageState extends State return Row( children: [ Expanded( - flex: _ratio[0].toInt(), + flex: controller.ratio[0].toInt(), child: CustomScrollView( - controller: _controller.scrollController, + controller: controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( @@ -346,9 +182,9 @@ class _DynamicDetailPageState extends State ), sliver: SliverToBoxAdapter( child: DynamicPanel( - item: _controller.dynItem, + item: controller.dynItem, isDetail: true, - callback: _imageCallback, + callback: imageCallback, ), ), ), @@ -356,26 +192,26 @@ class _DynamicDetailPageState extends State ), ), Expanded( - flex: _ratio[1].toInt(), + flex: controller.ratio[1].toInt(), child: Scaffold( - key: _key, + key: scaffoldKey, backgroundColor: Colors.transparent, body: refreshIndicator( - onRefresh: _controller.onRefresh, + onRefresh: controller.onRefresh, child: CustomScrollView( - controller: _controller.scrollController, + controller: controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( padding: EdgeInsets.only(right: padding / 4), - sliver: replyPersistentHeader(theme), + sliver: buildReplyHeader(theme), ), SliverPadding( padding: EdgeInsets.only(right: padding / 4), sliver: Obx( () => replyList( theme, - _controller.loadingState.value, + controller.loadingState.value, ), ), ), @@ -389,396 +225,170 @@ class _DynamicDetailPageState extends State } }, ), - 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, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ], - ); - }, - ), - ), - ), + _buildBottom(theme), ], ); - 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), + Widget _buildBottom(ThemeData theme) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: SlideTransition( + position: controller.fabAnim, + 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), + ); + + final bottom = MediaQuery.paddingOf(context).bottom; + if (!controller.showDynActionBar) { + return Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.only( + right: 14, + bottom: bottom + 14, ), + child: button(), ), - ), - 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, + ); + } + + final moduleStat = controller.dynItem.modules.moduleStat; + final primary = theme.colorScheme.primary; + final outline = theme.colorScheme.outline; + final btnStyle = TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 15), + foregroundColor: outline, + ); + + Widget textIconButton({ + required IconData icon, + required String text, + required DynamicStat? stat, + required VoidCallback onPressed, + IconData? activitedIcon, + }) { + final status = stat?.status == true; + final color = status ? primary : outline; + return TextButton.icon( + onPressed: onPressed, + icon: Icon( + status ? activitedIcon : icon, + size: 16, + color: color, + ), + style: btnStyle, + label: Text( + stat?.count != null ? NumUtil.numFormat(stat!.count) : text, + style: TextStyle(color: color), + ), + ); + } + + return 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, + ), ), ), ), - ), - ), - ], - ), - ), - ), - 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, + padding: EdgeInsets.only(bottom: bottom), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: Builder( + builder: (btnContext) { + final forward = moduleStat?.forward; + return textIconButton( + icon: FontAwesomeIcons.shareFromSquare, + text: '转发', + stat: forward, + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => RepostPanel( + item: controller.dynItem, + callback: () { + if (forward != null) { + int count = forward.count ?? 0; + forward.count = count + 1; + if (btnContext.mounted) { + (btnContext as Element?) + ?.markNeedsBuild(); + } + } + }, + ), + ), + ); + }, ), ), - ); - } else { - return ReplyItemGrpc( - replyItem: response[index], - replyLevel: 1, - replyReply: (replyItem, id) => - replyReply(context, replyItem, id), - onReply: (replyItem) => _controller.onReply( - context, - replyItem: replyItem, + Expanded( + child: textIconButton( + icon: CustomIcon.share_node, + text: '分享', + stat: null, + onPressed: () => Utils.shareText( + '${HttpString.dynamicShareBaseUrl}/${controller.dynItem.idStr}', + ), + ), ), - onDelete: (item, subIndex) => - _controller.onRemove(index, item, subIndex), - upMid: _controller.upMid, - callback: _imageCallback, - onCheckReply: (item) => - _controller.onCheckReply(item, isManual: true), - onToggleTop: (item) => _controller.onToggleTop( - item, - index, - _controller.oid, - _controller.replyType, + Expanded( + child: Builder( + builder: (context) { + return textIconButton( + icon: FontAwesomeIcons.thumbsUp, + activitedIcon: FontAwesomeIcons.solidThumbsUp, + text: '点赞', + stat: moduleStat?.like, + onPressed: () => RequestUtils.onLikeDynamic( + controller.dynItem, + () { + if (context.mounted) { + (context as Element?)?.markNeedsBuild(); + } + }, + ), + ); + }, + ), ), - ); - } - }, - itemCount: response!.length + 1, - ) - : HttpError( - onReload: _controller.onReload, - ), - Error(:var errMsg) => HttpError( - errMsg: errMsg, - onReload: _controller.onReload, + ], + ), + ), + ], + ); + }, + ), ), - }; + ); } } diff --git a/lib/pages/match_info/controller.dart b/lib/pages/match_info/controller.dart index d48f3867e..653a1d37b 100644 --- a/lib/pages/match_info/controller.dart +++ b/lib/pages/match_info/controller.dart @@ -3,28 +3,30 @@ import 'package:PiliPlus/grpc/reply.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/match.dart'; import 'package:PiliPlus/models_new/match/match_info/contest.dart'; -import 'package:PiliPlus/pages/common/reply_controller.dart'; +import 'package:PiliPlus/pages/common/dyn/common_dyn_controller.dart'; import 'package:get/get.dart'; -class MatchInfoController extends ReplyController { - final int cid = int.parse(Get.parameters['cid']!); - +class MatchInfoController extends CommonDynController { + @override + final int oid = int.parse(Get.parameters['cid']!); + @override int get replyType => 27; @override - dynamic get sourceId => cid.toString(); + dynamic get sourceId => oid.toString(); final Rx> infoState = LoadingState.loading().obs; @override void onInit() { + offsetDy = 2; super.onInit(); getMatchInfo(); } Future getMatchInfo() async { - var res = await MatchHttp.matchInfo(cid); + var res = await MatchHttp.matchInfo(oid); if (res.isSuccess) { queryData(); } @@ -39,7 +41,7 @@ class MatchInfoController extends ReplyController { @override Future> customGetData() => ReplyGrpc.mainList( type: replyType, - oid: cid, + oid: oid, mode: mode.value, cursorNext: cursorNext, offset: paginationReply?.nextOffset, diff --git a/lib/pages/match_info/view.dart b/lib/pages/match_info/view.dart index d2dd06a5f..4b6981138 100644 --- a/lib/pages/match_info/view.dart +++ b/lib/pages/match_info/view.dart @@ -1,5 +1,4 @@ import 'package:PiliPlus/common/skeleton/video_reply.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'; @@ -9,31 +8,35 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/models_new/match/match_info/contest.dart'; import 'package:PiliPlus/models_new/match/match_info/team.dart'; +import 'package:PiliPlus/pages/common/dyn/common_dyn_page.dart'; import 'package:PiliPlus/pages/match_info/controller.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/feed_back.dart'; -import 'package:PiliPlus/utils/num_util.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; -class MatchInfoPage extends StatefulWidget { +class MatchInfoPage extends CommonDynPage { const MatchInfoPage({super.key}); @override State createState() => _MatchInfoPageState(); } -class _MatchInfoPageState extends State { - final _controller = Get.put( +class _MatchInfoPageState extends CommonDynPageState { + @override + final MatchInfoController controller = Get.put( MatchInfoController(), tag: Utils.generateRandomString(8), ); + @override + dynamic get arguments => null; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -42,36 +45,39 @@ class _MatchInfoPageState extends State { body: SafeArea( bottom: false, child: refreshIndicator( - onRefresh: _controller.onRefresh, + onRefresh: controller.onRefresh, child: CustomScrollView( - controller: _controller.scrollController, + controller: controller.scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - Obx(() => _buildInfo(theme, _controller.infoState.value)), + Obx(() => _buildInfo(theme, controller.infoState.value)), SliverPadding( padding: EdgeInsets.only( bottom: MediaQuery.paddingOf(context).bottom + 80, ), sliver: Obx( - () => _buildReply(theme, _controller.loadingState.value), + () => _buildReply(theme, controller.loadingState.value), ), ), ], ), ), ), - floatingActionButton: FloatingActionButton( - heroTag: null, - onPressed: () { - feedBack(); - _controller.onReply( - context, - oid: _controller.cid, - replyType: _controller.replyType, - ); - }, - tooltip: '评论动态', - child: const Icon(Icons.reply), + floatingActionButton: SlideTransition( + position: controller.fabAnim, + child: FloatingActionButton( + heroTag: null, + onPressed: () { + feedBack(); + controller.onReply( + context, + oid: controller.oid, + replyType: controller.replyType, + ); + }, + tooltip: '评论动态', + child: const Icon(Icons.reply), + ), ), ); } @@ -214,46 +220,47 @@ class _MatchInfoPageState extends State { response?.isNotEmpty == true ? SliverMainAxisGroup( slivers: [ - _buildHeader(theme), + buildReplyHeader(theme), SliverList.builder( itemCount: response!.length, itemBuilder: (context, index) { if (index == response.length - 1) { - _controller.onLoadMore(); + controller.onLoadMore(); } return ReplyItemGrpc( replyItem: response[index], replyLevel: 1, replyReply: (replyItem, id) => replyReply(context, replyItem, id), - onReply: (replyItem) => _controller.onReply( + onReply: (replyItem) => controller.onReply( context, replyItem: replyItem, ), onDelete: (item, subIndex) => - _controller.onRemove(index, item, subIndex), - upMid: _controller.upMid, + controller.onRemove(index, item, subIndex), + upMid: controller.upMid, onCheckReply: (item) => - _controller.onCheckReply(item, isManual: true), - onToggleTop: (item) => _controller.onToggleTop( + controller.onCheckReply(item, isManual: true), + onToggleTop: (item) => controller.onToggleTop( item, index, - _controller.cid, - _controller.replyType, + controller.oid, + controller.replyType, ), ); }, ), ], ) - : HttpError(onReload: _controller.onReload), + : HttpError(onReload: controller.onReload), Error(:var errMsg) => HttpError( errMsg: errMsg, - onReload: _controller.onReload, + onReload: controller.onReload, ), }; } + @override void replyReply(BuildContext context, ReplyInfo replyItem, int? id) { EasyThrottle.throttle('replyReply', const Duration(milliseconds: 500), () { int oid = replyItem.oid.toInt(); @@ -270,7 +277,7 @@ class _MatchInfoPageState extends State { oid: oid, rpid: rpid, isVideoDetail: false, - replyType: _controller.replyType, + replyType: controller.replyType, firstFloor: replyItem, ), ), @@ -278,61 +285,4 @@ class _MatchInfoPageState extends State { ); }); } - - Widget _buildHeader(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( - () { - final count = _controller.count.value; - return AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - transitionBuilder: - (Widget child, Animation animation) { - return ScaleTransition( - scale: animation, - child: child, - ); - }, - child: Text( - '${count == -1 ? 0 : NumUtil.numFormat(count)}条回复', - key: ValueKey(count), - ), - ); - }, - ), - 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, - ); - } } diff --git a/lib/pages/video/reply/widgets/zan_grpc.dart b/lib/pages/video/reply/widgets/zan_grpc.dart index 83e37571a..d762f333a 100644 --- a/lib/pages/video/reply/widgets/zan_grpc.dart +++ b/lib/pages/video/reply/widgets/zan_grpc.dart @@ -8,7 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -class ZanButtonGrpc extends StatefulWidget { +class ZanButtonGrpc extends StatelessWidget { const ZanButtonGrpc({ super.key, required this.replyItem, @@ -16,23 +16,24 @@ class ZanButtonGrpc extends StatefulWidget { final ReplyInfo replyItem; - @override - State createState() => _ZanButtonGrpcState(); -} - -class _ZanButtonGrpcState extends State { - bool get isLike => widget.replyItem.replyControl.action == $fixnum.Int64.ONE; - bool get isDislike => - widget.replyItem.replyControl.action == $fixnum.Int64.TWO; - - Future onHateReply() async { + Future onHateReply( + BuildContext context, + bool isProcessing, + VoidCallback onDone, { + required bool isLike, + required bool isDislike, + }) async { + if (isProcessing) { + return; + } + isProcessing = true; feedBack(); - final int oid = widget.replyItem.oid.toInt(); - final int rpid = widget.replyItem.id.toInt(); + final int oid = replyItem.oid.toInt(); + final int rpid = replyItem.id.toInt(); // 1 已点赞 2 不喜欢 0 未操作 final int action = isDislike ? 0 : 2; final res = await ReplyHttp.hateReply( - type: widget.replyItem.type.toInt(), + type: replyItem.type.toInt(), action: action == 2 ? 1 : 0, oid: oid, rpid: rpid, @@ -41,28 +42,39 @@ class _ZanButtonGrpcState extends State { if (res['status']) { SmartDialog.showToast(isDislike ? '取消踩' : '点踩成功'); if (action == 2) { - if (isLike) widget.replyItem.like -= $fixnum.Int64.ONE; - widget.replyItem.replyControl.action = $fixnum.Int64.TWO; + if (isLike) replyItem.like -= $fixnum.Int64.ONE; + replyItem.replyControl.action = $fixnum.Int64.TWO; } else { - widget.replyItem.replyControl.action = $fixnum.Int64.ZERO; + replyItem.replyControl.action = $fixnum.Int64.ZERO; } - if (mounted) { - setState(() {}); + if (context.mounted) { + (context as Element?)?.markNeedsBuild(); } } else { SmartDialog.showToast(res['msg']); } + onDone(); } // 评论点赞 - Future onLikeReply() async { + Future onLikeReply( + BuildContext context, + bool isProcessing, + VoidCallback onDone, { + required bool isLike, + required bool isDislike, + }) async { + if (isProcessing) { + return; + } + isProcessing = true; feedBack(); - final int oid = widget.replyItem.oid.toInt(); - final int rpid = widget.replyItem.id.toInt(); + final int oid = replyItem.oid.toInt(); + final int rpid = replyItem.id.toInt(); // 1 已点赞 2 不喜欢 0 未操作 final int action = isLike ? 0 : 1; final res = await ReplyHttp.likeReply( - type: widget.replyItem.type.toInt(), + type: replyItem.type.toInt(), oid: oid, rpid: rpid, action: action, @@ -70,36 +82,32 @@ class _ZanButtonGrpcState extends State { if (res['status']) { SmartDialog.showToast(isLike ? '取消赞' : '点赞成功'); if (action == 1) { - widget.replyItem + replyItem ..like += $fixnum.Int64.ONE ..replyControl.action = $fixnum.Int64.ONE; } else { - widget.replyItem + replyItem ..like -= $fixnum.Int64.ONE ..replyControl.action = $fixnum.Int64.ZERO; } - if (mounted) { - setState(() {}); + if (context.mounted) { + (context as Element?)?.markNeedsBuild(); } } else { SmartDialog.showToast(res['msg']); } - } - - bool isProcessing = false; - Future handleState(Future Function() action) async { - if (!isProcessing) { - isProcessing = true; - await action(); - isProcessing = false; - } + onDone(); } @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - final Color color = theme.colorScheme.outline; - final Color primary = theme.colorScheme.primary; + late bool isProcessing = false; + final action = replyItem.replyControl.action; + final isLike = action == $fixnum.Int64.ONE; + final isDislike = action == $fixnum.Int64.TWO; + final outline = theme.colorScheme.outline; + final primary = theme.colorScheme.primary; final ButtonStyle style = TextButton.styleFrom( padding: EdgeInsets.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -112,13 +120,19 @@ class _ZanButtonGrpcState extends State { height: 32, child: TextButton( style: style, - onPressed: () => handleState(onHateReply), + onPressed: () => onHateReply( + context, + isProcessing, + () => isProcessing = false, + isLike: isLike, + isDislike: isDislike, + ), child: Icon( isDislike ? FontAwesomeIcons.solidThumbsDown : FontAwesomeIcons.thumbsDown, size: 16, - color: isDislike ? primary : color, + color: isDislike ? primary : outline, semanticLabel: isDislike ? '已踩' : '点踩', ), ), @@ -127,30 +141,29 @@ class _ZanButtonGrpcState extends State { height: 32, child: TextButton( style: style, - onPressed: () => handleState(onLikeReply), + onPressed: () => onLikeReply( + context, + isProcessing, + () => isProcessing = false, + isLike: isLike, + isDislike: isDislike, + ), child: Row( + spacing: 4, children: [ Icon( isLike ? FontAwesomeIcons.solidThumbsUp : FontAwesomeIcons.thumbsUp, size: 16, - color: isLike ? primary : color, + color: isLike ? primary : outline, semanticLabel: isLike ? '已赞' : '点赞', ), - const SizedBox(width: 4), - AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - transitionBuilder: - (Widget child, Animation animation) { - return ScaleTransition(scale: animation, child: child); - }, - child: Text( - NumUtil.numFormat(widget.replyItem.like.toInt()), - style: TextStyle( - color: isLike ? primary : color, - fontSize: theme.textTheme.labelSmall!.fontSize, - ), + Text( + NumUtil.numFormat(replyItem.like.toInt()), + style: TextStyle( + color: isLike ? primary : outline, + fontSize: theme.textTheme.labelSmall!.fontSize, ), ), ], diff --git a/lib/utils/request_utils.dart b/lib/utils/request_utils.dart index 3d02f52ee..0f0d1c46b 100644 --- a/lib/utils/request_utils.dart +++ b/lib/utils/request_utils.dart @@ -341,7 +341,7 @@ class RequestUtils { // 动态点赞 static Future onLikeDynamic( DynamicItemModel item, - VoidCallback callback, + VoidCallback onSuccess, ) async { feedBack(); String dynamicId = item.idStr!; @@ -362,7 +362,7 @@ class RequestUtils { ?..count = count - 1 ..status = false; } - callback(); + onSuccess(); } else { SmartDialog.showToast(res['msg']); }