import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import '../../../app/app.dart'; import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/empty_view.dart'; import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/post_card.dart'; import '../../../shared/widgets/post_masonry_grid.dart'; import '../../collections/presentation/collection_form_dialog.dart'; import '../../favorites/presentation/favorites_controller.dart'; import 'feed_controller.dart'; import 'widgets/feed_toolbar.dart'; class FeedScreen extends ConsumerStatefulWidget { const FeedScreen({this.initialQuery, super.key}); final String? initialQuery; @override ConsumerState createState() => _FeedScreenState(); } class _FeedScreenState extends ConsumerState { final _scrollController = ScrollController(); final Set _selectedKeys = {}; bool _selectionMode = false; bool _usedInitialQuery = false; Timer? _scrollSaveDebounce; @override void initState() { super.initState(); _scrollController.addListener(() { if (_scrollController.position.extentAfter < 800) { ref.read(feedControllerProvider.notifier).loadNextPage(); } _scrollSaveDebounce?.cancel(); _scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () { ref .read(feedControllerProvider.notifier) .saveSession(scrollOffset: _scrollController.offset); }); }); WidgetsBinding.instance.addPostFrameCallback((_) async { final settings = ref.read(appSettingsProvider).value ?? AppSettings.defaults; if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) { _scrollController.jumpTo( settings.lastFeedScrollOffset.clamp( 0.0, _scrollController.position.maxScrollExtent, ), ); } }); } @override void didUpdateWidget(covariant FeedScreen oldWidget) { super.didUpdateWidget(oldWidget); final nextQuery = widget.initialQuery; if (nextQuery != null && nextQuery.isNotEmpty && nextQuery != oldWidget.initialQuery) { _usedInitialQuery = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(feedControllerProvider.notifier).search(nextQuery); }); } } @override void dispose() { _scrollSaveDebounce?.cancel(); _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final feed = ref.watch(feedControllerProvider); final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) { _usedInitialQuery = true; WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(feedControllerProvider.notifier).search(widget.initialQuery!); }); } return Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(), LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(), LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA): const _SelectVisibleIntent(), }, child: Actions( actions: { _RefreshIntent: CallbackAction<_RefreshIntent>( onInvoke: (_) { ref.read(feedControllerProvider.notifier).refresh(); return null; }, ), _ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>( onInvoke: (_) { _clearSelection(); return null; }, ), _SelectVisibleIntent: CallbackAction<_SelectVisibleIntent>( onInvoke: (_) { final state = feed.value; if (state != null) { setState(() { _selectionMode = true; _selectedKeys ..clear() ..addAll(state.posts.map((post) => post.cacheKey)); }); } return null; }, ), }, child: AdaptiveScaffold( title: 'Feed', body: feed.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ErrorView( message: error.toString(), onRetry: () => ref.read(feedControllerProvider.notifier).loadInitial(), ), data: (state) => Column( children: [ FeedToolbar( selectedTags: state.selectedTags, selectedProviderIds: state.selectedProviderIds, topPeriodFilter: state.topPeriodFilter, tagSuggestions: state.tagSuggestions, providers: state.providers, rating: state.ratingFilter, onSearchChanged: (query) => ref .read(feedControllerProvider.notifier) .updateTagSuggestions(query), onSuggestionTap: (query) => ref.read(feedControllerProvider.notifier).search(query), onTopPeriodChanged: (period) => ref .read(feedControllerProvider.notifier) .setTopPeriod(period), onSearch: (query) => ref.read(feedControllerProvider.notifier).search(query), onRefresh: () => ref.read(feedControllerProvider.notifier).refresh(), onClearFilters: () => ref.read(feedControllerProvider.notifier).clearFilters(), selectionMode: _selectionMode, onToggleSelectionMode: () { setState(() { _selectionMode = !_selectionMode; if (!_selectionMode) _selectedKeys.clear(); }); }, onQuickProviderToggle: (providerId) { if (providerId == '__all__') { ref .read(feedControllerProvider.notifier) .setProviders([]); return; } final selected = state.selectedProviderIds.contains(providerId) ? state.selectedProviderIds .where((id) => id != providerId) .toList() : [...state.selectedProviderIds, providerId]; ref .read(feedControllerProvider.notifier) .setProviders(selected); }, onProviderFilter: () async { final selected = await showProviderFilterSheet( context, providers: state.providers, selectedIds: state.selectedProviderIds, ); if (selected != null) { await ref .read(feedControllerProvider.notifier) .setProviders(selected); } }, onRatingFilter: () async { final rating = await showRatingFilterSheet( context, state.ratingFilter); await ref .read(feedControllerProvider.notifier) .setRating(rating); }, ), Expanded( child: state.posts.isEmpty && !state.isLoadingMore ? const EmptyView( title: 'No posts yet', message: 'Try another tag or check providers.', ) : RefreshIndicator( onRefresh: () => ref .read(feedControllerProvider.notifier) .refresh(), child: PostMasonryGrid( controller: _scrollController, posts: state.posts, columns: Responsive.columnsFor( context, mobileColumns: settings.mobileColumns, desktopColumns: settings.desktopColumns, ), blurExplicit: settings.blurExplicitContent, showBadges: settings.showPostBadges, nsfwEnabled: settings.nsfwEnabled, loading: state.isLoadingMore, favoriteKeys: favoriteKeys, viewedKeys: viewedKeys, selectionMode: _selectionMode, selectedKeys: _selectedKeys, onOpen: (post) => context.push( '/post/${post.providerId}/${post.id}', extra: post, ), onPreview: (post) => _showPreview(context, post), onToggleSelected: (post) => _toggleSelected(post), onFavorite: (post) => _toggleFavorite(ref, post, favoriteKeys), onAddToCollection: (post) => _addToCollection(context, ref, post), onHide: (post) => _hidePost(context, ref, post), ), ), ), if (_selectionMode && _selectedKeys.isNotEmpty) _BatchActionBar( count: _selectedKeys.length, onFavorite: () => _favoriteSelected( ref, state.posts, favoriteKeys, ), onCollection: () => _addSelectedToCollection( context, ref, state.posts, ), onClear: _clearSelection, ), ], ), ), ), ), ); } void _toggleSelected(Post post) { setState(() { _selectionMode = true; if (!_selectedKeys.add(post.cacheKey)) { _selectedKeys.remove(post.cacheKey); } if (_selectedKeys.isEmpty) _selectionMode = false; }); } void _clearSelection() { if (!_selectionMode && _selectedKeys.isEmpty) return; setState(() { _selectionMode = false; _selectedKeys.clear(); }); } List _selectedPosts(List posts) { return posts .where((post) => _selectedKeys.contains(post.cacheKey)) .toList(); } Future _favoriteSelected( WidgetRef ref, List posts, Set favoriteKeys, ) async { for (final post in _selectedPosts(posts)) { if (!favoriteKeys.contains(post.cacheKey)) { await ref.read(favoriteServiceProvider).addFavorite(post); } } ref.invalidate(favoriteKeysProvider); ref.invalidate(favoritesControllerProvider); _clearSelection(); } Future _addSelectedToCollection( BuildContext context, WidgetRef ref, List posts, ) async { final selectedPosts = _selectedPosts(posts); final result = await ref.read(collectionServiceProvider).getCollections(); final collections = result is Success> ? result.data : []; if (!context.mounted) return; await showAddToCollectionPicker( context, collections: collections, onSelected: (collection) async { await ref .read(collectionServiceProvider) .addPostsToCollection(collection.id, selectedPosts); _clearSelection(); }, onCreate: () => showCollectionFormDialog(context, ref), ); } Future _showPreview(BuildContext context, Post post) { final imageUrl = MediaUrlSelector.preview(post).firstOrNull; final child = imageUrl == null ? const Center(child: Icon(Icons.broken_image_rounded, size: 48)) : CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.contain); if (Responsive.isMobile(context)) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) => SizedBox( height: MediaQuery.sizeOf(context).height * 0.72, child: Padding(padding: const EdgeInsets.all(8), child: child), ), ); } return showDialog( context: context, builder: (context) => Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 900, maxHeight: 760), child: Padding(padding: const EdgeInsets.all(8), child: child), ), ), ); } Future _addToCollection( BuildContext context, WidgetRef ref, Post post, ) async { final result = await ref.read(collectionServiceProvider).getCollections(); final collections = result is Success> ? result.data : []; if (!context.mounted) return; await showAddToCollectionPicker( context, collections: collections, onSelected: (collection) { ref .read(collectionServiceProvider) .addPostToCollection(collection.id, post); }, onCreate: () => showCollectionFormDialog(context, ref), ); } Future _toggleFavorite( WidgetRef ref, Post post, Set favoriteKeys, ) async { if (favoriteKeys.contains(post.cacheKey)) { await ref .read(favoriteServiceProvider) .removeFavorite(post.id, post.providerId); } else { await ref.read(favoriteServiceProvider).addFavorite(post); } ref.invalidate(favoriteKeysProvider); ref.invalidate(favoritesControllerProvider); } Future _hidePost( BuildContext context, WidgetRef ref, Post post, ) async { await ref.read(feedControllerProvider.notifier).hidePost(post); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Post hidden locally'), action: SnackBarAction( label: 'Undo', onPressed: () async { await ref .read(settingsServiceProvider) .unhidePostKey(post.cacheKey); ref.invalidate(appSettingsProvider); ref.read(feedControllerProvider.notifier).refresh(); }, ), ), ); } } class _RefreshIntent extends Intent { const _RefreshIntent(); } class _ClearSelectionIntent extends Intent { const _ClearSelectionIntent(); } class _SelectVisibleIntent extends Intent { const _SelectVisibleIntent(); } class _BatchActionBar extends StatelessWidget { const _BatchActionBar({ required this.count, required this.onFavorite, required this.onCollection, required this.onClear, }); final int count; final VoidCallback onFavorite; final VoidCallback onCollection; final VoidCallback onClear; @override Widget build(BuildContext context) { return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 6, 12, 10), child: DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), boxShadow: const [ BoxShadow(color: Colors.black26, blurRadius: 16), ], ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ Text('$count selected', style: Theme.of(context).textTheme.titleMedium), const Spacer(), IconButton.filledTonal( tooltip: 'Favorite selected', onPressed: onFavorite, icon: const Icon(Icons.favorite_rounded), ), const SizedBox(width: 8), FilledButton.tonalIcon( onPressed: onCollection, icon: const Icon(Icons.add_rounded), label: const Text('Collection'), ), IconButton( tooltip: 'Clear', onPressed: onClear, icon: const Icon(Icons.close_rounded), ), ], ), ), ), ), ); } }