Compare commits

..
2 Commits
Author SHA1 Message Date
RarDog 3c91af8790 Release v3.7.3: Add artist posts row and fix comic page indicator
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 21:27:29 +03:00
RarDog 5434c5dc7b feat: open full e621 comic sequence in post pager with page indicator (v3.7.2)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 20:31:59 +03:00
7 changed files with 458 additions and 57 deletions
+9
View File
@@ -1,4 +1,13 @@
const prismaChangelog = [
PrismaChange(
version: '3.7.2',
title: 'Режим непрерывного чтения комиксов и серий e621',
bullets: [
'Мгновенное открытие комикса целиком: по нажатию «Читать весь комикс» загружаются все страницы серии в оригинальном порядке и открываются в полноэкранном ридере со свайпом (влево-вправо на мобильных, стрелками и кнопками на ПК).',
'Плавающий бейдж страниц: во время чтения комикса отображается аккуратный стеклянный индикатор номера страницы («Комикс • Стр. X из Y»).',
'Быстрое оглавление: отдельная кнопка сетки страниц для перехода на любую конкретную главу или страницу без потери последовательности чтения.',
],
),
PrismaChange(
version: '3.7.1',
title: 'Вкладка «e621» в Избранном и исправление листания комиксов',
+46
View File
@@ -123,6 +123,52 @@ class E621Provider
return null;
}
Future<List<Post>> getPoolPosts(String poolId) async {
final pool = await getPool(poolId);
if (pool == null || pool.postIds.isEmpty) return const [];
final fetchedMap = <String, Post>{};
int page = 1;
while (true) {
await _throttle();
try {
final response = await _dio.get<dynamic>(
'/posts.json',
queryParameters: {
'page': page,
'limit': isAuthorized ? 320 : 75,
'tags': 'pool:$poolId',
..._queryParameters,
},
);
final items = E621Mapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
if (items.isEmpty) break;
for (final item in items) {
fetchedMap[item.id] = item;
}
if (items.length < (isAuthorized ? 320 : 75) ||
fetchedMap.length >= pool.postIds.length) {
break;
}
page++;
} catch (_) {
break;
}
}
final ordered = <Post>[];
for (final id in pool.postIds) {
if (fetchedMap.containsKey(id)) {
ordered.add(fetchedMap[id]!);
}
}
return ordered.isNotEmpty ? ordered : fetchedMap.values.toList();
}
Future<bool> votePost(String postId, int score) async {
await _throttle();
try {
@@ -48,6 +48,18 @@ final postProviderInstanceProvider =
return ref.watch(providerManagerProvider).getProviderInstance(providerId);
});
/// Fetches up to 6 recent posts from the same artist.
final artistPostsProvider =
FutureProvider.family<List<Post>, String>((ref, artistTag) async {
if (artistTag.isEmpty) return const [];
final result = await ref.watch(feedServiceProvider).refresh(
tags: [artistTag],
limit: 7,
);
if (result is! Success<List<Post>>) return const [];
return (result as Success<List<Post>>).data.take(6).toList(growable: false);
});
class PostDetailsScreen extends ConsumerWidget {
const PostDetailsScreen({
required this.providerId,
@@ -377,6 +389,7 @@ class PostDetailsScreen extends ConsumerWidget {
fileSizeBytes: fileSizeBytes,
e621Provider: e621Provider,
),
_ArtistPostsCard(post: post),
if (post.hasRelations) ...[
const SizedBox(height: 16),
PostRelationsCard(
@@ -390,6 +403,9 @@ class PostDetailsScreen extends ConsumerWidget {
PostPoolsCard(
post: post,
provider: e621Provider,
onOpenComic: (poolId, currentPost, [targetId]) =>
_openComic(context, e621Provider, poolId,
currentPost, targetId),
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
@@ -635,6 +651,7 @@ class PostDetailsScreen extends ConsumerWidget {
fileSizeBytes: fileSizeBytes,
e621Provider: e621Provider,
),
_ArtistPostsCard(post: post),
if (post.hasRelations) ...[
const SizedBox(height: 12),
PostRelationsCard(
@@ -648,6 +665,8 @@ class PostDetailsScreen extends ConsumerWidget {
PostPoolsCard(
post: post,
provider: e621Provider,
onOpenComic: (poolId, currentPost, [targetId]) =>
_openComic(context, e621Provider, poolId, currentPost, targetId),
onOpenPostId: (targetId) =>
_openPostById(context, post.providerId, targetId),
),
@@ -885,6 +904,64 @@ class PostDetailsScreen extends ConsumerWidget {
ref.invalidate(favoritesControllerProvider);
}
Future<void> _openComic(
BuildContext context,
E621Provider provider,
String poolId,
Post currentPost, [
String? targetId,
]) async {
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
),
const SizedBox(width: 12),
Text(isRu ? 'Загрузка комикса...' : 'Loading comic...'),
],
),
duration: const Duration(seconds: 4),
),
);
final poolPosts = await provider.getPoolPosts(poolId);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (poolPosts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isRu
? 'Не удалось загрузить страницы комикса'
: 'Could not load comic pages'),
),
);
return;
}
final idToFind = targetId ?? currentPost.id;
final targetIndex = poolPosts.indexWhere((p) => p.id == idToFind);
final startPost =
targetIndex >= 0 ? poolPosts[targetIndex] : poolPosts.first;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => PostDetailsScreen(
providerId: provider.id,
postId: startPost.id,
initialPost: startPost,
postsList: poolPosts,
),
),
);
}
void _openPostById(
BuildContext context, String providerId, String targetId) {
Navigator.of(context).push(
@@ -1053,10 +1130,12 @@ class _MobilePostPager extends StatefulWidget {
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
bool _mediaGestureLocked = false;
late int _currentPage = widget.initialIndex;
@override
void initState() {
super.initState();
_currentPage = widget.initialIndex;
_controller = PageController(initialPage: widget.initialIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
@@ -1066,10 +1145,10 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
super.didUpdateWidget(oldWidget);
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
_currentPage = widget.initialIndex;
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
}
@@ -1087,7 +1166,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
Widget build(BuildContext context) {
return ScrollConfiguration(
final pager = ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
@@ -1103,6 +1182,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
: const PageScrollPhysics(),
itemCount: widget.posts.length,
onPageChanged: (index) {
setState(() => _currentPage = index);
_prefetchAround(index);
},
itemBuilder: (context, index) {
@@ -1135,7 +1215,63 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
},
),
);
}
if (widget.posts.length <= 1) return pager;
// Show the page indicator overlay only for comic/pool lists.
final isComic = widget.posts.first.hasPools;
if (!isComic) return pager;
return Stack(
children: [
pager,
Positioned(
top: MediaQuery.paddingOf(context).top + 10,
left: 0,
right: 0,
child: Center(
child: IgnorePointer(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.60),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.18),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_stories_rounded,
size: 14, color: Colors.white),
const SizedBox(width: 6),
Text(
'Комикс • Стр. ${_currentPage + 1} из ${widget.posts.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
],
),
),
),
),
),
),
),
],
);
}
void _setMediaGestureLocked(bool locked) {
if (_mediaGestureLocked == locked) return;
@@ -1273,6 +1409,163 @@ class _DownloadIntent extends Intent {
const _DownloadIntent();
}
// ---------------------------------------------------------------------------
// Artist posts card
// ---------------------------------------------------------------------------
class _ArtistPostsCard extends ConsumerWidget {
const _ArtistPostsCard({required this.post});
final Post post;
String _artistTag(Post post) {
final artists = post.tagGroups['artist'] ??
post.tags
.where((t) => t.startsWith('artist:'))
.map((t) => t.replaceFirst('artist:', ''))
.toList();
if (artists.isEmpty) return '';
return 'artist:${artists.first}';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final tag = _artistTag(post);
if (tag.isEmpty) return const SizedBox.shrink();
final artistPosts = ref.watch(artistPostsProvider(tag));
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final scheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final artistName = tag.replaceFirst('artist:', '');
return artistPosts.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (posts) {
if (posts.isEmpty) return const SizedBox.shrink();
// exclude the current post
final filtered =
posts.where((p) => p.cacheKey != post.cacheKey).take(6).toList();
if (filtered.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.70)
: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: scheme.outlineVariant.withValues(alpha: 0.40),
width: 1.1,
),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.palette_rounded,
size: 18, color: scheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
isRu
? 'Ещё от $artistName'
: 'More by $artistName',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
InkWell(
onTap: () => context.push(
'/search?q=${Uri.encodeComponent(tag)}',
),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isRu ? 'Все' : 'See all',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 3),
Icon(Icons.arrow_forward_ios_rounded,
size: 11, color: scheme.primary),
],
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
height: 110,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final p = filtered[index];
return InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PostDetailsScreen(
providerId: p.providerId,
postId: p.id,
initialPost: p,
),
),
),
borderRadius: BorderRadius.circular(12),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: p.previewUrl.isNotEmpty
? p.previewUrl
: p.sampleUrl,
width: 110,
height: 110,
fit: BoxFit.cover,
memCacheWidth: 220,
memCacheHeight: 220,
errorWidget: (_, __, ___) => Container(
width: 110,
height: 110,
color: scheme.surfaceContainerHigh,
child: Icon(Icons.broken_image_rounded,
color: scheme.onSurfaceVariant),
),
),
),
);
},
),
),
],
),
),
);
},
);
}
}
class _CommentsSection extends ConsumerStatefulWidget {
const _CommentsSection({required this.post});
@@ -280,7 +280,36 @@ class _E621PoolSheetState extends State<E621PoolSheet> {
),
],
const SizedBox(height: 18),
const SizedBox(height: 16),
// Big "Read Full Comic" Button
FilledButton.icon(
onPressed: () {
HapticFeedback.mediumImpact();
Navigator.of(context).pop();
final startId = pool.postIds.contains(widget.currentPost.id)
? widget.currentPost.id
: pool.postIds.first;
widget.onSelectPostId(startId);
},
icon: const Icon(Icons.auto_stories_rounded, size: 18),
label: Text(
isRu
? 'Читать весь комикс (${pool.postCount} стр.)'
: 'Read Full Comic (${pool.postCount} pages)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF0055AA),
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
const SizedBox(height: 16),
// Quick Page Navigator Banner
Container(
@@ -9,12 +9,15 @@ class PostPoolsCard extends StatelessWidget {
const PostPoolsCard({
required this.post,
required this.provider,
required this.onOpenComic,
required this.onOpenPostId,
super.key,
});
final Post post;
final E621Provider provider;
final void Function(String poolId, Post currentPost, [String? targetId])
onOpenComic;
final ValueChanged<String> onOpenPostId;
@override
@@ -83,62 +86,83 @@ class PostPoolsCard extends StatelessWidget {
runSpacing: 8,
children: [
for (final poolId in pools)
Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
E621PoolSheet.show(
context,
currentPost: post,
poolId: poolId,
provider: provider,
onSelectPostId: onOpenPostId,
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onOpenComic(poolId, post);
},
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color:
const Color(0xFF0055AA).withValues(alpha: 0.25),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_stories_rounded,
size: 15, color: Colors.white),
const SizedBox(width: 6),
Text(
isRu
? 'Открыть комикс (Пул #$poolId)'
: 'Open Pool #$poolId',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 9),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: const Color(0xFF0055AA)
.withValues(alpha: 0.25),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward_ios_rounded,
size: 10, color: Colors.white),
],
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_stories_rounded,
size: 16, color: Colors.white),
const SizedBox(width: 7),
Text(
isRu
? 'Читать весь комикс (#$poolId)'
: 'Read Full Comic (#$poolId)',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(width: 5),
const Icon(Icons.arrow_forward_rounded,
size: 13, color: Colors.white),
],
),
),
),
),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: isRu ? 'Все страницы' : 'All pages',
style: IconButton.styleFrom(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () {
HapticFeedback.lightImpact();
E621PoolSheet.show(
context,
currentPost: post,
poolId: poolId,
provider: provider,
onSelectPostId: (targetId) =>
onOpenComic(poolId, post, targetId),
);
},
icon: const Icon(Icons.grid_view_rounded, size: 16),
),
],
),
],
),
+1 -1
View File
@@ -580,7 +580,7 @@ class _DesktopShellState extends State<_DesktopShell>
),
),
child: Text(
'v3.7.1',
'v3.7.2',
style: TextStyle(
fontSize: 10,
fontWeight:
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 3.7.1+89
version: 3.7.3+91
environment:
sdk: ">=3.3.0 <4.0.0"