diff --git a/lib/common/widgets/sliver/sliver_to_box_adapter.dart b/lib/common/widgets/sliver/sliver_to_box_adapter.dart new file mode 100644 index 000000000..ee5a0a32b --- /dev/null +++ b/lib/common/widgets/sliver/sliver_to_box_adapter.dart @@ -0,0 +1,85 @@ +import 'package:flutter/rendering.dart' show RenderSliverToBoxAdapter; +import 'package:flutter/widgets.dart'; + +class SliverToBoxWithOffsetAdapter extends SliverToBoxAdapter { + const SliverToBoxWithOffsetAdapter({ + super.key, + required this.offset, + required this.onVisibilityChanged, + super.child, + }); + + final double offset; + final ValueChanged onVisibilityChanged; + + @override + RenderSliverToBoxWithOffsetAdapter createRenderObject(BuildContext context) => + RenderSliverToBoxWithOffsetAdapter( + offset: offset, + onVisibilityChanged: onVisibilityChanged, + ); +} + +class RenderSliverToBoxWithOffsetAdapter extends RenderSliverToBoxAdapter { + RenderSliverToBoxWithOffsetAdapter({ + required this.offset, + required this.onVisibilityChanged, + super.child, + }); + + bool? _visible; + final double offset; + final ValueChanged onVisibilityChanged; + + @override + void performLayout() { + final visible = constraints.scrollOffset > offset; + if (_visible != visible) { + _visible = visible; + WidgetsBinding.instance.addPostFrameCallback( + (_) => onVisibilityChanged(visible), + ); + } + super.performLayout(); + } +} + +class SliverToBoxWithVisibilityAdapter extends SliverToBoxAdapter { + const SliverToBoxWithVisibilityAdapter({ + super.key, + required this.onVisibilityChanged, + super.child, + }); + + final ValueChanged onVisibilityChanged; + + @override + RenderSliverToBoxWithVisibilityAdapter createRenderObject( + BuildContext context, + ) => RenderSliverToBoxWithVisibilityAdapter( + onVisibilityChanged: onVisibilityChanged, + ); +} + +class RenderSliverToBoxWithVisibilityAdapter extends RenderSliverToBoxAdapter { + RenderSliverToBoxWithVisibilityAdapter({ + required this.onVisibilityChanged, + super.child, + }); + + final ValueChanged onVisibilityChanged; + + bool? _visible; + + @override + void performLayout() { + super.performLayout(); + final visible = geometry!.visible; + if (_visible != visible) { + _visible = visible; + WidgetsBinding.instance.addPostFrameCallback( + (_) => onVisibilityChanged(visible), + ); + } + } +} diff --git a/lib/http/api.dart b/lib/http/api.dart index bea77e0ac..27821f7aa 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -1008,4 +1008,8 @@ abstract final class Api { static const String bubble = '/x/tribee/v1/dyn/all'; static const String sortFollowTag = '/x/relation/tags/update_sort'; + + static const String replyReport = '/x/v2/reply/report'; + + static const String dynReaction = '/x/polymer/web-dynamic/v1/detail/reaction'; } diff --git a/lib/http/dynamics.dart b/lib/http/dynamics.dart index 6a7f3d69d..3ff0e3509 100644 --- a/lib/http/dynamics.dart +++ b/lib/http/dynamics.dart @@ -18,6 +18,7 @@ import 'package:PiliPlus/models_new/article/article_view/data.dart'; import 'package:PiliPlus/models_new/bubble/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_mention/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_reaction/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_reserve/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_reserve_info/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/topic_card_list.dart'; @@ -807,4 +808,23 @@ abstract final class DynamicsHttp { return Error(res.data['message']); } } + + static Future> dynReaction({ + required Object id, + String? offset, + }) async { + final res = await Request().get( + Api.dynReaction, + queryParameters: { + 'id': id, + 'offset': offset, + 'web_location': 333.1369, + }, + ); + if (res.data['code'] == 0) { + return Success(DynReactionData.fromJson(res.data['data'])); + } else { + return Error(res.data['message']); + } + } } diff --git a/lib/http/reply.dart b/lib/http/reply.dart index db9bc1009..a96d81a09 100644 --- a/lib/http/reply.dart +++ b/lib/http/reply.dart @@ -186,7 +186,7 @@ abstract final class ReplyHttp { String? reasonDesc, }) async { final res = await Request().post( - '/x/v2/reply/report', + Api.replyReport, data: { 'add_blacklist': banUid, 'csrf': Accounts.main.csrf, diff --git a/lib/models_new/dynamic/dyn_reaction/data.dart b/lib/models_new/dynamic/dyn_reaction/data.dart new file mode 100644 index 000000000..16269d99f --- /dev/null +++ b/lib/models_new/dynamic/dyn_reaction/data.dart @@ -0,0 +1,20 @@ +import 'package:PiliPlus/models_new/dynamic/dyn_reaction/item.dart'; + +class DynReactionData { + bool? hasMore; + List? items; + String? offset; + int total; + + DynReactionData({this.hasMore, this.items, this.offset, required this.total}); + + factory DynReactionData.fromJson(Map json) => + DynReactionData( + hasMore: json['has_more'] as bool?, + items: (json['items'] as List?) + ?.map((e) => DynReactionItem.fromJson(e as Map)) + .toList(), + offset: json['offset'] as String?, + total: json['total'] as int? ?? 0, + ); +} diff --git a/lib/models_new/dynamic/dyn_reaction/item.dart b/lib/models_new/dynamic/dyn_reaction/item.dart new file mode 100644 index 000000000..2743cd722 --- /dev/null +++ b/lib/models_new/dynamic/dyn_reaction/item.dart @@ -0,0 +1,21 @@ +class DynReactionItem { + String? action; + String? face; + String? mid; + String? name; + + DynReactionItem({ + this.action, + this.face, + this.mid, + this.name, + }); + + factory DynReactionItem.fromJson(Map json) => + DynReactionItem( + action: json['action'] as String?, + face: json['face'] as String?, + mid: json['mid'] as String?, + name: json['name'] as String?, + ); +} diff --git a/lib/pages/article/view.dart b/lib/pages/article/view.dart index 3283ddc21..f404e1f5c 100644 --- a/lib/pages/article/view.dart +++ b/lib/pages/article/view.dart @@ -5,6 +5,7 @@ import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; +import 'package:PiliPlus/common/widgets/sliver/sliver_to_box_adapter.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'; @@ -50,46 +51,33 @@ class _ArticlePageState extends CommonDynPageState { 'id': controller.id, }; - @override - void didChangeDependencies() { - super.didChangeDependencies(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - controller.showTitle.value = - scrollController.positions.last.pixels >= 45; - } - }); - } - @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( + final child = Scaffold( resizeToAvoidBottomInset: false, appBar: _buildAppBar(), body: Padding( padding: EdgeInsets.only(left: padding.left, right: padding.right), - child: _buildPage(theme), + child: _buildPage(), ), floatingActionButtonLocation: floatingActionButtonLocation, floatingActionButton: SlideTransition( position: fabAnimation, - child: _buildBottom(theme), + child: _buildBottom(), ), ); + return fabAnimWrapper(child); } - Widget _buildPage(ThemeData theme) { + Widget _buildPage() { double padding = max(maxWidth / 2 - Grid.smallCardWidth, 0); if (isPortrait) { return Padding( padding: EdgeInsets.symmetric(horizontal: padding), child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ _buildContent( - theme, maxWidth - this.padding.horizontal - 2 * padding - 24, ), SliverToBoxAdapter( @@ -98,8 +86,8 @@ class _ArticlePageState extends CommonDynPageState { color: theme.dividerColor.withValues(alpha: 0.05), ), ), - buildReplyHeader(theme), - Obx(() => replyList(theme, controller.loadingState.value)), + buildReplyHeader(), + Obx(() => replyList(controller.loadingState.value)), ], ), ); @@ -114,7 +102,6 @@ class _ArticlePageState extends CommonDynPageState { Expanded( flex: flex, child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( @@ -123,7 +110,6 @@ class _ArticlePageState extends CommonDynPageState { bottom: this.padding.bottom + 100, ), sliver: _buildContent( - theme, (maxWidth - this.padding.horizontal) * flex / (flex + flex1) - padding - 32, @@ -146,11 +132,10 @@ class _ArticlePageState extends CommonDynPageState { body: refreshIndicator( onRefresh: controller.onRefresh, child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - buildReplyHeader(theme), - Obx(() => replyList(theme, controller.loadingState.value)), + buildReplyHeader(), + Obx(() => replyList(controller.loadingState.value)), ], ), ), @@ -161,7 +146,7 @@ class _ArticlePageState extends CommonDynPageState { ); } - Widget _buildContent(ThemeData theme, double maxWidth) => SliverPadding( + Widget _buildContent(double maxWidth) => SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), sliver: Obx( () { @@ -339,7 +324,9 @@ class _ArticlePageState extends CommonDynPageState { ), ), if (controller.summary.title != null) - SliverToBoxAdapter( + SliverToBoxWithVisibilityAdapter( + onVisibilityChanged: (bool visible) => + controller.showTitle.value = !visible, child: Text( controller.summary.title!, style: const TextStyle( @@ -495,7 +482,7 @@ class _ArticlePageState extends CommonDynPageState { ], ); - Widget _buildBottom(ThemeData theme) { + Widget _buildBottom() { if (!controller.showDynActionBar) { return fabButton; } diff --git a/lib/pages/common/dyn/common_dyn_page.dart b/lib/pages/common/dyn/common_dyn_page.dart index 1c4649ba5..cfec13a79 100644 --- a/lib/pages/common/dyn/common_dyn_page.dart +++ b/lib/pages/common/dyn/common_dyn_page.dart @@ -7,6 +7,7 @@ import 'package:PiliPlus/common/widgets/view_safe_area.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/enum_with_label.dart'; import 'package:PiliPlus/pages/common/dyn/common_dyn_controller.dart'; import 'package:PiliPlus/pages/common/fab_mixin.dart'; import 'package:PiliPlus/pages/video/reply/widgets/reply_item_grpc.dart'; @@ -21,56 +22,70 @@ import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -abstract class CommonDynPageState extends State - with SingleTickerProviderStateMixin, BaseFabMixin, FabMixin { - CommonDynController get controller; +enum DynType implements EnumWithLabel { + reply('评论'), + reaction('赞与转发'); - late final ScrollController scrollController; + @override + final String label; + const DynType(this.label); +} + +abstract class CommonDynPageState extends State + with + SingleTickerProviderStateMixin, + BaseFabMixin, + FabMixin, + CommonDynPageMixin {} + +abstract class CommonDynPageMultiState + extends State + with + TickerProviderStateMixin, + BaseFabMixin, + FabMixin, + CommonDynPageMixin { + late final TabController tabController; + + @override + void initState() { + super.initState(); + tabController = TabController(length: DynType.values.length, vsync: this); + } + + @override + void dispose() { + tabController.dispose(); + super.dispose(); + } +} + +mixin CommonDynPageMixin + on State, TickerProvider, BaseFabMixin, FabMixin { + CommonDynController get controller; bool get horizontalPreview => !isPortrait && controller.horizontalPreview; dynamic get arguments; + late ThemeData theme; late EdgeInsets padding; late bool isPortrait; late double maxWidth; late double maxHeight; - @override - void initState() { - super.initState(); - scrollController = ScrollController()..addListener(listener); - } - - void listener() { - final pos = scrollController.positions; - controller.showTitle.value = pos.first.pixels > 55; - if (pos.any((e) => e.userScrollDirection == .forward)) { - showFab(); - } else if (pos.any((e) => e.userScrollDirection == .reverse)) { - hideFab(); - } - } - @override void didChangeDependencies() { super.didChangeDependencies(); final size = MediaQuery.sizeOf(context); + theme = Theme.of(context); maxWidth = size.width; maxHeight = size.height; isPortrait = size.isPortrait; padding = MediaQuery.viewPaddingOf(context); } - @override - void dispose() { - scrollController - ..removeListener(listener) - ..dispose(); - super.dispose(); - } - - Widget buildReplyHeader(ThemeData theme) { + Widget buildReplyHeader() { final secondary = theme.colorScheme.secondary; return SliverPinnedHeader( backgroundColor: theme.colorScheme.surface, @@ -104,10 +119,7 @@ abstract class CommonDynPageState extends State ); } - Widget replyList( - ThemeData theme, - LoadingState?> loadingState, - ) { + Widget replyList(LoadingState?> loadingState) { return switch (loadingState) { Loading() => SliverList.builder( itemCount: 12, @@ -137,7 +149,7 @@ abstract class CommonDynPageState extends State replyItem: response[index], replyLevel: 1, replyReply: (replyItem, id) => - replyReply(context, replyItem, id, theme), + replyReply(context, replyItem, id), onReply: controller.onReply, onDelete: (item, subIndex) => controller.onRemove(index, item, subIndex), @@ -166,12 +178,7 @@ abstract class CommonDynPageState extends State }; } - void replyReply( - BuildContext context, - ReplyInfo replyItem, - int? id, - ThemeData theme, - ) { + 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(); @@ -295,4 +302,22 @@ abstract class CommonDynPageState extends State tooltip: '评论', child: const Icon(Icons.reply), ); + + Widget fabAnimWrapper(Widget child) { + return NotificationListener( + onNotification: (notification) { + if (notification.metrics.axisDirection == .down) { + switch (notification.direction) { + case .forward: + showFab(); + case .reverse: + hideFab(); + default: + } + } + return false; + }, + child: child, + ); + } } diff --git a/lib/pages/common/dyn/reaction/controller.dart b/lib/pages/common/dyn/reaction/controller.dart new file mode 100644 index 000000000..2ceeadc48 --- /dev/null +++ b/lib/pages/common/dyn/reaction/controller.dart @@ -0,0 +1,42 @@ +import 'package:PiliPlus/http/dynamics.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_reaction/data.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_reaction/item.dart'; +import 'package:PiliPlus/pages/common/common_list_controller.dart'; +import 'package:get/get.dart'; + +class DynReactController + extends CommonListController { + DynReactController(this.id, {int count = -1}) : count = RxInt(count); + final Object id; + + String? _offset; + final RxInt count; + + @override + List? getDataList(DynReactionData response) { + _offset = response.offset; + if (response.hasMore != true) { + isEnd = true; + } + return response.items; + } + + @override + bool customHandleResponse(bool isRefresh, Success response) { + if (isRefresh) { + count.value = response.response.total; + } + return false; + } + + @override + Future> customGetData() => + DynamicsHttp.dynReaction(id: id, offset: _offset); + + @override + Future onRefresh() { + _offset = null; + return super.onRefresh(); + } +} diff --git a/lib/pages/common/dyn/reaction/view.dart b/lib/pages/common/dyn/reaction/view.dart new file mode 100644 index 000000000..da3c03dbd --- /dev/null +++ b/lib/pages/common/dyn/reaction/view.dart @@ -0,0 +1,93 @@ +import 'package:PiliPlus/common/widgets/flutter/list_tile.dart'; +import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart'; +import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; +import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; +import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_reaction/item.dart'; +import 'package:PiliPlus/pages/common/dyn/common_dyn_page.dart'; +import 'package:PiliPlus/pages/common/dyn/reaction/controller.dart'; +import 'package:flutter/material.dart' hide ListTile; +import 'package:get/get.dart'; + +class DynReactPage extends StatelessWidget { + const DynReactPage({ + super.key, + required this.id, + this.isPortrait = true, + required this.controller, + }); + + final Object id; + final bool isPortrait; + final DynReactController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (controller.loadingState.value == .loading()) { + controller.queryData(); + } + Widget buildBody( + ThemeData theme, + LoadingState?> state, + ) { + return switch (state) { + Loading() => const SliverFillRemaining(child: m3eLoading), + Success(:final response) => + response != null && response.isNotEmpty + ? SliverList.builder( + itemCount: response.length, + itemBuilder: (context, index) { + if (index == response.length - 1) { + controller.onLoadMore(); + } + + final item = response[index]; + return ListTile( + dense: true, + safeArea: false, + visualDensity: .standard, + onTap: () => Get.toNamed('/member?mid=${item.mid}'), + leading: PendantAvatar(item.face!, size: 36), + title: Text.rich( + TextSpan( + text: item.name, + style: const TextStyle(fontSize: 14), + children: [ + TextSpan( + text: ' ${item.action}', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.outline, + ), + ), + ], + ), + ), + ); + }, + ) + : HttpError(onReload: controller.onReload), + Error(:final errMsg) => HttpError( + errMsg: errMsg, + onReload: controller.onReload, + ), + }; + } + + final child = CustomScrollView( + key: const PageStorageKey(DynType.reaction), + slivers: [ + SliverPadding( + padding: .only( + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, + ), + sliver: Obx(() => buildBody(theme, controller.loadingState.value)), + ), + ], + ); + if (isPortrait) return child; + return refreshIndicator(onRefresh: controller.onRefresh, child: child); + } +} diff --git a/lib/pages/dynamics/widgets/action_panel.dart b/lib/pages/dynamics/widgets/action_panel.dart index c50356350..12332a0e3 100644 --- a/lib/pages/dynamics/widgets/action_panel.dart +++ b/lib/pages/dynamics/widgets/action_panel.dart @@ -67,7 +67,11 @@ class ActionPanel extends StatelessWidget { ), Expanded( child: TextButton.icon( - onPressed: () => PageUtils.pushDynDetail(item, isPush: true), + onPressed: () => PageUtils.pushDynDetail( + item, + isPush: true, + viewComment: true, + ), icon: Icon( FontAwesomeIcons.comment, size: 16, diff --git a/lib/pages/dynamics_detail/controller.dart b/lib/pages/dynamics_detail/controller.dart index 261ea8155..4a02ce59f 100644 --- a/lib/pages/dynamics_detail/controller.dart +++ b/lib/pages/dynamics_detail/controller.dart @@ -1,3 +1,4 @@ +import 'package:PiliPlus/common/widgets/scroll_physics.dart' show ReloadMixin; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/reply.dart'; @@ -7,7 +8,7 @@ import 'package:PiliPlus/utils/id_utils.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class DynamicDetailController extends CommonDynController { +class DynamicDetailController extends CommonDynController with ReloadMixin { @override late int oid; @override @@ -73,4 +74,10 @@ class DynamicDetailController extends CommonDynController { }); } } + + @override + Future onReload() { + reload = true; + return super.onReload(); + } } diff --git a/lib/pages/dynamics_detail/view.dart b/lib/pages/dynamics_detail/view.dart index a5db534c7..600cd57c8 100644 --- a/lib/pages/dynamics_detail/view.dart +++ b/lib/pages/dynamics_detail/view.dart @@ -1,15 +1,21 @@ import 'dart:math'; +import 'package:PiliPlus/common/style.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart'; import 'package:PiliPlus/common/widgets/flutter/text_field/controller.dart'; import 'package:PiliPlus/common/widgets/pair.dart'; +import 'package:PiliPlus/common/widgets/scroll_physics.dart'; +import 'package:PiliPlus/common/widgets/sliver/sliver_floating_header.dart'; +import 'package:PiliPlus/common/widgets/sliver/sliver_to_box_adapter.dart'; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/common/reply/reply_option_type.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/common/dyn/common_dyn_page.dart'; +import 'package:PiliPlus/pages/common/dyn/reaction/controller.dart'; +import 'package:PiliPlus/pages/common/dyn/reaction/view.dart'; import 'package:PiliPlus/pages/dynamics/widgets/author_panel.dart'; import 'package:PiliPlus/pages/dynamics/widgets/dynamic_panel.dart'; import 'package:PiliPlus/pages/dynamics_create/view.dart'; @@ -32,32 +38,71 @@ class DynamicDetailPage extends StatefulWidget { State createState() => _DynamicDetailPageState(); } -class _DynamicDetailPageState extends CommonDynPageState { +class _DynamicDetailPageState + extends CommonDynPageMultiState { @override - final DynamicDetailController controller = Get.putOrFind( - DynamicDetailController.new, - tag: (Get.arguments['item'] as DynamicItemModel).idStr.toString(), - ); + late final DynamicDetailController controller; + late final DynReactController _reactController; + + late final RxBool _isRefreshing = false.obs; + + void _startRefresh() { + _isRefreshing.value = true; + _refreshController.repeat(); + } + + void _stopRefresh() { + if (!mounted) return; + _isRefreshing.value = false; + _refreshController.stop(); + } + + void _onRefresh(Future future) { + _startRefresh(); + future.whenComplete(_stopRefresh); + // Future.delayed( + // const Duration(milliseconds: 800), + // ).whenComplete(_stopRefresh); + } + + late final AnimationController _refreshController; @override - dynamic get arguments => { - 'item': controller.dynItem, - }; + dynamic get arguments => {'item': controller.dynItem}; @override - void didChangeDependencies() { - super.didChangeDependencies(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - controller.showTitle.value = - scrollController.positions.first.pixels > 55; - } - }); + void initState() { + super.initState(); + final args = Get.arguments; + final item = args['item'] as DynamicItemModel; + final id = item.idStr.toString(); + if (args['viewComment'] ?? false) { + WidgetsBinding.instance.addPostFrameCallback(_jumpToComment); + } + controller = Get.putOrFind(DynamicDetailController.new, tag: id); + final stat = item.modules.moduleStat; + controller.count.value = stat?.comment?.count ?? -1; + _reactController = Get.put( + DynReactController( + id, + count: (stat?.like?.count ?? -1) + (stat?.forward?.count ?? -1), + ), + tag: id, + ); + _refreshController = AnimationController( + vsync: this, + duration: CircularProgressIndicator.defaultAnimationDuration, + ); + } + + @override + void dispose() { + _refreshController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Scaffold( resizeToAvoidBottomInset: false, appBar: _buildAppBar(), @@ -66,14 +111,14 @@ class _DynamicDetailPageState extends CommonDynPageState { child: isPortrait ? refreshIndicator( onRefresh: controller.onRefresh, - child: _buildBody(theme), + child: _buildBody(), ) - : _buildBody(theme), + : _buildBody(), ), floatingActionButtonLocation: floatingActionButtonLocation, floatingActionButton: SlideTransition( position: fabAnimation, - child: _buildBottom(theme), + child: _buildBottom(), ), ); } @@ -242,17 +287,126 @@ class _DynamicDetailPageState extends CommonDynPageState { : [ratioWidget(maxWidth), const SizedBox(width: 16)], ); - Widget _buildBody(ThemeData theme) { - double padding = max(maxWidth / 2 - Grid.smallCardWidth, 0); - Widget child; - if (isPortrait) { - child = Padding( - padding: EdgeInsets.symmetric(horizontal: padding), - child: CustomScrollView( - controller: scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter( + Widget _buildTabBar() { + return SizedBox( + height: 40, + child: TabBar( + padding: .zero, + isScrollable: true, + indicatorSize: .tab, + tabAlignment: .start, + controller: tabController, + labelPadding: const .symmetric(horizontal: 12), + dividerColor: theme.colorScheme.outline.withValues(alpha: 0.1), + onTap: (value) { + if (!tabController.indexIsChanging) { + final positions = PrimaryScrollController.of(context).positions; + if (positions.length == 1) { + final postion = positions.single; + if (postion.pixels >= postion.maxScrollExtent) { + postion.jumpTo(postion.pixels); + } + } + switch (value) { + case 0: + _onRefresh(controller.onRefresh()); + case 1: + _onRefresh(_reactController.onRefresh()); + } + } + }, + tabs: [ + Tab( + child: Obx(() { + final count = controller.count.value; + return Text( + '${DynType.reply.label}${count < 0 ? '' : ' ${NumUtils.numFormat(count)}'}', + ); + }), + ), + Tab( + child: Obx(() { + final count = _reactController.count.value; + return Text( + '${DynType.reaction.label}${count < 0 ? '' : ' ${NumUtils.numFormat(count)}'}', + ); + }), + ), + ], + ), + ); + } + + Widget _buildTabBody([bool isPortrait = true]) { + final reply = CustomScrollView( + key: const PageStorageKey(DynType.reply), + physics: ReloadScrollPhysics(controller: controller), + slivers: [ + buildReplyHeader(isPortrait), + Obx(() => replyList(controller.loadingState.value)), + ], + ); + return Stack( + clipBehavior: .none, + children: [ + tabBarView( + controller: tabController, + children: [ + isPortrait + ? reply + : refreshIndicator( + onRefresh: controller.onRefresh, + child: reply, + ), + DynReactPage( + isPortrait: isPortrait, + id: controller.dynItem.idStr, + controller: _reactController, + ), + ], + ), + Positioned( + left: 0, + right: 0, + top: displacement, + child: Obx(() { + final isRefreshing = _isRefreshing.value; + return AnimatedScale( + scale: isRefreshing ? 1 : 0, + duration: const Duration(milliseconds: 200), + child: Center( + child: SizedBox.fromSize( + size: const .square(40), + child: Material( + type: .circle, + color: theme.colorScheme.onSecondary, + elevation: 2.0, + child: Padding( + padding: const .all(6), + child: CircularProgressIndicator( + strokeWidth: 2.5, + controller: _refreshController, + ), + ), + ), + ), + ), + ); + }), + ), + ], + ); + } + + Widget _buildPortrait(double padding) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: padding), + child: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverToBoxWithOffsetAdapter( + offset: 55, + onVisibilityChanged: controller.showTitle.call, child: DynamicPanel( item: controller.dynItem, isDetail: true, @@ -262,72 +416,81 @@ class _DynamicDetailPageState extends CommonDynPageState { onSetReplySubject: controller.onSetReplySubject, ), ), - buildReplyHeader(theme), - Obx(() => replyList(theme, controller.loadingState.value)), + ]; + }, + body: Column( + children: [ + _buildTabBar(), + Expanded(child: _buildTabBody()), ], ), - ); - } else { - padding = padding / 4; - final flex = controller.ratio[0].toInt(); - final flex1 = controller.ratio[1].toInt(); - child = Row( - children: [ - Expanded( - flex: flex, - child: CustomScrollView( - controller: scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverPadding( - padding: EdgeInsets.only( - left: padding, - bottom: this.padding.bottom + 100, - ), - sliver: SliverToBoxAdapter( - child: DynamicPanel( - item: controller.dynItem, - isDetail: true, - isDetailPortraitW: isPortrait, - onSetPubSetting: controller.onSetPubSetting, - onEdit: _onEdit, - onSetReplySubject: controller.onSetReplySubject, - ), - ), + ), + ); + } + + Widget _buildHorizontal(double padding) { + padding = padding / 4; + final flex = controller.ratio[0].toInt(); + final flex1 = controller.ratio[1].toInt(); + return Row( + children: [ + Expanded( + flex: flex, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: .only( + left: padding, + bottom: this.padding.bottom + 100, ), - ], - ), - ), - Expanded( - flex: flex1, - child: Padding( - padding: EdgeInsets.only(right: padding), - child: Scaffold( - backgroundColor: Colors.transparent, - resizeToAvoidBottomInset: false, - body: refreshIndicator( - onRefresh: controller.onRefresh, - child: CustomScrollView( - controller: scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - buildReplyHeader(theme), - Obx( - () => replyList(theme, controller.loadingState.value), - ), - ], + sliver: SliverToBoxWithOffsetAdapter( + offset: 55, + onVisibilityChanged: controller.showTitle.call, + child: DynamicPanel( + item: controller.dynItem, + isDetail: true, + isDetailPortraitW: isPortrait, + onSetPubSetting: controller.onSetPubSetting, + onEdit: _onEdit, + onSetReplySubject: controller.onSetReplySubject, ), ), ), + ], + ), + ), + Expanded( + flex: flex1, + child: Padding( + padding: EdgeInsets.only(right: padding), + child: Scaffold( + backgroundColor: Colors.transparent, + resizeToAvoidBottomInset: false, + body: Column( + children: [ + _buildTabBar(), + Expanded(child: _buildTabBody(false)), + ], + ), ), ), - ], - ); - } - return child; + ), + ], + ); } - Widget _buildBottom(ThemeData theme) { + Widget _buildBody() { + double padding = max(maxWidth / 2 - Grid.smallCardWidth, 0); + Widget child; + if (isPortrait) { + child = _buildPortrait(padding); + } else { + child = _buildHorizontal(padding); + } + return fabAnimWrapper(child); + } + + Widget _buildBottom() { if (!controller.showDynActionBar) { return fabButton; } @@ -432,6 +595,14 @@ class _DynamicDetailPageState extends CommonDynPageState { ), ), ), + Expanded( + child: textIconButton( + icon: FontAwesomeIcons.comment, + text: '评论', + stat: moduleStat?.comment, + onPressed: _jumpToComment, + ), + ), Expanded( child: Builder( builder: (context) { @@ -460,4 +631,44 @@ class _DynamicDetailPageState extends CommonDynPageState { ), ); } + + @override + Widget buildReplyHeader([bool isPortrait = true]) { + final secondary = theme.colorScheme.secondary; + final child = Padding( + padding: const .fromLTRB(12, 2.5, 6, 2.5), + child: Obx( + () { + final sortType = controller.sortType.value; + return Row( + mainAxisAlignment: .spaceBetween, + children: [ + Text(sortType.title), + TextButton.icon( + style: Style.buttonStyle, + onPressed: controller.queryBySort, + icon: Icon(Icons.sort, size: 16, color: secondary), + label: Text( + sortType.label, + style: TextStyle(fontSize: 13, color: secondary), + ), + ), + ], + ); + }, + ), + ); + return SliverFloatingHeaderWidget( + backgroundColor: theme.colorScheme.surface, + child: child, + ); + } + + void _jumpToComment([_]) { + if (!isPortrait) return; + try { + final position = PrimaryScrollController.of(context).position; + position.jumpTo(position.maxScrollExtent); + } catch (_) {} + } } diff --git a/lib/pages/match_info/view.dart b/lib/pages/match_info/view.dart index c903a4456..882d78693 100644 --- a/lib/pages/match_info/view.dart +++ b/lib/pages/match_info/view.dart @@ -40,20 +40,18 @@ class _MatchInfoPageState extends CommonDynPageState { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( + final child = Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar(title: const Text('比赛详情')), body: ViewSafeArea( child: refreshIndicator( onRefresh: controller.onRefresh, child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - Obx(() => _buildInfo(theme, controller.infoState.value)), - buildReplyHeader(theme), - Obx(() => replyList(theme, controller.loadingState.value)), + Obx(() => _buildInfo(controller.infoState.value)), + buildReplyHeader(), + Obx(() => replyList(controller.loadingState.value)), ], ), ), @@ -64,9 +62,10 @@ class _MatchInfoPageState extends CommonDynPageState { child: fabButton, ), ); + return fabAnimWrapper(child); } - Widget _buildInfo(ThemeData theme, LoadingState infoState) { + Widget _buildInfo(LoadingState infoState) { if (infoState case Success(:final response?)) { try { Widget teamInfo(MatchTeam team) { @@ -188,12 +187,7 @@ class _MatchInfoPageState extends CommonDynPageState { } @override - void replyReply( - BuildContext context, - ReplyInfo replyItem, - int? id, - ThemeData theme, - ) { + 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(); diff --git a/lib/pages/music/view.dart b/lib/pages/music/view.dart index 40b224bb0..b2e204fb6 100644 --- a/lib/pages/music/view.dart +++ b/lib/pages/music/view.dart @@ -7,6 +7,7 @@ import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/image_viewer/hero.dart'; import 'package:PiliPlus/common/widgets/marquee.dart'; +import 'package:PiliPlus/common/widgets/sliver/sliver_to_box_adapter.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/music.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; @@ -52,8 +53,7 @@ class _MusicDetailPageState extends CommonDynPageState { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( + final child = Scaffold( resizeToAvoidBottomInset: false, appBar: _buildAppBar(), body: Padding( @@ -61,11 +61,12 @@ class _MusicDetailPageState extends CommonDynPageState { child: isPortrait ? refreshIndicator( onRefresh: controller.onRefresh, - child: _buildBody(theme), + child: _buildBody(), ) - : _buildBody(theme), + : _buildBody(), ), ); + return fabAnimWrapper(child); } PreferredSizeWidget _buildAppBar() => AppBar( @@ -107,7 +108,7 @@ class _MusicDetailPageState extends CommonDynPageState { ], ); - Widget _buildBody(ThemeData theme) => Obx(() { + Widget _buildBody() => Obx(() { switch (controller.infoState.value) { case Success(:final response): double padding = max(maxWidth / 2 - Grid.smallCardWidth, 0); @@ -116,17 +117,18 @@ class _MusicDetailPageState extends CommonDynPageState { child = Padding( padding: EdgeInsets.symmetric(horizontal: padding), child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - SliverToBoxAdapter( - child: _buildCard(theme, response, maxWidth), + SliverToBoxWithOffsetAdapter( + offset: 45, + onVisibilityChanged: controller.showTitle.call, + child: _buildCard(response, maxWidth), ), SliverToBoxAdapter( - child: _buildChart(theme, response, maxWidth), + child: _buildChart(response, maxWidth), ), - buildReplyHeader(theme), - Obx(() => replyList(theme, controller.loadingState.value)), + buildReplyHeader(), + Obx(() => replyList(controller.loadingState.value)), ], ), ); @@ -142,7 +144,6 @@ class _MusicDetailPageState extends CommonDynPageState { Expanded( flex: flex, child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( @@ -150,7 +151,7 @@ class _MusicDetailPageState extends CommonDynPageState { left: padding, ), sliver: SliverToBoxAdapter( - child: _buildCard(theme, response, leftWidth), + child: _buildCard(response, leftWidth), ), ), SliverPadding( @@ -159,7 +160,7 @@ class _MusicDetailPageState extends CommonDynPageState { bottom: this.padding.bottom + 100, ), sliver: SliverToBoxAdapter( - child: _buildChart(theme, response, leftWidth), + child: _buildChart(response, leftWidth), ), ), ], @@ -175,13 +176,11 @@ class _MusicDetailPageState extends CommonDynPageState { body: refreshIndicator( onRefresh: controller.onRefresh, child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - buildReplyHeader(theme), + buildReplyHeader(), Obx( - () => - replyList(theme, controller.loadingState.value), + () => replyList(controller.loadingState.value), ), ], ), @@ -196,7 +195,7 @@ class _MusicDetailPageState extends CommonDynPageState { clipBehavior: Clip.none, children: [ child, - _buildBottom(theme, response), + _buildBottom(response), ], ); default: @@ -204,7 +203,7 @@ class _MusicDetailPageState extends CommonDynPageState { } }); - Widget _buildBottom(ThemeData theme, MusicDetail item) { + Widget _buildBottom(MusicDetail item) { if (!controller.showDynActionBar) { return Positioned( right: kFloatingActionButtonMargin, @@ -387,8 +386,7 @@ class _MusicDetailPageState extends CommonDynPageState { Widget _buildRank( int? rank, - String name, - ThemeData theme, [ + String name, [ VoidCallback? onTap, ]) { final outline = theme.colorScheme.outline; @@ -424,7 +422,7 @@ class _MusicDetailPageState extends CommonDynPageState { ); } - Widget _buildCard(ThemeData theme, MusicDetail item, double maxWidth) { + Widget _buildCard(MusicDetail item, double maxWidth) { final textTheme = theme.textTheme; return SizedBox( width: maxWidth, @@ -569,12 +567,11 @@ class _MusicDetailPageState extends CommonDynPageState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('热歌榜排名'), - _buildRank(item.hotSongHeat?.lastHeat, '热度', theme), - _buildRank(item.listenPv, '总播放量', theme), + _buildRank(item.hotSongHeat?.lastHeat, '热度'), + _buildRank(item.listenPv, '总播放量'), _buildRank( item.musicRelation, '使用稿件量', - theme, () => Get.to( const MusicRecommendPage(), arguments: (id: controller.musicId, item: item), @@ -589,7 +586,7 @@ class _MusicDetailPageState extends CommonDynPageState { ); } - Widget? _buildChart(ThemeData theme, MusicDetail item, double maxWidth) { + Widget? _buildChart(MusicDetail item, double maxWidth) { final heat = item.hotSongHeat?.songHeat; if (heat == null || heat.isEmpty) return null; final colorScheme = theme.colorScheme; diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index 492e61317..464a72168 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -224,6 +224,7 @@ abstract final class PageUtils { static Future pushDynDetail( DynamicItemModel item, { bool isPush = false, + bool viewComment = false, }) async { feedBack(); @@ -245,6 +246,7 @@ abstract final class PageUtils { '/dynamicDetail', arguments: { 'item': item, + if (viewComment) 'viewComment': true, }, ); }