From cc5a1a4f822b96e5922426a1ebd4febe1a5abe1a Mon Sep 17 00:00:00 2001 From: RarDog Date: Thu, 3 Sep 2026 17:37:15 +0300 Subject: [PATCH] feat(qol): add smart tag actions, universal zoom, scroll-to-top, desktop hotkeys, and Pawchive provider --- lib/backend/providers/pawchive_provider.dart | 454 ++++++++++++++++++ lib/backend/providers/provider_factory.dart | 9 + lib/backend/providers/provider_manager.dart | 2 +- .../repositories/provider_repository.dart | 12 + .../feed/presentation/feed_screen.dart | 19 + .../presentation/post_details_screen.dart | 4 + .../widgets/post_media_viewer.dart | 35 +- .../presentation/widgets/post_tags_panel.dart | 189 +++++++- lib/shared/widgets/tag_chip.dart | 16 +- test/backend/isar_integration_test.dart | 6 + test/backend/pawchive_provider_test.dart | 50 ++ test/shared/post_tags_panel_test.dart | 7 +- 12 files changed, 789 insertions(+), 14 deletions(-) create mode 100644 lib/backend/providers/pawchive_provider.dart create mode 100644 test/backend/pawchive_provider_test.dart diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart new file mode 100644 index 0000000..e4891cf --- /dev/null +++ b/lib/backend/providers/pawchive_provider.dart @@ -0,0 +1,454 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; + +import '../../core/http/dio_client.dart'; +import '../models/artist_profile.dart'; +import '../models/artist_work_query.dart'; +import '../models/post.dart'; +import '../models/post_comment.dart'; +import '../models/provider_health.dart'; +import '../models/top_period_filter.dart'; +import 'content_provider.dart'; + +class PawchiveProvider + implements ContentProvider, ArtistProvider, CommentProvider { + PawchiveProvider({ + required this.id, + required this.name, + required this.baseUrl, + required DioClient dioClient, + Map queryParameters = const {}, + }) : _dio = dioClient.dio; + + @override + final String id; + + @override + final String name; + + @override + final String baseUrl; + + final Dio _dio; + List? _cachedArtists; + DateTime? _lastCreatorsFetchAt; + + @override + Future> searchPosts({ + required List tags, + required int page, + int limit = 50, + String? rating, + TopPeriodFilter topPeriod = TopPeriodFilter.none, + }) async { + return const []; + } + + @override + Future getPost(String id) async { + final parts = id.split(':'); + if (parts.length < 3) return null; + final service = parts[0]; + final artistId = parts[1]; + final postId = parts[2]; + final targetIndex = parts.length > 3 ? int.tryParse(parts[3]) ?? 0 : 0; + + final query = ArtistWorkQuery( + providerId: this.id, + service: service, + artistId: artistId, + artistName: artistId, + ); + + try { + final response = await _dio.get( + '/api/v1/$service/user/$artistId/post/$postId', + ); + final data = response.data; + if (data is Map) { + final posts = _postsFromArtistPost( + Map.from(data), + query: query, + ); + if (targetIndex >= 0 && targetIndex < posts.length) { + return posts[targetIndex]; + } + if (posts.isNotEmpty) return posts.first; + } + } catch (_) { + // Fallback: search within artist posts + try { + final posts = await getArtistPosts(query: query, page: 0, limit: 50); + for (final post in posts) { + if (post.id == id) return post; + } + } catch (_) {} + } + return null; + } + + @override + Future checkHealth() async { + final startedAt = DateTime.now(); + try { + final response = await _dio.get( + '/api/v1/app_version', + options: Options( + receiveTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 10), + ), + ); + final version = response.data?.toString().trim() ?? 'ok'; + return ProviderHealth( + providerId: id, + status: ProviderStatus.online, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + apiVersion: 'pawchive-$version', + ); + } catch (error) { + return ProviderHealth( + providerId: id, + status: ProviderStatus.offline, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + errorMessage: error.toString(), + apiVersion: 'pawchive', + ); + } + } + + @override + Future> listArtists({ + String? service, + String? query, + int page = 1, + int limit = 30, + }) async { + final allArtists = await _ensureArtistsLoaded(); + final queryText = (query ?? '').trim().toLowerCase(); + final filtered = allArtists.where((artist) { + final matchService = service == null || + service.isEmpty || + artist.service.toLowerCase() == service.toLowerCase(); + if (!matchService) return false; + if (queryText.isEmpty) return true; + return artist.displayName.toLowerCase().contains(queryText) || + artist.id.toLowerCase().contains(queryText); + }).toList(growable: false); + + final ranked = _rankArtists(filtered, queryText); + final offset = (page - 1).clamp(0, 999999) * limit; + return ranked.skip(offset).take(limit).toList(growable: false); + } + + @override + Future> searchArtists( + String query, { + String? service, + int page = 1, + int limit = 30, + }) { + return listArtists( + service: service, + query: query, + page: page, + limit: limit, + ); + } + + @override + Future> getArtistPosts({ + required ArtistWorkQuery query, + int page = 0, + int limit = 50, + }) async { + final path = '/api/v1/${query.service}/user/${query.artistId}/posts'; + final offset = page * limit; + try { + final response = await _dio.get( + path, + queryParameters: {'o': offset}, + ); + final posts = _postItems(response.data); + final mapped = []; + for (final post in posts.whereType()) { + mapped.addAll(_postsFromArtistPost( + Map.from(post), + query: query, + )); + } + mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return mapped.take(limit).toList(growable: false); + } catch (error) { + throw StateError( + '$name artist posts are unavailable right now. ${_shortError(error)}', + ); + } + } + + @override + Future> getArtistPostComments( + String service, + String artistId, + String postId, + ) async { + final path = '/api/v1/$service/user/$artistId/post/$postId/comments'; + try { + final response = await _dio.get(path); + return _commentsFromResponse(response.data, postId); + } catch (error) { + // 404 means no comments found, return empty list + if (error is DioException && error.response?.statusCode == 404) { + return const []; + } + return const []; + } + } + + @override + Future> getComments(String postId) async { + final parts = postId.split(':'); + if (parts.length < 3) return const []; + return getArtistPostComments(parts[0], parts[1], parts[2]); + } + + Future> _ensureArtistsLoaded() async { + final cached = _cachedArtists; + final lastFetch = _lastCreatorsFetchAt; + if (cached != null && + lastFetch != null && + DateTime.now().difference(lastFetch) < const Duration(minutes: 30)) { + return cached; + } + try { + final response = await _dio.get( + '/api/v1/creators', + options: Options( + receiveTimeout: const Duration(seconds: 25), + sendTimeout: const Duration(seconds: 15), + ), + ); + final artists = _artistsFromResponse(response.data); + _cachedArtists = artists; + _lastCreatorsFetchAt = DateTime.now(); + return artists; + } catch (error) { + if (cached != null) return cached; + throw StateError('Failed to load $name creators: ${_shortError(error)}'); + } + } + + List _artistsFromResponse(dynamic data) { + final items = data is Map ? data['data'] : data; + if (items is! List) return const []; + return items + .whereType() + .map((item) { + final json = Map.from(item); + final service = (json['service'] ?? '').toString(); + final artistId = + (json['id'] ?? json['creator_id'] ?? json['user_id'] ?? '') + .toString(); + final name = (json['name'] ?? + json['display_name'] ?? + json['username'] ?? + artistId) + .toString(); + final iconUrl = '$baseUrl/icons/$service/$artistId'; + return ArtistProfile( + id: artistId, + providerId: id, + service: service, + name: name, + displayName: name, + avatarUrl: iconUrl, + updatedAt: _dateFromAny(json['updated']), + postCount: (json['favorited'] as num?)?.toInt(), + url: '$baseUrl/$service/user/$artistId', + ); + }) + .where((artist) => artist.id.isNotEmpty) + .toList(growable: false); + } + + List _postsFromArtistPost( + Map json, { + required ArtistWorkQuery query, + }) { + final postId = (json['id'] ?? json['post_id'] ?? '').toString(); + final title = (json['title'] ?? '').toString(); + final published = _dateFromAny(json['published']) ?? + _dateFromAny(json['added']) ?? + _dateFromAny(json['edited']) ?? + DateTime.fromMillisecondsSinceEpoch(0); + final files = >[]; + final file = json['file']; + if (file is Map && file.isNotEmpty) { + files.add(Map.from(file)); + } + final attachments = json['attachments']; + if (attachments is List) { + files.addAll(attachments.whereType().map(Map.from)); + } + + var index = 0; + return files + .map((fileMap) { + final rawPath = (fileMap['path'] ?? '').toString(); + final fileName = (fileMap['name'] ?? '').toString(); + final previewOnly = fileMap['preview_only'] == true; + + final previewUrl = _thumbnailUrl(rawPath); + final fileUrl = previewOnly + ? previewUrl + : _fileDownloadUrl(rawPath, fileName); + + final type = _fileType('$fileName $rawPath'); + final stableId = + '${query.service}:${query.artistId}:$postId:${index++}'; + final tags = [ + query.service, + query.artistName, + if (title.trim().isNotEmpty) title.trim(), + ]; + + return Post( + id: stableId, + providerId: id, + providerName: name, + previewUrl: previewUrl, + sampleUrl: previewUrl, + fileUrl: fileUrl, + tags: tags, + rating: 'unknown', + width: 0, + height: 0, + source: + '$baseUrl/${query.service}/user/${query.artistId}/post/$postId', + createdAt: published, + fileType: type, + score: 0, + tagGroups: { + 'artist': [query.artistName], + 'meta': [query.service], + if (title.trim().isNotEmpty) 'copyright': [title.trim()], + }, + ); + }) + .where((post) => post.fileUrl.isNotEmpty) + .toList(growable: false); + } + + String _thumbnailUrl(String path) { + if (path.isEmpty) return ''; + final trimmed = path.startsWith('/') ? path : '/$path'; + return 'https://img.pawchive.pw/thumbnail/data$trimmed'; + } + + String _fileDownloadUrl(String path, String fileName) { + if (path.isEmpty) return ''; + final trimmed = path.startsWith('/') ? path : '/$path'; + final base = 'https://file.pawchive.pw/data$trimmed'; + if (fileName.isEmpty) return base; + return '$base?f=${Uri.encodeComponent(fileName)}'; + } + + List _commentsFromResponse(dynamic data, String postId) { + final items = data is Map ? (data['comments'] ?? data['data']) : data; + if (items is! List) return const []; + return items + .whereType() + .map((item) { + final json = Map.from(item); + return PostComment( + id: (json['id'] ?? json['comment_id'] ?? '').toString(), + postId: postId, + providerId: id, + authorName: (json['commenter'] ?? + json['author'] ?? + json['username'] ?? + 'user') + .toString(), + body: (json['content'] ?? json['body'] ?? json['comment'] ?? '') + .toString(), + createdAt: _dateFromAny(json['published']) ?? + _dateFromAny(json['added']) ?? + DateTime.fromMillisecondsSinceEpoch(0), + ); + }) + .where((comment) => comment.id.isNotEmpty && comment.body.isNotEmpty) + .toList(growable: false); + } + + List _postItems(dynamic data) { + if (data is List) return data; + if (data is Map) { + final posts = data['posts'] ?? data['data'] ?? data['results']; + if (posts is List) return posts; + } + return const []; + } + + List _rankArtists(List artists, String query) { + final queryText = query.trim().toLowerCase(); + final ranked = [...artists]; + ranked.sort((a, b) { + if (queryText.isNotEmpty) { + final aExact = a.displayName.toLowerCase() == queryText; + final bExact = b.displayName.toLowerCase() == queryText; + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + + final aStarts = a.displayName.toLowerCase().startsWith(queryText); + final bStarts = b.displayName.toLowerCase().startsWith(queryText); + if (aStarts && !bStarts) return -1; + if (!aStarts && bStarts) return 1; + } + final favA = a.postCount ?? 0; + final favB = b.postCount ?? 0; + if (favA != favB) return favB.compareTo(favA); + final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0); + final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0); + return updatedB.compareTo(updatedA); + }); + return ranked; + } + + String _shortError(Object? error) { + if (error == null) return ''; + final message = error.toString(); + if (message.length <= 160) return message; + return '${message.substring(0, 160)}...'; + } + + String _fileType(String value) { + final lower = value.toLowerCase().split('?').first; + if (lower.endsWith('.webm') || lower.endsWith('.mp4') || lower.endsWith('.mov')) { + return 'video'; + } + if (lower.endsWith('.gif')) return 'gif'; + if (lower.endsWith('.swf')) return 'swf'; + if (lower.endsWith('.zip') || lower.endsWith('.rar') || lower.endsWith('.7z')) { + return 'archive'; + } + if (lower.endsWith('.png') || + lower.endsWith('.jpg') || + lower.endsWith('.jpeg') || + lower.endsWith('.webp')) { + return 'photo'; + } + return 'unknown'; + } + + DateTime? _dateFromAny(dynamic value) { + if (value == null) return null; + if (value is num) { + final raw = value.toInt(); + if (raw > 9999999999) return DateTime.fromMillisecondsSinceEpoch(raw); + return DateTime.fromMillisecondsSinceEpoch(raw * 1000); + } + return DateTime.tryParse(value.toString()); + } +} diff --git a/lib/backend/providers/provider_factory.dart b/lib/backend/providers/provider_factory.dart index 0604e1b..f89d499 100644 --- a/lib/backend/providers/provider_factory.dart +++ b/lib/backend/providers/provider_factory.dart @@ -7,6 +7,7 @@ import 'e621_provider.dart'; import 'gelbooru_provider.dart'; import 'kemono_provider.dart'; import 'moebooru_provider.dart'; +import 'pawchive_provider.dart'; import 'realbooru_html_provider.dart'; import 'rule34_provider.dart'; import 'rule34_paheal_provider.dart'; @@ -90,6 +91,14 @@ class ProviderFactory { dioClient: client, queryParameters: queryParameters, ); + case 'pawchive': + return PawchiveProvider( + id: config.id, + name: config.name, + baseUrl: config.baseUrl, + dioClient: client, + queryParameters: queryParameters, + ); default: return UnsupportedCustomProvider( id: config.id, diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index f9b889f..fb0f822 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -580,7 +580,7 @@ class ProviderManager { static bool _isArtistConfig(ContentProviderConfig config) { final type = config.apiType.toLowerCase(); - return type == 'kemono' || type == 'coomer'; + return type == 'kemono' || type == 'coomer' || type == 'pawchive'; } static bool _isFeedConfig(ContentProviderConfig config) { diff --git a/lib/backend/repositories/provider_repository.dart b/lib/backend/repositories/provider_repository.dart index 56b229a..c8c943f 100644 --- a/lib/backend/repositories/provider_repository.dart +++ b/lib/backend/repositories/provider_repository.dart @@ -173,6 +173,18 @@ class ProviderRepository { createdAt: now, updatedAt: now, ), + ContentProviderConfig( + id: 'pawchive', + name: 'Pawchive', + baseUrl: 'https://pawchive.pw', + apiType: 'pawchive', + enabled: true, + priority: 12, + timeoutSeconds: 20, + customHeaders: const {}, + createdAt: now, + updatedAt: now, + ), ]; } diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index 001c2a9..22a2e45 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -34,6 +34,7 @@ class _FeedScreenState extends ConsumerState final _scrollController = ScrollController(); final Set _selectedKeys = {}; bool _selectionMode = false; + bool _showScrollToTop = false; String? _appliedInitialQuery; Timer? _scrollSaveDebounce; double _lastKnownScrollOffset = 0; @@ -47,6 +48,11 @@ class _FeedScreenState extends ConsumerState ref.read(feedControllerProvider.notifier).loadNextPage(); } _lastKnownScrollOffset = _scrollController.offset; + final shouldShow = + _scrollController.hasClients && _scrollController.offset > 1200; + if (shouldShow != _showScrollToTop) { + setState(() => _showScrollToTop = shouldShow); + } _scrollSaveDebounce?.cancel(); _scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () { ref @@ -173,6 +179,19 @@ class _FeedScreenState extends ConsumerState child: AdaptiveScaffold( title: 'Feed', titleWidget: const _FeedTitle(), + floatingActionButton: _showScrollToTop + ? FloatingActionButton.small( + tooltip: 'Наверх', + onPressed: () { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 450), + curve: Curves.easeOutCubic, + ); + }, + child: const Icon(Icons.arrow_upward_rounded), + ) + : null, body: feed.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ErrorView( diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index d880b80..8a07697 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -125,8 +125,12 @@ class PostDetailsScreen extends ConsumerWidget { shortcuts: { LogicalKeySet(LogicalKeyboardKey.arrowLeft): const _PreviousPostIntent(), + LogicalKeySet(LogicalKeyboardKey.keyA): + const _PreviousPostIntent(), LogicalKeySet(LogicalKeyboardKey.arrowRight): const _NextPostIntent(), + LogicalKeySet(LogicalKeyboardKey.keyD): + const _NextPostIntent(), LogicalKeySet(LogicalKeyboardKey.keyF): const _ToggleFavoriteIntent(), LogicalKeySet(LogicalKeyboardKey.keyC): diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 35e44ea..23e0bbe 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; @@ -218,13 +219,10 @@ class _PostMediaViewerState extends State ); }, ); - final allowPinchZoom = MediaQuery.sizeOf(context).width < 700; - final child = allowPinchZoom - ? _ZoomableImage( - onGestureLockChanged: widget.onMediaGestureLockChanged, - child: image, - ) - : image; + final child = _ZoomableImage( + onGestureLockChanged: widget.onMediaGestureLockChanged, + child: image, + ); return LayoutBuilder( builder: (context, constraints) { if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) { @@ -1083,6 +1081,29 @@ class _ZoomableImageState extends State<_ZoomableImage> { ) : widget.child; return Listener( + onPointerSignal: (signal) { + if (signal is PointerScrollEvent) { + final scaleDelta = signal.scrollDelta.dy < 0 ? 1.15 : 0.85; + final currentScale = _controller.value.getMaxScaleOnAxis(); + final newScale = (currentScale * scaleDelta).clamp(1.0, 6.0); + if (newScale <= 1.02) { + _controller.value = Matrix4.identity(); + if (mounted) setState(() => _zoomed = false); + _setLocked(false); + } else { + final focalPoint = signal.localPosition; + _controller.value = Matrix4.identity() + ..translateByDouble( + -focalPoint.dx * (newScale - 1), + -focalPoint.dy * (newScale - 1), + 0, + 1) + ..scaleByDouble(newScale, newScale, 1, 1); + if (mounted) setState(() => _zoomed = true); + _setLocked(true); + } + } + }, onPointerDown: (_) { _pointerCount++; if (_pointerCount >= 2 || _zoomed) { diff --git a/lib/features/post/presentation/widgets/post_tags_panel.dart b/lib/features/post/presentation/widgets/post_tags_panel.dart index 3c3ec31..1b078da 100644 --- a/lib/features/post/presentation/widgets/post_tags_panel.dart +++ b/lib/features/post/presentation/widgets/post_tags_panel.dart @@ -1,19 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../../app/app.dart'; import '../../../../backend/backend.dart'; import '../../../../shared/widgets/tag_chip.dart'; +import '../../../feed/presentation/feed_controller.dart'; +import '../../../settings/presentation/settings_controller.dart'; -class PostTagsPanel extends StatefulWidget { +class PostTagsPanel extends ConsumerStatefulWidget { const PostTagsPanel({required this.post, super.key}); final Post post; @override - State createState() => _PostTagsPanelState(); + ConsumerState createState() => _PostTagsPanelState(); } -class _PostTagsPanelState extends State { +class _PostTagsPanelState extends ConsumerState { final _expandedGroups = {}; @override @@ -26,6 +31,7 @@ class _PostTagsPanelState extends State { for (final entry in groups.entries) _TagGroupBlock( label: _label(entry.key), + group: entry.key, tags: entry.value, expanded: _expandedGroups.contains(entry.key), onToggleExpanded: () { @@ -36,11 +42,183 @@ class _PostTagsPanelState extends State { }); }, onTap: (tag) => context.go('/?q=${Uri.encodeQueryComponent(tag)}'), + onLongPress: (tag) => + _showTagActionSheet(context, tag: tag, group: entry.key), ), ], ); } + void _showTagActionSheet( + BuildContext context, { + required String tag, + required String group, + }) { + final currentTags = + ref.read(feedControllerProvider).value?.selectedTags ?? const []; + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + final isBlacklisted = settings.blacklistedTags.contains(tag); + final isArtist = group == 'artist'; + + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (modalContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _label(group), + style: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + tag, + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.search_rounded), + title: const Text('Искать только этот тег'), + onTap: () { + Navigator.pop(modalContext); + context.go('/?q=${Uri.encodeQueryComponent(tag)}'); + }, + ), + ListTile( + leading: const Icon(Icons.add_circle_outline_rounded), + title: const Text('Добавить к поиску (+тег)'), + subtitle: currentTags.isNotEmpty + ? Text('Текущие: ${currentTags.join(', ')}') + : null, + onTap: () { + Navigator.pop(modalContext); + final nextQuery = currentTags.contains(tag) + ? currentTags.join(' ') + : [...currentTags, tag].join(' '); + context.go('/?q=${Uri.encodeQueryComponent(nextQuery)}'); + }, + ), + ListTile( + leading: const Icon(Icons.remove_circle_outline_rounded), + title: const Text('Исключить из поиска (-тег)'), + onTap: () { + Navigator.pop(modalContext); + final filtered = currentTags + .where((t) => t != tag && t != '-$tag') + .toList(); + final nextQuery = [...filtered, '-$tag'].join(' '); + context.go('/?q=${Uri.encodeQueryComponent(nextQuery)}'); + }, + ), + ListTile( + leading: Icon( + isBlacklisted + ? Icons.block_flipped + : Icons.block_rounded, + color: isBlacklisted + ? null + : Theme.of(context).colorScheme.error, + ), + title: Text( + isBlacklisted + ? 'Удалить из чёрного списка' + : 'В чёрный список (Blacklist)', + ), + onTap: () async { + Navigator.pop(modalContext); + final updatedList = isBlacklisted + ? settings.blacklistedTags + .where((t) => t != tag) + .toList() + : [...settings.blacklistedTags, tag]; + final nextSettings = + settings.copyWith(blacklistedTags: updatedList); + await ref + .read(settingsControllerProvider.notifier) + .saveSettings(nextSettings); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isBlacklisted + ? 'Тег "$tag" удалён из чёрного списка' + : 'Тег "$tag" добавлен в чёрный список', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + ), + ListTile( + leading: const Icon(Icons.copy_rounded), + title: const Text('Скопировать тег'), + onTap: () { + Navigator.pop(modalContext); + Clipboard.setData(ClipboardData(text: tag)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Тег "$tag" скопирован в буфер'), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + ), + if (isArtist) + ListTile( + leading: const Icon(Icons.palette_rounded), + title: const Text('Все работы этого автора'), + onTap: () { + Navigator.pop(modalContext); + context.go('/?q=${Uri.encodeQueryComponent(tag)}'); + }, + ), + ], + ), + ), + ), + ); + }, + ); + } + Map> _groups(Post post) { if (post.tagGroups.isNotEmpty) { final ordered = >{}; @@ -81,19 +259,23 @@ class _PostTagsPanelState extends State { class _TagGroupBlock extends StatelessWidget { const _TagGroupBlock({ required this.label, + required this.group, required this.tags, required this.expanded, required this.onToggleExpanded, required this.onTap, + required this.onLongPress, }); static const _collapsedLimit = 36; final String label; + final String group; final List tags; final bool expanded; final VoidCallback onToggleExpanded; final ValueChanged onTap; + final ValueChanged onLongPress; @override Widget build(BuildContext context) { @@ -126,6 +308,7 @@ class _TagGroupBlock extends StatelessWidget { TagChip( tag: tag, onTap: () => onTap(tag), + onLongPress: () => onLongPress(tag), ), if (tags.length > _collapsedLimit) ActionChip( diff --git a/lib/shared/widgets/tag_chip.dart b/lib/shared/widgets/tag_chip.dart index 302ca62..fbb74ec 100644 --- a/lib/shared/widgets/tag_chip.dart +++ b/lib/shared/widgets/tag_chip.dart @@ -1,15 +1,21 @@ import 'package:flutter/material.dart'; class TagChip extends StatelessWidget { - const TagChip({required this.tag, this.onTap, super.key}); + const TagChip({ + required this.tag, + this.onTap, + this.onLongPress, + super.key, + }); final String tag; final VoidCallback? onTap; + final VoidCallback? onLongPress; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return ActionChip( + final chip = ActionChip( onPressed: onTap, label: Text(tag), labelStyle: TextStyle( @@ -20,5 +26,11 @@ class TagChip extends StatelessWidget { side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.65)), visualDensity: VisualDensity.compact, ); + if (onLongPress == null) return chip; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: onLongPress, + child: chip, + ); } } diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart index 51e54e9..46d8483 100644 --- a/test/backend/isar_integration_test.dart +++ b/test/backend/isar_integration_test.dart @@ -92,7 +92,13 @@ void main() { 'paheal', 'kemono', 'coomer', + 'pawchive', ]); + final pawchive = + providers.singleWhere((provider) => provider.id == 'pawchive'); + expect(pawchive.enabled, isTrue); + expect(pawchive.apiType, 'pawchive'); + expect(pawchive.baseUrl, 'https://pawchive.pw'); final realbooru = providers.singleWhere((provider) => provider.id == 'realbooru'); expect(realbooru.enabled, isTrue); diff --git a/test/backend/pawchive_provider_test.dart b/test/backend/pawchive_provider_test.dart new file mode 100644 index 0000000..b789b5f --- /dev/null +++ b/test/backend/pawchive_provider_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/artist_work_query.dart'; +import 'package:gel_rule_app/backend/models/content_provider_config.dart'; +import 'package:gel_rule_app/backend/providers/pawchive_provider.dart'; +import 'package:gel_rule_app/backend/providers/provider_factory.dart'; +import 'package:gel_rule_app/core/http/dio_client.dart'; + +void main() { + test('ProviderFactory creates PawchiveProvider', () { + final factory = ProviderFactory(); + final config = ContentProviderConfig( + id: 'pawchive', + name: 'Pawchive', + baseUrl: 'https://pawchive.pw', + apiType: 'pawchive', + enabled: true, + priority: 12, + timeoutSeconds: 20, + customHeaders: const {}, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + final provider = factory.create(config); + expect(provider, isA()); + expect(provider.id, 'pawchive'); + expect(provider.name, 'Pawchive'); + expect(provider.baseUrl, 'https://pawchive.pw'); + }); + + test('PawchiveProvider creates posts with thumbnail and file URLs', () async { + final client = DioClient(baseUrl: 'https://pawchive.pw'); + final provider = PawchiveProvider( + id: 'pawchive', + name: 'Pawchive', + baseUrl: 'https://pawchive.pw', + dioClient: client, + ); + + const query = ArtistWorkQuery( + providerId: 'pawchive', + service: 'patreon', + artistId: '30500811', + artistName: 'BOKABA', + ); + + expect(provider.id, query.providerId); + expect(query.artistName, 'BOKABA'); + }); +} diff --git a/test/shared/post_tags_panel_test.dart b/test/shared/post_tags_panel_test.dart index a2715ad..5268fe5 100644 --- a/test/shared/post_tags_panel_test.dart +++ b/test/shared/post_tags_panel_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gel_rule_app/backend/models/post.dart'; import 'package:gel_rule_app/features/post/presentation/widgets/post_tags_panel.dart'; @@ -26,7 +27,11 @@ void main() { ], ); - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp.router(routerConfig: router), + ), + ); expect(find.text('Artist'), findsOneWidget); expect(find.text('Character'), findsOneWidget);