♻️ Replaced all list with own pagination list
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -6,134 +5,55 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/post_category.dart';
|
||||
import 'package:island/models/post_tag.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/paging.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:island/widgets/paging/pagination_list.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
|
||||
// Post Categories Notifier
|
||||
final postCategoriesNotifierProvider = StateNotifierProvider.autoDispose<
|
||||
final postCategoriesNotifierProvider = AsyncNotifierProvider.autoDispose<
|
||||
PostCategoriesNotifier,
|
||||
AsyncValue<CursorPagingData<SnPostCategory>>
|
||||
>((ref) {
|
||||
return PostCategoriesNotifier(ref);
|
||||
});
|
||||
List<SnPostCategory>
|
||||
>(PostCategoriesNotifier.new);
|
||||
|
||||
class PostCategoriesNotifier
|
||||
extends StateNotifier<AsyncValue<CursorPagingData<SnPostCategory>>> {
|
||||
final AutoDisposeRef ref;
|
||||
static const int _pageSize = 20;
|
||||
bool _isLoading = false;
|
||||
extends AutoDisposeAsyncNotifier<List<SnPostCategory>>
|
||||
with AutoDisposeAsyncPaginationController<SnPostCategory> {
|
||||
@override
|
||||
Future<List<SnPostCategory>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
PostCategoriesNotifier(this.ref) : super(const AsyncValue.loading()) {
|
||||
state = const AsyncValue.data(
|
||||
CursorPagingData(items: [], hasMore: false, nextCursor: null),
|
||||
final response = await client.get(
|
||||
'/sphere/posts/categories',
|
||||
queryParameters: {'offset': fetchedCount, 'take': 20, 'order': 'usage'},
|
||||
);
|
||||
fetch(cursor: null);
|
||||
}
|
||||
|
||||
Future<void> fetch({String? cursor}) async {
|
||||
if (_isLoading) return;
|
||||
|
||||
_isLoading = true;
|
||||
if (cursor == null) {
|
||||
state = const AsyncValue.loading();
|
||||
}
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||
|
||||
final response = await client.get(
|
||||
'/sphere/posts/categories',
|
||||
queryParameters: {
|
||||
'offset': offset,
|
||||
'take': _pageSize,
|
||||
'order': 'usage',
|
||||
},
|
||||
);
|
||||
|
||||
final data = response.data as List;
|
||||
final categories =
|
||||
data.map((json) => SnPostCategory.fromJson(json)).toList();
|
||||
final hasMore = categories.length == _pageSize;
|
||||
final nextCursor =
|
||||
hasMore ? (offset + categories.length).toString() : null;
|
||||
|
||||
state = AsyncValue.data(
|
||||
CursorPagingData(
|
||||
items: [...(state.value?.items ?? []), ...categories],
|
||||
hasMore: hasMore,
|
||||
nextCursor: nextCursor,
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
}
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final data = response.data as List;
|
||||
return data.map((json) => SnPostCategory.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// Post Tags Notifier
|
||||
final postTagsNotifierProvider = StateNotifierProvider.autoDispose<
|
||||
PostTagsNotifier,
|
||||
AsyncValue<CursorPagingData<SnPostTag>>
|
||||
>((ref) {
|
||||
return PostTagsNotifier(ref);
|
||||
});
|
||||
|
||||
class PostTagsNotifier
|
||||
extends StateNotifier<AsyncValue<CursorPagingData<SnPostTag>>> {
|
||||
final AutoDisposeRef ref;
|
||||
static const int _pageSize = 20;
|
||||
bool _isLoading = false;
|
||||
|
||||
PostTagsNotifier(this.ref) : super(const AsyncValue.loading()) {
|
||||
state = const AsyncValue.data(
|
||||
CursorPagingData(items: [], hasMore: false, nextCursor: null),
|
||||
final postTagsNotifierProvider =
|
||||
AsyncNotifierProvider.autoDispose<PostTagsNotifier, List<SnPostTag>>(
|
||||
PostTagsNotifier.new,
|
||||
);
|
||||
fetch(cursor: null);
|
||||
}
|
||||
|
||||
Future<void> fetch({String? cursor}) async {
|
||||
if (_isLoading) return;
|
||||
class PostTagsNotifier extends AutoDisposeAsyncNotifier<List<SnPostTag>>
|
||||
with AutoDisposeAsyncPaginationController<SnPostTag> {
|
||||
@override
|
||||
Future<List<SnPostTag>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
_isLoading = true;
|
||||
if (cursor == null) {
|
||||
state = const AsyncValue.loading();
|
||||
}
|
||||
final response = await client.get(
|
||||
'/sphere/posts/tags',
|
||||
queryParameters: {'offset': fetchedCount, 'take': 20, 'order': 'usage'},
|
||||
);
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||
|
||||
final response = await client.get(
|
||||
'/sphere/posts/tags',
|
||||
queryParameters: {
|
||||
'offset': offset,
|
||||
'take': _pageSize,
|
||||
'order': 'usage',
|
||||
},
|
||||
);
|
||||
|
||||
final data = response.data as List;
|
||||
final tags = data.map((json) => SnPostTag.fromJson(json)).toList();
|
||||
final hasMore = tags.length == _pageSize;
|
||||
final nextCursor = hasMore ? (offset + tags.length).toString() : null;
|
||||
|
||||
state = AsyncValue.data(
|
||||
CursorPagingData(
|
||||
items: [...(state.value?.items ?? []), ...tags],
|
||||
hasMore: hasMore,
|
||||
nextCursor: nextCursor,
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
}
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final data = response.data as List;
|
||||
return data.map((json) => SnPostTag.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,48 +62,27 @@ class PostCategoriesListScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final categoriesState = ref.watch(postCategoriesNotifierProvider);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: const Text('categories').tr()),
|
||||
body: categoriesState.when(
|
||||
data: (data) {
|
||||
if (data.items.isEmpty) {
|
||||
return const Center(child: Text('No categories found'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: data.items.length + (data.hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= data.items.length) {
|
||||
ref
|
||||
.read(postCategoriesNotifierProvider.notifier)
|
||||
.fetch(cursor: data.nextCursor);
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final category = data.items[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Symbols.category),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text(category.categoryDisplayTitle),
|
||||
subtitle: Text('postCount'.plural(category.usage)),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'postCategoryDetail',
|
||||
pathParameters: {'slug': category.slug},
|
||||
);
|
||||
},
|
||||
body: PaginationList(
|
||||
provider: postCategoriesNotifierProvider,
|
||||
notifier: postCategoriesNotifierProvider.notifier,
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context, index, category) {
|
||||
return ListTile(
|
||||
leading: const Icon(Symbols.category),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text(category.categoryDisplayTitle),
|
||||
subtitle: Text('postCount'.plural(category.usage)),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'postCategoryDetail',
|
||||
pathParameters: {'slug': category.slug},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () => ref.invalidate(postCategoriesNotifierProvider),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -194,48 +93,27 @@ class PostTagsListScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tagsState = ref.watch(postTagsNotifierProvider);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: const Text('tags').tr()),
|
||||
body: tagsState.when(
|
||||
data: (data) {
|
||||
if (data.items.isEmpty) {
|
||||
return const Center(child: Text('No tags found'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: data.items.length + (data.hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= data.items.length) {
|
||||
ref
|
||||
.read(postTagsNotifierProvider.notifier)
|
||||
.fetch(cursor: data.nextCursor);
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final tag = data.items[index];
|
||||
return ListTile(
|
||||
title: Text(tag.name ?? '#${tag.slug}'),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.label),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
subtitle: Text('postCount'.plural(tag.usage)),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'postTagDetail',
|
||||
pathParameters: {'slug': tag.slug},
|
||||
);
|
||||
},
|
||||
body: PaginationList(
|
||||
provider: postTagsNotifierProvider,
|
||||
notifier: postTagsNotifierProvider.notifier,
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context, index, tag) {
|
||||
return ListTile(
|
||||
title: Text(tag.name ?? '#${tag.slug}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.label),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
subtitle: Text('postCount'.plural(tag.usage)),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'postTagDetail',
|
||||
pathParameters: {'slug': tag.slug},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () => ref.invalidate(postTagsNotifierProvider),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@ import 'package:island/models/post.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
|
||||
import 'package:island/pods/paging.dart';
|
||||
import 'package:island/widgets/paging/pagination_list.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
final postSearchNotifierProvider = StateNotifierProvider.autoDispose<
|
||||
PostSearchNotifier,
|
||||
AsyncValue<CursorPagingData<SnPost>>
|
||||
>((ref) => PostSearchNotifier(ref));
|
||||
final postSearchNotifierProvider =
|
||||
AsyncNotifierProvider.autoDispose<PostSearchNotifier, List<SnPost>>(
|
||||
PostSearchNotifier.new,
|
||||
);
|
||||
|
||||
class PostSearchNotifier
|
||||
extends StateNotifier<AsyncValue<CursorPagingData<SnPost>>> {
|
||||
final AutoDisposeRef ref;
|
||||
class PostSearchNotifier extends AutoDisposeAsyncNotifier<List<SnPost>>
|
||||
with AutoDisposeAsyncPaginationController<SnPost> {
|
||||
static const int _pageSize = 20;
|
||||
String _currentQuery = '';
|
||||
String? _pubName;
|
||||
@@ -28,12 +28,13 @@ class PostSearchNotifier
|
||||
List<String>? _tags;
|
||||
bool _shuffle = false;
|
||||
bool? _pinned;
|
||||
bool _isLoading = false;
|
||||
|
||||
PostSearchNotifier(this.ref) : super(const AsyncValue.loading()) {
|
||||
state = const AsyncValue.data(
|
||||
CursorPagingData(items: [], hasMore: false, nextCursor: null),
|
||||
);
|
||||
@override
|
||||
FutureOr<List<SnPost>> build() async {
|
||||
// Initial state is empty if no query/filters, or fetch if needed
|
||||
// But original logic allowed initial empty state.
|
||||
// Let's replicate original logic: return empty list initially if no query.
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> search(
|
||||
@@ -46,8 +47,6 @@ class PostSearchNotifier
|
||||
bool shuffle = false,
|
||||
bool? pinned,
|
||||
}) async {
|
||||
if (_isLoading) return;
|
||||
|
||||
_currentQuery = query.trim();
|
||||
_pubName = pubName;
|
||||
_realm = realm;
|
||||
@@ -57,7 +56,6 @@ class PostSearchNotifier
|
||||
_shuffle = shuffle;
|
||||
_pinned = pinned;
|
||||
|
||||
// Allow search even with empty query if any filters are applied
|
||||
final hasFilters =
|
||||
pubName != null ||
|
||||
realm != null ||
|
||||
@@ -68,59 +66,38 @@ class PostSearchNotifier
|
||||
pinned != null;
|
||||
|
||||
if (_currentQuery.isEmpty && !hasFilters) {
|
||||
state = AsyncValue.data(
|
||||
CursorPagingData(items: [], hasMore: false, nextCursor: null),
|
||||
);
|
||||
state = const AsyncData([]);
|
||||
totalCount = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(cursor: null);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> fetch({String? cursor}) async {
|
||||
if (_isLoading) return;
|
||||
@override
|
||||
Future<List<SnPost>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
_isLoading = true;
|
||||
state = const AsyncValue.loading();
|
||||
final response = await client.get(
|
||||
'/sphere/posts',
|
||||
queryParameters: {
|
||||
'query': _currentQuery,
|
||||
'offset': fetchedCount,
|
||||
'take': _pageSize,
|
||||
'vector': false,
|
||||
if (_pubName != null) 'pub': _pubName,
|
||||
if (_realm != null) 'realm': _realm,
|
||||
if (_type != null) 'type': _type,
|
||||
if (_tags != null) 'tags': _tags,
|
||||
if (_categories != null) 'categories': _categories,
|
||||
if (_shuffle) 'shuffle': true,
|
||||
if (_pinned != null) 'pinned': _pinned,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||
|
||||
final response = await client.get(
|
||||
'/sphere/posts',
|
||||
queryParameters: {
|
||||
'query': _currentQuery,
|
||||
'offset': offset,
|
||||
'take': _pageSize,
|
||||
'vector': false,
|
||||
if (_pubName != null) 'pub': _pubName,
|
||||
if (_realm != null) 'realm': _realm,
|
||||
if (_type != null) 'type': _type,
|
||||
if (_tags != null) 'tags': _tags,
|
||||
if (_categories != null) 'categories': _categories,
|
||||
if (_shuffle) 'shuffle': true,
|
||||
if (_pinned != null) 'pinned': _pinned,
|
||||
},
|
||||
);
|
||||
|
||||
final data = response.data as List;
|
||||
final posts = data.map((json) => SnPost.fromJson(json)).toList();
|
||||
final hasMore = posts.length == _pageSize;
|
||||
final nextCursor = hasMore ? (offset + posts.length).toString() : null;
|
||||
|
||||
state = AsyncValue.data(
|
||||
CursorPagingData(
|
||||
items: posts,
|
||||
hasMore: hasMore,
|
||||
nextCursor: nextCursor,
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
}
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final data = response.data as List;
|
||||
return data.map((json) => SnPost.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,55 +316,34 @@ class PostSearchScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
searchState.when(
|
||||
data: (data) {
|
||||
if (data.items.isEmpty && searchController.text.isNotEmpty) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(child: Text('noResultsFound'.tr())),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index >= data.items.length) {
|
||||
ref
|
||||
.read(postSearchNotifierProvider.notifier)
|
||||
.fetch(cursor: data.nextCursor);
|
||||
return Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final post = data.items[index];
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 600),
|
||||
child: Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: PostActionableItem(
|
||||
item: post,
|
||||
borderRadius: 8,
|
||||
),
|
||||
),
|
||||
// Use PaginationList with isSliver=true
|
||||
PaginationList(
|
||||
provider: postSearchNotifierProvider,
|
||||
notifier: postSearchNotifierProvider.notifier,
|
||||
isSliver: true,
|
||||
isRefreshable:
|
||||
false, // CustomScrollView handles refreshing usually, but here we don't have PullToRefresh
|
||||
itemBuilder: (context, index, post) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 600),
|
||||
child: Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
);
|
||||
}, childCount: data.items.length + (data.hasMore ? 1 : 0)),
|
||||
);
|
||||
},
|
||||
loading:
|
||||
() => SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error:
|
||||
(error, stack) => SliverFillRemaining(
|
||||
child: ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry:
|
||||
() => ref.invalidate(postSearchNotifierProvider),
|
||||
child: PostActionableItem(item: post, borderRadius: 8),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (searchState.valueOrNull?.isEmpty == true &&
|
||||
searchController.text.isNotEmpty &&
|
||||
!searchState.isLoading)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text('noResultsFound'.tr())),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user