feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements
This commit is contained in:
@@ -12,11 +12,22 @@ final feedControllerProvider =
|
||||
|
||||
class FeedController extends AsyncNotifier<FeedState> {
|
||||
Timer? _providerDebounce;
|
||||
Timer? _suggestionDebounce;
|
||||
StreamSubscription<ProviderSoftRetryResult>? _softRetrySubscription;
|
||||
int _loadRequestId = 0;
|
||||
int _suggestionRequestId = 0;
|
||||
|
||||
@override
|
||||
Future<FeedState> build() async {
|
||||
ref.onDispose(() => _providerDebounce?.cancel());
|
||||
ref.onDispose(() {
|
||||
_providerDebounce?.cancel();
|
||||
_suggestionDebounce?.cancel();
|
||||
_softRetrySubscription?.cancel();
|
||||
});
|
||||
_softRetrySubscription ??= ref
|
||||
.read(providerManagerProvider)
|
||||
.softRetryResults
|
||||
.listen(_applySoftRetryResult);
|
||||
final providers = await _loadProviders();
|
||||
final settings = await _settings();
|
||||
final providerIds = providers.map((provider) => provider.id).toSet();
|
||||
@@ -42,7 +53,13 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
state = const AsyncLoading();
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
state = AsyncData(current.copyWith(posts: posts, clearError: true));
|
||||
state = AsyncData(current.copyWith(
|
||||
posts: posts,
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
clearError: true,
|
||||
providerStatusMessage: await _providerStatusMessage(current),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
@@ -51,7 +68,13 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false));
|
||||
state = AsyncData(current.copyWith(
|
||||
posts: posts,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
providerStatusMessage: await _providerStatusMessage(current),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
@@ -59,17 +82,27 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
||||
final requestId = ++_loadRequestId;
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: false, current: current);
|
||||
final posts = <Post>[];
|
||||
const maxEmptyPageSkips = 3;
|
||||
var attempts = 0;
|
||||
while (posts.isEmpty && attempts < maxEmptyPageSkips) {
|
||||
attempts++;
|
||||
posts.addAll(await _load(refresh: false, current: current));
|
||||
if (requestId != _loadRequestId) return;
|
||||
}
|
||||
if (requestId != _loadRequestId) return;
|
||||
final existingKeys = current.posts.map((post) => post.cacheKey).toSet();
|
||||
final newPosts = posts
|
||||
.where((post) => existingKeys.add(post.cacheKey))
|
||||
.toList(growable: false);
|
||||
final emptyPageStreak =
|
||||
newPosts.isEmpty ? current.emptyPageStreak + attempts : 0;
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
posts: [...current.posts, ...newPosts],
|
||||
isLoadingMore: false,
|
||||
hasMore: posts.isNotEmpty,
|
||||
hasMore: newPosts.isNotEmpty || emptyPageStreak < maxEmptyPageSkips,
|
||||
emptyPageStreak: emptyPageStreak,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -78,7 +111,13 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
final tags = ref.read(searchServiceProvider).parseTags(query);
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []),
|
||||
current.copyWith(
|
||||
selectedTags: tags,
|
||||
posts: [],
|
||||
tagSuggestions: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
),
|
||||
);
|
||||
await saveSession(scrollOffset: 0);
|
||||
await ref.read(searchServiceProvider).saveSearch(query, 0);
|
||||
@@ -89,31 +128,42 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
|
||||
Future<void> updateTagSuggestions(String query) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
if (query.trim().isEmpty) {
|
||||
final lastToken =
|
||||
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
||||
final requestId = ++_suggestionRequestId;
|
||||
_suggestionDebounce?.cancel();
|
||||
if (lastToken.trim().isEmpty) {
|
||||
state = AsyncData(current.copyWith(tagSuggestions: []));
|
||||
return;
|
||||
}
|
||||
final lastToken = query.trim().split(RegExp(r'\s+')).last;
|
||||
final result = await ref
|
||||
.read(searchServiceProvider)
|
||||
.autocompleteDetailed(lastToken, limit: 16);
|
||||
final normalizedToken = lastToken.trim().toLowerCase();
|
||||
final suggestions = result is Success<List<TagSuggestion>>
|
||||
? result.data
|
||||
.where((item) => item.name.toLowerCase().startsWith(
|
||||
normalizedToken,
|
||||
))
|
||||
.toList(growable: false)
|
||||
: const <TagSuggestion>[];
|
||||
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
|
||||
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
|
||||
final result = await ref
|
||||
.read(searchServiceProvider)
|
||||
.autocompleteDetailed(lastToken, limit: 16);
|
||||
if (requestId != _suggestionRequestId) return;
|
||||
final normalizedToken = lastToken.trim().toLowerCase();
|
||||
final suggestions = result is Success<List<TagSuggestion>>
|
||||
? result.data
|
||||
.where((item) => item.name.toLowerCase().startsWith(
|
||||
normalizedToken,
|
||||
))
|
||||
.toList(growable: false)
|
||||
: const <TagSuggestion>[];
|
||||
final latest = state.value ?? current;
|
||||
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setProviders(List<String> providerIds) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
final enabledIds = current.providers.map((provider) => provider.id).toSet();
|
||||
final selected = providerIds.where(enabledIds.contains).toList();
|
||||
state =
|
||||
AsyncData(current.copyWith(selectedProviderIds: selected, posts: []));
|
||||
state = AsyncData(current.copyWith(
|
||||
selectedProviderIds: selected,
|
||||
posts: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(
|
||||
@@ -129,7 +179,12 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
|
||||
Future<void> setTopPeriod(TopPeriodFilter period) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(current.copyWith(topPeriodFilter: period, posts: []));
|
||||
state = AsyncData(current.copyWith(
|
||||
topPeriodFilter: period,
|
||||
posts: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(defaultTopPeriodFilter: period.name),
|
||||
@@ -142,7 +197,12 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
ratingFilter: rating, clearRating: rating == null, posts: []),
|
||||
ratingFilter: rating,
|
||||
clearRating: rating == null,
|
||||
posts: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
await saveSession(scrollOffset: 0);
|
||||
@@ -157,6 +217,8 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
tagSuggestions: [],
|
||||
clearRating: true,
|
||||
posts: [],
|
||||
hasMore: true,
|
||||
emptyPageStreak: 0,
|
||||
),
|
||||
);
|
||||
final settings = await _settings();
|
||||
@@ -248,6 +310,56 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
return posts;
|
||||
}
|
||||
|
||||
void _applySoftRetryResult(ProviderSoftRetryResult result) {
|
||||
final current = state.value;
|
||||
if (current == null || result.page != 0) return;
|
||||
if (!_sameTags(current.selectedTags, result.tags)) return;
|
||||
if (current.ratingFilter != result.rating) return;
|
||||
if (current.topPeriodFilter != result.topPeriod) return;
|
||||
if (current.selectedProviderIds.isNotEmpty &&
|
||||
!current.selectedProviderIds.contains(result.providerId)) {
|
||||
return;
|
||||
}
|
||||
final seen = current.posts.map((post) => post.cacheKey).toSet();
|
||||
final newPosts = result.posts
|
||||
.where((post) => seen.add(post.cacheKey))
|
||||
.toList(growable: false);
|
||||
if (newPosts.isEmpty) return;
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
posts: [...current.posts, ...newPosts]..sort(
|
||||
(a, b) => _mixKey(a).compareTo(_mixKey(b)),
|
||||
),
|
||||
providerStatusMessage: 'Retry added ${newPosts.length} posts',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _sameTags(List<String> left, List<String> right) {
|
||||
if (left.length != right.length) return false;
|
||||
for (var i = 0; i < left.length; i++) {
|
||||
if (left[i] != right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<String?> _providerStatusMessage(FeedState current) async {
|
||||
final result = await ref.read(providerRepositoryProvider).getDiagnostics();
|
||||
if (result is! Success<List<ProviderDiagnostics>>) return null;
|
||||
final selected = current.selectedProviderIds.isEmpty
|
||||
? current.providers.map((provider) => provider.id).toSet()
|
||||
: current.selectedProviderIds.toSet();
|
||||
final failed = result.data
|
||||
.where((item) =>
|
||||
selected.contains(item.providerId) &&
|
||||
item.lastErrorMessage != null &&
|
||||
item.lastErrorMessage!.isNotEmpty)
|
||||
.map((item) => item.providerId)
|
||||
.toSet();
|
||||
if (failed.isEmpty) return null;
|
||||
return 'Retrying ${failed.length} provider${failed.length == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
int _mixKey(Post post) {
|
||||
var hash = 0x811c9dc5;
|
||||
for (final code in post.cacheKey.codeUnits) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -28,20 +29,24 @@ class FeedScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<FeedScreen> createState() => _FeedScreenState();
|
||||
}
|
||||
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _scrollController = ScrollController();
|
||||
final Set<String> _selectedKeys = {};
|
||||
bool _selectionMode = false;
|
||||
String? _appliedInitialQuery;
|
||||
Timer? _scrollSaveDebounce;
|
||||
double _lastKnownScrollOffset = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.extentAfter < 800) {
|
||||
ref.read(feedControllerProvider.notifier).loadNextPage();
|
||||
}
|
||||
_lastKnownScrollOffset = _scrollController.offset;
|
||||
_scrollSaveDebounce?.cancel();
|
||||
_scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () {
|
||||
ref
|
||||
@@ -53,6 +58,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) {
|
||||
_lastKnownScrollOffset = settings.lastFeedScrollOffset;
|
||||
_scrollController.jumpTo(
|
||||
settings.lastFeedScrollOffset.clamp(
|
||||
0.0,
|
||||
@@ -80,11 +86,32 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_scrollSaveDebounce?.cancel();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
if (_scrollController.hasClients) {
|
||||
_lastKnownScrollOffset = _scrollController.offset;
|
||||
ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.saveSession(scrollOffset: _lastKnownScrollOffset);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
final target = _lastKnownScrollOffset.clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
if ((_scrollController.offset - target).abs() > 24) {
|
||||
_scrollController.jumpTo(target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final feed = ref.watch(feedControllerProvider);
|
||||
@@ -162,6 +189,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
tagSuggestions: state.tagSuggestions,
|
||||
providers: state.providers,
|
||||
rating: state.ratingFilter,
|
||||
providerStatusMessage: state.providerStatusMessage,
|
||||
onSearchChanged: (query) => ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.updateTagSuggestions(query),
|
||||
@@ -173,6 +201,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
onRefresh: () =>
|
||||
ref.read(feedControllerProvider.notifier).refresh(),
|
||||
onClearFilters: _clearFilters,
|
||||
onRandom: () => _openRandom(state.posts),
|
||||
selectionMode: _selectionMode,
|
||||
onToggleSelectionMode: () {
|
||||
setState(() {
|
||||
@@ -228,6 +257,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
.read(feedControllerProvider.notifier)
|
||||
.refresh(),
|
||||
child: PostMasonryGrid(
|
||||
key: const PageStorageKey('feed_masonry_grid'),
|
||||
controller: _scrollController,
|
||||
posts: state.posts,
|
||||
columns: Responsive.columnsFor(
|
||||
@@ -296,6 +326,12 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
context.go(location);
|
||||
}
|
||||
|
||||
void _openRandom(List<Post> posts) {
|
||||
if (posts.isEmpty) return;
|
||||
final post = posts[Random().nextInt(posts.length)];
|
||||
context.push('/post/${post.providerId}/${post.id}', extra: post);
|
||||
}
|
||||
|
||||
Future<void> _applySearchQuery(String query) async {
|
||||
await ref.read(feedControllerProvider.notifier).search(query);
|
||||
if (_scrollController.hasClients) {
|
||||
|
||||
@@ -11,7 +11,9 @@ class FeedState {
|
||||
this.providers = const [],
|
||||
this.isLoadingMore = false,
|
||||
this.hasMore = true,
|
||||
this.emptyPageStreak = 0,
|
||||
this.error,
|
||||
this.providerStatusMessage,
|
||||
});
|
||||
|
||||
final List<Post> posts;
|
||||
@@ -23,7 +25,9 @@ class FeedState {
|
||||
final List<ContentProviderConfig> providers;
|
||||
final bool isLoadingMore;
|
||||
final bool hasMore;
|
||||
final int emptyPageStreak;
|
||||
final String? error;
|
||||
final String? providerStatusMessage;
|
||||
|
||||
FeedState copyWith({
|
||||
List<Post>? posts,
|
||||
@@ -36,8 +40,11 @@ class FeedState {
|
||||
List<ContentProviderConfig>? providers,
|
||||
bool? isLoadingMore,
|
||||
bool? hasMore,
|
||||
int? emptyPageStreak,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
String? providerStatusMessage,
|
||||
bool clearProviderStatus = false,
|
||||
}) {
|
||||
return FeedState(
|
||||
posts: posts ?? this.posts,
|
||||
@@ -49,7 +56,11 @@ class FeedState {
|
||||
providers: providers ?? this.providers,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
emptyPageStreak: emptyPageStreak ?? this.emptyPageStreak,
|
||||
error: clearError ? null : error ?? this.error,
|
||||
providerStatusMessage: clearProviderStatus
|
||||
? null
|
||||
: providerStatusMessage ?? this.providerStatusMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ class FeedToolbar extends StatefulWidget {
|
||||
required this.onSuggestionTap,
|
||||
required this.onTopPeriodChanged,
|
||||
this.onToggleSelectionMode,
|
||||
this.onRandom,
|
||||
this.providerStatusMessage,
|
||||
this.selectionMode = false,
|
||||
super.key,
|
||||
});
|
||||
@@ -43,6 +45,8 @@ class FeedToolbar extends StatefulWidget {
|
||||
final ValueChanged<String> onSuggestionTap;
|
||||
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
|
||||
final VoidCallback? onToggleSelectionMode;
|
||||
final VoidCallback? onRandom;
|
||||
final String? providerStatusMessage;
|
||||
final bool selectionMode;
|
||||
|
||||
@override
|
||||
@@ -122,9 +126,24 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Random post',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.providerStatusMessage != null) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: InputChip(
|
||||
avatar: const Icon(Icons.sync_problem_rounded, size: 16),
|
||||
label: Text(widget.providerStatusMessage!),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_HorizontalWheelScroller(
|
||||
height: 36,
|
||||
builder: (controller) => ListView(
|
||||
@@ -187,6 +206,16 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.providerStatusMessage != null) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: InputChip(
|
||||
avatar: const Icon(Icons.sync_problem_rounded, size: 16),
|
||||
label: Text(widget.providerStatusMessage!),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
_HorizontalWheelScroller(
|
||||
height: 40,
|
||||
builder: (controller) => ListView(
|
||||
@@ -219,6 +248,11 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Random post',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
|
||||
Reference in New Issue
Block a user