diff --git a/lib/pods/paging.dart b/lib/pods/paging.dart index 3945c652..97479736 100644 --- a/lib/pods/paging.dart +++ b/lib/pods/paging.dart @@ -39,7 +39,7 @@ mixin AsyncPaginationController on AsyncNotifier> Future refresh() async { totalCount = null; fetchedCount = 0; - state = AsyncLoading>(); + state = AsyncData>([]); final newState = await AsyncValue.guard>(() async { return await fetch(); @@ -74,7 +74,7 @@ mixin AsyncPaginationFilter on AsyncPaginationController fetchedCount = 0; currentFilter = filter; - state = AsyncLoading>(); + state = AsyncData>([]); final newState = await AsyncValue.guard>(() async { return await fetch(); diff --git a/lib/pods/timeline.dart b/lib/pods/timeline.dart index 16b73775..d1abd4eb 100644 --- a/lib/pods/timeline.dart +++ b/lib/pods/timeline.dart @@ -23,11 +23,7 @@ class ActivityListNotifier extends AsyncNotifier> final client = ref.read(apiClientProvider); final cursor = - state.isLoading - ? null - : state.valueOrNull?.lastOrNull?.createdAt - .toUtc() - .toIso8601String(); + state.valueOrNull?.lastOrNull?.createdAt.toUtc().toIso8601String(); final queryParameters = { if (cursor != null) 'cursor': cursor, diff --git a/lib/screens/account/leveling.dart b/lib/screens/account/leveling.dart index e64b151a..46bc8907 100644 --- a/lib/screens/account/leveling.dart +++ b/lib/screens/account/leveling.dart @@ -3,6 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/account.dart'; import 'package:island/pods/network.dart'; +import 'package:island/pods/paging.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/screens/account/credits.dart'; import 'package:island/services/time.dart'; @@ -10,46 +11,37 @@ import 'package:island/widgets/account/leveling_progress.dart'; import 'package:island/widgets/account/stellar_program_tab.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; +import 'package:island/widgets/paging/pagination_list.dart'; import 'package:styled_widget/styled_widget.dart'; -part 'leveling.g.dart'; +final levelingHistoryNotifierProvider = AsyncNotifierProvider( + LevelingHistoryNotifier.new, +); -@riverpod -class LevelingHistoryNotifier extends _$LevelingHistoryNotifier - with CursorPagingNotifierMixin { - static const int _pageSize = 20; +class LevelingHistoryNotifier extends AsyncNotifier> + with AsyncPaginationController { + static const int pageSize = 20; @override - Future> build() => fetch(cursor: null); - - @override - Future> fetch({ - required String? cursor, - }) async { + Future> fetch() async { final client = ref.read(apiClientProvider); - final offset = cursor == null ? 0 : int.parse(cursor); - final queryParams = {'offset': offset, 'take': _pageSize}; + final queryParams = {'offset': fetchedCount.toString(), 'take': pageSize}; final response = await client.get( '/pass/accounts/me/leveling', queryParameters: queryParams, ); - final total = int.parse(response.headers.value('X-Total') ?? '0'); - final List data = response.data; - final records = - data.map((json) => SnExperienceRecord.fromJson(json)).toList(); - final hasMore = offset + records.length < total; - final nextCursor = hasMore ? (offset + records.length).toString() : null; + totalCount = int.parse(response.headers.value('X-Total') ?? '0'); - return CursorPagingData( - items: records, - hasMore: hasMore, - nextCursor: nextCursor, - ); + final List records = + response.data + .map((json) => SnExperienceRecord.fromJson(json)) + .cast() + .toList(); + + return records; } } @@ -189,52 +181,42 @@ class LevelingScreen extends HookConsumerWidget { ), ), const SliverGap(8), - PagingHelperSliverView( + PaginationList( provider: levelingHistoryNotifierProvider, - futureRefreshable: levelingHistoryNotifierProvider.future, - notifierRefreshable: levelingHistoryNotifierProvider.notifier, - contentBuilder: - (data, widgetCount, endItemView) => SliverList.builder( - itemCount: widgetCount, - itemBuilder: (context, index) { - if (index == widgetCount - 1) { - return endItemView; - } - final record = data.items[index]; - return ListTile( - title: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(record.reason), - Row( - spacing: 4, - children: [ - Text( - record.createdAt.formatRelative(context), - ).fontSize(13), - Text('·').fontSize(13).bold(), - Text( - record.createdAt.formatSystem(), - ).fontSize(13), - ], - ).opacity(0.8), - ], - ), - subtitle: Row( - spacing: 8, + notifier: levelingHistoryNotifierProvider.notifier, + isRefreshable: false, + isSliver: true, + itemBuilder: + (context, idx, record) => ListTile( + title: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(record.reason), + Row( + spacing: 4, children: [ Text( - '${record.delta > 0 ? '+' : ''}${record.delta} EXP', - ), - if (record.bonusMultiplier != 1.0) - Text('x${record.bonusMultiplier}'), + record.createdAt.formatRelative(context), + ).fontSize(13), + Text('·').fontSize(13).bold(), + Text(record.createdAt.formatSystem()).fontSize(13), ], + ).opacity(0.8), + ], + ), + subtitle: Row( + spacing: 8, + children: [ + Text( + '${record.delta > 0 ? '+' : ''}${record.delta} EXP', ), - minTileHeight: 56, - contentPadding: EdgeInsets.symmetric(horizontal: 4), - ); - }, + if (record.bonusMultiplier != 1.0) + Text('x${record.bonusMultiplier}'), + ], + ), + minTileHeight: 56, + contentPadding: EdgeInsets.symmetric(horizontal: 4), ), ), diff --git a/lib/screens/account/leveling.g.dart b/lib/screens/account/leveling.g.dart deleted file mode 100644 index a0c58fd7..00000000 --- a/lib/screens/account/leveling.g.dart +++ /dev/null @@ -1,31 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'leveling.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$levelingHistoryNotifierHash() => - r'de51012e1590ac46388b6f3f2050b21cb96698d1'; - -/// See also [LevelingHistoryNotifier]. -@ProviderFor(LevelingHistoryNotifier) -final levelingHistoryNotifierProvider = AutoDisposeAsyncNotifierProvider< - LevelingHistoryNotifier, - CursorPagingData ->.internal( - LevelingHistoryNotifier.new, - name: r'levelingHistoryNotifierProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$levelingHistoryNotifierHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$LevelingHistoryNotifier = - AutoDisposeAsyncNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/creators/hub.dart b/lib/screens/creators/hub.dart index 50c4f9ac..389f7e46 100644 --- a/lib/screens/creators/hub.dart +++ b/lib/screens/creators/hub.dart @@ -573,6 +573,7 @@ class CreatorHubScreen extends HookConsumerWidget { child: publisherStats.when( data: (stats) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 24), child: currentPublisher.value == null ? ConstrainedBox( @@ -602,7 +603,7 @@ class CreatorHubScreen extends HookConsumerWidget { ).padding(horizontal: 12), buildNavigationWidget(true), ], - ).padding(vertical: 24) + ) : Column( spacing: 12, children: [ diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 018601f5..18524c18 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -127,11 +127,14 @@ class ExploreScreen extends HookConsumerWidget { child: Row( children: [ Row( - spacing: 4, + spacing: 8, children: [ IconButton( onPressed: () => handleFilterChange(null), - icon: Icon(Symbols.explore), + icon: Icon( + Symbols.explore, + fill: currentFilter.value == null ? 1 : null, + ), tooltip: 'explore'.tr(), isSelected: currentFilter.value == null, color: @@ -141,7 +144,10 @@ class ExploreScreen extends HookConsumerWidget { ), IconButton( onPressed: () => handleFilterChange('subscriptions'), - icon: Icon(Symbols.subscriptions), + icon: Icon( + Symbols.subscriptions, + fill: currentFilter.value == 'subscriptions' ? 1 : null, + ), tooltip: 'exploreFilterSubscriptions'.tr(), isSelected: currentFilter.value == 'subscriptions', color: @@ -151,7 +157,10 @@ class ExploreScreen extends HookConsumerWidget { ), IconButton( onPressed: () => handleFilterChange('friends'), - icon: Icon(Symbols.people), + icon: Icon( + Symbols.people, + fill: currentFilter.value == 'friends' ? 1 : null, + ), tooltip: 'exploreFilterFriends'.tr(), isSelected: currentFilter.value == 'friends', color: @@ -312,7 +321,9 @@ class ExploreScreen extends HookConsumerWidget { // Sliver list cannot provide refresh handled by the pagination list isRefreshable: false, isSliver: true, - contentBuilder: (data) => _ActivityListView(data: data, isWide: isWide), + contentBuilder: + (data, footer) => + _ActivityListView(data: data, isWide: isWide, footer: footer), ); } @@ -428,31 +439,46 @@ class ExploreScreen extends HookConsumerWidget { final foregroundColor = Theme.of(context).appBarTheme.foregroundColor; return AppBar( - toolbarHeight: 48 + 4, + toolbarHeight: 48, flexibleSpace: Container( height: 48, margin: EdgeInsets.only( left: 8, right: 8, top: 4 + MediaQuery.of(context).padding.top, + bottom: 4, ), child: Row( + spacing: 8, children: [ IconButton( onPressed: () => handleFilterChange(null), - icon: Icon(Symbols.explore, color: foregroundColor), + icon: Icon( + Symbols.explore, + color: foregroundColor, + fill: currentFilter == null ? 1 : null, + ), tooltip: 'explore'.tr(), isSelected: currentFilter == null, + color: currentFilter == null ? foregroundColor : null, ), IconButton( onPressed: () => handleFilterChange('subscriptions'), - icon: Icon(Symbols.subscriptions, color: foregroundColor), + icon: Icon( + Symbols.subscriptions, + color: foregroundColor, + fill: currentFilter == 'subscription' ? 1 : null, + ), tooltip: 'exploreFilterSubscriptions'.tr(), isSelected: currentFilter == 'subscriptions', ), IconButton( onPressed: () => handleFilterChange('friends'), - icon: Icon(Symbols.people, color: foregroundColor), + icon: Icon( + Symbols.people, + color: foregroundColor, + fill: currentFilter == 'friends' ? 1 : null, + ), tooltip: 'exploreFilterFriends'.tr(), isSelected: currentFilter == 'friends', ), @@ -701,17 +727,26 @@ class _DiscoveryActivityItem extends StatelessWidget { class _ActivityListView extends HookConsumerWidget { final List data; final bool isWide; + final Widget footer; - const _ActivityListView({required this.data, required this.isWide}); + const _ActivityListView({ + required this.data, + required this.isWide, + required this.footer, + }); @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.watch(activityListNotifierProvider.notifier); return SliverList.separated( - itemCount: data.length, + itemCount: data.length + 1, separatorBuilder: (_, _) => const Gap(8), itemBuilder: (context, index) { + if (index == data.length) { + return footer; + } + final item = data[index]; if (item.data == null) { return const SizedBox.shrink(); diff --git a/lib/widgets/file_list_view.dart b/lib/widgets/file_list_view.dart index 5c626b22..e7983a30 100644 --- a/lib/widgets/file_list_view.dart +++ b/lib/widgets/file_list_view.dart @@ -131,8 +131,10 @@ class FileListView extends HookConsumerWidget { FileListMode.unindexed => PaginationWidget( provider: unindexedFileListNotifierProvider, notifier: unindexedFileListNotifierProvider.notifier, + isRefreshable: false, + isSliver: true, contentBuilder: - (data) => + (data, footer) => data.isEmpty ? SliverToBoxAdapter( child: _buildEmptyUnindexedFilesHint(ref), @@ -145,13 +147,16 @@ class FileListView extends HookConsumerWidget { isSelectionMode, selectedFileIds, currentVisibleItems, + footer, ), ), _ => PaginationWidget( provider: indexedCloudFileListNotifierProvider, notifier: indexedCloudFileListNotifierProvider.notifier, + isRefreshable: false, + isSliver: true, contentBuilder: - (data) => + (data, footer) => data.isEmpty ? SliverToBoxAdapter( child: _buildEmptyDirectoryHint(ref, currentPath), @@ -165,6 +170,7 @@ class FileListView extends HookConsumerWidget { isSelectionMode, selectedFileIds, currentVisibleItems, + footer, ), ), }; @@ -567,6 +573,7 @@ class FileListView extends HookConsumerWidget { ValueNotifier isSelectionMode, ValueNotifier> selectedFileIds, ValueNotifier> currentVisibleItems, + Widget footer, ) { currentVisibleItems.value = items; return switch (currentViewMode.value) { @@ -578,7 +585,10 @@ class FileListView extends HookConsumerWidget { crossAxisSpacing: 8, mainAxisSpacing: 8, delegate: SliverChildBuilderDelegate((context, index) { - if (index >= items.length) { + if (index == items.length) { + return footer; + } + if (index > items.length) { return const SizedBox.shrink(); } @@ -609,12 +619,15 @@ class FileListView extends HookConsumerWidget { return const SizedBox.shrink(); }, ); - }, childCount: items.length), + }, childCount: items.length + 1), ), // ListView mode _ => SliverList.builder( - itemCount: items.length, + itemCount: items.length + 1, itemBuilder: (context, index) { + if (index == items.length) { + return footer; + } final item = items[index]; return item.map( file: @@ -1006,6 +1019,7 @@ class FileListView extends HookConsumerWidget { ValueNotifier isSelectionMode, ValueNotifier> selectedFileIds, ValueNotifier> currentVisibleItems, + Widget footer, ) { currentVisibleItems.value = items; return switch (currentViewMode.value) { @@ -1017,7 +1031,10 @@ class FileListView extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, delegate: SliverChildBuilderDelegate((context, index) { - if (index >= items.length) { + if (index == items.length) { + return footer; + } + if (index > items.length) { return const SizedBox.shrink(); } @@ -1051,12 +1068,15 @@ class FileListView extends HookConsumerWidget { }, ), ); - }, childCount: items.length), + }, childCount: items.length + 1), ), // ListView mode _ => SliverList.builder( - itemCount: items.length, + itemCount: items.length + 1, itemBuilder: (context, index) { + if (index == items.length) { + return footer; + } final item = items[index]; return item.map( file: (fileItem) { diff --git a/lib/widgets/paging/pagination_list.dart b/lib/widgets/paging/pagination_list.dart index b397ebff..df7e7094 100644 --- a/lib/widgets/paging/pagination_list.dart +++ b/lib/widgets/paging/pagination_list.dart @@ -1,59 +1,83 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/paging.dart'; + import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/response.dart'; +import 'package:styled_widget/styled_widget.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; +import 'package:visibility_detector/visibility_detector.dart'; class PaginationList extends HookConsumerWidget { final ProviderListenable>> provider; final Refreshable> notifier; final Widget? Function(BuildContext, int, T) itemBuilder; final bool isRefreshable; + final bool isSliver; + final bool showDefaultWidgets; const PaginationList({ super.key, required this.provider, required this.notifier, required this.itemBuilder, this.isRefreshable = true, + this.isSliver = false, + this.showDefaultWidgets = true, }); @override Widget build(BuildContext context, WidgetRef ref) { final data = ref.watch(provider); final noti = ref.watch(notifier); - final listView = SuperListView.builder( - itemBuilder: (context, idx) { - final entry = data.valueOrNull?[idx]; - if (entry != null) return itemBuilder(context, idx, entry); - return null; - }, - ); - final child = NotificationListener( - onNotification: (ScrollNotification scrollInfo) { - if (scrollInfo is ScrollEndNotification && - scrollInfo.metrics.axisDirection == AxisDirection.down && - scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent) { - if (!noti.fetchedAll && !data.isLoading && !data.hasError) { - noti.fetchFurther(); - } - } - return true; - }, - child: listView, - ); + if (data.isLoading && data.valueOrNull?.isEmpty == true) { + final content = ResponseLoadingWidget(); + return isSliver ? SliverFillRemaining(child: content) : content; + } + + if (data.hasError) { + final content = ResponseErrorWidget( + error: data.error, + onRetry: noti.refresh, + ); + return isSliver ? SliverFillRemaining(child: content) : content; + } + + final listView = + isSliver + ? SuperSliverList.builder( + itemCount: (data.valueOrNull?.length ?? 0) + 1, + itemBuilder: (context, idx) { + if (idx == data.valueOrNull?.length) { + return PaginationListFooter(noti: noti, data: data); + } + final entry = data.valueOrNull?[idx]; + if (entry != null) return itemBuilder(context, idx, entry); + return null; + }, + ) + : SuperListView.builder( + itemCount: (data.valueOrNull?.length ?? 0) + 1, + itemBuilder: (context, idx) { + if (idx == data.valueOrNull?.length) { + return PaginationListFooter(noti: noti, data: data); + } + final entry = data.valueOrNull?[idx]; + if (entry != null) return itemBuilder(context, idx, entry); + return null; + }, + ); return isRefreshable - ? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: child) - : child; + ? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView) + : listView; } } class PaginationWidget extends HookConsumerWidget { final ProviderListenable>> provider; final Refreshable> notifier; - final Widget Function(List) contentBuilder; + final Widget Function(List, Widget) contentBuilder; final bool isRefreshable; final bool isSliver; final bool showDefaultWidgets; @@ -72,7 +96,7 @@ class PaginationWidget extends HookConsumerWidget { final data = ref.watch(provider); final noti = ref.watch(notifier); - if (data.isLoading) { + if (data.isLoading && data.valueOrNull?.isEmpty == true) { final content = ResponseLoadingWidget(); return isSliver ? SliverFillRemaining(child: content) : content; } @@ -85,22 +109,42 @@ class PaginationWidget extends HookConsumerWidget { return isSliver ? SliverFillRemaining(child: content) : content; } - final content = NotificationListener( - onNotification: (ScrollNotification scrollInfo) { - if (scrollInfo is ScrollEndNotification && - scrollInfo.metrics.axisDirection == AxisDirection.down && - scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent) { - if (!noti.fetchedAll && !data.isLoading && !data.hasError) { - noti.fetchFurther(); - } - } - return true; - }, - child: contentBuilder(data.valueOrNull ?? []), - ); + final footer = PaginationListFooter(noti: noti, data: data); + final content = contentBuilder(data.valueOrNull ?? [], footer); return isRefreshable ? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content) : content; } } + +class PaginationListFooter extends StatelessWidget { + final PaginationController noti; + final AsyncValue> data; + final bool isSliver; + + const PaginationListFooter({ + super.key, + required this.noti, + required this.data, + this.isSliver = false, + }); + + @override + Widget build(BuildContext context) { + final child = SizedBox( + height: 64, + child: Center(child: CircularProgressIndicator()).padding(all: 8), + ); + + return VisibilityDetector( + key: Key("pagination-list-${noti.hashCode}"), + onVisibilityChanged: (VisibilityInfo info) { + if (!noti.fetchedAll && !data.isLoading && !data.hasError) { + noti.fetchFurther(); + } + }, + child: isSliver ? SliverToBoxAdapter(child: child) : child, + ); + } +}