Initial Flutter booru app
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import 'feed_state.dart';
|
||||
|
||||
final feedControllerProvider =
|
||||
AsyncNotifierProvider<FeedController, FeedState>(FeedController.new);
|
||||
|
||||
class FeedController extends AsyncNotifier<FeedState> {
|
||||
@override
|
||||
Future<FeedState> build() async {
|
||||
final providers = await _loadProviders();
|
||||
final settings = await _settings();
|
||||
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,
|
||||
orElse: () => TopPeriodFilter.none,
|
||||
),
|
||||
selectedProviderIds:
|
||||
settings.selectedFeedProviderIds.where(providerIds.contains).toList(),
|
||||
);
|
||||
state = AsyncData(initial);
|
||||
await loadInitial();
|
||||
return state.value ?? initial;
|
||||
}
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = const AsyncLoading();
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
state = AsyncData(current.copyWith(posts: posts, clearError: true));
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false));
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: false, current: current);
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
posts: [...current.posts, ...posts],
|
||||
isLoadingMore: false,
|
||||
hasMore: posts.isNotEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> search(String query) async {
|
||||
final tags = ref.read(searchServiceProvider).parseTags(query);
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(current.copyWith(selectedTags: tags, posts: []));
|
||||
await ref.read(searchServiceProvider).saveSearch(query, 0);
|
||||
await refresh();
|
||||
final count = state.value?.posts.length ?? 0;
|
||||
await ref.read(searchServiceProvider).saveSearch(query, count);
|
||||
}
|
||||
|
||||
Future<void> 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).autocomplete(lastToken);
|
||||
final suggestions =
|
||||
result is Success<List<String>> ? result.data : const <String>[];
|
||||
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
|
||||
}
|
||||
|
||||
Future<void> setProviders(List<String> providerIds) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(selectedProviderIds: providerIds, posts: []));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(selectedFeedProviderIds: providerIds));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> 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 refresh();
|
||||
}
|
||||
|
||||
Future<void> setRating(String? rating) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
ratingFilter: rating, clearRating: rating == null, posts: []),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> 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: []));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<List<Post>> _load({
|
||||
required bool refresh,
|
||||
required FeedState current,
|
||||
}) async {
|
||||
final service = ref.read(feedServiceProvider);
|
||||
final providerIds = current.selectedProviderIds.isEmpty
|
||||
? <String?>[null]
|
||||
: current.selectedProviderIds;
|
||||
final posts = <Post>[];
|
||||
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<List<Post>>) posts.addAll(result.data);
|
||||
}
|
||||
return posts;
|
||||
}
|
||||
|
||||
Future<List<ContentProviderConfig>> _loadProviders() async {
|
||||
final result =
|
||||
await ref.read(providerManagerProvider).loadConfigs(enabledOnly: true);
|
||||
return result is Success<List<ContentProviderConfig>>
|
||||
? result.data
|
||||
: const [];
|
||||
}
|
||||
|
||||
Future<AppSettings> _settings() async {
|
||||
final result = await ref.read(settingsServiceProvider).getSettings();
|
||||
return result is Success<AppSettings> ? result.data : AppSettings.defaults;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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 '../../../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 'feed_controller.dart';
|
||||
import 'widgets/feed_toolbar.dart';
|
||||
|
||||
class FeedScreen extends ConsumerStatefulWidget {
|
||||
const FeedScreen({this.initialQuery, super.key});
|
||||
|
||||
final String? initialQuery;
|
||||
|
||||
@override
|
||||
ConsumerState<FeedScreen> createState() => _FeedScreenState();
|
||||
}
|
||||
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
bool _usedInitialQuery = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.extentAfter < 800) {
|
||||
ref.read(feedControllerProvider.notifier).loadNextPage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final feed = ref.watch(feedControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
|
||||
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(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
_RefreshIntent: CallbackAction<_RefreshIntent>(
|
||||
onInvoke: (_) {
|
||||
ref.read(feedControllerProvider.notifier).refresh();
|
||||
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(),
|
||||
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,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) => ref
|
||||
.read(favoriteServiceProvider)
|
||||
.addFavorite(post),
|
||||
onAddToCollection: (post) =>
|
||||
_addToCollection(context, ref, post),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addToCollection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
) async {
|
||||
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) {
|
||||
ref
|
||||
.read(collectionServiceProvider)
|
||||
.addPostToCollection(collection.id, post);
|
||||
},
|
||||
onCreate: () => showCollectionFormDialog(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefreshIntent extends Intent {
|
||||
const _RefreshIntent();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import '../../../backend/backend.dart';
|
||||
|
||||
class FeedState {
|
||||
const FeedState({
|
||||
this.posts = const [],
|
||||
this.selectedTags = const [],
|
||||
this.selectedProviderIds = const [],
|
||||
this.ratingFilter,
|
||||
this.topPeriodFilter = TopPeriodFilter.none,
|
||||
this.tagSuggestions = const [],
|
||||
this.providers = const [],
|
||||
this.isLoadingMore = false,
|
||||
this.hasMore = true,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final List<Post> posts;
|
||||
final List<String> selectedTags;
|
||||
final List<String> selectedProviderIds;
|
||||
final String? ratingFilter;
|
||||
final TopPeriodFilter topPeriodFilter;
|
||||
final List<String> tagSuggestions;
|
||||
final List<ContentProviderConfig> providers;
|
||||
final bool isLoadingMore;
|
||||
final bool hasMore;
|
||||
final String? error;
|
||||
|
||||
FeedState copyWith({
|
||||
List<Post>? posts,
|
||||
List<String>? selectedTags,
|
||||
List<String>? selectedProviderIds,
|
||||
String? ratingFilter,
|
||||
TopPeriodFilter? topPeriodFilter,
|
||||
List<String>? tagSuggestions,
|
||||
bool clearRating = false,
|
||||
List<ContentProviderConfig>? providers,
|
||||
bool? isLoadingMore,
|
||||
bool? hasMore,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return FeedState(
|
||||
posts: posts ?? this.posts,
|
||||
selectedTags: selectedTags ?? this.selectedTags,
|
||||
selectedProviderIds: selectedProviderIds ?? this.selectedProviderIds,
|
||||
ratingFilter: clearRating ? null : ratingFilter ?? this.ratingFilter,
|
||||
topPeriodFilter: topPeriodFilter ?? this.topPeriodFilter,
|
||||
tagSuggestions: tagSuggestions ?? this.tagSuggestions,
|
||||
providers: providers ?? this.providers,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
error: clearError ? null : error ?? this.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../app/responsive.dart';
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../../shared/widgets/app_search_bar.dart';
|
||||
|
||||
class FeedToolbar extends StatefulWidget {
|
||||
const FeedToolbar({
|
||||
required this.onSearch,
|
||||
required this.onRefresh,
|
||||
required this.onProviderFilter,
|
||||
required this.onRatingFilter,
|
||||
required this.onClearFilters,
|
||||
required this.providers,
|
||||
required this.selectedTags,
|
||||
required this.selectedProviderIds,
|
||||
required this.topPeriodFilter,
|
||||
required this.tagSuggestions,
|
||||
required this.rating,
|
||||
required this.onQuickProviderToggle,
|
||||
required this.onSearchChanged,
|
||||
required this.onSuggestionTap,
|
||||
required this.onTopPeriodChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ValueChanged<String> onSearch;
|
||||
final VoidCallback onRefresh;
|
||||
final VoidCallback onProviderFilter;
|
||||
final VoidCallback onRatingFilter;
|
||||
final VoidCallback onClearFilters;
|
||||
final List<ContentProviderConfig> providers;
|
||||
final List<String> selectedTags;
|
||||
final List<String> selectedProviderIds;
|
||||
final TopPeriodFilter topPeriodFilter;
|
||||
final List<String> tagSuggestions;
|
||||
final String? rating;
|
||||
final ValueChanged<String> onQuickProviderToggle;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<String> onSuggestionTap;
|
||||
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
|
||||
|
||||
@override
|
||||
State<FeedToolbar> createState() => _FeedToolbarState();
|
||||
}
|
||||
|
||||
class _FeedToolbarState extends State<FeedToolbar> {
|
||||
late String _query;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_query = widget.selectedTags.join(' ');
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FeedToolbar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selectedTags != widget.selectedTags) {
|
||||
_query = widget.selectedTags.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (Responsive.isMobile(context)) return _buildMobile(context);
|
||||
return _buildDesktop(context);
|
||||
}
|
||||
|
||||
Widget _buildDesktop(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppSearchBar(
|
||||
initialValue: _query,
|
||||
onChanged: (value) {
|
||||
_query = value;
|
||||
widget.onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Rating',
|
||||
onPressed: widget.onRatingFilter,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear filters',
|
||||
onPressed: widget.onClearFilters,
|
||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('All providers'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
for (final provider in widget.providers) ...[
|
||||
FilterChip(
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
avatar: const Icon(Icons.hub_rounded, size: 16),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) =>
|
||||
widget.onQuickProviderToggle(provider.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
selected: widget.topPeriodFilter == period,
|
||||
label: Text(period.label),
|
||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.tagSuggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.tagSuggestions.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => ActionChip(
|
||||
avatar: const Icon(Icons.tag_rounded, size: 15),
|
||||
label: Text(widget.tagSuggestions[index]),
|
||||
onPressed: () =>
|
||||
_applySuggestion(widget.tagSuggestions[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobile(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Column(
|
||||
children: [
|
||||
AppSearchBar(
|
||||
initialValue: _query,
|
||||
onChanged: (value) {
|
||||
_query = value;
|
||||
widget.onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Rating',
|
||||
onPressed: widget.onRatingFilter,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: 'Clear filters',
|
||||
onPressed: widget.onClearFilters,
|
||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('All'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
for (final provider in widget.providers) ...[
|
||||
FilterChip(
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) =>
|
||||
widget.onQuickProviderToggle(provider.id),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
selected: widget.topPeriodFilter == period,
|
||||
label: Text(period.label),
|
||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.tagSuggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.tagSuggestions.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 6),
|
||||
itemBuilder: (context, index) => ActionChip(
|
||||
label: Text(widget.tagSuggestions[index]),
|
||||
onPressed: () =>
|
||||
_applySuggestion(widget.tagSuggestions[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _applySuggestion(String suggestion) {
|
||||
final tokens = _query.trim().isEmpty
|
||||
? <String>[]
|
||||
: _query.trim().split(RegExp(r'\s+'));
|
||||
if (tokens.isEmpty) {
|
||||
tokens.add(suggestion);
|
||||
} else {
|
||||
tokens[tokens.length - 1] = suggestion;
|
||||
}
|
||||
final next = tokens.toSet().join(' ');
|
||||
setState(() => _query = next);
|
||||
widget.onSuggestionTap(next);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>?> showProviderFilterSheet(
|
||||
BuildContext context, {
|
||||
required List<ContentProviderConfig> providers,
|
||||
required List<String> selectedIds,
|
||||
}) {
|
||||
final selected = selectedIds.toSet();
|
||||
return showModalBottomSheet<List<String>>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Providers', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
for (final provider in providers)
|
||||
CheckboxListTile(
|
||||
value: selected.contains(provider.id),
|
||||
title: Text(provider.name),
|
||||
subtitle: Text(provider.baseUrl),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value ?? false) {
|
||||
selected.add(provider.id);
|
||||
} else {
|
||||
selected.remove(provider.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, selected.toList()),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showRatingFilterSheet(BuildContext context, String? selected) {
|
||||
return showModalBottomSheet<String?>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Rating', style: Theme.of(context).textTheme.titleLarge),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
selected == null
|
||||
? Icons.radio_button_checked_rounded
|
||||
: Icons.radio_button_off_rounded,
|
||||
),
|
||||
title: const Text('Any'),
|
||||
onTap: () => Navigator.pop(context, null),
|
||||
),
|
||||
for (final rating in ['safe', 'questionable', 'explicit'])
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
selected == rating
|
||||
? Icons.radio_button_checked_rounded
|
||||
: Icons.radio_button_off_rounded,
|
||||
),
|
||||
title: Text(rating),
|
||||
onTap: () => Navigator.pop(context, rating),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'feed_toolbar.dart' show showProviderFilterSheet;
|
||||
@@ -0,0 +1 @@
|
||||
export 'feed_toolbar.dart' show showRatingFilterSheet;
|
||||
Reference in New Issue
Block a user