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 83c47aa32..0c1e9a7f4 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'; @@ -804,4 +805,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 ed2ca6cef..5abfe6eed 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 df07660ef..b626cfd57 100644 --- a/lib/pages/article/view.dart +++ b/lib/pages/article/view.dart @@ -7,6 +7,7 @@ import 'package:PiliPlus/common/widgets/icon/custom_icons.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/scaffold.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/image_preview_type.dart'; import 'package:PiliPlus/models/dynamics/result.dart' show DynamicStat; import 'package:PiliPlus/pages/article/controller.dart'; @@ -49,28 +50,16 @@ 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( appBar: _buildAppBar(), body: Stack( clipBehavior: .none, children: [ Padding( padding: .only(left: padding.left, right: padding.right), - child: _buildPage(theme), + child: _buildPage(), ), Positioned( left: 0, @@ -78,25 +67,24 @@ class _ArticlePageState extends CommonDynPageState { bottom: 0, child: 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( @@ -105,8 +93,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)), ], ), ); @@ -121,7 +109,6 @@ class _ArticlePageState extends CommonDynPageState { Expanded( flex: flex, child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( @@ -130,7 +117,6 @@ class _ArticlePageState extends CommonDynPageState { bottom: this.padding.bottom + 100, ), sliver: _buildContent( - theme, (maxWidth - this.padding.horizontal) * flex / (flex + flex1) - padding - 32, @@ -153,11 +139,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)), ], ), ), @@ -168,7 +153,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( () { @@ -351,7 +336,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( @@ -507,7 +494,7 @@ class _ArticlePageState extends CommonDynPageState { ], ); - Widget _buildBottom(ThemeData theme) { + Widget _buildBottom() { late final primary = theme.colorScheme.primary; late final outline = theme.colorScheme.outline; late final btnStyle = TextButton.styleFrom( diff --git a/lib/pages/common/dyn/common_dyn_page.dart b/lib/pages/common/dyn/common_dyn_page.dart index d2e200f96..09e1dc384 100644 --- a/lib/pages/common/dyn/common_dyn_page.dart +++ b/lib/pages/common/dyn/common_dyn_page.dart @@ -8,6 +8,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(); @@ -283,4 +290,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 44e7dddef..27e192276 100644 --- a/lib/pages/dynamics/widgets/action_panel.dart +++ b/lib/pages/dynamics/widgets/action_panel.dart @@ -75,7 +75,11 @@ class ActionPanel extends StatelessWidget { onPressed: () => EasyThrottle.throttle( 'interactAction', const Duration(milliseconds: 200), - () => PageUtils.pushDynDetail(item, isPush: true), + () => PageUtils.pushDynDetail( + item, + isPush: true, + viewComment: true, + ), ), icon: Icon( BiliIcons.ic_comment, diff --git a/lib/pages/dynamics_detail/controller.dart b/lib/pages/dynamics_detail/controller.dart index 8d44e015f..63a2774c2 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'; @@ -6,7 +7,7 @@ import 'package:PiliPlus/pages/common/dyn/common_dyn_controller.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 @@ -69,4 +70,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 668740d4a..5b776af32 100644 --- a/lib/pages/dynamics_detail/view.dart +++ b/lib/pages/dynamics_detail/view.dart @@ -1,17 +1,23 @@ import 'dart:math'; +import 'package:PiliPlus/common/style.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/icon/bili_icons.dart'; import 'package:PiliPlus/common/widgets/icon/custom_icons.dart'; import 'package:PiliPlus/common/widgets/pair.dart'; import 'package:PiliPlus/common/widgets/scaffold.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'; @@ -33,32 +39,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( appBar: _buildAppBar(), body: Stack( @@ -66,12 +111,7 @@ class _DynamicDetailPageState extends CommonDynPageState { children: [ Padding( padding: .only(left: padding.left, right: padding.right), - child: isPortrait - ? refreshIndicator( - onRefresh: controller.onRefresh, - child: _buildBody(theme), - ) - : _buildBody(theme), + child: _buildBody(), ), Positioned( left: 0, @@ -79,7 +119,7 @@ class _DynamicDetailPageState extends CommonDynPageState { bottom: 0, child: SlideTransition( position: fabAnimation, - child: _buildBottom(theme), + child: _buildBottom(), ), ), ], @@ -251,17 +291,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, @@ -271,72 +420,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() { final primary = theme.colorScheme.primary; final outline = theme.colorScheme.outline; final btnStyle = TextButton.styleFrom( @@ -437,6 +595,14 @@ class _DynamicDetailPageState extends CommonDynPageState { ), ), ), + Expanded( + child: textIconButton( + icon: BiliIcons.ic_comment, + text: '评论', + stat: moduleStat?.comment, + onPressed: _jumpToComment, + ), + ), Expanded( child: Builder( builder: (context) { @@ -465,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 6601d4d6c..a3605225e 100644 --- a/lib/pages/match_info/view.dart +++ b/lib/pages/match_info/view.dart @@ -38,8 +38,7 @@ class _MatchInfoPageState extends CommonDynPageState { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return scaffold( + final child = scaffold( appBar: AppBar(title: const Text('比赛详情')), body: Stack( clipBehavior: .none, @@ -48,12 +47,11 @@ class _MatchInfoPageState extends CommonDynPageState { 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)), ], ), ), @@ -75,9 +73,10 @@ class _MatchInfoPageState extends CommonDynPageState { ], ), ); + return fabAnimWrapper(child); } - Widget _buildInfo(ThemeData theme, LoadingState infoState) { + Widget _buildInfo(LoadingState infoState) { if (infoState case Success(:final response?)) { try { Widget teamInfo(MatchTeam team) { @@ -199,12 +198,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 d389b0c71..3417d7c3e 100644 --- a/lib/pages/music/view.dart +++ b/lib/pages/music/view.dart @@ -8,6 +8,7 @@ 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/scaffold.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'; @@ -48,19 +49,19 @@ class _MusicDetailPageState extends CommonDynPageState { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return scaffold( + final child = scaffold( appBar: _buildAppBar(), body: Padding( padding: EdgeInsets.only(left: padding.left, right: padding.right), child: isPortrait ? refreshIndicator( onRefresh: controller.onRefresh, - child: _buildBody(theme), + child: _buildBody(), ) - : _buildBody(theme), + : _buildBody(), ), ); + return fabAnimWrapper(child); } PreferredSizeWidget _buildAppBar() => AppBar( @@ -99,7 +100,7 @@ class _MusicDetailPageState extends CommonDynPageState { : [ratioWidget(maxWidth), const SizedBox(width: 16)], ); - Widget _buildBody(ThemeData theme) => Obx(() { + Widget _buildBody() => Obx(() { switch (controller.infoState.value) { case Success(:final response): double padding = math.max(maxWidth / 2 - Grid.smallCardWidth, 0); @@ -108,14 +109,15 @@ 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), ), - buildReplyHeader(theme), - Obx(() => replyList(theme, controller.loadingState.value)), + buildReplyHeader(), + Obx(() => replyList(controller.loadingState.value)), ], ), ); @@ -131,7 +133,6 @@ class _MusicDetailPageState extends CommonDynPageState { Expanded( flex: flex, child: CustomScrollView( - controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( @@ -139,7 +140,7 @@ class _MusicDetailPageState extends CommonDynPageState { left: padding, ), sliver: SliverToBoxAdapter( - child: _buildCard(theme, response, leftWidth), + child: _buildCard(response, leftWidth), ), ), ], @@ -155,13 +156,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), ), ], ), @@ -176,7 +175,7 @@ class _MusicDetailPageState extends CommonDynPageState { clipBehavior: Clip.none, children: [ child, - _buildBottom(theme, response), + _buildBottom(response), ], ); default: @@ -184,7 +183,7 @@ class _MusicDetailPageState extends CommonDynPageState { } }); - Widget _buildBottom(ThemeData theme, MusicDetail item) { + Widget _buildBottom(MusicDetail item) { final primary = theme.colorScheme.primary; final outline = theme.colorScheme.outline; final style = TextButton.styleFrom( @@ -356,8 +355,7 @@ class _MusicDetailPageState extends CommonDynPageState { Widget _buildRank( int? rank, - String name, - ThemeData theme, [ + String name, [ VoidCallback? onTap, ]) { final outline = theme.colorScheme.outline; @@ -393,7 +391,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, @@ -533,12 +531,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), diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index 71a31a3cf..f3e86fffc 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -219,6 +219,7 @@ abstract final class PageUtils { static Future pushDynDetail( DynamicItemModel item, { bool isPush = false, + bool viewComment = false, }) async { void push() { if (item.basic?.commentType == 12) { @@ -238,6 +239,7 @@ abstract final class PageUtils { '/dynamicDetail', arguments: { 'item': item, + if (viewComment) 'viewComment': true, }, ); }