// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/paged_list_footer.dart'; import '../../l10n/app_localizations.dart'; class FollowingPagedGrid extends StatelessWidget { const FollowingPagedGrid({ super.key, required this.scrollController, required this.loading, required this.loadingMore, required this.hasMore, required this.hasItems, required this.itemCount, required this.itemBuilder, required this.empty, required this.onRefresh, required this.onLoadMore, this.showInitialLoading = true, this.singleColumn = false, this.errorMessage, this.loadMoreError, this.topSlivers = const [], }); static const double _loadMoreThreshold = 240; final ScrollController scrollController; final bool loading; final bool loadingMore; final bool hasMore; final bool hasItems; final int itemCount; final IndexedWidgetBuilder itemBuilder; final Widget empty; final String? errorMessage; final String? loadMoreError; final bool showInitialLoading; final bool singleColumn; /// 列表顶部的非分页内容(继续观看横幅、区块标题等)。 /// 必须是 sliver;空列表时也会保留,因此可携带空态上方的固定区块。 final List topSlivers; final Future Function() onRefresh; final Future Function() onLoadMore; @override Widget build(BuildContext context) { if (showInitialLoading && loading && !hasItems) { return const Center(child: CircularProgressIndicator()); } return LayoutBuilder( builder: (context, constraints) { final gridWidth = constraints.maxWidth - AppDimens.feedPageHorizontal * 2; final itemWidth = (gridWidth - AppDimens.followingGridGap * 2) / 3; final itemExtent = itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.followingCardTextExtent; final gridDelegate = singleColumn ? const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 1, mainAxisSpacing: AppDimens.followingHistoryRowGap, mainAxisExtent: AppDimens.followingHistoryCoverHeight, ) : SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: AppDimens.followingGridGap, mainAxisSpacing: AppDimens.followingGridRowGap, mainAxisExtent: itemExtent, ); return NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( onRefresh: onRefresh, child: CustomScrollView( key: ValueKey('following-scroll-$runtimeType'), controller: scrollController, physics: AppPullToRefresh.physics, slivers: [ ...topSlivers, if (itemCount == 0) SliverFillRemaining( hasScrollBody: false, child: errorMessage == null ? empty : EmptyState( icon: Icons.cloud_off_rounded, title: errorMessage!, actionLabel: AppLocalizations.of(context)!.retry, onAction: () => unawaited(onRefresh()), ), ) else ...[ if (errorMessage != null) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, AppDimens.spaceSm, AppDimens.feedPageHorizontal, 0, ), child: Text( errorMessage!, style: const TextStyle( color: AppColors.textTertiary, fontSize: AppDimens.followingSubtitleFont, ), ), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, 0, AppDimens.feedPageHorizontal, AppDimens.spaceSm, ), sliver: SliverGrid( gridDelegate: gridDelegate, delegate: SliverChildBuilderDelegate( itemBuilder, childCount: itemCount, ), ), ), SliverToBoxAdapter( child: PagedListFooter( loading: loadingMore, hasMore: hasMore, hasItems: hasItems, errorMessage: loadMoreError, onRetry: onLoadMore, ), ), const SliverToBoxAdapter( child: SizedBox(height: AppDimens.spaceXl), ), ], ], ), ), ); }, ); } bool _onScroll(ScrollNotification notification) { if (notification.depth != 0) return false; if (notification.metrics.extentAfter > _loadMoreThreshold) return false; if (notification is ScrollUpdateNotification || notification is ScrollEndNotification || notification is OverscrollNotification) { unawaited(onLoadMore()); } return false; } }