diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 2442fb1..f7854da 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,13 @@ const prismaChangelog = [ + PrismaChange( + version: '3.7.2', + title: 'Режим непрерывного чтения комиксов и серий e621', + bullets: [ + 'Мгновенное открытие комикса целиком: по нажатию «Читать весь комикс» загружаются все страницы серии в оригинальном порядке и открываются в полноэкранном ридере со свайпом (влево-вправо на мобильных, стрелками и кнопками на ПК).', + 'Плавающий бейдж страниц: во время чтения комикса отображается аккуратный стеклянный индикатор номера страницы («Комикс • Стр. X из Y»).', + 'Быстрое оглавление: отдельная кнопка сетки страниц для перехода на любую конкретную главу или страницу без потери последовательности чтения.', + ], + ), PrismaChange( version: '3.7.1', title: 'Вкладка «e621» в Избранном и исправление листания комиксов', diff --git a/lib/backend/providers/e621_provider.dart b/lib/backend/providers/e621_provider.dart index 6650c2e..04c13f1 100644 --- a/lib/backend/providers/e621_provider.dart +++ b/lib/backend/providers/e621_provider.dart @@ -123,6 +123,52 @@ class E621Provider return null; } + Future> getPoolPosts(String poolId) async { + final pool = await getPool(poolId); + if (pool == null || pool.postIds.isEmpty) return const []; + + final fetchedMap = {}; + int page = 1; + while (true) { + await _throttle(); + try { + final response = await _dio.get( + '/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 = []; + for (final id in pool.postIds) { + if (fetchedMap.containsKey(id)) { + ordered.add(fetchedMap[id]!); + } + } + return ordered.isNotEmpty ? ordered : fetchedMap.values.toList(); + } + Future votePost(String postId, int score) async { await _throttle(); try { diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index fa8b255..7bc79a9 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -390,6 +390,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), ), @@ -648,6 +651,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 +890,64 @@ class PostDetailsScreen extends ConsumerWidget { ref.invalidate(favoritesControllerProvider); } + Future _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( + 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 +1116,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 +1131,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 +1152,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 +1168,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> { : const PageScrollPhysics(), itemCount: widget.posts.length, onPageChanged: (index) { + setState(() => _currentPage = index); _prefetchAround(index); }, itemBuilder: (context, index) { @@ -1135,7 +1201,64 @@ class _MobilePostPagerState extends State<_MobilePostPager> { }, ), ); -} + + if (widget.posts.length <= 1) return pager; + + final isComic = widget.posts.first.hasPools; + 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: [ + if (isComic) ...[ + const Icon(Icons.auto_stories_rounded, + size: 14, color: Colors.white), + const SizedBox(width: 6), + ], + Text( + isComic + ? 'Комикс • Стр. ${_currentPage + 1} из ${widget.posts.length}' + : '${_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; diff --git a/lib/features/post/presentation/widgets/e621_pool_sheet.dart b/lib/features/post/presentation/widgets/e621_pool_sheet.dart index 7138efb..c02bcca 100644 --- a/lib/features/post/presentation/widgets/e621_pool_sheet.dart +++ b/lib/features/post/presentation/widgets/e621_pool_sheet.dart @@ -280,7 +280,36 @@ class _E621PoolSheetState extends State { ), ], - 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( diff --git a/lib/features/post/presentation/widgets/post_pools_card.dart b/lib/features/post/presentation/widgets/post_pools_card.dart index 987e3bb..dedc8c0 100644 --- a/lib/features/post/presentation/widgets/post_pools_card.dart +++ b/lib/features/post/presentation/widgets/post_pools_card.dart @@ -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 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), + ), + ], ), ], ), diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 6574776..e6dce2b 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -580,7 +580,7 @@ class _DesktopShellState extends State<_DesktopShell> ), ), child: Text( - 'v3.7.1', + 'v3.7.2', style: TextStyle( fontSize: 10, fontWeight: diff --git a/pubspec.yaml b/pubspec.yaml index c89ba31..9aaa88d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.2+90 environment: sdk: ">=3.3.0 <4.0.0"