feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements

This commit is contained in:
2026-09-03 16:40:37 +03:00
parent 7cccb2cd8d
commit 45faa12133
55 changed files with 4263 additions and 2076 deletions
@@ -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(