diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 7f2a7ab..818f592 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,15 @@ const prismaChangelog = [ + PrismaChange( + version: '3.7.0', + title: 'Большое обновление e621: авторизация по API-ключу, комиксы/пулы, вариации постов и синхронизация', + bullets: [ + 'Окно авторизации e621 (Username + API Key): красивое модальное окно прямо в карточке источника e621 с быстрой проверкой ключа, снятием жесткого лимита в 2 тега при поиске и персональным User-Agent.', + 'Комиксы и Пулы (Pools): удобная модальная панель для серий и комиксов с индикатором страниц, навигацией «Предыдущая/Следующая» и сеткой всех страниц серии для быстрого перехода.', + 'Связанные посты и вариации (Parent/Child relations): карточка версий с быстрым переходом к родительскому арту и альтернативным вариациям (позы, одежда, скетчи).', + 'Серверное голосование и синхронизация Избранного: возможность ставить лайки/дизлайки с отправкой на e621 и автоматическая синхронизация избранного при наличии API-ключа.', + 'Защита Rate Limiting: интеллектуальное ограничение частоты запросов (до 2 rps), предотвращающее ошибки 503 и блокировки по IP.', + ], + ), PrismaChange( version: '3.6.5', title: 'Исправление наложения превью на видеоплеер', diff --git a/lib/backend/mappers/e621_mapper.dart b/lib/backend/mappers/e621_mapper.dart index fe26c4b..096e2b1 100644 --- a/lib/backend/mappers/e621_mapper.dart +++ b/lib/backend/mappers/e621_mapper.dart @@ -36,6 +36,38 @@ class E621Mapper { for (final entry in tagsMap.entries) entry.key: List.from((entry.value as List?) ?? const []), }..removeWhere((_, value) => value.isEmpty); + + final relationships = Map.from((json['relationships'] as Map?) ?? const {}); + final parentId = relationships['parent_id']?.toString(); + if (parentId != null && parentId.isNotEmpty && parentId != 'null') { + tagGroups['parent_id'] = [parentId]; + } + final children = (relationships['children'] as List?) + ?.map((e) => e?.toString() ?? '') + .where((s) => s.isNotEmpty && s != 'null') + .toList(); + if (children != null && children.isNotEmpty) { + tagGroups['children'] = children; + } + + final rawPools = (json['pools'] as List?) + ?.map((e) => e?.toString() ?? '') + .where((s) => s.isNotEmpty) + .toList(); + if (rawPools != null && rawPools.isNotEmpty) { + tagGroups['pools'] = rawPools; + } + + final favCount = json['fav_count']; + if (favCount != null) { + tagGroups['fav_count'] = [favCount.toString()]; + } + + final desc = _string(json['description']); + if (desc.isNotEmpty) { + tagGroups['description'] = [desc]; + } + final tags = tagGroups.values.expand((items) => items).toSet().toList(); final fileUrl = _string(file['url']); return Post( diff --git a/lib/backend/models/e621_pool.dart b/lib/backend/models/e621_pool.dart new file mode 100644 index 0000000..f454b03 --- /dev/null +++ b/lib/backend/models/e621_pool.dart @@ -0,0 +1,58 @@ +class E621Pool { + const E621Pool({ + required this.id, + required this.name, + required this.description, + required this.postIds, + required this.postCount, + required this.category, + required this.isActive, + this.creatorName, + }); + + final int id; + final String name; + final String description; + final List postIds; + final int postCount; + final String category; // 'series' or 'collection' + final bool isActive; + final String? creatorName; + + String get cleanTitle => name.replaceAll('_', ' '); + + int pageOf(String postId) { + final idx = postIds.indexOf(postId); + return idx >= 0 ? idx + 1 : 0; + } + + String? nextPostId(String postId) { + final idx = postIds.indexOf(postId); + if (idx >= 0 && idx < postIds.length - 1) { + return postIds[idx + 1]; + } + return null; + } + + String? previousPostId(String postId) { + final idx = postIds.indexOf(postId); + if (idx > 0) { + return postIds[idx - 1]; + } + return null; + } + + factory E621Pool.fromJson(Map json) { + final rawPostIds = (json['post_ids'] as List?) ?? const []; + return E621Pool( + id: (json['id'] as num?)?.toInt() ?? 0, + name: (json['name'] ?? '').toString(), + description: (json['description'] ?? '').toString(), + postIds: rawPostIds.map((e) => e.toString()).toList(), + postCount: (json['post_count'] as num?)?.toInt() ?? rawPostIds.length, + category: (json['category'] ?? 'series').toString(), + isActive: (json['is_active'] as bool?) ?? true, + creatorName: json['creator_name']?.toString(), + ); + } +} diff --git a/lib/backend/models/post.dart b/lib/backend/models/post.dart index 930a35e..9a43788 100644 --- a/lib/backend/models/post.dart +++ b/lib/backend/models/post.dart @@ -62,6 +62,27 @@ class Post { String? get description => commentary; + String? get parentId { + final list = tagGroups['parent_id']; + return (list != null && list.isNotEmpty && list.first.trim().isNotEmpty) + ? list.first.trim() + : null; + } + + List get childrenIds => tagGroups['children'] ?? const []; + + List get poolIds => tagGroups['pools'] ?? const []; + + bool get hasRelations => + (parentId != null && parentId!.isNotEmpty) || childrenIds.isNotEmpty; + + bool get hasPools => poolIds.isNotEmpty; + + int? get favCount { + final raw = tagGroups['fav_count']?.firstOrNull; + return raw != null ? int.tryParse(raw) : null; + } + String? get title { final list = tagGroups['copyright']; if (list != null && list.isNotEmpty) { diff --git a/lib/backend/providers/e621_provider.dart b/lib/backend/providers/e621_provider.dart index bd84064..21c67de 100644 --- a/lib/backend/providers/e621_provider.dart +++ b/lib/backend/providers/e621_provider.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import '../../core/http/dio_client.dart'; import '../mappers/e621_mapper.dart'; +import '../models/e621_pool.dart'; import '../models/post.dart'; import '../models/post_comment.dart'; import '../models/provider_health.dart'; @@ -33,6 +34,24 @@ class E621Provider final Dio _dio; final Map _queryParameters; + static DateTime _lastRequestTime = DateTime.fromMillisecondsSinceEpoch(0); + + static Future _throttle() async { + final now = DateTime.now(); + final diff = now.difference(_lastRequestTime); + if (diff.inMilliseconds < 500) { + final waitMs = 500 - diff.inMilliseconds; + await Future.delayed(Duration(milliseconds: waitMs)); + } + _lastRequestTime = DateTime.now(); + } + + bool get isAuthorized => + _queryParameters.containsKey('login') || + _dio.options.headers.containsKey('Authorization'); + + String? get login => _queryParameters['login']; + @override String postPageUrl(Post post) => '$baseUrl/posts/${post.id}'; @@ -44,6 +63,7 @@ class E621Provider String? rating, TopPeriodFilter topPeriod = TopPeriodFilter.none, }) async { + await _throttle(); final response = await _dio.get( '/posts.json', queryParameters: { @@ -66,6 +86,7 @@ class E621Provider @override Future getPost(String id) async { + await _throttle(); final response = await _dio.get( '/posts/$id.json', queryParameters: _queryParameters, @@ -78,6 +99,63 @@ class E621Provider return posts.isEmpty ? null : posts.first; } + Future getPool(String poolId) async { + await _throttle(); + try { + final response = await _dio.get( + '/pools/$poolId.json', + queryParameters: _queryParameters, + ); + if (response.data is Map) { + return E621Pool.fromJson(Map.from(response.data as Map)); + } + } catch (_) {} + return null; + } + + Future votePost(String postId, int score) async { + await _throttle(); + try { + final response = await _dio.post( + '/posts/$postId/votes.json', + data: {'score': score, 'no_unvote': 'true'}, + queryParameters: _queryParameters, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + return response.statusCode == 200 || response.statusCode == 201; + } catch (_) { + return false; + } + } + + Future addFavorite(String postId) async { + await _throttle(); + try { + final response = await _dio.post( + '/favorites.json', + data: {'post_id': postId}, + queryParameters: _queryParameters, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + return response.statusCode == 200 || response.statusCode == 201; + } catch (_) { + return false; + } + } + + Future removeFavorite(String postId) async { + await _throttle(); + try { + final response = await _dio.delete( + '/favorites/$postId.json', + queryParameters: _queryParameters, + ); + return response.statusCode == 200 || response.statusCode == 204; + } catch (_) { + return false; + } + } + @override Future checkHealth() async { final startedAt = DateTime.now(); @@ -109,6 +187,7 @@ class E621Provider Future> suggestTags(String query, {int limit = 20}) async { if (query.trim().isEmpty) return const []; + await _throttle(); final response = await _dio.get( '/tags.json', queryParameters: { @@ -136,6 +215,7 @@ class E621Provider @override Future> getComments(String postId) async { + await _throttle(); final response = await _dio.get( '/comments.json', queryParameters: { diff --git a/lib/backend/providers/provider_factory.dart b/lib/backend/providers/provider_factory.dart index dfb1e5d..0872f5b 100644 --- a/lib/backend/providers/provider_factory.dart +++ b/lib/backend/providers/provider_factory.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import '../../core/http/dio_client.dart'; import '../models/content_provider_config.dart'; import 'content_provider.dart'; @@ -20,6 +22,17 @@ class ProviderFactory { if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty) entry.key.substring('query.'.length): entry.value.trim(), }; + + if (config.apiType.toLowerCase() == 'e621') { + final login = queryParameters['login'] ?? headers['login']; + final apiKey = queryParameters['api_key'] ?? headers['api_key']; + if (login != null && apiKey != null && login.isNotEmpty && apiKey.isNotEmpty) { + final basicAuth = base64Encode(utf8.encode('$login:$apiKey')); + headers['Authorization'] = 'Basic $basicAuth'; + headers['User-Agent'] = 'Prisma/3.6.6 (by $login on e621)'; + } + } + final client = DioClient( baseUrl: config.baseUrl, timeout: Duration(seconds: config.timeoutSeconds), diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index fe0cc86..39351e0 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -144,6 +144,14 @@ class ProviderManager { }); } + Future getProviderInstance(String id) async { + final result = await _repository.getProvider(id); + if (result is Success && result.data != null) { + return _factory.create(result.data!); + } + return null; + } + Future>> checkAll({int concurrency = 3}) async { final providersResult = await activeProviders(); if (providersResult is Error>) { diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 1029885..fa8b255 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -28,7 +29,10 @@ import 'post_details_controller.dart'; import 'widgets/cloud_mirrors_card.dart'; import 'widgets/post_action_bar.dart'; import 'widgets/post_media_viewer.dart'; +import 'widgets/post_pools_card.dart'; +import 'widgets/post_relations_card.dart'; import 'widgets/post_tags_panel.dart'; +import '../../../backend/providers/e621_provider.dart'; final postCommentsProvider = FutureProvider.family, PostDetailsArgs>( @@ -39,6 +43,11 @@ final postCommentsProvider = return result is Success> ? result.data : const []; }); +final postProviderInstanceProvider = + FutureProvider.family((ref, providerId) async { + return ref.watch(providerManagerProvider).getProviderInstance(providerId); +}); + class PostDetailsScreen extends ConsumerWidget { const PostDetailsScreen({ required this.providerId, @@ -123,6 +132,11 @@ class PostDetailsScreen extends ConsumerWidget { (post.commentary != null && post.commentary!.trim().isNotEmpty)); + final providerInstance = + ref.watch(postProviderInstanceProvider(post.providerId)).value; + final e621Provider = + providerInstance is E621Provider ? providerInstance : null; + if (Responsive.isMobile(context)) { if (currentIndex >= 0 && feedPosts.length > 1) { return _MobilePostPager( @@ -361,7 +375,25 @@ class PostDetailsScreen extends ConsumerWidget { strings: strings, localMedia: localMedia, fileSizeBytes: fileSizeBytes, + e621Provider: e621Provider, ), + if (post.hasRelations) ...[ + const SizedBox(height: 16), + PostRelationsCard( + post: post, + onOpenPostId: (targetId) => + _openPostById(context, post.providerId, targetId), + ), + ], + if (post.hasPools && e621Provider != null) ...[ + const SizedBox(height: 16), + PostPoolsCard( + post: post, + provider: e621Provider, + onOpenPostId: (targetId) => + _openPostById(context, post.providerId, targetId), + ), + ], if (shouldShowCloudCard) ...[ const SizedBox(height: 16), CloudMirrorsCard( @@ -470,6 +502,11 @@ class PostDetailsScreen extends ConsumerWidget { (post.commentary != null && post.commentary!.trim().isNotEmpty)); + final providerInstance = + ref.watch(postProviderInstanceProvider(post.providerId)).value; + final e621Provider = + providerInstance is E621Provider ? providerInstance : null; + return GestureDetector( behavior: HitTestBehavior.translucent, onVerticalDragEnd: (details) { @@ -596,7 +633,25 @@ class PostDetailsScreen extends ConsumerWidget { strings: strings, localMedia: localMedia, fileSizeBytes: fileSizeBytes, + e621Provider: e621Provider, ), + if (post.hasRelations) ...[ + const SizedBox(height: 12), + PostRelationsCard( + post: post, + onOpenPostId: (targetId) => + _openPostById(context, post.providerId, targetId), + ), + ], + if (post.hasPools && e621Provider != null) ...[ + const SizedBox(height: 12), + PostPoolsCard( + post: post, + provider: e621Provider, + onOpenPostId: (targetId) => + _openPostById(context, post.providerId, targetId), + ), + ], if (shouldShowCloudCard) ...[ const SizedBox(height: 12), CloudMirrorsCard( @@ -805,18 +860,43 @@ class PostDetailsScreen extends ConsumerWidget { Post post, Set favoriteKeys, ) async { - if (favoriteKeys.contains(post.cacheKey)) { + final isFav = favoriteKeys.contains(post.cacheKey); + if (isFav) { await ref .read(favoriteServiceProvider) .removeFavorite(post.id, post.providerId); + final provider = await ref + .read(providerManagerProvider) + .getProviderInstance(post.providerId); + if (provider is E621Provider && provider.isAuthorized) { + unawaited(provider.removeFavorite(post.id)); + } } else { await ref.read(favoriteServiceProvider).addFavorite(post); await _maybeAutoDownloadFavorite(ref, post); + final provider = await ref + .read(providerManagerProvider) + .getProviderInstance(post.providerId); + if (provider is E621Provider && provider.isAuthorized) { + unawaited(provider.addFavorite(post.id)); + } } ref.invalidate(favoriteKeysProvider); ref.invalidate(favoritesControllerProvider); } + void _openPostById( + BuildContext context, String providerId, String targetId) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => PostDetailsScreen( + providerId: providerId, + postId: targetId, + ), + ), + ); + } + Future _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async { final settings = ref.read(appSettingsProvider).value ?? AppSettings.defaults; @@ -1340,12 +1420,14 @@ class _PostInfoCard extends StatefulWidget { required this.strings, this.localMedia, this.fileSizeBytes, + this.e621Provider, }); final Post post; final AppStrings strings; final DownloadedMedia? localMedia; final int? fileSizeBytes; + final E621Provider? e621Provider; @override State<_PostInfoCard> createState() => _PostInfoCardState(); @@ -1356,6 +1438,8 @@ class _PostInfoCardState extends State<_PostInfoCard> { int? _resolvedWidth; int? _resolvedHeight; bool _resolvingDimensions = false; + int? _votedScore; + bool _isVoting = false; static final _dimensionCache = {}; @@ -1364,6 +1448,43 @@ class _PostInfoCardState extends State<_PostInfoCard> { return imageTypes.contains(fileType.toLowerCase()); } + Future _handleVote(int score) async { + final provider = widget.e621Provider; + if (provider == null) return; + if (!provider.isAuthorized) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Для голосования на e621 настройте API-ключ в Источниках'), + duration: Duration(seconds: 3), + ), + ); + return; + } + if (_isVoting) return; + setState(() => _isVoting = true); + final success = await provider.votePost(widget.post.id, score); + if (mounted) { + setState(() { + _isVoting = false; + if (success) { + _votedScore = (_votedScore == score) ? 0 : score; + } + }); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar( + content: Text( + success + ? (score > 0 + ? 'Голос ЗА (+1) отправлен на e621!' + : 'Голос ПРОТИВ (-1) отправлен на e621!') + : 'Не удалось отправить голос', + ), + duration: const Duration(seconds: 2), + ), + ); + } + } + @override void initState() { super.initState(); @@ -1591,11 +1712,59 @@ class _PostInfoCardState extends State<_PostInfoCard> { label: post.fileType.toUpperCase(), ), RatingBadge(rating: post.rating), - if (post.score != 0) + if (post.score != 0 || widget.e621Provider != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _SpecBadge( + icon: Icons.star_rounded, + label: '${post.score + (_votedScore ?? 0)}', + iconColor: Colors.amber, + ), + if (widget.e621Provider != null) ...[ + const SizedBox(width: 4), + IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + icon: Icon( + _votedScore == 1 + ? Icons.thumb_up_rounded + : Icons.thumb_up_alt_outlined, + size: 15, + color: _votedScore == 1 + ? const Color(0xFF10B981) + : scheme.onSurfaceVariant, + ), + onPressed: _isVoting ? null : () => _handleVote(1), + tooltip: 'Голос ЗА (+1)', + ), + IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + icon: Icon( + _votedScore == -1 + ? Icons.thumb_down_rounded + : Icons.thumb_down_alt_outlined, + size: 15, + color: _votedScore == -1 + ? const Color(0xFFEF4444) + : scheme.onSurfaceVariant, + ), + onPressed: _isVoting ? null : () => _handleVote(-1), + tooltip: 'Голос ПРОТИВ (-1)', + ), + ], + ], + ), + if (post.favCount != null && post.favCount! > 0) _SpecBadge( - icon: Icons.star_rounded, - label: '${post.score}', - iconColor: Colors.amber, + icon: Icons.favorite_rounded, + label: '${post.favCount}', + iconColor: const Color(0xFFEF4444), ), if (post.source != null && post.source!.isNotEmpty) InkWell( diff --git a/lib/features/post/presentation/widgets/e621_pool_sheet.dart b/lib/features/post/presentation/widgets/e621_pool_sheet.dart new file mode 100644 index 0000000..7138efb --- /dev/null +++ b/lib/features/post/presentation/widgets/e621_pool_sheet.dart @@ -0,0 +1,472 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../backend/models/e621_pool.dart'; +import '../../../../backend/models/post.dart'; +import '../../../../backend/providers/e621_provider.dart'; + +class E621PoolSheet extends StatefulWidget { + const E621PoolSheet({ + required this.currentPost, + required this.poolId, + required this.provider, + required this.onSelectPostId, + super.key, + }); + + final Post currentPost; + final String poolId; + final E621Provider provider; + final ValueChanged onSelectPostId; + + static Future show( + BuildContext context, { + required Post currentPost, + required String poolId, + required E621Provider provider, + required ValueChanged onSelectPostId, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => E621PoolSheet( + currentPost: currentPost, + poolId: poolId, + provider: provider, + onSelectPostId: onSelectPostId, + ), + ); + } + + @override + State createState() => _E621PoolSheetState(); +} + +class _E621PoolSheetState extends State { + E621Pool? _pool; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadPool(); + } + + Future _loadPool() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final pool = await widget.provider.getPool(widget.poolId); + if (mounted) { + setState(() { + _pool = pool; + _isLoading = false; + if (pool == null) { + _error = 'Не удалось загрузить информацию о серии'; + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = 'Ошибка: $e'; + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru'; + final pool = _pool; + + final currentPage = pool?.pageOf(widget.currentPost.id) ?? 0; + final prevId = pool?.previousPostId(widget.currentPost.id); + final nextId = pool?.nextPostId(widget.currentPost.id); + + return DraggableScrollableSheet( + initialChildSize: 0.55, + minChildSize: 0.35, + maxChildSize: 0.88, + builder: (context, scrollController) { + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF161B22).withValues(alpha: 0.92) + : Colors.white.withValues(alpha: 0.94), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(28)), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : Colors.black.withValues(alpha: 0.08), + width: 1.1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 28, + offset: const Offset(0, -6), + ), + ], + ), + child: ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + children: [ + // Drag handle + Center( + child: Container( + width: 44, + height: 5, + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.20), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + const SizedBox(height: 16), + + if (_isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: CircularProgressIndicator(), + ), + ) + else if (_error != null) + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + 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: _loadPool, + child: const Text('Повторить'), + ), + ], + ), + ), + ) + else if (pool != null) ...[ + // Pool header + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0055AA), Color(0xFF0088FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0055AA) + .withValues(alpha: 0.35), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.menu_book_rounded, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pool.cleanTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF0055AA) + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFF0055AA) + .withValues(alpha: 0.30), + width: 0.8, + ), + ), + child: Text( + pool.category == 'series' + ? (isRu ? 'Комикс / Серия' : 'Series') + : (isRu + ? 'Коллекция' + : 'Collection'), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF0077EE), + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${pool.postCount} ${isRu ? 'страниц' : 'pages'}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme + .colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + + if (pool.description.trim().isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + pool.description.trim(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 18), + + // Quick Page Navigator Banner + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? Colors.black.withValues(alpha: 0.25) + : theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + ), + ), + child: Row( + children: [ + IconButton.filledTonal( + tooltip: isRu ? 'Предыдущая' : 'Previous', + onPressed: prevId == null + ? null + : () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + widget.onSelectPostId(prevId); + }, + icon: const Icon(Icons.arrow_back_rounded, size: 18), + ), + Expanded( + child: Column( + children: [ + Text( + currentPage > 0 + ? (isRu + ? 'Страница $currentPage из ${pool.postCount}' + : 'Page $currentPage of ${pool.postCount}') + : (isRu + ? 'Страниц в пуле: ${pool.postCount}' + : '${pool.postCount} pages in pool'), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + if (currentPage > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: LinearProgressIndicator( + value: (currentPage / pool.postCount) + .clamp(0.0, 1.0), + borderRadius: BorderRadius.circular(4), + minHeight: 4, + backgroundColor: isDark + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.08), + ), + ), + ], + ), + ), + IconButton.filledTonal( + tooltip: isRu ? 'Следующая' : 'Next', + onPressed: nextId == null + ? null + : () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + widget.onSelectPostId(nextId); + }, + icon: const Icon(Icons.arrow_forward_rounded, + size: 18), + ), + ], + ), + ), + + const SizedBox(height: 18), + + Text( + isRu ? 'Все страницы серии:' : 'All pages in pool:', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + + // Grid of page buttons + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (int i = 0; i < pool.postIds.length; i++) ...[ + _PageButton( + pageNumber: i + 1, + postId: pool.postIds[i], + isCurrent: pool.postIds[i] == widget.currentPost.id, + onTap: () { + if (pool.postIds[i] == widget.currentPost.id) { + return; + } + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + widget.onSelectPostId(pool.postIds[i]); + }, + ), + ], + ], + ), + ], + ], + ), + ), + ), + ); + }, + ); + } +} + +class _PageButton extends StatelessWidget { + const _PageButton({ + required this.pageNumber, + required this.postId, + required this.isCurrent, + required this.onTap, + }); + + final int pageNumber; + final String postId; + final bool isCurrent; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + constraints: const BoxConstraints(minWidth: 42, minHeight: 40), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: isCurrent + ? const Color(0xFF0055AA) + : (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05)), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isCurrent + ? const Color(0xFF0077EE) + : (isDark + ? Colors.white.withValues(alpha: 0.12) + : Colors.black.withValues(alpha: 0.08)), + width: isCurrent ? 1.4 : 0.8, + ), + boxShadow: isCurrent + ? [ + BoxShadow( + color: const Color(0xFF0055AA).withValues(alpha: 0.40), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: Text( + '$pageNumber', + style: TextStyle( + fontSize: 13, + fontWeight: isCurrent ? FontWeight.bold : FontWeight.w600, + color: isCurrent + ? Colors.white + : theme.colorScheme.onSurface, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/post/presentation/widgets/post_pools_card.dart b/lib/features/post/presentation/widgets/post_pools_card.dart new file mode 100644 index 0000000..987e3bb --- /dev/null +++ b/lib/features/post/presentation/widgets/post_pools_card.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../backend/models/post.dart'; +import '../../../../backend/providers/e621_provider.dart'; +import 'e621_pool_sheet.dart'; + +class PostPoolsCard extends StatelessWidget { + const PostPoolsCard({ + required this.post, + required this.provider, + required this.onOpenPostId, + super.key, + }); + + final Post post; + final E621Provider provider; + final ValueChanged onOpenPostId; + + @override + Widget build(BuildContext context) { + if (!post.hasPools) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru'; + final pools = post.poolIds; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: isDark + ? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.70) + : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : theme.colorScheme.outlineVariant.withValues(alpha: 0.40), + width: 1.1, + ), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.menu_book_rounded, + size: 20, + color: Color(0xFF0077EE), + ), + const SizedBox(width: 8), + Text( + isRu ? 'Входит в комикс / серию' : 'Part of Series / Pool', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF0055AA).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${pools.length}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Color(0xFF0077EE), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + Wrap( + spacing: 8, + 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, + ), + 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, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_forward_ios_rounded, + size: 10, color: Colors.white), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/post/presentation/widgets/post_relations_card.dart b/lib/features/post/presentation/widgets/post_relations_card.dart new file mode 100644 index 0000000..70d0679 --- /dev/null +++ b/lib/features/post/presentation/widgets/post_relations_card.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../backend/models/post.dart'; + +class PostRelationsCard extends StatelessWidget { + const PostRelationsCard({ + required this.post, + required this.onOpenPostId, + super.key, + }); + + final Post post; + final ValueChanged onOpenPostId; + + @override + Widget build(BuildContext context) { + if (!post.hasRelations) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru'; + + final parentId = post.parentId; + final children = post.childrenIds; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: isDark + ? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.70) + : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : theme.colorScheme.outlineVariant.withValues(alpha: 0.40), + width: 1.1, + ), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.copy_all_rounded, + size: 20, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + isRu ? 'Связанные посты и версии' : 'Variations & Relations', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${(parentId != null ? 1 : 0) + children.length}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (parentId != null) + _RelationPill( + icon: Icons.north_west_rounded, + label: isRu ? 'Родительский арт #$parentId' : 'Parent #$parentId', + color: const Color(0xFF0077EE), + onTap: () { + HapticFeedback.lightImpact(); + onOpenPostId(parentId); + }, + ), + for (final childId in children) + _RelationPill( + icon: Icons.style_rounded, + label: isRu ? 'Версия #$childId' : 'Variant #$childId', + color: const Color(0xFF10B981), + onTap: () { + HapticFeedback.lightImpact(); + onOpenPostId(childId); + }, + ), + ], + ), + ], + ), + ); + } +} + +class _RelationPill extends StatelessWidget { + const _RelationPill({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.15 : 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withValues(alpha: isDark ? 0.35 : 0.25), + width: 0.9, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + const SizedBox(width: 4), + Icon(Icons.arrow_forward_ios_rounded, size: 10, color: color), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/providers/presentation/providers_screen.dart b/lib/features/providers/presentation/providers_screen.dart index fbe2acc..e9ed083 100644 --- a/lib/features/providers/presentation/providers_screen.dart +++ b/lib/features/providers/presentation/providers_screen.dart @@ -358,6 +358,9 @@ class ProvidersScreen extends ConsumerWidget { '/providers/new', extra: item, ), + onSaveConfig: (updated) => ref + .read(providersControllerProvider.notifier) + .save(updated), onDelete: () async { final ok = await showConfirmDialog( context, diff --git a/lib/features/providers/presentation/widgets/e621_auth_dialog.dart b/lib/features/providers/presentation/widgets/e621_auth_dialog.dart new file mode 100644 index 0000000..1251b9d --- /dev/null +++ b/lib/features/providers/presentation/widgets/e621_auth_dialog.dart @@ -0,0 +1,490 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../backend/models/content_provider_config.dart'; + +class E621AuthDialog extends StatefulWidget { + const E621AuthDialog({ + required this.config, + required this.onSaved, + super.key, + }); + + final ContentProviderConfig config; + final ValueChanged onSaved; + + static Future show( + BuildContext context, { + required ContentProviderConfig config, + required ValueChanged onSaved, + }) { + return showDialog( + context: context, + barrierColor: Colors.black54, + builder: (context) => E621AuthDialog( + config: config, + onSaved: onSaved, + ), + ); + } + + @override + State createState() => _E621AuthDialogState(); +} + +class _E621AuthDialogState extends State { + late final TextEditingController _loginController; + late final TextEditingController _apiKeyController; + bool _obscureKey = true; + bool _isLoading = false; + String? _statusMessage; + bool _isSuccess = false; + + @override + void initState() { + super.initState(); + final login = widget.config.customHeaders['query.login'] ?? ''; + final apiKey = widget.config.customHeaders['query.api_key'] ?? ''; + _loginController = TextEditingController(text: login); + _apiKeyController = TextEditingController(text: apiKey); + } + + @override + void dispose() { + _loginController.dispose(); + _apiKeyController.dispose(); + super.dispose(); + } + + Future _testAndSave() async { + final login = _loginController.text.trim(); + final apiKey = _apiKeyController.text.trim(); + + if (login.isEmpty || apiKey.isEmpty) { + setState(() { + _statusMessage = 'Укажите логин и API ключ'; + _isSuccess = false; + }); + return; + } + + setState(() { + _isLoading = true; + _statusMessage = null; + }); + + try { + final dio = Dio( + BaseOptions( + baseUrl: widget.config.baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + headers: { + 'Authorization': + 'Basic ${base64Encode(utf8.encode('$login:$apiKey'))}', + 'User-Agent': 'Prisma/3.6.6 (by $login on e621)', + 'Accept': 'application/json', + }, + ), + ); + + final response = await dio.get('/posts.json', queryParameters: { + 'limit': 1, + }); + + if (response.statusCode == 200) { + final updatedHeaders = + Map.from(widget.config.customHeaders) + ..['query.login'] = login + ..['query.api_key'] = apiKey; + + final updatedConfig = widget.config.copyWith( + customHeaders: updatedHeaders, + updatedAt: DateTime.now(), + ); + + widget.onSaved(updatedConfig); + + setState(() { + _isLoading = false; + _isSuccess = true; + _statusMessage = 'Успешно авторизован! Лимит тегов снят.'; + }); + + await Future.delayed(const Duration(milliseconds: 650)); + if (mounted) Navigator.of(context).pop(); + } else { + setState(() { + _isLoading = false; + _isSuccess = false; + _statusMessage = + 'Ошибка проверки (код ${response.statusCode}). Проверьте логин и ключ.'; + }); + } + } on DioException catch (e) { + setState(() { + _isLoading = false; + _isSuccess = false; + if (e.response?.statusCode == 401) { + _statusMessage = 'Неверный логин или API-ключ (401 Unauthorized)'; + } else if (e.response?.statusCode == 403) { + _statusMessage = 'Доступ запрещен (403 Forbidden). Проверьте API access в профиле e621.'; + } else { + _statusMessage = 'Ошибка подключения: ${e.message ?? e.toString()}'; + } + }); + } catch (e) { + setState(() { + _isLoading = false; + _isSuccess = false; + _statusMessage = 'Ошибка: $e'; + }); + } + } + + void _clearAuth() { + final updatedHeaders = + Map.from(widget.config.customHeaders) + ..remove('query.login') + ..remove('query.api_key'); + + final updatedConfig = widget.config.copyWith( + customHeaders: updatedHeaders, + updatedAt: DateTime.now(), + ); + + widget.onSaved(updatedConfig); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final currentLogin = widget.config.customHeaders['query.login']; + final isAlreadyAuthorized = + currentLogin != null && currentLogin.trim().isNotEmpty; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: ClipRRect( + borderRadius: BorderRadius.circular(28), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF1E2430).withValues(alpha: 0.90) + : Colors.white.withValues(alpha: 0.94), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: isDark + ? const Color(0xFF0055AA).withValues(alpha: 0.40) + : const Color(0xFF0055AA).withValues(alpha: 0.20), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0055AA).withValues(alpha: 0.15), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Title row + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0055AA), Color(0xFF0077EE)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0055AA).withValues(alpha: 0.35), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.vpn_key_rounded, + color: Colors.white, + size: 22, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Авторизация e621', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -0.2, + ), + ), + Text( + isAlreadyAuthorized + ? 'Вход выполнен как $currentLogin' + : 'Снятие ограничений поиска и функций', + style: theme.textTheme.bodySmall?.copyWith( + color: isAlreadyAuthorized + ? const Color(0xFF10B981) + : theme.colorScheme.onSurfaceVariant, + fontWeight: isAlreadyAuthorized + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.of(context).pop(), + visualDensity: VisualDensity.compact, + ), + ], + ), + + const SizedBox(height: 18), + + // Benefits badge + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0055AA).withValues(alpha: isDark ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFF0055AA).withValues(alpha: 0.25), + ), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _BenefitRow( + icon: Icons.check_circle_outline_rounded, + text: 'Снятие лимита в 2 тега (поиск по 4–6+ тегам)', + ), + SizedBox(height: 6), + _BenefitRow( + icon: Icons.favorite_border_rounded, + text: 'Серверная синхронизация Избранного (Favorites)', + ), + SizedBox(height: 6), + _BenefitRow( + icon: Icons.thumb_up_alt_outlined, + text: 'Голосование за посты (Upvote / Downvote)', + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Username field + TextField( + controller: _loginController, + decoration: InputDecoration( + labelText: 'Имя пользователя (Username на e621)', + prefixIcon: const Icon(Icons.person_outline_rounded), + filled: true, + fillColor: isDark + ? Colors.black.withValues(alpha: 0.25) + : Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + ), + ), + + const SizedBox(height: 12), + + // API Key field + TextField( + controller: _apiKeyController, + obscureText: _obscureKey, + decoration: InputDecoration( + labelText: 'API Ключ (API Key)', + prefixIcon: const Icon(Icons.key_rounded), + suffixIcon: IconButton( + icon: Icon( + _obscureKey + ? Icons.visibility_off_rounded + : Icons.visibility_rounded, + ), + onPressed: () => + setState(() => _obscureKey = !_obscureKey), + ), + filled: true, + fillColor: isDark + ? Colors.black.withValues(alpha: 0.25) + : Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + ), + ), + + const SizedBox(height: 10), + + // How to get API key link + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () { + launchUrl( + Uri.parse('https://e621.net/users/home'), + mode: LaunchMode.externalApplication, + ); + }, + icon: const Icon(Icons.open_in_new_rounded, size: 14), + label: const Text( + 'Как получить ключ (e621 -> Manage API Access)', + style: TextStyle(fontSize: 12), + ), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + ), + + if (_statusMessage != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _isSuccess + ? const Color(0xFF10B981).withValues(alpha: 0.15) + : const Color(0xFFEF4444).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isSuccess + ? const Color(0xFF10B981).withValues(alpha: 0.40) + : const Color(0xFFEF4444).withValues(alpha: 0.40), + ), + ), + child: Row( + children: [ + Icon( + _isSuccess + ? Icons.check_circle_rounded + : Icons.error_outline_rounded, + size: 16, + color: _isSuccess + ? const Color(0xFF10B981) + : const Color(0xFFEF4444), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage!, + style: TextStyle( + fontSize: 12, + color: _isSuccess + ? const Color(0xFF10B981) + : const Color(0xFFEF4444), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 20), + + // Action buttons + Row( + children: [ + if (isAlreadyAuthorized) ...[ + TextButton.icon( + onPressed: _isLoading ? null : _clearAuth, + icon: const Icon(Icons.logout_rounded, + size: 16, color: Color(0xFFEF4444)), + label: const Text('Выйти', + style: TextStyle(color: Color(0xFFEF4444))), + ), + const Spacer(), + ] else + const Spacer(), + FilledButton.icon( + onPressed: _isLoading ? null : _testAndSave, + icon: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.check_rounded, size: 18), + label: Text(_isLoading ? 'Проверка...' : 'Сохранить'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF0055AA), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 12), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _BenefitRow extends StatelessWidget { + const _BenefitRow({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 14, color: const Color(0xFF0077EE)), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + ], + ); + } +} diff --git a/lib/features/providers/presentation/widgets/provider_card.dart b/lib/features/providers/presentation/widgets/provider_card.dart index 44637b8..9d342b6 100644 --- a/lib/features/providers/presentation/widgets/provider_card.dart +++ b/lib/features/providers/presentation/widgets/provider_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../../../backend/backend.dart'; +import 'e621_auth_dialog.dart'; class ProviderCard extends StatelessWidget { const ProviderCard({ @@ -9,6 +10,7 @@ class ProviderCard extends StatelessWidget { required this.onToggle, required this.onEdit, required this.onDelete, + this.onSaveConfig, super.key, }); @@ -16,6 +18,7 @@ class ProviderCard extends StatelessWidget { final ValueChanged onToggle; final VoidCallback onEdit; final VoidCallback onDelete; + final ValueChanged? onSaveConfig; @override Widget build(BuildContext context) { @@ -164,6 +167,16 @@ class ProviderCard extends StatelessWidget { color: theme.colorScheme.primary, isDark: isDark, ), + if (config.apiType.toLowerCase() == 'e621') + _GlassPill( + label: (config.customHeaders['query.login']?.isNotEmpty ?? false) + ? 'e621: ${config.customHeaders['query.login']}' + : (isRu ? 'e621: Гость (макс 2 тега)' : 'e621: Guest (max 2 tags)'), + color: (config.customHeaders['query.login']?.isNotEmpty ?? false) + ? const Color(0xFF10B981) + : Colors.amber, + isDark: isDark, + ), ], ), ], @@ -231,10 +244,30 @@ class ProviderCard extends StatelessWidget { const SizedBox(height: 12), - // Row 3: Action Buttons (Edit & Delete) + // Row 3: Action Buttons (Edit & Delete & e621 Auth) Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + if (config.apiType.toLowerCase() == 'e621' && onSaveConfig != null) ...[ + _GlassActionButton( + icon: Icons.vpn_key_rounded, + label: (config.customHeaders['query.login']?.isNotEmpty ?? false) + ? (isRu ? 'Аккаунт' : 'Account') + : (isRu ? 'API Ключ' : 'API Key'), + color: const Color(0xFF0077EE), + isDark: isDark, + onTap: () { + HapticFeedback.lightImpact(); + E621AuthDialog.show( + context, + config: config, + onSaved: onSaveConfig!, + ); + }, + ), + const SizedBox(width: 8), + ], + // Edit Action Button _GlassActionButton( icon: Icons.edit_rounded, diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index f7f6481..1b8a47d 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.6.5', + 'v3.7.0', style: TextStyle( fontSize: 10, fontWeight: diff --git a/pubspec.yaml b/pubspec.yaml index 95988aa..5d6b67e 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.6.5+87 +version: 3.7.0+88 environment: sdk: ">=3.3.0 <4.0.0"