import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/app.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; import 'feed_state.dart'; final feedControllerProvider = AsyncNotifierProvider(FeedController.new); class FeedController extends AsyncNotifier { Timer? _providerDebounce; int _loadRequestId = 0; @override Future build() async { ref.onDispose(() => _providerDebounce?.cancel()); final providers = await _loadProviders(); final settings = await _settings(); final providerIds = providers.map((provider) => provider.id).toSet(); final initial = FeedState( providers: providers, topPeriodFilter: TopPeriodFilter.values.firstWhere( (value) => value.name == settings.lastFeedTopPeriod, orElse: () => TopPeriodFilter.none, ), selectedTags: settings.lastFeedTags, selectedProviderIds: settings.lastFeedProviderIds.where(providerIds.contains).toList(), ratingFilter: settings.lastFeedRating ?? settings.defaultRatingFilter, ); state = AsyncData(initial); await loadInitial(); return state.value ?? initial; } Future loadInitial() async { final current = state.value ?? const FeedState(); final requestId = ++_loadRequestId; state = const AsyncLoading(); final posts = await _load(refresh: true, current: current); if (requestId != _loadRequestId) return; state = AsyncData(current.copyWith(posts: posts, clearError: true)); } Future refresh() async { final current = state.value ?? const FeedState(); final requestId = ++_loadRequestId; 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)); } Future loadNextPage() async { final current = state.value; 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); 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); state = AsyncData( current.copyWith( posts: [...current.posts, ...newPosts], isLoadingMore: false, hasMore: posts.isNotEmpty, ), ); } Future search(String query) async { final tags = ref.read(searchServiceProvider).parseTags(query); final current = state.value ?? const FeedState(); state = AsyncData( current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []), ); await saveSession(scrollOffset: 0); await ref.read(searchServiceProvider).saveSearch(query, 0); await refresh(); final count = state.value?.posts.length ?? 0; await ref.read(searchServiceProvider).saveSearch(query, count); } Future updateTagSuggestions(String query) async { final current = state.value ?? const FeedState(); if (query.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> ? result.data .where((item) => item.name.toLowerCase().startsWith( normalizedToken, )) .toList(growable: false) : const []; state = AsyncData(current.copyWith(tagSuggestions: suggestions)); } Future setProviders(List 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: [])); final settings = await _settings(); await ref.read(settingsServiceProvider).updateSettings( settings.copyWith( selectedFeedProviderIds: selected, lastFeedProviderIds: selected, ), ); _providerDebounce?.cancel(); _providerDebounce = Timer(const Duration(milliseconds: 320), () { refresh(); }); } Future setTopPeriod(TopPeriodFilter period) async { final current = state.value ?? const FeedState(); state = AsyncData(current.copyWith(topPeriodFilter: period, posts: [])); final settings = await _settings(); await ref.read(settingsServiceProvider).updateSettings( settings.copyWith(defaultTopPeriodFilter: period.name), ); await saveSession(scrollOffset: 0); await refresh(); } Future setRating(String? rating) async { final current = state.value ?? const FeedState(); state = AsyncData( current.copyWith( ratingFilter: rating, clearRating: rating == null, posts: []), ); await refresh(); await saveSession(scrollOffset: 0); } Future clearFilters() async { final current = state.value ?? const FeedState(); state = AsyncData( current.copyWith( selectedTags: [], selectedProviderIds: [], tagSuggestions: [], clearRating: true, posts: [], ), ); final settings = await _settings(); await ref.read(settingsServiceProvider).updateSettings(settings.copyWith( selectedFeedProviderIds: [], lastFeedTags: [], lastFeedProviderIds: [], clearLastFeedRating: true, lastFeedTopPeriod: TopPeriodFilter.none.name, lastFeedScrollOffset: 0, )); await refresh(); } Future hidePost(Post post) async { final current = state.value; if (current == null) return; final result = await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey); if (result is Error) return; state = AsyncData( current.copyWith( posts: current.posts .where((item) => item.cacheKey != post.cacheKey) .toList(growable: false), ), ); ref.invalidate(appSettingsProvider); } Future saveSession({double? scrollOffset}) async { final current = state.value; if (current == null) return; final settings = await _settings(); await ref.read(settingsServiceProvider).updateSettings( settings.copyWith( lastFeedTags: current.selectedTags, lastFeedProviderIds: current.selectedProviderIds, lastFeedRating: current.ratingFilter, lastFeedTopPeriod: current.topPeriodFilter.name, lastFeedScrollOffset: scrollOffset ?? settings.lastFeedScrollOffset, ), ); } Future> _load({ required bool refresh, required FeedState current, }) async { final service = ref.read(feedServiceProvider); final providerIds = current.selectedProviderIds.isEmpty ? [null] : current.selectedProviderIds; final posts = []; final providerResults = >[]; for (final providerId in providerIds) { final result = refresh ? await service.refresh( tags: current.selectedTags, rating: current.ratingFilter, providerId: providerId, topPeriod: current.topPeriodFilter, ) : await service.loadNextPage( tags: current.selectedTags, rating: current.ratingFilter, providerId: providerId, topPeriod: current.topPeriodFilter, ); if (result is Success>) { providerResults.add(result.data); } } final seen = {}; if (providerResults.length <= 1) { for (final group in providerResults) { for (final post in group) { if (seen.add(post.cacheKey)) posts.add(post); } } return posts; } for (final group in providerResults) { for (final post in group) { if (seen.add(post.cacheKey)) posts.add(post); } } posts.sort((a, b) => _mixKey(a).compareTo(_mixKey(b))); return posts; } int _mixKey(Post post) { var hash = 0x811c9dc5; for (final code in post.cacheKey.codeUnits) { hash ^= code; hash = (hash * 0x01000193) & 0x7fffffff; } return hash; } Future> _loadProviders() async { final result = await ref .read(providerManagerProvider) .loadFeedConfigs(enabledOnly: true); return result is Success> ? result.data : const []; } Future _settings() async { final result = await ref.read(settingsServiceProvider).getSettings(); return result is Success ? result.data : AppSettings.defaults; } }