Add viewed history, diagnostics, tag chips and SWF viewer

This commit is contained in:
2026-05-24 11:35:43 +03:00
parent b02c776628
commit 361d0b3910
49 changed files with 4956 additions and 446 deletions
@@ -2,6 +2,7 @@ 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 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
@@ -16,6 +17,7 @@ import '../../../shared/widgets/rating_badge.dart';
import '../../collections/presentation/collection_form_dialog.dart';
import '../../favorites/presentation/favorites_controller.dart';
import '../../feed/presentation/feed_controller.dart';
import '../../viewed/presentation/viewed_controller.dart';
import 'post_details_controller.dart';
import 'widgets/post_action_bar.dart';
import 'widgets/post_media_viewer.dart';
@@ -69,6 +71,11 @@ class PostDetailsScreen extends ConsumerWidget {
error: (error, _) => ErrorView(message: error.toString()),
data: (post) {
if (post == null) return const EmptyView(title: 'Post not found');
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(viewedHistoryServiceProvider).markViewed(post);
ref.invalidate(viewedKeysProvider);
ref.invalidate(viewedControllerProvider);
});
final currentIndex = feedPosts.indexWhere(
(item) => item.providerId == post.providerId && item.id == post.id,
);
@@ -99,70 +106,131 @@ class PostDetailsScreen extends ConsumerWidget {
favoriteKeys,
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton.filledTonal(
tooltip: 'Previous',
onPressed: previous == null
? null
: () => _openPost(context, previous),
icon: const Icon(Icons.chevron_left_rounded),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 760),
child: PostMediaViewer(post: post),
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.arrowLeft):
const _PreviousPostIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowRight):
const _NextPostIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF):
const _ToggleFavoriteIntent(),
LogicalKeySet(LogicalKeyboardKey.keyC):
const _AddCollectionIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
},
child: Actions(
actions: {
_PreviousPostIntent: CallbackAction<_PreviousPostIntent>(
onInvoke: (_) {
if (previous != null) _openPost(context, previous);
return null;
},
),
_NextPostIntent: CallbackAction<_NextPostIntent>(
onInvoke: (_) {
if (next != null) _openPost(context, next);
return null;
},
),
_ToggleFavoriteIntent: CallbackAction<_ToggleFavoriteIntent>(
onInvoke: (_) {
_toggleFavorite(ref, post, favoriteKeys);
return null;
},
),
_AddCollectionIntent: CallbackAction<_AddCollectionIntent>(
onInvoke: (_) {
_addToCollection(context, ref, post);
return null;
},
),
_CloseIntent: CallbackAction<_CloseIntent>(
onInvoke: (_) {
_close(context);
return null;
},
),
},
child: Focus(
autofocus: true,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton.filledTonal(
tooltip: 'Previous',
onPressed: previous == null
? null
: () => _openPost(context, previous),
icon: const Icon(Icons.chevron_left_rounded),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 760),
child: PostMediaViewer(post: post),
),
),
),
IconButton.filledTonal(
tooltip: 'Next',
onPressed: next == null
? null
: () => _openPost(context, next),
icon: const Icon(Icons.chevron_right_rounded),
),
],
),
if (currentIndex >= 0) ...[
const SizedBox(height: 10),
_NeighborStrip(
posts: feedPosts,
currentIndex: currentIndex,
onOpen: (post) => _openPost(context, post),
),
],
const SizedBox(height: 16),
PostActionBar(
isFavorite: favoriteKeys.contains(post.cacheKey),
onFavorite: () =>
_toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onCopy: () =>
Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post),
onDownload: settings.allowDownloads
? () => _download(context, ref, post)
: null,
),
),
IconButton.filledTonal(
tooltip: 'Next',
onPressed:
next == null ? null : () => _openPost(context, next),
icon: const Icon(Icons.chevron_right_rounded),
),
],
),
const SizedBox(height: 16),
PostActionBar(
isFavorite: favoriteKeys.contains(post.cacheKey),
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onCopy: () =>
Clipboard.setData(ClipboardData(text: post.fileUrl)),
onDownload: settings.allowDownloads
? () => _download(context, ref, post)
: null,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(label: Text(post.providerName)),
RatingBadge(rating: post.rating),
Chip(label: Text('${post.width} x ${post.height}')),
if (post.source != null && post.source!.isNotEmpty)
ActionChip(
label: const Text('Source'),
onPressed: () => launchUrl(Uri.parse(post.source!)),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(label: Text(post.providerName)),
RatingBadge(rating: post.rating),
Chip(label: Text('${post.width} x ${post.height}')),
if (post.source != null && post.source!.isNotEmpty)
ActionChip(
label: const Text('Source'),
onPressed: () => launchUrl(Uri.parse(post.source!)),
),
],
),
],
const SizedBox(height: 16),
Text('Tags', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
PostTagsPanel(post: post),
const SizedBox(height: 16),
_CommentsSection(post: post),
],
),
),
const SizedBox(height: 16),
Text('Tags', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
PostTagsPanel(post: post),
const SizedBox(height: 16),
_CommentsSection(post: post),
],
),
);
},
),
@@ -192,6 +260,7 @@ class PostDetailsScreen extends ConsumerWidget {
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post),
onDownload: settings.allowDownloads
? () => _download(context, ref, post)
: null,
@@ -288,6 +357,13 @@ class PostDetailsScreen extends ConsumerWidget {
_replacePost(context, post);
}
void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
final query = similarTagsFor(post).join(' ');
if (query.trim().isEmpty) return;
ref.read(feedControllerProvider.notifier).search(query);
context.go('/?q=${Uri.encodeQueryComponent(query)}');
}
void _replacePost(BuildContext context, Post post) {
context.replace('/post/${post.providerId}/${post.id}', extra: post);
}
@@ -360,14 +436,6 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
onPageChanged: (index) {
_currentIndex = index;
_prefetchAround(index);
final post = widget.posts[index];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
GoRouter.of(context).replace(
'/post/${post.providerId}/${post.id}',
extra: post,
);
});
},
itemBuilder: (context, index) {
return _KeepAlivePostPage(
@@ -381,7 +449,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
}
void _prefetchAround(int index) {
for (final offset in [-1, 0, 1]) {
for (final offset in [-2, -1, 0, 1, 2]) {
final target = index + offset;
if (target < 0 || target >= widget.posts.length) continue;
final post = widget.posts[target];
@@ -391,10 +459,30 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
if (post.fileType.toLowerCase().contains('gif')) post.fileUrl,
].where((url) => url.trim().isNotEmpty).toSet();
for (final url in urls) {
precacheImage(NetworkImage(url), context);
precacheImage(
CachedNetworkImageProvider(url, headers: _headersFor(post)),
context,
);
}
}
}
Map<String, String> _headersFor(Post post) {
return {
'User-Agent': 'RuleGel/0.2 Flutter local booru browser',
'Accept': '*/*',
if (post.providerName.toLowerCase().contains('gelbooru') ||
post.fileUrl.contains('gelbooru.com') ||
post.sampleUrl.contains('gelbooru.com') ||
post.previewUrl.contains('gelbooru.com'))
'Referer': 'https://gelbooru.com/',
if (post.providerName.toLowerCase().contains('rule34') ||
post.fileUrl.contains('rule34') ||
post.sampleUrl.contains('rule34') ||
post.previewUrl.contains('rule34'))
'Referer': 'https://rule34.xxx/',
};
}
}
class _KeepAlivePostPage extends StatefulWidget {
@@ -418,6 +506,83 @@ class _KeepAlivePostPageState extends State<_KeepAlivePostPage>
}
}
class _NeighborStrip extends StatelessWidget {
const _NeighborStrip({
required this.posts,
required this.currentIndex,
required this.onOpen,
});
final List<Post> posts;
final int currentIndex;
final ValueChanged<Post> onOpen;
@override
Widget build(BuildContext context) {
final start = (currentIndex - 3).clamp(0, posts.length);
final end = (currentIndex + 4).clamp(0, posts.length);
final visible = posts.sublist(start, end);
return SizedBox(
height: 72,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: visible.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final post = visible[index];
final selected = post.cacheKey == posts[currentIndex].cacheKey;
return InkWell(
onTap: () => onOpen(post),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
width: 72,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: selected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 2,
),
),
clipBehavior: Clip.antiAlias,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
const Center(child: Icon(Icons.broken_image_rounded)),
),
),
);
},
),
);
}
}
class _PreviousPostIntent extends Intent {
const _PreviousPostIntent();
}
class _NextPostIntent extends Intent {
const _NextPostIntent();
}
class _ToggleFavoriteIntent extends Intent {
const _ToggleFavoriteIntent();
}
class _AddCollectionIntent extends Intent {
const _AddCollectionIntent();
}
class _CloseIntent extends Intent {
const _CloseIntent();
}
class _CommentsSection extends ConsumerStatefulWidget {
const _CommentsSection({required this.post});
@@ -466,7 +631,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(bottom: 12),
child: Text('Comments unavailable'),
child: Text('No comments'),
),
);
}
@@ -8,6 +8,7 @@ class PostActionBar extends StatelessWidget {
required this.onCopy,
required this.isFavorite,
this.onDownload,
this.onSimilar,
super.key,
});
@@ -17,6 +18,7 @@ class PostActionBar extends StatelessWidget {
final VoidCallback onCopy;
final bool isFavorite;
final VoidCallback? onDownload;
final VoidCallback? onSimilar;
@override
Widget build(BuildContext context) {
@@ -36,6 +38,12 @@ class PostActionBar extends StatelessWidget {
icon: const Icon(Icons.add_rounded),
label: const Text('Collection'),
),
if (onSimilar != null)
FilledButton.tonalIcon(
onPressed: onSimilar,
icon: const Icon(Icons.auto_awesome_rounded),
label: const Text('Similar'),
),
IconButton.filledTonal(
tooltip: 'Open original',
onPressed: onOpen,
File diff suppressed because it is too large Load Diff