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
@@ -4,11 +4,13 @@ import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final viewedControllerProvider =
AsyncNotifierProvider<ViewedController, List<Post>>(ViewedController.new);
AsyncNotifierProvider<ViewedController, List<ViewedTimelineGroup>>(
ViewedController.new,
);
class ViewedController extends AsyncNotifier<List<Post>> {
class ViewedController extends AsyncNotifier<List<ViewedTimelineGroup>> {
@override
Future<List<Post>> build() => _load();
Future<List<ViewedTimelineGroup>> build() => _load();
Future<void> refresh() async {
state = AsyncData(await _load());
@@ -20,9 +22,44 @@ class ViewedController extends AsyncNotifier<List<Post>> {
state = const AsyncData([]);
}
Future<List<Post>> _load() async {
Future<List<ViewedTimelineGroup>> _load() async {
final result =
await ref.read(viewedHistoryServiceProvider).getViewedPosts();
return result is Success<List<Post>> ? result.data : const [];
await ref.read(viewedHistoryServiceProvider).getViewedPostEntries();
if (result is! Success<List<ViewedPostEntry>>) return const [];
return groupViewedTimeline(result.data);
}
}
class ViewedTimelineGroup {
const ViewedTimelineGroup({required this.label, required this.items});
final String label;
final List<ViewedPostEntry> items;
}
List<ViewedTimelineGroup> groupViewedTimeline(
List<ViewedPostEntry> entries, {
DateTime? now,
}) {
final today = DateTime(now?.year ?? DateTime.now().year,
now?.month ?? DateTime.now().month, now?.day ?? DateTime.now().day);
final buckets = <String, List<ViewedPostEntry>>{};
for (final entry in entries) {
final date =
DateTime(entry.viewedAt.year, entry.viewedAt.month, entry.viewedAt.day);
final days = today.difference(date).inDays;
final label = switch (days) {
0 => 'Today',
1 => 'Yesterday',
_ => _dateLabel(date),
};
buckets.putIfAbsent(label, () => []).add(entry);
}
return [
for (final entry in buckets.entries)
ViewedTimelineGroup(label: entry.key, items: entry.value),
];
}
String _dateLabel(DateTime date) =>
'${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
@@ -3,12 +3,10 @@ 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 '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import '../../favorites/presentation/favorites_controller.dart';
import 'viewed_controller.dart';
@@ -21,7 +19,6 @@ class ViewedScreen extends ConsumerWidget {
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
return AdaptiveScaffold(
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
actions: [
@@ -34,47 +31,111 @@ class ViewedScreen extends ConsumerWidget {
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (posts) => posts.isEmpty
data: (groups) => groups.isEmpty
? const EmptyView(title: 'No viewed posts yet')
: RefreshIndicator(
onRefresh: () =>
ref.read(viewedControllerProvider.notifier).refresh(),
child: PostMasonryGrid(
posts: posts,
columns: Responsive.columnsFor(
context,
mobileColumns: settings.mobileColumns,
desktopColumns: settings.desktopColumns,
),
blurExplicit: settings.blurExplicitContent,
showBadges: settings.showPostBadges,
nsfwEnabled: settings.nsfwEnabled,
mediaQualityMode:
MediaQualityMode.fromName(settings.mediaQualityMode),
favoriteKeys: favoriteKeys,
viewedKeys: viewedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
await ref
.read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId);
} else {
await ref.read(favoriteServiceProvider).addFavorite(
post,
settings: settings,
downloadManager:
ref.read(downloadManagerServiceProvider),
);
}
ref.invalidate(favoriteKeysProvider);
},
child: ListView(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
children: [
for (final group in groups) ...[
Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Text(
group.label,
style: Theme.of(context).textTheme.titleMedium,
),
),
for (final item in group.items)
_ViewedPostTile(
post: item.post,
viewedAt: item.viewedAt,
isFavorite: favoriteKeys.contains(item.post.cacheKey),
onOpen: () => context.push(
'/post/${item.post.providerId}/${item.post.id}',
extra: item.post,
),
onFavorite: () => _toggleFavorite(
ref,
item.post,
settings,
favoriteKeys,
),
),
],
],
),
),
),
);
}
Future<void> _toggleFavorite(
WidgetRef ref,
Post post,
AppSettings settings,
Set<String> favoriteKeys,
) async {
if (favoriteKeys.contains(post.cacheKey)) {
await ref
.read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId);
} else {
await ref.read(favoriteServiceProvider).addFavorite(
post,
settings: settings,
downloadManager: ref.read(downloadManagerServiceProvider),
);
}
ref.invalidate(favoriteKeysProvider);
}
}
class _ViewedPostTile extends StatelessWidget {
const _ViewedPostTile({
required this.post,
required this.viewedAt,
required this.isFavorite,
required this.onOpen,
required this.onFavorite,
});
final Post post;
final DateTime viewedAt;
final bool isFavorite;
final VoidCallback onOpen;
final VoidCallback onFavorite;
@override
Widget build(BuildContext context) {
final image = MediaUrlSelector.preview(post).firstOrNull;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
onTap: onOpen,
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 56,
height: 56,
child: image == null
? const Icon(Icons.image_not_supported_rounded)
: Image.network(image, fit: BoxFit.cover),
),
),
title: Text(post.tags.take(4).join(' '), maxLines: 1),
subtitle: Text(
'${post.providerName} • ${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}',
),
trailing: IconButton(
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
onPressed: onFavorite,
icon: Icon(isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded),
),
),
);
}
}