From cd1a9ccc0ca41cf625836461cd7d71a9ae37e34b Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 5 Sep 2026 20:19:27 +0300 Subject: [PATCH] feat: add dedicated e621 tab in Favorites and fix comic post-not-found parsing (v3.7.1) --- lib/app/changelog.dart | 8 + lib/backend/mappers/e621_mapper.dart | 15 +- lib/backend/providers/e621_provider.dart | 12 +- .../presentation/favorites_screen.dart | 18 +- .../widgets/e621_favorites_view.dart | 464 ++++++++++++++++++ lib/shared/widgets/app_shell.dart | 2 +- pubspec.yaml | 2 +- 7 files changed, 515 insertions(+), 6 deletions(-) create mode 100644 lib/features/favorites/presentation/widgets/e621_favorites_view.dart diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 818f592..2442fb1 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,12 @@ const prismaChangelog = [ + PrismaChange( + version: '3.7.1', + title: 'Вкладка «e621» в Избранном и исправление листания комиксов', + bullets: [ + 'Отдельная вкладка «e621» в разделе «Избранное»: прямое отображение серверного избранного привязанного аккаунта e621 (своего или друга), плавная подгрузка при скролле и кнопка импорта в локальные закладки.', + 'Исправление ошибки «Post not found» в комиксах: исправлен парсер ответов e621 для одиночных постов (/posts/.json), теперь перелистывание страниц серий и переход по вариациям работают стабильно.', + ], + ), PrismaChange( version: '3.7.0', title: 'Большое обновление e621: авторизация по API-ключу, комиксы/пулы, вариации постов и синхронизация', diff --git a/lib/backend/mappers/e621_mapper.dart b/lib/backend/mappers/e621_mapper.dart index 096e2b1..35e900b 100644 --- a/lib/backend/mappers/e621_mapper.dart +++ b/lib/backend/mappers/e621_mapper.dart @@ -6,8 +6,19 @@ class E621Mapper { required String providerId, required String providerName, }) { - final posts = data is Map ? data['posts'] : data; - final items = posts is List ? posts : const []; + if (data is Map && data['post'] is Map) { + return [ + postFromJson( + Map.from(data['post'] as Map), + providerId: providerId, + providerName: providerName, + ), + ]; + } + final posts = data is Map ? (data['posts'] ?? data['post']) : data; + final items = posts is List + ? posts + : (posts is Map ? [posts] : const []); return items .whereType() .map((item) => postFromJson( diff --git a/lib/backend/providers/e621_provider.dart b/lib/backend/providers/e621_provider.dart index 21c67de..6650c2e 100644 --- a/lib/backend/providers/e621_provider.dart +++ b/lib/backend/providers/e621_provider.dart @@ -48,9 +48,19 @@ class E621Provider bool get isAuthorized => _queryParameters.containsKey('login') || + _queryParameters.containsKey('query.login') || _dio.options.headers.containsKey('Authorization'); - String? get login => _queryParameters['login']; + String? get login => + _queryParameters['login'] ?? _queryParameters['query.login']; + + Future> getFavorites({int page = 1, int limit = 50}) async { + final currentLogin = login?.trim(); + final targetTag = (currentLogin != null && currentLogin.isNotEmpty) + ? 'fav:$currentLogin' + : 'fav:me'; + return searchPosts(tags: [targetTag], page: page - 1, limit: limit); + } @override String postPageUrl(Post post) => '$baseUrl/posts/${post.id}'; diff --git a/lib/features/favorites/presentation/favorites_screen.dart b/lib/features/favorites/presentation/favorites_screen.dart index b5999ee..a7c1e14 100644 --- a/lib/features/favorites/presentation/favorites_screen.dart +++ b/lib/features/favorites/presentation/favorites_screen.dart @@ -15,6 +15,7 @@ import '../../../shared/widgets/empty_view.dart'; import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/post_masonry_grid.dart'; import 'favorites_controller.dart'; +import 'widgets/e621_favorites_view.dart'; enum FavoriteMediaType { all, video, image } enum OfflineSortOption { dateAddedDesc, sizeDesc, artist } @@ -315,6 +316,17 @@ class _FavoritesScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), ), + const ButtonSegment( + value: 3, + icon: Icon(Icons.hub_rounded, + size: 18, color: Color(0xFF0077EE)), + label: Text( + 'e621', + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), ], selected: {_tabIndex}, onSelectionChanged: (value) { @@ -333,7 +345,7 @@ class _FavoritesScreenState extends ConsumerState { ), // Media Type Filter Chips (for Tab 0 and Tab 1) - if (_tabIndex != 2) + if (_tabIndex != 2 && _tabIndex != 3) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( @@ -399,6 +411,10 @@ class _FavoritesScreenState extends ConsumerState { downloaded.keys.toSet(), isRu, ), + 3 => E621FavoritesView( + settings: settings, + isRu: isRu, + ), _ => _buildAllView( allPosts, settings, diff --git a/lib/features/favorites/presentation/widgets/e621_favorites_view.dart b/lib/features/favorites/presentation/widgets/e621_favorites_view.dart new file mode 100644 index 0000000..2209893 --- /dev/null +++ b/lib/features/favorites/presentation/widgets/e621_favorites_view.dart @@ -0,0 +1,464 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../app/app_navigator.dart'; +import '../../../../app/responsive.dart'; +import '../../../../backend/backend.dart'; +import '../../../../backend/providers/e621_provider.dart'; +import '../../../../shared/widgets/empty_view.dart'; +import '../../../../shared/widgets/post_masonry_grid.dart'; +import '../../../providers/presentation/providers_controller.dart'; +import '../../../providers/presentation/widgets/e621_auth_dialog.dart'; +import '../favorites_controller.dart'; + +class E621FavoritesView extends ConsumerStatefulWidget { + const E621FavoritesView({ + required this.settings, + required this.isRu, + super.key, + }); + + final AppSettings settings; + final bool isRu; + + @override + ConsumerState createState() => _E621FavoritesViewState(); +} + +class _E621FavoritesViewState extends ConsumerState { + final List _posts = []; + final ScrollController _scrollController = ScrollController(); + int _page = 1; + bool _isLoading = false; + bool _hasMore = true; + String? _error; + String? _currentLogin; + bool _isImporting = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _loadInitial(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 400 && + !_isLoading && + _hasMore) { + _loadNextPage(); + } + } + + Future _getE621Provider() async { + final instance = await ref + .read(providerManagerProvider) + .getProviderInstance('e621'); + return instance is E621Provider ? instance : null; + } + + Future _loadInitial() async { + setState(() { + _isLoading = true; + _error = null; + _posts.clear(); + _page = 1; + _hasMore = true; + }); + + try { + final provider = await _getE621Provider(); + if (provider == null) { + setState(() { + _isLoading = false; + _error = widget.isRu + ? 'Источник e621 не найден' + : 'e621 provider not found'; + }); + return; + } + + final login = provider.login?.trim(); + setState(() => _currentLogin = login); + + if (login == null || login.isEmpty) { + setState(() => _isLoading = false); + return; + } + + final items = await provider.getFavorites(page: 1, limit: 50); + if (mounted) { + setState(() { + _posts.addAll(items); + _isLoading = false; + _hasMore = items.length >= 25; + _page = 2; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = widget.isRu ? 'Ошибка загрузки: $e' : 'Loading error: $e'; + _isLoading = false; + }); + } + } + } + + Future _loadNextPage() async { + if (_isLoading || !_hasMore) return; + setState(() => _isLoading = true); + + try { + final provider = await _getE621Provider(); + if (provider == null) { + setState(() => _isLoading = false); + return; + } + + final items = await provider.getFavorites(page: _page, limit: 50); + if (mounted) { + setState(() { + final existingIds = _posts.map((p) => p.id).toSet(); + final newItems = + items.where((p) => !existingIds.contains(p.id)).toList(); + _posts.addAll(newItems); + _isLoading = false; + _hasMore = items.length >= 25; + _page++; + }); + } + } catch (_) { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _importAllToLocal() async { + if (_posts.isEmpty || _isImporting) return; + setState(() => _isImporting = true); + HapticFeedback.mediumImpact(); + + final favService = ref.read(favoriteServiceProvider); + int imported = 0; + for (final post in _posts) { + await favService.addFavorite(post); + imported++; + } + + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); + + if (mounted) { + setState(() => _isImporting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + widget.isRu + ? 'Импортировано $imported постов в локальное избранное!' + : 'Imported $imported posts to local favorites!', + ), + duration: const Duration(seconds: 3), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final downloaded = ref.watch( + downloadedMediaByKeysProvider( + _posts.map((post) => post.cacheKey).toList(growable: false), + ), + ).value ?? + const {}; + + final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; + + // If no login is set + if (_currentLogin == null || _currentLogin!.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0055AA), Color(0xFF0088FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0055AA).withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.hub_rounded, + size: 34, + color: Colors.white, + ), + ), + const SizedBox(height: 18), + Text( + widget.isRu ? 'Аккаунт e621 не указан' : 'e621 account not set', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Text( + widget.isRu + ? 'Чтобы смотреть избранное (своё или друга) прямо здесь, укажите логин и API-ключ в настройках источника e621.' + : 'To browse favorites directly here, provide your e621 login and API key.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 22), + FilledButton.icon( + onPressed: () async { + final configs = + ref.read(providersControllerProvider).value ?? []; + final e621Config = configs.firstWhere( + (p) => p.apiType.toLowerCase() == 'e621', + orElse: () => configs.first, + ); + await E621AuthDialog.show( + context, + config: e621Config, + onSaved: (updated) { + ref + .read(providersControllerProvider.notifier) + .save(updated); + _loadInitial(); + }, + ); + }, + icon: const Icon(Icons.vpn_key_rounded, size: 18), + label: Text( + widget.isRu + ? 'Войти / Настроить API-ключ e621' + : 'Set e621 API Key', + ), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF0055AA), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadInitial, + child: Column( + children: [ + // Header Banner + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF131A26).withValues(alpha: 0.85) + : Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isDark + ? const Color(0xFF0055AA).withValues(alpha: 0.35) + : const Color(0xFF0055AA).withValues(alpha: 0.20), + ), + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0055AA), Color(0xFF0077EE)], + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.hub_rounded, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Избранное: @$_currentLogin', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + widget.isRu + ? '${_posts.length} постов на сервере e621' + : '${_posts.length} posts on e621', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (_posts.isNotEmpty) + IconButton.filledTonal( + tooltip: widget.isRu + ? 'Импортировать в локальное избранное' + : 'Import to local favorites', + onPressed: _isImporting ? null : _importAllToLocal, + icon: _isImporting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Icon(Icons.download_rounded, size: 18), + ), + const SizedBox(width: 4), + IconButton.filledTonal( + tooltip: widget.isRu ? 'Обновить' : 'Refresh', + onPressed: _isLoading ? null : _loadInitial, + icon: const Icon(Icons.refresh_rounded, size: 18), + ), + ], + ), + ), + ), + ), + ), + + // Content + Expanded( + child: _isLoading && _posts.isEmpty + ? const Center(child: CircularProgressIndicator()) + : _error != null && _posts.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, + size: 40, color: Color(0xFFEF4444)), + const SizedBox(height: 12), + Text(_error!), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: _loadInitial, + child: Text(widget.isRu ? 'Повторить' : 'Retry'), + ), + ], + ), + ) + : _posts.isEmpty + ? Center( + child: EmptyView( + title: widget.isRu + ? 'У пользователя нет избранных постов' + : 'No favorite posts for this user', + ), + ) + : ListView( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + PostMasonryGrid( + posts: _posts, + columns: Responsive.columnsFor( + context, + mobileColumns: widget.settings.mobileColumns, + desktopColumns: + widget.settings.desktopColumns, + ), + blurExplicit: + widget.settings.blurExplicitContent, + showBadges: widget.settings.showPostBadges, + nsfwEnabled: widget.settings.nsfwEnabled, + mediaQualityMode: MediaQualityMode.fromName( + widget.settings.mediaQualityMode, + ), + favoriteKeys: favoriteKeys, + downloadedKeys: downloaded.keys.toSet(), + onOpen: (post) => AppNavigator.openPost( + context, + post: post, + postsList: _posts, + ), + onFavorite: (post) async { + final isFav = + favoriteKeys.contains(post.cacheKey); + final favService = + ref.read(favoriteServiceProvider); + if (isFav) { + await favService.removeFavorite( + post.id, post.providerId); + } else { + await favService.addFavorite(post); + } + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); + }, + ), + if (_isLoading) + const Padding( + padding: EdgeInsets.all(20), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 1b8a47d..6574776 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.0', + 'v3.7.1', style: TextStyle( fontSize: 10, fontWeight: diff --git a/pubspec.yaml b/pubspec.yaml index 5d6b67e..c89ba31 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.0+88 +version: 3.7.1+89 environment: sdk: ">=3.3.0 <4.0.0"