Add QoL feed, download and quality improvements
This commit is contained in:
@@ -15,13 +15,14 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
final providerIds = providers.map((provider) => provider.id).toSet();
|
||||
final initial = FeedState(
|
||||
providers: providers,
|
||||
ratingFilter: settings.defaultRatingFilter,
|
||||
topPeriodFilter: TopPeriodFilter.values.firstWhere(
|
||||
(value) => value.name == settings.defaultTopPeriodFilter,
|
||||
(value) => value.name == settings.lastFeedTopPeriod,
|
||||
orElse: () => TopPeriodFilter.none,
|
||||
),
|
||||
selectedTags: settings.lastFeedTags,
|
||||
selectedProviderIds:
|
||||
settings.selectedFeedProviderIds.where(providerIds.contains).toList(),
|
||||
settings.lastFeedProviderIds.where(providerIds.contains).toList(),
|
||||
ratingFilter: settings.lastFeedRating ?? settings.defaultRatingFilter,
|
||||
);
|
||||
state = AsyncData(initial);
|
||||
await loadInitial();
|
||||
@@ -66,6 +67,7 @@ class FeedController extends AsyncNotifier<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;
|
||||
@@ -99,7 +101,11 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
current.copyWith(selectedProviderIds: providerIds, posts: []));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(selectedFeedProviderIds: providerIds));
|
||||
settings.copyWith(
|
||||
selectedFeedProviderIds: providerIds,
|
||||
lastFeedProviderIds: providerIds,
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
@@ -110,6 +116,7 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(defaultTopPeriodFilter: period.name),
|
||||
);
|
||||
await saveSession(scrollOffset: 0);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
@@ -120,6 +127,7 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
ratingFilter: rating, clearRating: rating == null, posts: []),
|
||||
);
|
||||
await refresh();
|
||||
await saveSession(scrollOffset: 0);
|
||||
}
|
||||
|
||||
Future<void> clearFilters() async {
|
||||
@@ -134,12 +142,32 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
),
|
||||
);
|
||||
final settings = await _settings();
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.updateSettings(settings.copyWith(selectedFeedProviderIds: []));
|
||||
await ref.read(settingsServiceProvider).updateSettings(settings.copyWith(
|
||||
selectedFeedProviderIds: [],
|
||||
lastFeedTags: [],
|
||||
lastFeedProviderIds: [],
|
||||
clearLastFeedRating: true,
|
||||
lastFeedTopPeriod: TopPeriodFilter.none.name,
|
||||
lastFeedScrollOffset: 0,
|
||||
));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> 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<List<Post>> _load({
|
||||
required bool refresh,
|
||||
required FeedState current,
|
||||
|
||||
@@ -2,6 +2,8 @@ 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';
|
||||
@@ -28,7 +30,10 @@ class FeedScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
final Set<String> _selectedKeys = {};
|
||||
bool _selectionMode = false;
|
||||
bool _usedInitialQuery = false;
|
||||
Timer? _scrollSaveDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -37,11 +42,30 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
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 dispose() {
|
||||
_scrollSaveDebounce?.cancel();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -64,6 +88,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
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: {
|
||||
@@ -73,6 +101,26 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
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',
|
||||
@@ -106,6 +154,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
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
|
||||
@@ -167,10 +222,14 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
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) =>
|
||||
@@ -178,6 +237,21 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_selectionMode && _selectedKeys.isNotEmpty)
|
||||
_BatchActionBar(
|
||||
count: _selectedKeys.length,
|
||||
onFavorite: () => _favoriteSelected(
|
||||
ref,
|
||||
state.posts,
|
||||
favoriteKeys,
|
||||
),
|
||||
onCollection: () => _addSelectedToCollection(
|
||||
context,
|
||||
ref,
|
||||
state.posts,
|
||||
),
|
||||
onClear: _clearSelection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -186,6 +260,95 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<Post> _selectedPosts(List<Post> posts) {
|
||||
return posts
|
||||
.where((post) => _selectedKeys.contains(post.cacheKey))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _favoriteSelected(
|
||||
WidgetRef ref,
|
||||
List<Post> posts,
|
||||
Set<String> 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<void> _addSelectedToCollection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<Post> posts,
|
||||
) async {
|
||||
final selectedPosts = _selectedPosts(posts);
|
||||
final result = await ref.read(collectionServiceProvider).getCollections();
|
||||
final collections =
|
||||
result is Success<List<Collection>> ? result.data : <Collection>[];
|
||||
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<void> _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<void>(
|
||||
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<void>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 900, maxHeight: 760),
|
||||
child: Padding(padding: const EdgeInsets.all(8), child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addToCollection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -227,3 +390,70 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class FeedToolbar extends StatefulWidget {
|
||||
required this.onSearchChanged,
|
||||
required this.onSuggestionTap,
|
||||
required this.onTopPeriodChanged,
|
||||
this.onToggleSelectionMode,
|
||||
this.selectionMode = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -40,6 +42,8 @@ class FeedToolbar extends StatefulWidget {
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<String> onSuggestionTap;
|
||||
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
|
||||
final VoidCallback? onToggleSelectionMode;
|
||||
final bool selectionMode;
|
||||
|
||||
@override
|
||||
State<FeedToolbar> createState() => _FeedToolbarState();
|
||||
@@ -91,6 +95,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: widget.selectionMode ? 'Exit select' : 'Select posts',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
@@ -184,9 +195,12 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
primary: false,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
tooltip:
|
||||
widget.selectionMode ? 'Exit select' : 'Select posts',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
|
||||
Reference in New Issue
Block a user