diff --git a/README.md b/README.md index 7ea0755..c9cce47 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ The MVP ships with seed configs for: - Gelbooru: `https://gelbooru.com` - Rule34: `https://api.rule34.xxx` - Safebooru: `https://safebooru.org` +- Konachan: `https://konachan.com` +- Yande.re: `https://yande.re` +- e621: `https://e621.net` +- e926: `https://e926.net` Providers implement: @@ -109,6 +113,8 @@ For custom providers, MVP supports these `apiType` values: - `gelbooru` - `rule34` - `danbooru` +- `moebooru` +- `e621` Unsupported types return `ProviderUnavailableException` instead of crashing the app. diff --git a/lib/app/router.dart b/lib/app/router.dart index ef52806..2839cf2 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -12,6 +12,7 @@ import '../features/providers/presentation/provider_form_screen.dart'; import '../features/providers/presentation/providers_screen.dart'; import '../features/search/presentation/search_screen.dart'; import '../features/settings/presentation/settings_screen.dart'; +import '../features/viewed/presentation/viewed_screen.dart'; import '../shared/widgets/app_shell.dart'; final appRouterProvider = Provider((ref) { @@ -43,6 +44,10 @@ final appRouterProvider = Provider((ref) { path: '/favorites', builder: (context, state) => const FavoritesScreen(), ), + GoRoute( + path: '/viewed', + builder: (context, state) => const ViewedScreen(), + ), GoRoute( path: '/collections', builder: (context, state) => const CollectionsScreen(), diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index f0211ab..c7bda10 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -4,10 +4,12 @@ export 'models/content_provider_config.dart'; export 'models/favorite.dart'; export 'models/post.dart'; export 'models/post_comment.dart'; +export 'models/provider_diagnostics.dart'; export 'models/provider_health.dart'; export 'models/search_history.dart'; export 'models/tag_suggestion.dart'; export 'models/top_period_filter.dart'; +export 'models/viewed_post.dart'; export 'providers/content_provider.dart'; export 'providers/provider_manager.dart'; export 'services/collection_service.dart'; @@ -17,3 +19,5 @@ export 'services/feed_service.dart'; export 'services/provider_check_service.dart'; export 'services/search_service.dart'; export 'services/settings_service.dart'; +export 'services/viewed_history_service.dart'; +export 'utils/smart_blacklist.dart'; diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index 48b091e..d84419a 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -6,6 +6,7 @@ import '../../core/cache/image_cache_service.dart'; import '../../core/database/app_database.dart'; import '../../core/database/database_service.dart'; import '../../core/http/network_info.dart'; +import '../../core/utils/result.dart'; import '../providers/provider_factory.dart'; import '../providers/provider_manager.dart'; import '../repositories/collection_repository.dart'; @@ -13,6 +14,7 @@ import '../repositories/favorite_repository.dart'; import '../repositories/post_repository.dart'; import '../repositories/provider_repository.dart'; import '../repositories/search_repository.dart'; +import '../repositories/viewed_post_repository.dart'; import '../services/collection_service.dart'; import '../services/download_service.dart'; import '../services/favorite_service.dart'; @@ -20,6 +22,7 @@ import '../services/feed_service.dart'; import '../services/provider_check_service.dart'; import '../services/search_service.dart'; import '../services/settings_service.dart'; +import '../services/viewed_history_service.dart'; final appDatabaseProvider = FutureProvider((ref) async { final database = await AppDatabase.open(); @@ -54,6 +57,10 @@ final searchRepositoryProvider = Provider((ref) { return SearchRepository(ref.watch(databaseServiceProvider)); }); +final viewedPostRepositoryProvider = Provider((ref) { + return ViewedPostRepository(ref.watch(databaseServiceProvider)); +}); + final favoriteRepositoryProvider = Provider((ref) { return FavoriteRepository( ref.watch(databaseServiceProvider), @@ -88,9 +95,22 @@ final feedServiceProvider = Provider((ref) { ref.watch(providerManagerProvider), ref.watch(cacheServiceProvider), ref.watch(settingsServiceProvider), + ref.watch(viewedHistoryServiceProvider), ); }); +final viewedHistoryServiceProvider = Provider((ref) { + return ViewedHistoryService( + ref.watch(viewedPostRepositoryProvider), + ref.watch(postRepositoryProvider), + ); +}); + +final viewedKeysProvider = FutureProvider>((ref) async { + final result = await ref.watch(viewedHistoryServiceProvider).getViewedKeys(); + return result is Success> ? result.data : {}; +}); + final searchServiceProvider = Provider((ref) { return SearchService( ref.watch(searchRepositoryProvider), diff --git a/lib/backend/mappers/gelbooru_mapper.dart b/lib/backend/mappers/gelbooru_mapper.dart index 5a9c39f..a463a28 100644 --- a/lib/backend/mappers/gelbooru_mapper.dart +++ b/lib/backend/mappers/gelbooru_mapper.dart @@ -30,7 +30,10 @@ class GelbooruMapper { fileUrl); final previewUrl = _string(json['preview_url'] ?? json['previewUrl'] ?? sampleUrl); - final tags = _tags(json['tags'] ?? json['tag_string']); + final explicitGroups = _tagGroupsFromJson(json); + final tags = explicitGroups.isEmpty + ? _tags(json['tags'] ?? json['tag_string']) + : explicitGroups.values.expand((items) => items).toSet().toList(); return Post( id: id, providerId: providerId, @@ -46,7 +49,7 @@ class GelbooruMapper { createdAt: _date(json['created_at'] ?? json['createdAt']), fileType: _fileType(fileUrl, json['file_ext']), score: _int(json['score']), - tagGroups: _tagGroups(tags), + tagGroups: explicitGroups.isEmpty ? _tagGroups(tags) : explicitGroups, ); } @@ -108,4 +111,21 @@ class GelbooruMapper { static Map> _tagGroups(List tags) { return tags.isEmpty ? const {} : {'general': tags}; } + + static Map> _tagGroupsFromJson( + Map json, + ) { + final groups = >{}; + void add(String key, dynamic value) { + final tags = _tags(value); + if (tags.isNotEmpty) groups[key] = tags; + } + + add('general', json['tag_string_general'] ?? json['tags_general']); + add('artist', json['tag_string_artist'] ?? json['tags_artist']); + add('copyright', json['tag_string_copyright'] ?? json['tags_copyright']); + add('character', json['tag_string_character'] ?? json['tags_character']); + add('meta', json['tag_string_meta'] ?? json['tags_meta']); + return groups; + } } diff --git a/lib/backend/models/provider_diagnostics.dart b/lib/backend/models/provider_diagnostics.dart new file mode 100644 index 0000000..dc0c788 --- /dev/null +++ b/lib/backend/models/provider_diagnostics.dart @@ -0,0 +1,13 @@ +class ProviderDiagnostics { + const ProviderDiagnostics({ + required this.providerId, + required this.lastSearchAt, + required this.lastResultCount, + this.lastErrorMessage, + }); + + final String providerId; + final DateTime lastSearchAt; + final int lastResultCount; + final String? lastErrorMessage; +} diff --git a/lib/backend/models/viewed_post.dart b/lib/backend/models/viewed_post.dart new file mode 100644 index 0000000..3aed12b --- /dev/null +++ b/lib/backend/models/viewed_post.dart @@ -0,0 +1,16 @@ +class ViewedPost { + const ViewedPost({ + required this.viewedKey, + required this.providerId, + required this.postId, + required this.viewedAt, + }); + + final String viewedKey; + final String providerId; + final String postId; + final DateTime viewedAt; + + static String keyFor(String providerId, String postId) => + '$providerId:$postId'; +} diff --git a/lib/backend/providers/danbooru_provider.dart b/lib/backend/providers/danbooru_provider.dart index 5720ef2..39c122b 100644 --- a/lib/backend/providers/danbooru_provider.dart +++ b/lib/backend/providers/danbooru_provider.dart @@ -43,7 +43,7 @@ class DanbooruProvider implements ContentProvider, CommentProvider { final response = await _dio.get( '/posts.json', queryParameters: { - 'page': page <= 0 ? 1 : page, + 'page': page + 1, 'limit': limit, 'tags': queryTags.join(' '), ..._queryParameters, @@ -107,8 +107,7 @@ class DanbooruProvider implements ContentProvider, CommentProvider { ..._queryParameters, }, ); - final data = response.data; - final items = data is List ? data : const []; + final items = _commentItems(response.data); return items .whereType() .map((item) { @@ -117,9 +116,13 @@ class DanbooruProvider implements ContentProvider, CommentProvider { id: (json['id'] ?? '').toString(), postId: (json['post_id'] ?? postId).toString(), providerId: id, - authorName: (json['creator_name'] ?? json['creator_id'] ?? 'user') + authorName: (json['creator_name'] ?? + json['creator_id'] ?? + json['updater_name'] ?? + 'user') + .toString(), + body: (json['body'] ?? json['text'] ?? json['comment'] ?? '') .toString(), - body: (json['body'] ?? '').toString(), createdAt: DateTime.tryParse((json['created_at'] ?? '').toString()) ?? DateTime.fromMillisecondsSinceEpoch(0), @@ -129,6 +132,20 @@ class DanbooruProvider implements ContentProvider, CommentProvider { .toList(); } + List _commentItems(dynamic data) { + if (data is List) return data; + if (data is! Map) return const []; + final json = Map.from(data); + final comments = json['comments']; + if (comments is List) return comments; + final records = json['records']; + if (records is List) return records; + final comment = json['comment']; + if (comment is List) return comment; + if (comment is Map) return [comment]; + return const []; + } + List _topTags(TopPeriodFilter period) { return switch (period) { TopPeriodFilter.none => const [], diff --git a/lib/backend/providers/e621_provider.dart b/lib/backend/providers/e621_provider.dart index c854050..bad41b9 100644 --- a/lib/backend/providers/e621_provider.dart +++ b/lib/backend/providers/e621_provider.dart @@ -3,12 +3,14 @@ import 'package:dio/dio.dart'; import '../../core/http/dio_client.dart'; import '../mappers/e621_mapper.dart'; import '../models/post.dart'; +import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/tag_suggestion.dart'; import '../models/top_period_filter.dart'; import 'content_provider.dart'; -class E621Provider implements ContentProvider, TagSuggestionProvider { +class E621Provider + implements ContentProvider, TagSuggestionProvider, CommentProvider { E621Provider({ required this.id, required this.name, @@ -38,7 +40,7 @@ class E621Provider implements ContentProvider, TagSuggestionProvider { final response = await _dio.get( '/posts.json', queryParameters: { - 'page': page <= 0 ? 1 : page, + 'page': page + 1, 'limit': limit.clamp(1, 75), 'tags': [ ...tags, @@ -125,6 +127,37 @@ class E621Provider implements ContentProvider, TagSuggestionProvider { .toList(); } + @override + Future> getComments(String postId) async { + final response = await _dio.get( + '/comments.json', + queryParameters: { + 'search[post_id]': postId, + 'limit': 50, + ..._queryParameters, + }, + ); + final items = response.data is List ? response.data as List : const []; + return items + .whereType() + .map((item) { + final json = Map.from(item); + return PostComment( + id: (json['id'] ?? '').toString(), + postId: (json['post_id'] ?? postId).toString(), + providerId: id, + authorName: (json['creator_name'] ?? json['creator_id'] ?? 'user') + .toString(), + body: (json['body'] ?? '').toString(), + createdAt: + DateTime.tryParse((json['created_at'] ?? '').toString()) ?? + DateTime.fromMillisecondsSinceEpoch(0), + ); + }) + .where((comment) => comment.body.trim().isNotEmpty) + .toList(); + } + String _rating(String value) { return switch (value.toLowerCase()) { 'safe' => 's', diff --git a/lib/backend/providers/gelbooru_provider.dart b/lib/backend/providers/gelbooru_provider.dart index 3228dc2..35e3a84 100644 --- a/lib/backend/providers/gelbooru_provider.dart +++ b/lib/backend/providers/gelbooru_provider.dart @@ -148,13 +148,7 @@ class GelbooruProvider implements ContentProvider, CommentProvider { } List _commentsFromResponse(dynamic data, String postId) { - final raw = switch (data) { - List() => data, - {'comments': final List comments} => comments, - {'@attributes': _, 'comment': final List comments} => comments, - {'comment': final List comments} => comments, - _ => const [], - }; + final raw = _commentItems(data); return raw .whereType() .map((item) { @@ -165,10 +159,18 @@ class GelbooruProvider implements ContentProvider, CommentProvider { providerId: id, authorName: (json['creator'] ?? json['creator_name'] ?? + json['creator_id'] ?? + json['user'] ?? + json['username'] ?? json['author'] ?? '') .toString(), - body: (json['body'] ?? json['comment'] ?? '').toString(), + body: (json['body'] ?? + json['comment'] ?? + json['content'] ?? + json['text'] ?? + '') + .toString(), createdAt: DateTime.tryParse( (json['created_at'] ?? json['created'] ?? '').toString(), ) ?? @@ -178,4 +180,66 @@ class GelbooruProvider implements ContentProvider, CommentProvider { .where((comment) => comment.body.trim().isNotEmpty) .toList(); } + + List _commentItems(dynamic data) { + if (data is List) return data; + if (data is String) return _commentItemsFromText(data); + if (data is! Map) return const []; + final json = Map.from(data); + final comments = json['comments']; + if (comments is List) return comments; + if (comments is Map) return _commentItems(comments); + final comment = json['comment']; + if (comment is List) return comment; + if (comment is Map) return [comment]; + return const []; + } + + List> _commentItemsFromText(String text) { + final items = >[]; + for (final match in RegExp(r']*)>(.*?)', + caseSensitive: false, dotAll: true) + .allMatches(text)) { + final attrs = _attributes(match.group(1) ?? ''); + final body = _tagValue(match.group(2) ?? '', 'body') ?? + _tagValue(match.group(2) ?? '', 'comment') ?? + match.group(2) ?? + ''; + items.add({...attrs, 'body': body}); + } + for (final match + in RegExp(r']*)/?>', caseSensitive: false, dotAll: true) + .allMatches(text)) { + final attrs = _attributes(match.group(1) ?? ''); + if (attrs.isNotEmpty) items.add(attrs); + } + return items; + } + + Map _attributes(String source) { + final result = {}; + for (final match in RegExp(r'''([A-Za-z0-9_:-]+)\s*=\s*["']([^"']*)["']''') + .allMatches(source)) { + result[match.group(1)!] = _decodeEntities(match.group(2) ?? ''); + } + return result; + } + + String? _tagValue(String source, String tag) { + final match = + RegExp('<$tag[^>]*>(.*?)', caseSensitive: false, dotAll: true) + .firstMatch(source); + final value = match?.group(1); + return value == null ? null : _decodeEntities(value.trim()); + } + + String _decodeEntities(String value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); + } } diff --git a/lib/backend/providers/moebooru_provider.dart b/lib/backend/providers/moebooru_provider.dart index 25de230..96bdf5e 100644 --- a/lib/backend/providers/moebooru_provider.dart +++ b/lib/backend/providers/moebooru_provider.dart @@ -3,12 +3,14 @@ import 'package:dio/dio.dart'; import '../../core/http/dio_client.dart'; import '../mappers/moebooru_mapper.dart'; import '../models/post.dart'; +import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/tag_suggestion.dart'; import '../models/top_period_filter.dart'; import 'content_provider.dart'; -class MoebooruProvider implements ContentProvider, TagSuggestionProvider { +class MoebooruProvider + implements ContentProvider, TagSuggestionProvider, CommentProvider { MoebooruProvider({ required this.id, required this.name, @@ -38,7 +40,7 @@ class MoebooruProvider implements ContentProvider, TagSuggestionProvider { final response = await _dio.get( '/post.json', queryParameters: { - 'page': page <= 0 ? 1 : page, + 'page': page + 1, 'limit': limit.clamp(1, 100), 'tags': [ ...tags, @@ -127,6 +129,40 @@ class MoebooruProvider implements ContentProvider, TagSuggestionProvider { .toList(); } + @override + Future> getComments(String postId) async { + final response = await _dio.get( + '/comment.json', + queryParameters: { + 'post_id': postId, + 'limit': 50, + ..._queryParameters, + }, + ); + final items = response.data is List ? response.data as List : const []; + return items + .whereType() + .map((item) { + final json = Map.from(item); + return PostComment( + id: (json['id'] ?? '').toString(), + postId: (json['post_id'] ?? postId).toString(), + providerId: id, + authorName: + (json['creator'] ?? json['author'] ?? json['user'] ?? 'user') + .toString(), + body: (json['body'] ?? json['comment'] ?? json['text'] ?? '') + .toString(), + createdAt: DateTime.tryParse( + (json['created_at'] ?? json['created_on'] ?? '').toString(), + ) ?? + DateTime.fromMillisecondsSinceEpoch(0), + ); + }) + .where((comment) => comment.body.trim().isNotEmpty) + .toList(); + } + List _topTags(TopPeriodFilter period) { return switch (period) { TopPeriodFilter.none => const [], diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index 3c1becd..8205fe3 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -5,6 +5,7 @@ import '../../core/utils/result.dart'; import '../models/content_provider_config.dart'; import '../models/post.dart'; import '../models/post_comment.dart'; +import '../models/provider_diagnostics.dart'; import '../models/provider_health.dart'; import '../models/tag_suggestion.dart'; import '../models/top_period_filter.dart'; @@ -97,6 +98,7 @@ class ProviderManager { } final posts = []; + final seen = {}; for (final provider in providers) { try { final providerPosts = await provider.searchPosts( @@ -106,8 +108,25 @@ class ProviderManager { rating: rating, topPeriod: topPeriod, ); - posts.addAll(providerPosts); + for (final post in providerPosts) { + if (seen.add(post.cacheKey)) posts.add(post); + } + await _repository.saveDiagnostics( + ProviderDiagnostics( + providerId: provider.id, + lastSearchAt: DateTime.now(), + lastResultCount: providerPosts.length, + ), + ); } catch (_) { + await _repository.saveDiagnostics( + ProviderDiagnostics( + providerId: provider.id, + lastSearchAt: DateTime.now(), + lastResultCount: 0, + lastErrorMessage: 'Search failed', + ), + ); await _repository.saveHealth( ProviderHealth( providerId: provider.id, diff --git a/lib/backend/repositories/provider_repository.dart b/lib/backend/repositories/provider_repository.dart index 41f3b15..eb738f6 100644 --- a/lib/backend/repositories/provider_repository.dart +++ b/lib/backend/repositories/provider_repository.dart @@ -4,6 +4,7 @@ import '../../core/database/app_database.dart'; import '../../core/database/database_service.dart'; import '../../core/utils/result.dart'; import '../models/content_provider_config.dart'; +import '../models/provider_diagnostics.dart'; import '../models/provider_health.dart'; class ProviderRepository { @@ -111,6 +112,14 @@ class ProviderRepository { Future> ensureSeedProviders() { return _databaseService.safeWrite((isar) async { + final existingProviders = + await isar.providerConfigEntitys.where().findAll(); + for (final provider in existingProviders) { + if (provider.providerId == 'kemono' || + provider.providerId == 'coomer') { + await isar.providerConfigEntitys.delete(provider.isarId); + } + } final seeds = seedProviders(); for (final seed in seeds) { final exists = await isar.providerConfigEntitys @@ -186,4 +195,19 @@ class ProviderRepository { return entity?.toModel(); }); } + + Future> saveDiagnostics(ProviderDiagnostics diagnostics) { + return _databaseService.safeWrite((isar) async { + await isar.providerDiagnosticsEntitys.put( + ProviderDiagnosticsEntity.fromModel(diagnostics), + ); + }); + } + + Future>> getDiagnostics() { + return _databaseService.safeRead((isar) async { + final items = await isar.providerDiagnosticsEntitys.where().findAll(); + return items.map((item) => item.toModel()).toList(); + }); + } } diff --git a/lib/backend/repositories/viewed_post_repository.dart b/lib/backend/repositories/viewed_post_repository.dart new file mode 100644 index 0000000..98cfa5e --- /dev/null +++ b/lib/backend/repositories/viewed_post_repository.dart @@ -0,0 +1,59 @@ +import 'package:isar/isar.dart'; + +import '../../core/database/app_database.dart'; +import '../../core/database/database_service.dart'; +import '../../core/utils/result.dart'; +import '../models/viewed_post.dart'; + +class ViewedPostRepository { + ViewedPostRepository(this._databaseService); + + final DatabaseService _databaseService; + + Future> markViewed(String providerId, String postId) { + return _databaseService.safeWrite((isar) async { + final key = ViewedPost.keyFor(providerId, postId); + await isar.viewedPostEntitys.put( + ViewedPostEntity.fromModel( + ViewedPost( + viewedKey: key, + providerId: providerId, + postId: postId, + viewedAt: DateTime.now(), + ), + ), + ); + }); + } + + Future> exists(String providerId, String postId) { + return _databaseService.safeRead((isar) async { + final entity = await isar.viewedPostEntitys + .filter() + .viewedKeyEqualTo(ViewedPost.keyFor(providerId, postId)) + .findFirst(); + return entity != null; + }); + } + + Future>> keys() { + return _databaseService.safeRead((isar) async { + final items = await isar.viewedPostEntitys.where().findAll(); + return items.map((item) => item.viewedKey).toSet(); + }); + } + + Future>> recent() { + return _databaseService.safeRead((isar) async { + final items = await isar.viewedPostEntitys.where().findAll(); + items.sort((a, b) => b.viewedAt.compareTo(a.viewedAt)); + return items.map((item) => item.toModel()).toList(); + }); + } + + Future> clear() { + return _databaseService.safeWrite((isar) async { + await isar.viewedPostEntitys.clear(); + }); + } +} diff --git a/lib/backend/services/feed_service.dart b/lib/backend/services/feed_service.dart index 38b3018..c2eb8f7 100644 --- a/lib/backend/services/feed_service.dart +++ b/lib/backend/services/feed_service.dart @@ -3,14 +3,22 @@ import '../../core/utils/result.dart'; import '../models/post.dart'; import '../models/top_period_filter.dart'; import '../providers/provider_manager.dart'; +import '../services/viewed_history_service.dart'; +import '../utils/smart_blacklist.dart'; import 'settings_service.dart'; class FeedService { - FeedService(this._providerManager, this._cacheService, this._settingsService); + FeedService( + this._providerManager, + this._cacheService, + this._settingsService, + this._viewedHistoryService, + ); final ProviderManager _providerManager; final CacheService _cacheService; final SettingsService _settingsService; + final ViewedHistoryService _viewedHistoryService; final Map _pages = {}; String _key( @@ -61,10 +69,65 @@ class FeedService { final posts = (result as Success>).data; _pages[key] = page + 1; final settingsResult = await _settingsService.getSettings(); - final maxItems = settingsResult is Success - ? settingsResult.data.cacheMaxItems - : null; - await _cacheService.cachePosts(posts, maxItems: maxItems); - return Success(posts); + final settings = settingsResult is Success + ? settingsResult.data + : AppSettings.defaults; + final viewedKeysResult = settings.hideViewedPosts + ? await _viewedHistoryService.getViewedKeys() + : const Success({}); + final viewedKeys = viewedKeysResult is Success> + ? viewedKeysResult.data + : {}; + final filteredPosts = posts + .where((post) => postMatchesRequestedTags(post, tags)) + .where((post) => postPassesTagFilters(post, settings)) + .where((post) => + !settings.hideViewedPosts || !viewedKeys.contains(post.cacheKey)) + .toList(growable: false); + await _cacheService.cachePosts( + filteredPosts, + maxItems: settings.cacheMaxItems, + ); + return Success(filteredPosts); } } + +bool postMatchesRequestedTags(Post post, List requestedTags) { + final requested = requestedTags + .map((tag) => tag.trim().toLowerCase()) + .where((tag) => tag.isNotEmpty && !tag.startsWith('-')) + .toList(growable: false); + if (requested.isEmpty) return true; + final postTags = _postTagSet(post); + return requested.every(postTags.contains); +} + +bool postPassesTagFilters(Post post, AppSettings settings) { + final tags = _postTagSet(post); + if (tags.isEmpty) return settings.whitelistedTags.isEmpty; + + final blacklist = [ + ...settings.smartBlacklistRules, + ...settings.blacklistedTags, + ].map((tag) => tag.trim().toLowerCase()).where((tag) => tag.isNotEmpty); + if (blacklist.any((rule) => SmartBlacklistMatcher.matches(post, rule))) { + return false; + } + + final whitelist = settings.whitelistedTags + .map((tag) => tag.trim().toLowerCase()) + .where((tag) => tag.isNotEmpty) + .toList(growable: false); + if (whitelist.isEmpty) return true; + return whitelist.any(tags.contains); +} + +Set _postTagSet(Post post) { + return { + ...post.tags, + for (final group in post.tagGroups.values) ...group, + } + .map((tag) => tag.trim().toLowerCase()) + .where((tag) => tag.isNotEmpty) + .toSet(); +} diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index a7d9578..944a85a 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -24,6 +24,10 @@ class AppSettings { required this.selectedFeedProviderIds, required this.showPostBadges, required this.defaultTopPeriodFilter, + required this.blacklistedTags, + required this.whitelistedTags, + required this.smartBlacklistRules, + required this.hideViewedPosts, this.defaultRatingFilter, }); @@ -40,6 +44,10 @@ class AppSettings { final List selectedFeedProviderIds; final bool showPostBadges; final String defaultTopPeriodFilter; + final List blacklistedTags; + final List whitelistedTags; + final List smartBlacklistRules; + final bool hideViewedPosts; final String? defaultRatingFilter; static const defaults = AppSettings( @@ -56,6 +64,10 @@ class AppSettings { selectedFeedProviderIds: [], showPostBadges: true, defaultTopPeriodFilter: 'none', + blacklistedTags: [], + whitelistedTags: [], + smartBlacklistRules: [], + hideViewedPosts: false, defaultRatingFilter: null, ); @@ -73,6 +85,10 @@ class AppSettings { List? selectedFeedProviderIds, bool? showPostBadges, String? defaultTopPeriodFilter, + List? blacklistedTags, + List? whitelistedTags, + List? smartBlacklistRules, + bool? hideViewedPosts, String? defaultRatingFilter, }) { return AppSettings( @@ -91,6 +107,10 @@ class AppSettings { showPostBadges: showPostBadges ?? this.showPostBadges, defaultTopPeriodFilter: defaultTopPeriodFilter ?? this.defaultTopPeriodFilter, + blacklistedTags: blacklistedTags ?? this.blacklistedTags, + whitelistedTags: whitelistedTags ?? this.whitelistedTags, + smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules, + hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts, defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter, ); } @@ -109,6 +129,10 @@ class AppSettings { 'selectedFeedProviderIds': selectedFeedProviderIds, 'showPostBadges': showPostBadges, 'defaultTopPeriodFilter': defaultTopPeriodFilter, + 'blacklistedTags': blacklistedTags, + 'whitelistedTags': whitelistedTags, + 'smartBlacklistRules': smartBlacklistRules, + 'hideViewedPosts': hideViewedPosts, 'defaultRatingFilter': defaultRatingFilter, }; @@ -141,6 +165,19 @@ class AppSettings { (json['showPostBadges'] as bool?) ?? defaults.showPostBadges, defaultTopPeriodFilter: (json['defaultTopPeriodFilter'] as String?) ?? defaults.defaultTopPeriodFilter, + blacklistedTags: List.from( + (json['blacklistedTags'] as List?) ?? defaults.blacklistedTags, + ), + whitelistedTags: List.from( + (json['whitelistedTags'] as List?) ?? defaults.whitelistedTags, + ), + smartBlacklistRules: List.from( + (json['smartBlacklistRules'] as List?) ?? + (json['blacklistedTags'] as List?) ?? + defaults.smartBlacklistRules, + ), + hideViewedPosts: + (json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts, defaultRatingFilter: json['defaultRatingFilter'] as String?, ); } diff --git a/lib/backend/services/viewed_history_service.dart b/lib/backend/services/viewed_history_service.dart new file mode 100644 index 0000000..9b1906a --- /dev/null +++ b/lib/backend/services/viewed_history_service.dart @@ -0,0 +1,41 @@ +import '../../core/utils/result.dart'; +import '../models/post.dart'; +import '../models/viewed_post.dart'; +import '../repositories/post_repository.dart'; +import '../repositories/viewed_post_repository.dart'; + +class ViewedHistoryService { + ViewedHistoryService(this._repository, this._postRepository); + + final ViewedPostRepository _repository; + final PostRepository _postRepository; + + Future> markViewed(Post post) async { + final cacheResult = await _postRepository.cachePosts([post]); + if (cacheResult is Error) return cacheResult; + return _repository.markViewed(post.providerId, post.id); + } + + Future> isViewed(String postId, String providerId) { + return _repository.exists(providerId, postId); + } + + Future>> getViewedKeys() { + return _repository.keys(); + } + + Future>> getViewedPosts() async { + final viewedResult = await _repository.recent(); + if (viewedResult is Error>) { + return Error(viewedResult.failure); + } + final viewed = (viewedResult as Success>).data; + return _postRepository.getCachedPostsByKeys( + viewed.map((item) => item.viewedKey), + ); + } + + Future> clearHistory() { + return _repository.clear(); + } +} diff --git a/lib/backend/utils/smart_blacklist.dart b/lib/backend/utils/smart_blacklist.dart new file mode 100644 index 0000000..f13d714 --- /dev/null +++ b/lib/backend/utils/smart_blacklist.dart @@ -0,0 +1,78 @@ +import '../models/post.dart'; + +class SmartBlacklistMatcher { + const SmartBlacklistMatcher._(); + + static bool matches(Post post, String rule) { + final tokens = rule + .trim() + .toLowerCase() + .split(RegExp(r'\s+')) + .where((token) => token.isNotEmpty) + .toList(growable: false); + if (tokens.isEmpty) return false; + return tokens.every((token) => _matchesToken(post, token)); + } + + static bool _matchesToken(Post post, String token) { + final parts = token.split(':'); + if (parts.length >= 2) { + final key = parts.first; + final value = parts.sublist(1).join(':'); + return switch (key) { + 'provider' => post.providerId.toLowerCase() == value, + 'rating' => post.rating.toLowerCase() == value || + post.rating.toLowerCase().startsWith(value), + 'type' || 'filetype' => post.fileType.toLowerCase().contains(value), + 'score' => _scoreMatches(post.score, value), + 'artist' || + 'character' || + 'copyright' || + 'meta' || + 'general' => + _groupContains(post, key, value), + _ => _allTags(post).contains(token), + }; + } + return _allTags(post).contains(token); + } + + static bool _scoreMatches(int score, String value) { + if (value.startsWith('>=')) { + return score >= (int.tryParse(value.substring(2)) ?? score + 1); + } + if (value.startsWith('<=')) { + return score <= (int.tryParse(value.substring(2)) ?? score - 1); + } + if (value.startsWith('>')) { + return score > (int.tryParse(value.substring(1)) ?? score); + } + if (value.startsWith('<')) { + return score < (int.tryParse(value.substring(1)) ?? score); + } + return score == int.tryParse(value); + } + + static bool _groupContains(Post post, String group, String tag) { + return (post.tagGroups[group] ?? const []) + .map((value) => value.toLowerCase()) + .contains(tag); + } + + static Set _allTags(Post post) { + return { + ...post.tags, + for (final group in post.tagGroups.values) ...group, + }.map((tag) => tag.toLowerCase()).toSet(); + } +} + +List similarTagsFor(Post post) { + final priority = [ + ...post.tagGroups['artist'] ?? const [], + ...post.tagGroups['character'] ?? const [], + ...post.tagGroups['copyright'] ?? const [], + ].where((tag) => tag.trim().isNotEmpty).toSet().take(5).toList(); + if (priority.isNotEmpty) return priority; + return post.tags.where((tag) => tag.trim().isNotEmpty).take(5).toList(); +} diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index bca0344..f1eb938 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -7,8 +7,10 @@ import '../../backend/models/collection.dart'; import '../../backend/models/content_provider_config.dart'; import '../../backend/models/favorite.dart'; import '../../backend/models/post.dart'; +import '../../backend/models/provider_diagnostics.dart'; import '../../backend/models/provider_health.dart'; import '../../backend/models/search_history.dart'; +import '../../backend/models/viewed_post.dart'; part 'app_database.g.dart'; @@ -29,6 +31,8 @@ class AppDatabase { CollectionPostEntitySchema, SearchHistoryEntitySchema, AppSettingEntitySchema, + ViewedPostEntitySchema, + ProviderDiagnosticsEntitySchema, ], directory: dir, name: 'gel_rule_app', @@ -276,3 +280,56 @@ class AppSettingEntity { late String jsonValue; late DateTime updatedAt; } + +@collection +class ViewedPostEntity { + Id isarId = Isar.autoIncrement; + + @Index(unique: true, replace: true) + late String viewedKey; + @Index(composite: [CompositeIndex('postId')]) + late String providerId; + late String postId; + late DateTime viewedAt; + + ViewedPost toModel() => ViewedPost( + viewedKey: viewedKey, + providerId: providerId, + postId: postId, + viewedAt: viewedAt, + ); + + static ViewedPostEntity fromModel(ViewedPost model) { + return ViewedPostEntity() + ..viewedKey = model.viewedKey + ..providerId = model.providerId + ..postId = model.postId + ..viewedAt = model.viewedAt; + } +} + +@collection +class ProviderDiagnosticsEntity { + Id isarId = Isar.autoIncrement; + + @Index(unique: true, replace: true) + late String providerId; + late DateTime lastSearchAt; + late int lastResultCount; + String? lastErrorMessage; + + ProviderDiagnostics toModel() => ProviderDiagnostics( + providerId: providerId, + lastSearchAt: lastSearchAt, + lastResultCount: lastResultCount, + lastErrorMessage: lastErrorMessage, + ); + + static ProviderDiagnosticsEntity fromModel(ProviderDiagnostics model) { + return ProviderDiagnosticsEntity() + ..providerId = model.providerId + ..lastSearchAt = model.lastSearchAt + ..lastResultCount = model.lastResultCount + ..lastErrorMessage = model.lastErrorMessage; + } +} diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index af5bf3c..b5207c5 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -12456,3 +12456,2147 @@ extension AppSettingEntityQueryProperty }); } } + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetViewedPostEntityCollection on Isar { + IsarCollection get viewedPostEntitys => this.collection(); +} + +const ViewedPostEntitySchema = CollectionSchema( + name: r'ViewedPostEntity', + id: 1092589981525841594, + properties: { + r'postId': PropertySchema( + id: 0, + name: r'postId', + type: IsarType.string, + ), + r'providerId': PropertySchema( + id: 1, + name: r'providerId', + type: IsarType.string, + ), + r'viewedAt': PropertySchema( + id: 2, + name: r'viewedAt', + type: IsarType.dateTime, + ), + r'viewedKey': PropertySchema( + id: 3, + name: r'viewedKey', + type: IsarType.string, + ) + }, + estimateSize: _viewedPostEntityEstimateSize, + serialize: _viewedPostEntitySerialize, + deserialize: _viewedPostEntityDeserialize, + deserializeProp: _viewedPostEntityDeserializeProp, + idName: r'isarId', + indexes: { + r'viewedKey': IndexSchema( + id: 7976054145357241653, + name: r'viewedKey', + unique: true, + replace: true, + properties: [ + IndexPropertySchema( + name: r'viewedKey', + type: IndexType.hash, + caseSensitive: true, + ) + ], + ), + r'providerId_postId': IndexSchema( + id: 711415917800973915, + name: r'providerId_postId', + unique: false, + replace: false, + properties: [ + IndexPropertySchema( + name: r'providerId', + type: IndexType.hash, + caseSensitive: true, + ), + IndexPropertySchema( + name: r'postId', + type: IndexType.hash, + caseSensitive: true, + ) + ], + ) + }, + links: {}, + embeddedSchemas: {}, + getId: _viewedPostEntityGetId, + getLinks: _viewedPostEntityGetLinks, + attach: _viewedPostEntityAttach, + version: '3.1.0+1', +); + +int _viewedPostEntityEstimateSize( + ViewedPostEntity object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.postId.length * 3; + bytesCount += 3 + object.providerId.length * 3; + bytesCount += 3 + object.viewedKey.length * 3; + return bytesCount; +} + +void _viewedPostEntitySerialize( + ViewedPostEntity object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.postId); + writer.writeString(offsets[1], object.providerId); + writer.writeDateTime(offsets[2], object.viewedAt); + writer.writeString(offsets[3], object.viewedKey); +} + +ViewedPostEntity _viewedPostEntityDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = ViewedPostEntity(); + object.isarId = id; + object.postId = reader.readString(offsets[0]); + object.providerId = reader.readString(offsets[1]); + object.viewedAt = reader.readDateTime(offsets[2]); + object.viewedKey = reader.readString(offsets[3]); + return object; +} + +P _viewedPostEntityDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readString(offset)) as P; + case 1: + return (reader.readString(offset)) as P; + case 2: + return (reader.readDateTime(offset)) as P; + case 3: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _viewedPostEntityGetId(ViewedPostEntity object) { + return object.isarId; +} + +List> _viewedPostEntityGetLinks(ViewedPostEntity object) { + return []; +} + +void _viewedPostEntityAttach( + IsarCollection col, Id id, ViewedPostEntity object) { + object.isarId = id; +} + +extension ViewedPostEntityByIndex on IsarCollection { + Future getByViewedKey(String viewedKey) { + return getByIndex(r'viewedKey', [viewedKey]); + } + + ViewedPostEntity? getByViewedKeySync(String viewedKey) { + return getByIndexSync(r'viewedKey', [viewedKey]); + } + + Future deleteByViewedKey(String viewedKey) { + return deleteByIndex(r'viewedKey', [viewedKey]); + } + + bool deleteByViewedKeySync(String viewedKey) { + return deleteByIndexSync(r'viewedKey', [viewedKey]); + } + + Future> getAllByViewedKey( + List viewedKeyValues) { + final values = viewedKeyValues.map((e) => [e]).toList(); + return getAllByIndex(r'viewedKey', values); + } + + List getAllByViewedKeySync(List viewedKeyValues) { + final values = viewedKeyValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'viewedKey', values); + } + + Future deleteAllByViewedKey(List viewedKeyValues) { + final values = viewedKeyValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'viewedKey', values); + } + + int deleteAllByViewedKeySync(List viewedKeyValues) { + final values = viewedKeyValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'viewedKey', values); + } + + Future putByViewedKey(ViewedPostEntity object) { + return putByIndex(r'viewedKey', object); + } + + Id putByViewedKeySync(ViewedPostEntity object, {bool saveLinks = true}) { + return putByIndexSync(r'viewedKey', object, saveLinks: saveLinks); + } + + Future> putAllByViewedKey(List objects) { + return putAllByIndex(r'viewedKey', objects); + } + + List putAllByViewedKeySync(List objects, + {bool saveLinks = true}) { + return putAllByIndexSync(r'viewedKey', objects, saveLinks: saveLinks); + } +} + +extension ViewedPostEntityQueryWhereSort + on QueryBuilder { + QueryBuilder anyIsarId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension ViewedPostEntityQueryWhere + on QueryBuilder { + QueryBuilder + isarIdEqualTo(Id isarId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: isarId, + upper: isarId, + )); + }); + } + + QueryBuilder + isarIdNotEqualTo(Id isarId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: isarId, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: isarId, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: isarId, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: isarId, includeUpper: false), + ); + } + }); + } + + QueryBuilder + isarIdGreaterThan(Id isarId, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: isarId, includeLower: include), + ); + }); + } + + QueryBuilder + isarIdLessThan(Id isarId, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: isarId, includeUpper: include), + ); + }); + } + + QueryBuilder + isarIdBetween( + Id lowerIsarId, + Id upperIsarId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerIsarId, + includeLower: includeLower, + upper: upperIsarId, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + viewedKeyEqualTo(String viewedKey) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'viewedKey', + value: [viewedKey], + )); + }); + } + + QueryBuilder + viewedKeyNotEqualTo(String viewedKey) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'viewedKey', + lower: [], + upper: [viewedKey], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'viewedKey', + lower: [viewedKey], + includeLower: false, + upper: [], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'viewedKey', + lower: [viewedKey], + includeLower: false, + upper: [], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'viewedKey', + lower: [], + upper: [viewedKey], + includeUpper: false, + )); + } + }); + } + + QueryBuilder + providerIdEqualToAnyPostId(String providerId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'providerId_postId', + value: [providerId], + )); + }); + } + + QueryBuilder + providerIdNotEqualToAnyPostId(String providerId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId_postId', + lower: [], + upper: [providerId], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId_postId', + lower: [providerId], + includeLower: false, + upper: [], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId_postId', + lower: [providerId], + includeLower: false, + upper: [], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId_postId', + lower: [], + upper: [providerId], + includeUpper: false, + )); + } + }); + } + + QueryBuilder + providerIdPostIdEqualTo(String providerId, String postId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'providerId_postId', + value: [providerId, postId], + )); + }); + } + + QueryBuilder + providerIdEqualToPostIdNotEqualTo(String providerId, String postId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId_postId', + lower: [providerId], + upper: [providerId, postId], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId_postId', + lower: [providerId, postId], + includeLower: false, + upper: [providerId], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId_postId', + lower: [providerId, postId], + includeLower: false, + upper: [providerId], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId_postId', + lower: [providerId], + upper: [providerId, postId], + includeUpper: false, + )); + } + }); + } +} + +extension ViewedPostEntityQueryFilter + on QueryBuilder { + QueryBuilder + isarIdEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'isarId', + value: value, + )); + }); + } + + QueryBuilder + isarIdGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'isarId', + value: value, + )); + }); + } + + QueryBuilder + isarIdLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'isarId', + value: value, + )); + }); + } + + QueryBuilder + isarIdBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'isarId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + postIdEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'postId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + postIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'postId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + postIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'postId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + postIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'postId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + postIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'postId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + postIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'postId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + postIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'postId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + postIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'postId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + postIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'postId', + value: '', + )); + }); + } + + QueryBuilder + postIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'postId', + value: '', + )); + }); + } + + QueryBuilder + providerIdEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'providerId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'providerId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'providerId', + value: '', + )); + }); + } + + QueryBuilder + providerIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'providerId', + value: '', + )); + }); + } + + QueryBuilder + viewedAtEqualTo(DateTime value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'viewedAt', + value: value, + )); + }); + } + + QueryBuilder + viewedAtGreaterThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'viewedAt', + value: value, + )); + }); + } + + QueryBuilder + viewedAtLessThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'viewedAt', + value: value, + )); + }); + } + + QueryBuilder + viewedAtBetween( + DateTime lower, + DateTime upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'viewedAt', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + viewedKeyEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'viewedKey', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + viewedKeyGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'viewedKey', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + viewedKeyLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'viewedKey', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + viewedKeyBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'viewedKey', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + viewedKeyStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'viewedKey', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + viewedKeyEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'viewedKey', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + viewedKeyContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'viewedKey', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + viewedKeyMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'viewedKey', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + viewedKeyIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'viewedKey', + value: '', + )); + }); + } + + QueryBuilder + viewedKeyIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'viewedKey', + value: '', + )); + }); + } +} + +extension ViewedPostEntityQueryObject + on QueryBuilder {} + +extension ViewedPostEntityQueryLinks + on QueryBuilder {} + +extension ViewedPostEntityQuerySortBy + on QueryBuilder { + QueryBuilder + sortByPostId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'postId', Sort.asc); + }); + } + + QueryBuilder + sortByPostIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'postId', Sort.desc); + }); + } + + QueryBuilder + sortByProviderId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'providerId', Sort.asc); + }); + } + + QueryBuilder + sortByProviderIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'providerId', Sort.desc); + }); + } + + QueryBuilder + sortByViewedAt() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'viewedAt', Sort.asc); + }); + } + + QueryBuilder + sortByViewedAtDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'viewedAt', Sort.desc); + }); + } + + QueryBuilder + sortByViewedKey() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'viewedKey', Sort.asc); + }); + } + + QueryBuilder + sortByViewedKeyDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'viewedKey', Sort.desc); + }); + } +} + +extension ViewedPostEntityQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByIsarId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isarId', Sort.asc); + }); + } + + QueryBuilder + thenByIsarIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isarId', Sort.desc); + }); + } + + QueryBuilder + thenByPostId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'postId', Sort.asc); + }); + } + + QueryBuilder + thenByPostIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'postId', Sort.desc); + }); + } + + QueryBuilder + thenByProviderId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'providerId', Sort.asc); + }); + } + + QueryBuilder + thenByProviderIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'providerId', Sort.desc); + }); + } + + QueryBuilder + thenByViewedAt() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'viewedAt', Sort.asc); + }); + } + + QueryBuilder + thenByViewedAtDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'viewedAt', Sort.desc); + }); + } + + QueryBuilder + thenByViewedKey() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'viewedKey', Sort.asc); + }); + } + + QueryBuilder + thenByViewedKeyDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'viewedKey', Sort.desc); + }); + } +} + +extension ViewedPostEntityQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByPostId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'postId', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByProviderId({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'providerId', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByViewedAt() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'viewedAt'); + }); + } + + QueryBuilder + distinctByViewedKey({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'viewedKey', caseSensitive: caseSensitive); + }); + } +} + +extension ViewedPostEntityQueryProperty + on QueryBuilder { + QueryBuilder isarIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'isarId'); + }); + } + + QueryBuilder postIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'postId'); + }); + } + + QueryBuilder + providerIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'providerId'); + }); + } + + QueryBuilder + viewedAtProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'viewedAt'); + }); + } + + QueryBuilder viewedKeyProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'viewedKey'); + }); + } +} + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetProviderDiagnosticsEntityCollection on Isar { + IsarCollection get providerDiagnosticsEntitys => + this.collection(); +} + +const ProviderDiagnosticsEntitySchema = CollectionSchema( + name: r'ProviderDiagnosticsEntity', + id: -3843424147953592067, + properties: { + r'lastErrorMessage': PropertySchema( + id: 0, + name: r'lastErrorMessage', + type: IsarType.string, + ), + r'lastResultCount': PropertySchema( + id: 1, + name: r'lastResultCount', + type: IsarType.long, + ), + r'lastSearchAt': PropertySchema( + id: 2, + name: r'lastSearchAt', + type: IsarType.dateTime, + ), + r'providerId': PropertySchema( + id: 3, + name: r'providerId', + type: IsarType.string, + ) + }, + estimateSize: _providerDiagnosticsEntityEstimateSize, + serialize: _providerDiagnosticsEntitySerialize, + deserialize: _providerDiagnosticsEntityDeserialize, + deserializeProp: _providerDiagnosticsEntityDeserializeProp, + idName: r'isarId', + indexes: { + r'providerId': IndexSchema( + id: -1675978104265523206, + name: r'providerId', + unique: true, + replace: true, + properties: [ + IndexPropertySchema( + name: r'providerId', + type: IndexType.hash, + caseSensitive: true, + ) + ], + ) + }, + links: {}, + embeddedSchemas: {}, + getId: _providerDiagnosticsEntityGetId, + getLinks: _providerDiagnosticsEntityGetLinks, + attach: _providerDiagnosticsEntityAttach, + version: '3.1.0+1', +); + +int _providerDiagnosticsEntityEstimateSize( + ProviderDiagnosticsEntity object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.lastErrorMessage; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.providerId.length * 3; + return bytesCount; +} + +void _providerDiagnosticsEntitySerialize( + ProviderDiagnosticsEntity object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.lastErrorMessage); + writer.writeLong(offsets[1], object.lastResultCount); + writer.writeDateTime(offsets[2], object.lastSearchAt); + writer.writeString(offsets[3], object.providerId); +} + +ProviderDiagnosticsEntity _providerDiagnosticsEntityDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = ProviderDiagnosticsEntity(); + object.isarId = id; + object.lastErrorMessage = reader.readStringOrNull(offsets[0]); + object.lastResultCount = reader.readLong(offsets[1]); + object.lastSearchAt = reader.readDateTime(offsets[2]); + object.providerId = reader.readString(offsets[3]); + return object; +} + +P _providerDiagnosticsEntityDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + case 1: + return (reader.readLong(offset)) as P; + case 2: + return (reader.readDateTime(offset)) as P; + case 3: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _providerDiagnosticsEntityGetId(ProviderDiagnosticsEntity object) { + return object.isarId; +} + +List> _providerDiagnosticsEntityGetLinks( + ProviderDiagnosticsEntity object) { + return []; +} + +void _providerDiagnosticsEntityAttach( + IsarCollection col, Id id, ProviderDiagnosticsEntity object) { + object.isarId = id; +} + +extension ProviderDiagnosticsEntityByIndex + on IsarCollection { + Future getByProviderId(String providerId) { + return getByIndex(r'providerId', [providerId]); + } + + ProviderDiagnosticsEntity? getByProviderIdSync(String providerId) { + return getByIndexSync(r'providerId', [providerId]); + } + + Future deleteByProviderId(String providerId) { + return deleteByIndex(r'providerId', [providerId]); + } + + bool deleteByProviderIdSync(String providerId) { + return deleteByIndexSync(r'providerId', [providerId]); + } + + Future> getAllByProviderId( + List providerIdValues) { + final values = providerIdValues.map((e) => [e]).toList(); + return getAllByIndex(r'providerId', values); + } + + List getAllByProviderIdSync( + List providerIdValues) { + final values = providerIdValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'providerId', values); + } + + Future deleteAllByProviderId(List providerIdValues) { + final values = providerIdValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'providerId', values); + } + + int deleteAllByProviderIdSync(List providerIdValues) { + final values = providerIdValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'providerId', values); + } + + Future putByProviderId(ProviderDiagnosticsEntity object) { + return putByIndex(r'providerId', object); + } + + Id putByProviderIdSync(ProviderDiagnosticsEntity object, + {bool saveLinks = true}) { + return putByIndexSync(r'providerId', object, saveLinks: saveLinks); + } + + Future> putAllByProviderId(List objects) { + return putAllByIndex(r'providerId', objects); + } + + List putAllByProviderIdSync(List objects, + {bool saveLinks = true}) { + return putAllByIndexSync(r'providerId', objects, saveLinks: saveLinks); + } +} + +extension ProviderDiagnosticsEntityQueryWhereSort on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QWhere> { + QueryBuilder anyIsarId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension ProviderDiagnosticsEntityQueryWhere on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QWhereClause> { + QueryBuilder isarIdEqualTo(Id isarId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: isarId, + upper: isarId, + )); + }); + } + + QueryBuilder isarIdNotEqualTo(Id isarId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: isarId, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: isarId, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: isarId, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: isarId, includeUpper: false), + ); + } + }); + } + + QueryBuilder isarIdGreaterThan(Id isarId, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: isarId, includeLower: include), + ); + }); + } + + QueryBuilder isarIdLessThan(Id isarId, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: isarId, includeUpper: include), + ); + }); + } + + QueryBuilder isarIdBetween( + Id lowerIsarId, + Id upperIsarId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerIsarId, + includeLower: includeLower, + upper: upperIsarId, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder providerIdEqualTo(String providerId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'providerId', + value: [providerId], + )); + }); + } + + QueryBuilder providerIdNotEqualTo(String providerId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId', + lower: [], + upper: [providerId], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId', + lower: [providerId], + includeLower: false, + upper: [], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId', + lower: [providerId], + includeLower: false, + upper: [], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'providerId', + lower: [], + upper: [providerId], + includeUpper: false, + )); + } + }); + } +} + +extension ProviderDiagnosticsEntityQueryFilter on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QFilterCondition> { + QueryBuilder isarIdEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'isarId', + value: value, + )); + }); + } + + QueryBuilder isarIdGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'isarId', + value: value, + )); + }); + } + + QueryBuilder isarIdLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'isarId', + value: value, + )); + }); + } + + QueryBuilder isarIdBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'isarId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder lastErrorMessageIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'lastErrorMessage', + )); + }); + } + + QueryBuilder lastErrorMessageIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'lastErrorMessage', + )); + }); + } + + QueryBuilder lastErrorMessageEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'lastErrorMessage', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder lastErrorMessageGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'lastErrorMessage', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder lastErrorMessageLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'lastErrorMessage', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder lastErrorMessageBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'lastErrorMessage', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder lastErrorMessageStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'lastErrorMessage', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder lastErrorMessageEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'lastErrorMessage', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + lastErrorMessageContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'lastErrorMessage', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + lastErrorMessageMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'lastErrorMessage', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder lastErrorMessageIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'lastErrorMessage', + value: '', + )); + }); + } + + QueryBuilder lastErrorMessageIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'lastErrorMessage', + value: '', + )); + }); + } + + QueryBuilder lastResultCountEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'lastResultCount', + value: value, + )); + }); + } + + QueryBuilder lastResultCountGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'lastResultCount', + value: value, + )); + }); + } + + QueryBuilder lastResultCountLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'lastResultCount', + value: value, + )); + }); + } + + QueryBuilder lastResultCountBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'lastResultCount', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder lastSearchAtEqualTo(DateTime value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'lastSearchAt', + value: value, + )); + }); + } + + QueryBuilder lastSearchAtGreaterThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'lastSearchAt', + value: value, + )); + }); + } + + QueryBuilder lastSearchAtLessThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'lastSearchAt', + value: value, + )); + }); + } + + QueryBuilder lastSearchAtBetween( + DateTime lower, + DateTime upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'lastSearchAt', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder providerIdEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder providerIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder providerIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder providerIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'providerId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder providerIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder providerIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'providerId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + providerIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'providerId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder providerIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'providerId', + value: '', + )); + }); + } + + QueryBuilder providerIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'providerId', + value: '', + )); + }); + } +} + +extension ProviderDiagnosticsEntityQueryObject on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QFilterCondition> {} + +extension ProviderDiagnosticsEntityQueryLinks on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QFilterCondition> {} + +extension ProviderDiagnosticsEntityQuerySortBy on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QSortBy> { + QueryBuilder sortByLastErrorMessage() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastErrorMessage', Sort.asc); + }); + } + + QueryBuilder sortByLastErrorMessageDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastErrorMessage', Sort.desc); + }); + } + + QueryBuilder sortByLastResultCount() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastResultCount', Sort.asc); + }); + } + + QueryBuilder sortByLastResultCountDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastResultCount', Sort.desc); + }); + } + + QueryBuilder sortByLastSearchAt() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastSearchAt', Sort.asc); + }); + } + + QueryBuilder sortByLastSearchAtDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastSearchAt', Sort.desc); + }); + } + + QueryBuilder sortByProviderId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'providerId', Sort.asc); + }); + } + + QueryBuilder sortByProviderIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'providerId', Sort.desc); + }); + } +} + +extension ProviderDiagnosticsEntityQuerySortThenBy on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QSortThenBy> { + QueryBuilder thenByIsarId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isarId', Sort.asc); + }); + } + + QueryBuilder thenByIsarIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isarId', Sort.desc); + }); + } + + QueryBuilder thenByLastErrorMessage() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastErrorMessage', Sort.asc); + }); + } + + QueryBuilder thenByLastErrorMessageDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastErrorMessage', Sort.desc); + }); + } + + QueryBuilder thenByLastResultCount() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastResultCount', Sort.asc); + }); + } + + QueryBuilder thenByLastResultCountDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastResultCount', Sort.desc); + }); + } + + QueryBuilder thenByLastSearchAt() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastSearchAt', Sort.asc); + }); + } + + QueryBuilder thenByLastSearchAtDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'lastSearchAt', Sort.desc); + }); + } + + QueryBuilder thenByProviderId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'providerId', Sort.asc); + }); + } + + QueryBuilder thenByProviderIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'providerId', Sort.desc); + }); + } +} + +extension ProviderDiagnosticsEntityQueryWhereDistinct on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QDistinct> { + QueryBuilder + distinctByLastErrorMessage({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'lastErrorMessage', + caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByLastResultCount() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'lastResultCount'); + }); + } + + QueryBuilder + distinctByLastSearchAt() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'lastSearchAt'); + }); + } + + QueryBuilder + distinctByProviderId({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'providerId', caseSensitive: caseSensitive); + }); + } +} + +extension ProviderDiagnosticsEntityQueryProperty on QueryBuilder< + ProviderDiagnosticsEntity, ProviderDiagnosticsEntity, QQueryProperty> { + QueryBuilder + isarIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'isarId'); + }); + } + + QueryBuilder + lastErrorMessageProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'lastErrorMessage'); + }); + } + + QueryBuilder + lastResultCountProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'lastResultCount'); + }); + } + + QueryBuilder + lastSearchAtProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'lastSearchAt'); + }); + } + + QueryBuilder + providerIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'providerId'); + }); + } +} diff --git a/lib/features/feed/presentation/feed_controller.dart b/lib/features/feed/presentation/feed_controller.dart index 91d58ce..264faf3 100644 --- a/lib/features/feed/presentation/feed_controller.dart +++ b/lib/features/feed/presentation/feed_controller.dart @@ -47,9 +47,13 @@ class FeedController extends AsyncNotifier { if (current == null || current.isLoadingMore || !current.hasMore) return; state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true)); final posts = await _load(refresh: false, current: current); + final existingKeys = current.posts.map((post) => post.cacheKey).toSet(); + final newPosts = posts + .where((post) => existingKeys.add(post.cacheKey)) + .toList(growable: false); state = AsyncData( current.copyWith( - posts: [...current.posts, ...posts], + posts: [...current.posts, ...newPosts], isLoadingMore: false, hasMore: posts.isNotEmpty, ), @@ -59,7 +63,9 @@ class FeedController extends AsyncNotifier { Future search(String query) async { final tags = ref.read(searchServiceProvider).parseTags(query); final current = state.value ?? const FeedState(); - state = AsyncData(current.copyWith(selectedTags: tags, posts: [])); + state = AsyncData( + current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []), + ); await ref.read(searchServiceProvider).saveSearch(query, 0); await refresh(); final count = state.value?.posts.length ?? 0; @@ -76,8 +82,13 @@ class FeedController extends AsyncNotifier { final result = await ref .read(searchServiceProvider) .autocompleteDetailed(lastToken, limit: 16); + final normalizedToken = lastToken.trim().toLowerCase(); final suggestions = result is Success> ? result.data + .where((item) => item.name.toLowerCase().startsWith( + normalizedToken, + )) + .toList(growable: false) : const []; state = AsyncData(current.copyWith(tagSuggestions: suggestions)); } @@ -138,6 +149,7 @@ class FeedController extends AsyncNotifier { ? [null] : current.selectedProviderIds; final posts = []; + final seen = {}; for (final providerId in providerIds) { final result = refresh ? await service.refresh( @@ -152,7 +164,11 @@ class FeedController extends AsyncNotifier { providerId: providerId, topPeriod: current.topPeriodFilter, ); - if (result is Success>) posts.addAll(result.data); + if (result is Success>) { + for (final post in result.data) { + if (seen.add(post.cacheKey)) posts.add(post); + } + } } return posts; } diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index 121a7aa..bd5d828 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -52,6 +52,7 @@ class _FeedScreenState extends ConsumerState { final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; + final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) { _usedInitialQuery = true; @@ -165,6 +166,7 @@ class _FeedScreenState extends ConsumerState { nsfwEnabled: settings.nsfwEnabled, loading: state.isLoadingMore, favoriteKeys: favoriteKeys, + viewedKeys: viewedKeys, onOpen: (post) => context.push( '/post/${post.providerId}/${post.id}', extra: post, diff --git a/lib/features/feed/presentation/widgets/feed_toolbar.dart b/lib/features/feed/presentation/widgets/feed_toolbar.dart index f522cda..4a5672b 100644 --- a/lib/features/feed/presentation/widgets/feed_toolbar.dart +++ b/lib/features/feed/presentation/widgets/feed_toolbar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import '../../../../app/responsive.dart'; import '../../../../backend/backend.dart'; @@ -75,13 +76,18 @@ class _FeedToolbarState extends State { Row( children: [ Expanded( - child: AppSearchBar( + child: TagInputSearchBar( initialValue: _query, + suggestions: widget.tagSuggestions, onChanged: (value) { _query = value; widget.onSearchChanged(value); }, onSubmitted: widget.onSearch, + onSuggestionApplied: (value) { + _query = value; + widget.onSuggestionTap(value); + }, ), ), const SizedBox(width: 8), @@ -108,10 +114,12 @@ class _FeedToolbarState extends State { ], ), const SizedBox(height: 8), - SizedBox( + _HorizontalWheelScroller( height: 36, - child: ListView( + builder: (controller) => ListView( + controller: controller, scrollDirection: Axis.horizontal, + primary: false, children: [ if (widget.providers.isNotEmpty) ...[ FilterChip( @@ -144,23 +152,6 @@ class _FeedToolbarState extends State { ], ), ), - if (widget.tagSuggestions.isNotEmpty) ...[ - const SizedBox(height: 8), - SizedBox( - height: 34, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: widget.tagSuggestions.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, index) => ActionChip( - avatar: const Icon(Icons.tag_rounded, size: 15), - label: _SuggestionLabel(widget.tagSuggestions[index]), - onPressed: () => - _applySuggestion(widget.tagSuggestions[index].name), - ), - ), - ), - ], ], ), ); @@ -171,19 +162,26 @@ class _FeedToolbarState extends State { padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), child: Column( children: [ - AppSearchBar( + TagInputSearchBar( initialValue: _query, + suggestions: widget.tagSuggestions, onChanged: (value) { _query = value; widget.onSearchChanged(value); }, onSubmitted: widget.onSearch, + onSuggestionApplied: (value) { + _query = value; + widget.onSuggestionTap(value); + }, ), const SizedBox(height: 8), - SizedBox( + _HorizontalWheelScroller( height: 40, - child: ListView( + builder: (controller) => ListView( + controller: controller, scrollDirection: Axis.horizontal, + primary: false, children: [ IconButton.filledTonal( tooltip: 'Providers', @@ -230,10 +228,12 @@ class _FeedToolbarState extends State { ), ), const SizedBox(height: 6), - SizedBox( + _HorizontalWheelScroller( height: 36, - child: ListView( + builder: (controller) => ListView( + controller: controller, scrollDirection: Axis.horizontal, + primary: false, children: [ for (final period in TopPeriodFilter.values) ...[ ChoiceChip( @@ -246,59 +246,53 @@ class _FeedToolbarState extends State { ], ), ), - if (widget.tagSuggestions.isNotEmpty) ...[ - const SizedBox(height: 6), - SizedBox( - height: 34, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: widget.tagSuggestions.length, - separatorBuilder: (_, __) => const SizedBox(width: 6), - itemBuilder: (context, index) => ActionChip( - label: _SuggestionLabel(widget.tagSuggestions[index]), - onPressed: () => - _applySuggestion(widget.tagSuggestions[index].name), - ), - ), - ), - ], ], ), ); } - - void _applySuggestion(String suggestion) { - final tokens = _query.trim().isEmpty - ? [] - : _query.trim().split(RegExp(r'\s+')); - if (tokens.isEmpty) { - tokens.add(suggestion); - } else { - tokens[tokens.length - 1] = suggestion; - } - final next = tokens.toSet().join(' '); - setState(() => _query = next); - widget.onSuggestionTap(next); - } } -class _SuggestionLabel extends StatelessWidget { - const _SuggestionLabel(this.suggestion); +class _HorizontalWheelScroller extends StatefulWidget { + const _HorizontalWheelScroller({ + required this.builder, + required this.height, + }); - final TagSuggestion suggestion; + final Widget Function(ScrollController controller) builder; + final double height; + + @override + State<_HorizontalWheelScroller> createState() => + _HorizontalWheelScrollerState(); +} + +class _HorizontalWheelScrollerState extends State<_HorizontalWheelScroller> { + final _controller = ScrollController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(suggestion.name), - const SizedBox(width: 6), - Text( - suggestion.categoryLabel, - style: Theme.of(context).textTheme.labelSmall, + return SizedBox( + height: widget.height, + child: Listener( + onPointerSignal: (event) { + if (event is! PointerScrollEvent || !_controller.hasClients) return; + final target = (_controller.offset + event.scrollDelta.dy) + .clamp(0.0, _controller.position.maxScrollExtent) + .toDouble(); + _controller.jumpTo(target); + }, + child: Scrollbar( + controller: _controller, + thumbVisibility: false, + child: widget.builder(_controller), ), - ], + ), ); } } diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 98a00b4..ade7fd8 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -2,6 +2,7 @@ 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 'package:cached_network_image/cached_network_image.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../app/app.dart'; @@ -16,6 +17,7 @@ import '../../../shared/widgets/rating_badge.dart'; import '../../collections/presentation/collection_form_dialog.dart'; import '../../favorites/presentation/favorites_controller.dart'; import '../../feed/presentation/feed_controller.dart'; +import '../../viewed/presentation/viewed_controller.dart'; import 'post_details_controller.dart'; import 'widgets/post_action_bar.dart'; import 'widgets/post_media_viewer.dart'; @@ -69,6 +71,11 @@ class PostDetailsScreen extends ConsumerWidget { error: (error, _) => ErrorView(message: error.toString()), data: (post) { if (post == null) return const EmptyView(title: 'Post not found'); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(viewedHistoryServiceProvider).markViewed(post); + ref.invalidate(viewedKeysProvider); + ref.invalidate(viewedControllerProvider); + }); final currentIndex = feedPosts.indexWhere( (item) => item.providerId == post.providerId && item.id == post.id, ); @@ -99,70 +106,131 @@ class PostDetailsScreen extends ConsumerWidget { favoriteKeys, ); } - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton.filledTonal( - tooltip: 'Previous', - onPressed: previous == null - ? null - : () => _openPost(context, previous), - icon: const Icon(Icons.chevron_left_rounded), - ), - Expanded( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 760), - child: PostMediaViewer(post: post), + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.arrowLeft): + const _PreviousPostIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): + const _NextPostIntent(), + LogicalKeySet(LogicalKeyboardKey.keyF): + const _ToggleFavoriteIntent(), + LogicalKeySet(LogicalKeyboardKey.keyC): + const _AddCollectionIntent(), + LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(), + }, + child: Actions( + actions: { + _PreviousPostIntent: CallbackAction<_PreviousPostIntent>( + onInvoke: (_) { + if (previous != null) _openPost(context, previous); + return null; + }, + ), + _NextPostIntent: CallbackAction<_NextPostIntent>( + onInvoke: (_) { + if (next != null) _openPost(context, next); + return null; + }, + ), + _ToggleFavoriteIntent: CallbackAction<_ToggleFavoriteIntent>( + onInvoke: (_) { + _toggleFavorite(ref, post, favoriteKeys); + return null; + }, + ), + _AddCollectionIntent: CallbackAction<_AddCollectionIntent>( + onInvoke: (_) { + _addToCollection(context, ref, post); + return null; + }, + ), + _CloseIntent: CallbackAction<_CloseIntent>( + onInvoke: (_) { + _close(context); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton.filledTonal( + tooltip: 'Previous', + onPressed: previous == null + ? null + : () => _openPost(context, previous), + icon: const Icon(Icons.chevron_left_rounded), + ), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 760), + child: PostMediaViewer(post: post), + ), + ), + ), + IconButton.filledTonal( + tooltip: 'Next', + onPressed: next == null + ? null + : () => _openPost(context, next), + icon: const Icon(Icons.chevron_right_rounded), + ), + ], + ), + if (currentIndex >= 0) ...[ + const SizedBox(height: 10), + _NeighborStrip( + posts: feedPosts, + currentIndex: currentIndex, + onOpen: (post) => _openPost(context, post), ), + ], + const SizedBox(height: 16), + PostActionBar( + isFavorite: favoriteKeys.contains(post.cacheKey), + onFavorite: () => + _toggleFavorite(ref, post, favoriteKeys), + onCollection: () => _addToCollection(context, ref, post), + onOpen: () => launchUrl(Uri.parse(post.fileUrl)), + onCopy: () => + Clipboard.setData(ClipboardData(text: post.fileUrl)), + onSimilar: () => _openSimilar(context, ref, post), + onDownload: settings.allowDownloads + ? () => _download(context, ref, post) + : null, ), - ), - IconButton.filledTonal( - tooltip: 'Next', - onPressed: - next == null ? null : () => _openPost(context, next), - icon: const Icon(Icons.chevron_right_rounded), - ), - ], - ), - const SizedBox(height: 16), - PostActionBar( - isFavorite: favoriteKeys.contains(post.cacheKey), - onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), - onCollection: () => _addToCollection(context, ref, post), - onOpen: () => launchUrl(Uri.parse(post.fileUrl)), - onCopy: () => - Clipboard.setData(ClipboardData(text: post.fileUrl)), - onDownload: settings.allowDownloads - ? () => _download(context, ref, post) - : null, - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Chip(label: Text(post.providerName)), - RatingBadge(rating: post.rating), - Chip(label: Text('${post.width} x ${post.height}')), - if (post.source != null && post.source!.isNotEmpty) - ActionChip( - label: const Text('Source'), - onPressed: () => launchUrl(Uri.parse(post.source!)), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Chip(label: Text(post.providerName)), + RatingBadge(rating: post.rating), + Chip(label: Text('${post.width} x ${post.height}')), + if (post.source != null && post.source!.isNotEmpty) + ActionChip( + label: const Text('Source'), + onPressed: () => launchUrl(Uri.parse(post.source!)), + ), + ], ), - ], + const SizedBox(height: 16), + Text('Tags', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + PostTagsPanel(post: post), + const SizedBox(height: 16), + _CommentsSection(post: post), + ], + ), ), - const SizedBox(height: 16), - Text('Tags', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - PostTagsPanel(post: post), - const SizedBox(height: 16), - _CommentsSection(post: post), - ], + ), ); }, ), @@ -192,6 +260,7 @@ class PostDetailsScreen extends ConsumerWidget { onCollection: () => _addToCollection(context, ref, post), onOpen: () => launchUrl(Uri.parse(post.fileUrl)), onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)), + onSimilar: () => _openSimilar(context, ref, post), onDownload: settings.allowDownloads ? () => _download(context, ref, post) : null, @@ -288,6 +357,13 @@ class PostDetailsScreen extends ConsumerWidget { _replacePost(context, post); } + void _openSimilar(BuildContext context, WidgetRef ref, Post post) { + final query = similarTagsFor(post).join(' '); + if (query.trim().isEmpty) return; + ref.read(feedControllerProvider.notifier).search(query); + context.go('/?q=${Uri.encodeQueryComponent(query)}'); + } + void _replacePost(BuildContext context, Post post) { context.replace('/post/${post.providerId}/${post.id}', extra: post); } @@ -360,14 +436,6 @@ class _MobilePostPagerState extends State<_MobilePostPager> { onPageChanged: (index) { _currentIndex = index; _prefetchAround(index); - final post = widget.posts[index]; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - GoRouter.of(context).replace( - '/post/${post.providerId}/${post.id}', - extra: post, - ); - }); }, itemBuilder: (context, index) { return _KeepAlivePostPage( @@ -381,7 +449,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> { } void _prefetchAround(int index) { - for (final offset in [-1, 0, 1]) { + for (final offset in [-2, -1, 0, 1, 2]) { final target = index + offset; if (target < 0 || target >= widget.posts.length) continue; final post = widget.posts[target]; @@ -391,10 +459,30 @@ class _MobilePostPagerState extends State<_MobilePostPager> { if (post.fileType.toLowerCase().contains('gif')) post.fileUrl, ].where((url) => url.trim().isNotEmpty).toSet(); for (final url in urls) { - precacheImage(NetworkImage(url), context); + precacheImage( + CachedNetworkImageProvider(url, headers: _headersFor(post)), + context, + ); } } } + + Map _headersFor(Post post) { + return { + 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', + 'Accept': '*/*', + if (post.providerName.toLowerCase().contains('gelbooru') || + post.fileUrl.contains('gelbooru.com') || + post.sampleUrl.contains('gelbooru.com') || + post.previewUrl.contains('gelbooru.com')) + 'Referer': 'https://gelbooru.com/', + if (post.providerName.toLowerCase().contains('rule34') || + post.fileUrl.contains('rule34') || + post.sampleUrl.contains('rule34') || + post.previewUrl.contains('rule34')) + 'Referer': 'https://rule34.xxx/', + }; + } } class _KeepAlivePostPage extends StatefulWidget { @@ -418,6 +506,83 @@ class _KeepAlivePostPageState extends State<_KeepAlivePostPage> } } +class _NeighborStrip extends StatelessWidget { + const _NeighborStrip({ + required this.posts, + required this.currentIndex, + required this.onOpen, + }); + + final List posts; + final int currentIndex; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final start = (currentIndex - 3).clamp(0, posts.length); + final end = (currentIndex + 4).clamp(0, posts.length); + final visible = posts.sublist(start, end); + return SizedBox( + height: 72, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: visible.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final post = visible[index]; + final selected = post.cacheKey == posts[currentIndex].cacheKey; + return InkWell( + onTap: () => onOpen(post), + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: 72, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + ), + clipBehavior: Clip.antiAlias, + child: CachedNetworkImage( + imageUrl: post.previewUrl.isNotEmpty + ? post.previewUrl + : post.sampleUrl, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => + const Center(child: Icon(Icons.broken_image_rounded)), + ), + ), + ); + }, + ), + ); + } +} + +class _PreviousPostIntent extends Intent { + const _PreviousPostIntent(); +} + +class _NextPostIntent extends Intent { + const _NextPostIntent(); +} + +class _ToggleFavoriteIntent extends Intent { + const _ToggleFavoriteIntent(); +} + +class _AddCollectionIntent extends Intent { + const _AddCollectionIntent(); +} + +class _CloseIntent extends Intent { + const _CloseIntent(); +} + class _CommentsSection extends ConsumerStatefulWidget { const _CommentsSection({required this.post}); @@ -466,7 +631,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> { alignment: Alignment.centerLeft, child: Padding( padding: EdgeInsets.only(bottom: 12), - child: Text('Comments unavailable'), + child: Text('No comments'), ), ); } diff --git a/lib/features/post/presentation/widgets/post_action_bar.dart b/lib/features/post/presentation/widgets/post_action_bar.dart index ed288bd..2126d58 100644 --- a/lib/features/post/presentation/widgets/post_action_bar.dart +++ b/lib/features/post/presentation/widgets/post_action_bar.dart @@ -8,6 +8,7 @@ class PostActionBar extends StatelessWidget { required this.onCopy, required this.isFavorite, this.onDownload, + this.onSimilar, super.key, }); @@ -17,6 +18,7 @@ class PostActionBar extends StatelessWidget { final VoidCallback onCopy; final bool isFavorite; final VoidCallback? onDownload; + final VoidCallback? onSimilar; @override Widget build(BuildContext context) { @@ -36,6 +38,12 @@ class PostActionBar extends StatelessWidget { icon: const Icon(Icons.add_rounded), label: const Text('Collection'), ), + if (onSimilar != null) + FilledButton.tonalIcon( + onPressed: onSimilar, + icon: const Icon(Icons.auto_awesome_rounded), + label: const Text('Similar'), + ), IconButton.filledTonal( tooltip: 'Open original', onPressed: onOpen, diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index ad37395..02a11e0 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -1,13 +1,19 @@ import 'dart:async'; +import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:webview_windows/webview_windows.dart'; import '../../../../backend/backend.dart'; +final Map _playbackMemory = + {}; + class PostMediaViewer extends StatefulWidget { const PostMediaViewer({ required this.post, @@ -17,6 +23,8 @@ class PostMediaViewer extends StatefulWidget { this.initialLoop = false, this.initialMuted = false, this.initialCoverVideo = false, + this.initialHalfVolume = false, + this.onPlaybackSnapshot, super.key, }); @@ -27,6 +35,8 @@ class PostMediaViewer extends StatefulWidget { final bool initialLoop; final bool initialMuted; final bool initialCoverVideo; + final bool initialHalfVolume; + final ValueChanged? onPlaybackSnapshot; @override State createState() => _PostMediaViewerState(); @@ -43,9 +53,12 @@ class _PostMediaViewerState extends State { late bool _coverVideo; late bool _muted; late bool _loopVideo; + late bool _halfVolume; String? _videoError; Timer? _hideTimer; StreamSubscription? _errorSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playingSubscription; @override void initState() { @@ -53,6 +66,7 @@ class _PostMediaViewerState extends State { _coverVideo = widget.initialCoverVideo; _muted = widget.initialMuted; _loopVideo = widget.initialLoop; + _halfVolume = widget.initialHalfVolume; _imageUrls = _buildImageUrls(widget.post); _videoUrls = _buildVideoUrls(widget.post); if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { @@ -84,86 +98,117 @@ class _PostMediaViewerState extends State { @override Widget build(BuildContext context) { - if (_controller != null) { - return _VideoSurface( - player: _player!, - controller: _controller!, - aspectRatio: widget.post.width > 0 && widget.post.height > 0 - ? widget.post.width / widget.post.height - : 16 / 9, - controlsVisible: _controlsVisible, - coverVideo: _coverVideo, - muted: _muted, - loopVideo: _loopVideo, + if (_isSwf(widget.post)) { + return _SwfMediaViewer( + post: widget.post, + url: _swfUrl(widget.post), fullscreen: widget.fullscreen, - errorMessage: _videoError, - onInteract: _showControls, - onRetry: _retryVideo, - onToggleFit: () { - setState(() => _coverVideo = !_coverVideo); - _showControls(); + ); + } + + if (_controller != null) { + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), }, - onToggleMute: () async { - final nextMuted = !_muted; - setState(() => _muted = nextMuted); - await _player!.setVolume(nextMuted ? 0 : 100); - _showControls(); - }, - onToggleLoop: () async { - final nextLoop = !_loopVideo; - setState(() => _loopVideo = nextLoop); - await _player!.setPlaylistMode( - nextLoop ? PlaylistMode.single : PlaylistMode.none, - ); - _showControls(); - }, - onVolumeChanged: (value) async { - setState(() => _muted = value <= 0); - await _player!.setVolume(value); - _showControls(); - }, - onFullscreen: widget.fullscreen - ? () => Navigator.of(context).maybePop() - : () => _openFullscreen(context), + child: Actions( + actions: { + _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( + onInvoke: (_) { + _togglePlay(); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: _VideoSurface( + player: _player!, + controller: _controller!, + aspectRatio: widget.post.width > 0 && widget.post.height > 0 + ? widget.post.width / widget.post.height + : 16 / 9, + controlsVisible: _controlsVisible, + coverVideo: _coverVideo, + muted: _muted, + loopVideo: _loopVideo, + halfVolume: _halfVolume, + fullscreen: widget.fullscreen, + errorMessage: _videoError, + onInteract: _showControls, + onRetry: _retryVideo, + onToggleFit: () { + setState(() => _coverVideo = !_coverVideo); + _showControls(); + }, + onToggleMute: () async { + final nextMuted = !_muted; + setState(() => _muted = nextMuted); + await _applyVolume(); + _showControls(); + }, + onToggleHalfVolume: () async { + setState(() { + _halfVolume = !_halfVolume; + if (_halfVolume) _muted = false; + }); + await _applyVolume(); + _showControls(); + }, + onToggleLoop: () async { + final nextLoop = !_loopVideo; + setState(() => _loopVideo = nextLoop); + await _player!.setPlaylistMode( + nextLoop ? PlaylistMode.single : PlaylistMode.none, + ); + _showControls(); + }, + onFullscreen: widget.fullscreen + ? () => Navigator.of(context, rootNavigator: true) + .pop(_snapshot()) + : () => _openFullscreen(context), + ), + ), + ), ); } final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; - return InteractiveViewer( - child: CachedNetworkImage( - key: ValueKey(url), - imageUrl: url, - httpHeaders: _headersFor(widget.post), - fit: BoxFit.contain, - placeholder: (context, url) => - const Center(child: CircularProgressIndicator()), - errorWidget: (context, url, error) { - if (_imageIndex < _imageUrls.length - 1) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _imageIndex++); - }); - return const Center(child: CircularProgressIndicator()); - } - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.broken_image_rounded, size: 48), - SizedBox(height: 8), - Text('Could not load image'), - ], - ), - ); - }, - ), + final image = CachedNetworkImage( + key: ValueKey(url), + imageUrl: url, + httpHeaders: _headersFor(widget.post), + fit: BoxFit.contain, + placeholder: (context, url) => + const Center(child: CircularProgressIndicator()), + errorWidget: (context, url, error) { + if (_imageIndex < _imageUrls.length - 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _imageIndex++); + }); + return const Center(child: CircularProgressIndicator()); + } + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_rounded, size: 48), + SizedBox(height: 8), + Text('Could not load image'), + ], + ), + ); + }, ); + final allowPinchZoom = MediaQuery.sizeOf(context).width < 700; + return allowPinchZoom ? InteractiveViewer(child: image) : image; } void _initializeVideo() { MediaKit.ensureInitialized(); _player = Player(); _controller = VideoController(_player!); - _player!.setVolume(_muted ? 0 : 100); + _applyVolume(); _player!.setPlaylistMode( _loopVideo ? PlaylistMode.single : PlaylistMode.none, ); @@ -177,6 +222,16 @@ class _PostMediaViewerState extends State { setState(() => _videoError = message); _showControls(); }); + _positionSubscription = _player!.stream.position.listen((_) { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + }); + _playingSubscription = _player!.stream.playing.listen((_) { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + }); _openVideo(play: widget.autoplay); _scheduleControlsHide(); } @@ -184,17 +239,22 @@ class _PostMediaViewerState extends State { Future _openVideo({required bool play}) async { final player = _player; if (player == null || _videoUrls.isEmpty) return; + final remembered = _playbackMemory[widget.post.cacheKey]; + final initialPosition = widget.initialPosition > Duration.zero + ? widget.initialPosition + : remembered?.position ?? Duration.zero; setState(() => _videoError = null); await player.open( Media( _videoUrls[_videoIndex], httpHeaders: _headersFor(widget.post), ), - play: play, + play: false, ); - if (widget.initialPosition > Duration.zero) { - await player.seek(widget.initialPosition); + if (initialPosition > Duration.zero) { + await player.seek(initialPosition); } + if (play) await player.play(); } Future _retryVideo() async { @@ -208,6 +268,13 @@ class _PostMediaViewerState extends State { _hideTimer = null; _errorSubscription?.cancel(); _errorSubscription = null; + _positionSubscription?.cancel(); + _positionSubscription = null; + _playingSubscription?.cancel(); + _playingSubscription = null; + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); _player?.dispose(); _player = null; _controller = null; @@ -236,6 +303,21 @@ class _PostMediaViewerState extends State { value.contains('.mov'); } + bool _isSwf(Post post) { + final value = + '${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}' + .toLowerCase(); + return value.contains('swf') || value.contains('.swf'); + } + + String _swfUrl(Post post) { + for (final url in [post.fileUrl, post.sampleUrl, post.source ?? '']) { + final value = url.trim(); + if (value.toLowerCase().contains('.swf')) return value; + } + return post.fileUrl.trim(); + } + bool _looksLikeVideoUrl(String url) { final value = url.toLowerCase(); return value.contains('.webm') || @@ -281,7 +363,8 @@ class _PostMediaViewerState extends State { final wasPlaying = player?.state.playing ?? false; if (wasPlaying) await player?.pause(); if (!context.mounted) return; - await Navigator.of(context).push( + final result = await Navigator.of(context, rootNavigator: true) + .push( PageRouteBuilder( opaque: true, pageBuilder: (_, __, ___) => _FullscreenVideoPage( @@ -290,6 +373,7 @@ class _PostMediaViewerState extends State { autoplay: wasPlaying, loopVideo: _loopVideo, muted: _muted, + halfVolume: _halfVolume, coverVideo: _coverVideo, ), transitionsBuilder: (_, animation, __, child) => FadeTransition( @@ -298,8 +382,344 @@ class _PostMediaViewerState extends State { ), ), ); + if (result != null && player != null) { + _playbackMemory[widget.post.cacheKey] = result; + setState(() { + _muted = result.muted; + _loopVideo = result.loopVideo; + _coverVideo = result.coverVideo; + _halfVolume = result.halfVolume; + }); + await _applyVolume(); + await player.setPlaylistMode( + _loopVideo ? PlaylistMode.single : PlaylistMode.none, + ); + await player.seek(result.position); + if (result.playing) await player.play(); + return; + } if (wasPlaying) await player?.play(); } + + VideoPlaybackSnapshot _snapshot() { + final player = _player; + return VideoPlaybackSnapshot( + position: player?.state.position ?? Duration.zero, + playing: player?.state.playing ?? false, + muted: _muted, + halfVolume: _halfVolume, + loopVideo: _loopVideo, + coverVideo: _coverVideo, + ); + } + + Future _applyVolume() async { + await _player?.setVolume(_muted + ? 0 + : _halfVolume + ? 50 + : 100); + } + + Future _togglePlay() async { + final player = _player; + if (player == null) return; + if (player.state.playing) { + await player.pause(); + } else { + await player.play(); + } + _showControls(); + } +} + +class _TogglePlayIntent extends Intent { + const _TogglePlayIntent(); +} + +class _SwfMediaViewer extends StatelessWidget { + const _SwfMediaViewer({ + required this.post, + required this.url, + required this.fullscreen, + }); + + final Post post; + final String url; + final bool fullscreen; + + @override + Widget build(BuildContext context) { + if (url.isEmpty) { + return const Center(child: Text('SWF URL is empty')); + } + if (Platform.isWindows) { + return _WindowsRuffleViewer(url: url, fullscreen: fullscreen); + } + return _SwfUnsupportedPanel(url: url); + } +} + +class _WindowsRuffleViewer extends StatefulWidget { + const _WindowsRuffleViewer({ + required this.url, + required this.fullscreen, + }); + + final String url; + final bool fullscreen; + + @override + State<_WindowsRuffleViewer> createState() => _WindowsRuffleViewerState(); +} + +class _WindowsRuffleViewerState extends State<_WindowsRuffleViewer> { + final _controller = WebviewController(); + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _initialize(); + } + + @override + void didUpdateWidget(covariant _WindowsRuffleViewer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.url != widget.url) { + _load(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final child = ClipRRect( + borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), + child: Stack( + fit: StackFit.expand, + children: [ + if (_controller.value.isInitialized) Webview(_controller), + if (_loading) + const ColoredBox( + color: Colors.black, + child: Center(child: CircularProgressIndicator()), + ), + if (_error != null) + _SwfErrorPanel( + message: _error!, + url: widget.url, + onRetry: _load, + ), + ], + ), + ); + if (widget.fullscreen) return child; + return AspectRatio(aspectRatio: 4 / 3, child: child); + } + + Future _initialize() async { + try { + await _controller.initialize(); + _controller.loadingState.listen((state) { + if (!mounted) return; + setState(() => _loading = state == LoadingState.loading); + }); + _controller.onLoadError.listen((error) { + if (!mounted) return; + setState(() => _error = 'Ruffle WebView error: $error'); + }); + await _load(); + } catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _error = + 'Could not start Windows WebView2. Install WebView2 Runtime or open original.'; + }); + } + } + + Future _load() async { + if (!_controller.value.isInitialized) return; + setState(() { + _loading = true; + _error = null; + }); + try { + await _controller.loadStringContent(_ruffleHtml(widget.url)); + } catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _error = 'Could not load Ruffle viewer: $error'; + }); + } + } + + String _ruffleHtml(String swfUrl) { + final escapedUrl = _htmlEscape(swfUrl); + return ''' + + + + + + + + + + +

+ +
+ + +'''; + } + + String _htmlEscape(String value) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); + } +} + +class _SwfUnsupportedPanel extends StatelessWidget { + const _SwfUnsupportedPanel({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + return _SwfErrorPanel( + message: Platform.isAndroid + ? 'Flash/SWF playback is desktop only.' + : 'SWF playback is available on Windows only in this build.', + url: url, + ); + } +} + +class _SwfErrorPanel extends StatelessWidget { + const _SwfErrorPanel({ + required this.message, + required this.url, + this.onRetry, + }); + + final String message; + final String url; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ColoredBox( + color: Colors.black, + child: Center( + child: Padding( + padding: const EdgeInsets.all(22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.extension_rounded, + color: Colors.white, size: 48), + const SizedBox(height: 12), + Text( + 'SWF / Flash', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.white), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + if (onRetry != null) + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry'), + ), + FilledButton.tonalIcon( + onPressed: () => launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ), + icon: const Icon(Icons.open_in_new_rounded), + label: const Text('Open original'), + ), + FilledButton.tonalIcon( + style: FilledButton.styleFrom( + backgroundColor: + scheme.primaryContainer.withValues(alpha: 0.82), + ), + onPressed: () => launchUrl( + Uri.parse( + 'https://ruffle.rs/demo/?url=${Uri.encodeComponent(url)}', + ), + mode: LaunchMode.externalApplication, + ), + icon: const Icon(Icons.play_circle_outline_rounded), + label: const Text('Open in Ruffle'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class VideoPlaybackSnapshot { + const VideoPlaybackSnapshot({ + required this.position, + required this.playing, + required this.muted, + required this.halfVolume, + required this.loopVideo, + required this.coverVideo, + }); + + final Duration position; + final bool playing; + final bool muted; + final bool halfVolume; + final bool loopVideo; + final bool coverVideo; } class _FullscreenVideoPage extends StatefulWidget { @@ -309,6 +729,7 @@ class _FullscreenVideoPage extends StatefulWidget { required this.autoplay, required this.loopVideo, required this.muted, + required this.halfVolume, required this.coverVideo, }); @@ -317,6 +738,7 @@ class _FullscreenVideoPage extends StatefulWidget { final bool autoplay; final bool loopVideo; final bool muted; + final bool halfVolume; final bool coverVideo; @override @@ -324,9 +746,19 @@ class _FullscreenVideoPage extends StatefulWidget { } class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { + late VideoPlaybackSnapshot _snapshot; + @override void initState() { super.initState(); + _snapshot = VideoPlaybackSnapshot( + position: widget.startAt, + playing: widget.autoplay, + muted: widget.muted, + halfVolume: widget.halfVolume, + loopVideo: widget.loopVideo, + coverVideo: widget.coverVideo, + ); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, @@ -344,33 +776,43 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - Positioned.fill( - child: PostMediaViewer( - post: widget.post, - fullscreen: true, - initialPosition: widget.startAt, - autoplay: widget.autoplay, - initialLoop: widget.loopVideo, - initialMuted: widget.muted, - initialCoverVideo: widget.coverVideo, - ), - ), - Positioned( - top: 12, - right: 12, - child: SafeArea( - child: IconButton.filledTonal( - tooltip: 'Close fullscreen', - onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon(Icons.close_rounded), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + Navigator.of(context, rootNavigator: true).pop(_snapshot); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: PostMediaViewer( + post: widget.post, + fullscreen: true, + initialPosition: widget.startAt, + autoplay: widget.autoplay, + initialLoop: widget.loopVideo, + initialMuted: widget.muted, + initialHalfVolume: widget.halfVolume, + initialCoverVideo: widget.coverVideo, + onPlaybackSnapshot: (snapshot) => _snapshot = snapshot, ), ), - ), - ], + Positioned( + top: 12, + right: 12, + child: SafeArea( + child: IconButton.filledTonal( + tooltip: 'Close fullscreen', + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(_snapshot), + icon: const Icon(Icons.close_rounded), + ), + ), + ), + ], + ), ), ); } @@ -384,6 +826,7 @@ class _VideoSurface extends StatelessWidget { required this.controlsVisible, required this.coverVideo, required this.muted, + required this.halfVolume, required this.loopVideo, required this.fullscreen, required this.errorMessage, @@ -391,8 +834,8 @@ class _VideoSurface extends StatelessWidget { required this.onRetry, required this.onToggleFit, required this.onToggleMute, + required this.onToggleHalfVolume, required this.onToggleLoop, - required this.onVolumeChanged, required this.onFullscreen, }); @@ -402,6 +845,7 @@ class _VideoSurface extends StatelessWidget { final bool controlsVisible; final bool coverVideo; final bool muted; + final bool halfVolume; final bool loopVideo; final bool fullscreen; final String? errorMessage; @@ -409,8 +853,8 @@ class _VideoSurface extends StatelessWidget { final VoidCallback onRetry; final VoidCallback onToggleFit; final VoidCallback onToggleMute; + final VoidCallback onToggleHalfVolume; final VoidCallback onToggleLoop; - final ValueChanged onVolumeChanged; final VoidCallback onFullscreen; @override @@ -438,19 +882,65 @@ class _VideoSurface extends StatelessWidget { ), if (errorMessage != null) _VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), + if (errorMessage == null) + StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, snapshot) { + final playing = snapshot.data ?? false; + return AnimatedScale( + scale: controlsVisible || !playing ? 1 : 0.85, + duration: const Duration(milliseconds: 160), + child: AnimatedOpacity( + opacity: controlsVisible || !playing ? 1 : 0, + duration: const Duration(milliseconds: 160), + child: Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.46), + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black45, + blurRadius: 24, + offset: Offset(0, 10), + ), + ], + ), + child: IconButton( + tooltip: playing ? 'Pause' : 'Play', + iconSize: fullscreen ? 58 : 44, + color: Colors.white, + onPressed: () { + player.playOrPause(); + onInteract(); + }, + icon: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + ), + ), + ), + ), + ); + }, + ), AnimatedOpacity( opacity: controlsVisible || errorMessage != null ? 1 : 0, duration: const Duration(milliseconds: 180), child: _VideoControls( player: player, muted: muted, + halfVolume: halfVolume, loopVideo: loopVideo, coverVideo: coverVideo, fullscreen: fullscreen, onToggleFit: onToggleFit, onToggleMute: onToggleMute, + onToggleHalfVolume: onToggleHalfVolume, onToggleLoop: onToggleLoop, - onVolumeChanged: onVolumeChanged, onFullscreen: onFullscreen, ), ), @@ -461,6 +951,7 @@ class _VideoSurface extends StatelessWidget { onHover: (_) => onInteract(), child: GestureDetector( onTap: onInteract, + onLongPress: player.playOrPause, onDoubleTap: onFullscreen, child: ClipRRect( borderRadius: BorderRadius.circular(fullscreen ? 0 : 10), @@ -527,33 +1018,85 @@ class _VideoErrorOverlay extends StatelessWidget { } } +class _RoundControlButton extends StatelessWidget { + const _RoundControlButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + this.emphasized = false, + this.selected = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final bool emphasized; + final bool selected; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final background = emphasized + ? scheme.primary + : selected + ? scheme.primaryContainer.withValues(alpha: 0.96) + : Colors.white.withValues(alpha: 0.13); + final foreground = emphasized + ? scheme.onPrimary + : selected + ? scheme.onPrimaryContainer + : Colors.white; + return Tooltip( + message: tooltip, + child: InkResponse( + onTap: onPressed, + radius: emphasized ? 30 : 24, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: emphasized ? 48 : 40, + height: emphasized ? 48 : 40, + decoration: BoxDecoration( + color: background, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Icon(icon, color: foreground), + ), + ), + ); + } +} + class _VideoControls extends StatelessWidget { const _VideoControls({ required this.player, required this.muted, + required this.halfVolume, required this.loopVideo, required this.coverVideo, required this.fullscreen, required this.onToggleFit, required this.onToggleMute, + required this.onToggleHalfVolume, required this.onToggleLoop, - required this.onVolumeChanged, required this.onFullscreen, }); final Player player; final bool muted; + final bool halfVolume; final bool loopVideo; final bool coverVideo; final bool fullscreen; final VoidCallback onToggleFit; final VoidCallback onToggleMute; + final VoidCallback onToggleHalfVolume; final VoidCallback onToggleLoop; - final ValueChanged onVolumeChanged; final VoidCallback onFullscreen; @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -569,7 +1112,12 @@ class _VideoControls extends StatelessWidget { child: Align( alignment: Alignment.bottomCenter, child: Padding( - padding: EdgeInsets.all(fullscreen ? 18 : 12), + padding: EdgeInsets.fromLTRB( + fullscreen ? 22 : 12, + 12, + fullscreen ? 22 : 12, + fullscreen ? 22 : 12, + ), child: StreamBuilder( stream: player.stream.playing, initialData: player.state.playing, @@ -592,167 +1140,162 @@ class _VideoControls extends StatelessWidget { .toDouble(); return LayoutBuilder( builder: (context, constraints) { - final compact = constraints.maxWidth < 560; - return Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (fullscreen) - StreamBuilder( - stream: player.stream.volume, - initialData: player.state.volume, - builder: (context, snapshot) { - final volume = - (snapshot.data ?? 100).clamp(0, 100); - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Row( - children: [ - Icon( - muted || volume <= 0 - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - color: Colors.white, - ), - Expanded( - child: Slider( - value: volume.toDouble(), - max: 100, - onChanged: onVolumeChanged, - ), - ), - SizedBox( - width: 42, - child: Text( - '${volume.round()}%', - textAlign: TextAlign.right, - style: const TextStyle( - color: Colors.white, - ), - ), - ), - ], - ), - ); - }, - ), - SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 4, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 8, - ), - ), - child: Slider( - value: valueMs, - max: maxMs, - onChanged: duration == Duration.zero - ? null - : (value) => player.seek( - Duration( - milliseconds: value.round(), - ), - ), - ), + final compact = constraints.maxWidth < 980; + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.58), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), ), - Row( + boxShadow: const [ + BoxShadow( + color: Colors.black54, + blurRadius: 26, + offset: Offset(0, 12), + ), + ], + ), + child: Padding( + padding: EdgeInsets.fromLTRB( + compact ? 10 : 14, + 8, + compact ? 10 : 14, + compact ? 10 : 12, + ), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - IconButton.filled( - tooltip: playing ? 'Pause' : 'Play', - onPressed: player.playOrPause, - icon: Icon( - playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - ), - const SizedBox(width: 10), - Flexible( - child: Text( - '${_format(position)} / ${_format(duration)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: scheme.primary, + inactiveTrackColor: + Colors.white.withValues(alpha: 0.22), + trackHeight: 5, + thumbColor: scheme.primary, + overlayColor: + scheme.primary.withValues(alpha: 0.2), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 7, ), ), + child: Slider( + value: valueMs, + max: maxMs, + onChanged: duration == Duration.zero + ? null + : (value) => player.seek( + Duration( + milliseconds: value.round(), + ), + ), + ), ), - const Spacer(), - if (!compact) ...[ - IconButton.filledTonal( - tooltip: 'Back 10s', - onPressed: () => player.seek( - _clampSeek( - position - - const Duration(seconds: 10), - duration, + Row( + children: [ + _RoundControlButton( + tooltip: playing ? 'Pause' : 'Play', + emphasized: true, + onPressed: player.playOrPause, + icon: playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + const SizedBox(width: 10), + Text( + _format(position), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, ), ), - icon: const Icon(Icons.replay_10_rounded), - ), - const SizedBox(width: 6), - IconButton.filledTonal( - tooltip: 'Forward 10s', - onPressed: () => player.seek( - _clampSeek( - position + - const Duration(seconds: 10), - duration, + Text( + ' / ${_format(duration)}', + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w600, ), ), - icon: - const Icon(Icons.forward_10_rounded), - ), - const SizedBox(width: 6), - ], - IconButton.filledTonal( - tooltip: loopVideo - ? 'Disable repeat' - : 'Repeat video', - onPressed: onToggleLoop, - icon: Icon( - loopVideo - ? Icons.repeat_one_on_rounded - : Icons.repeat_one_rounded, - ), - ), - const SizedBox(width: 6), - IconButton.filledTonal( - tooltip: muted ? 'Unmute' : 'Mute', - onPressed: onToggleMute, - icon: Icon( - muted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - ), - ), - const SizedBox(width: 6), - if (!compact) ...[ - IconButton.filledTonal( - tooltip: coverVideo ? 'Fit' : 'Fill', - onPressed: onToggleFit, - icon: Icon( - coverVideo - ? Icons.fit_screen_rounded - : Icons.crop_free_rounded, + const Spacer(), + if (!compact) ...[ + _RoundControlButton( + tooltip: 'Back 10s', + onPressed: () => player.seek( + _clampSeek( + position - + const Duration(seconds: 10), + duration, + ), + ), + icon: Icons.replay_10_rounded, + ), + const SizedBox(width: 6), + _RoundControlButton( + tooltip: 'Forward 10s', + onPressed: () => player.seek( + _clampSeek( + position + + const Duration(seconds: 10), + duration, + ), + ), + icon: Icons.forward_10_rounded, + ), + const SizedBox(width: 6), + ], + _RoundControlButton( + tooltip: muted ? 'Unmute' : 'Mute', + onPressed: onToggleMute, + icon: muted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, ), - ), - const SizedBox(width: 6), - ], - IconButton.filledTonal( - tooltip: fullscreen - ? 'Exit fullscreen' - : 'Fullscreen', - onPressed: onFullscreen, - icon: Icon( - fullscreen - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded, - ), + const SizedBox(width: 6), + _RoundControlButton( + tooltip: halfVolume + ? 'Normal app volume' + : 'Half app volume', + selected: halfVolume, + onPressed: onToggleHalfVolume, + icon: Icons.volume_down_rounded, + ), + const SizedBox(width: 6), + _RoundControlButton( + tooltip: loopVideo + ? 'Disable repeat' + : 'Repeat video', + selected: loopVideo, + onPressed: onToggleLoop, + icon: loopVideo + ? Icons.repeat_one_on_rounded + : Icons.repeat_one_rounded, + ), + const SizedBox(width: 6), + if (!compact) ...[ + _RoundControlButton( + tooltip: coverVideo ? 'Fit' : 'Fill', + selected: coverVideo, + onPressed: onToggleFit, + icon: coverVideo + ? Icons.fit_screen_rounded + : Icons.crop_free_rounded, + ), + const SizedBox(width: 6), + ], + _RoundControlButton( + tooltip: fullscreen + ? 'Exit fullscreen' + : 'Fullscreen', + onPressed: onFullscreen, + icon: fullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, + ), + ], ), ], ), - ], + ), ); }, ); diff --git a/lib/features/providers/presentation/provider_check_screen.dart b/lib/features/providers/presentation/provider_check_screen.dart index 2c5fd71..595e595 100644 --- a/lib/features/providers/presentation/provider_check_screen.dart +++ b/lib/features/providers/presentation/provider_check_screen.dart @@ -15,6 +15,7 @@ class ProviderCheckScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final providers = ref.watch(providersControllerProvider); final health = ref.watch(providerHealthProvider); + final diagnostics = ref.watch(providerDiagnosticsProvider).value ?? {}; return AdaptiveScaffold( title: 'Provider Check', actions: [ @@ -35,6 +36,7 @@ class ProviderCheckScreen extends ConsumerWidget { return ProviderHealthTile( config: item, health: health[item.id], + diagnostics: diagnostics[item.id], onCheck: () => _checkOne(ref, item.id), ); }, diff --git a/lib/features/providers/presentation/provider_form_screen.dart b/lib/features/providers/presentation/provider_form_screen.dart index f8aded7..629e7cb 100644 --- a/lib/features/providers/presentation/provider_form_screen.dart +++ b/lib/features/providers/presentation/provider_form_screen.dart @@ -90,6 +90,9 @@ class _ProviderFormScreenState extends ConsumerState { value: 'rule34', child: Text('rule34-compatible')), DropdownMenuItem( value: 'danbooru', child: Text('danbooru-compatible')), + DropdownMenuItem( + value: 'moebooru', child: Text('moebooru-compatible')), + DropdownMenuItem(value: 'e621', child: Text('e621/e926')), DropdownMenuItem(value: 'custom', child: Text('custom')), ], onChanged: (value) => diff --git a/lib/features/providers/presentation/providers_controller.dart b/lib/features/providers/presentation/providers_controller.dart index ee5d601..a47017b 100644 --- a/lib/features/providers/presentation/providers_controller.dart +++ b/lib/features/providers/presentation/providers_controller.dart @@ -38,3 +38,14 @@ class ProvidersController extends AsyncNotifier> { final providerHealthProvider = StateProvider>((ref) => {}); + +final providerDiagnosticsProvider = + FutureProvider>((ref) async { + final result = await ref.watch(providerRepositoryProvider).getDiagnostics(); + if (result is Success>) { + return { + for (final item in result.data) item.providerId: item, + }; + } + return {}; +}); diff --git a/lib/features/providers/presentation/widgets/provider_health_tile.dart b/lib/features/providers/presentation/widgets/provider_health_tile.dart index 616e65b..1878ca5 100644 --- a/lib/features/providers/presentation/widgets/provider_health_tile.dart +++ b/lib/features/providers/presentation/widgets/provider_health_tile.dart @@ -7,12 +7,14 @@ class ProviderHealthTile extends StatelessWidget { const ProviderHealthTile({ required this.config, required this.health, + required this.diagnostics, required this.onCheck, super.key, }); final ContentProviderConfig config; final ProviderHealth? health; + final ProviderDiagnostics? diagnostics; final VoidCallback onCheck; @override @@ -22,11 +24,19 @@ class ProviderHealthTile extends StatelessWidget { title: Text(config.name), subtitle: Text( [ + '${config.apiType} | ${config.enabled ? 'enabled' : 'disabled'} | priority ${config.priority}', if (health != null) 'Ping ${health!.pingMs} ms', + if (health?.apiVersion != null) 'API ${health!.apiVersion}', if (health?.lastCheckedAt != null) 'Checked ${health!.lastCheckedAt}', + if (diagnostics != null) + 'Last search: ${diagnostics!.lastResultCount} posts at ${diagnostics!.lastSearchAt}', + if (diagnostics?.lastErrorMessage != null) + diagnostics!.lastErrorMessage!, if (health?.errorMessage != null) health!.errorMessage!, ].join('\n'), + maxLines: 7, + overflow: TextOverflow.ellipsis, ), leading: ProviderStatusBadge(health: health), trailing: IconButton( diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index 2eb50a6..8322132 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -7,7 +7,6 @@ import '../../../shared/widgets/app_search_bar.dart'; import '../../../shared/widgets/error_view.dart'; import 'search_controller.dart'; import 'widgets/recent_searches.dart'; -import 'widgets/search_suggestions.dart'; class SearchScreen extends ConsumerWidget { const SearchScreen({super.key}); @@ -31,22 +30,20 @@ class SearchScreen extends ConsumerWidget { data: (data) => ListView( padding: const EdgeInsets.all(16), children: [ - AppSearchBar( + TagInputSearchBar( + initialValue: data.query, + suggestions: data.suggestions, onSubmitted: (query) => context.go('/?q=${Uri.encodeQueryComponent(query)}'), onChanged: (query) => ref .read(searchControllerProvider.notifier) .updateQuery(query), + onSuggestionApplied: (query) => ref + .read(searchControllerProvider.notifier) + .updateQuery(query), ), const SizedBox(height: 16), - SearchSuggestions( - suggestions: data.suggestions, - onTap: (suggestion) { - final query = _applySuggestion(data.query, suggestion); - context.go('/?q=${Uri.encodeQueryComponent(query)}'); - }, - ), - const SizedBox(height: 24), + const SizedBox(height: 8), Text('Recent', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), RecentSearches( @@ -59,15 +56,4 @@ class SearchScreen extends ConsumerWidget { ), ); } - - String _applySuggestion(String query, String suggestion) { - final tokens = - query.trim().isEmpty ? [] : query.trim().split(RegExp(r'\s+')); - if (tokens.isEmpty) { - tokens.add(suggestion); - } else { - tokens[tokens.length - 1] = suggestion; - } - return tokens.toSet().join(' '); - } } diff --git a/lib/features/settings/presentation/settings_controller.dart b/lib/features/settings/presentation/settings_controller.dart index 32aa2d8..588b31f 100644 --- a/lib/features/settings/presentation/settings_controller.dart +++ b/lib/features/settings/presentation/settings_controller.dart @@ -25,6 +25,11 @@ class SettingsController extends AsyncNotifier { await ref.read(cacheServiceProvider).clear(); } + Future clearViewedHistory() async { + await ref.read(viewedHistoryServiceProvider).clearHistory(); + ref.invalidate(viewedKeysProvider); + } + Future exportJson() async { final result = await ref.read(settingsServiceProvider).exportSettingsToJson(); diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 369d0af..1786483 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -57,6 +57,35 @@ class SettingsScreen extends ConsumerWidget { onChanged: (value) => _update(ref, settings.copyWith(allowDownloads: value)), ), + SwitchListTile( + value: settings.hideViewedPosts, + title: const Text('Hide viewed posts'), + onChanged: (value) => + _update(ref, settings.copyWith(hideViewedPosts: value)), + ), + const SizedBox(height: 12), + _TagListEditor( + title: 'Smart blacklist', + icon: Icons.visibility_off_rounded, + tags: settings.smartBlacklistRules, + helper: + 'Examples: tag, tag_a tag_b, provider:e621, rating:explicit, type:video, score:<10, artist:name', + onChanged: (tags) => _update( + ref, + settings.copyWith(smartBlacklistRules: tags), + ), + ), + const SizedBox(height: 12), + _TagListEditor( + title: 'Whitelisted tags', + icon: Icons.verified_rounded, + tags: settings.whitelistedTags, + onChanged: (tags) => _update( + ref, + settings.copyWith(whitelistedTags: tags), + ), + ), + const SizedBox(height: 12), _StepperTile( title: 'Desktop columns', value: settings.desktopColumns, @@ -94,6 +123,14 @@ class SettingsScreen extends ConsumerWidget { label: const Text('Clear cache'), ), const SizedBox(height: 12), + FilledButton.tonalIcon( + onPressed: () => ref + .read(settingsControllerProvider.notifier) + .clearViewedHistory(), + icon: const Icon(Icons.history_toggle_off_rounded), + label: const Text('Clear viewed history'), + ), + const SizedBox(height: 12), FilledButton.tonalIcon( onPressed: () async { final json = await ref @@ -160,6 +197,112 @@ class SettingsScreen extends ConsumerWidget { } } +class _TagListEditor extends StatefulWidget { + const _TagListEditor({ + required this.title, + required this.icon, + required this.tags, + required this.onChanged, + this.helper, + }); + + final String title; + final IconData icon; + final List tags; + final ValueChanged> onChanged; + final String? helper; + + @override + State<_TagListEditor> createState() => _TagListEditorState(); +} + +class _TagListEditorState extends State<_TagListEditor> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(widget.icon, size: 20), + const SizedBox(width: 8), + Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + if (widget.helper != null) ...[ + const SizedBox(height: 6), + Text( + widget.helper!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final tag in widget.tags) + InputChip( + label: Text(tag), + onDeleted: () => widget.onChanged( + widget.tags.where((item) => item != tag).toList(), + ), + ), + SizedBox( + width: 240, + child: TextField( + controller: _controller, + decoration: const InputDecoration( + isDense: true, + hintText: 'Add tags', + prefixIcon: Icon(Icons.tag_rounded), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + ), + ), + IconButton.filledTonal( + tooltip: 'Add tags', + onPressed: _submit, + icon: const Icon(Icons.add_rounded), + ), + ], + ), + ], + ), + ), + ); + } + + void _submit() { + final incoming = _controller.text + .split(RegExp(r'\s+')) + .map((tag) => tag.trim().toLowerCase()) + .where((tag) => tag.isNotEmpty); + final merged = {...widget.tags, ...incoming}.toList()..sort(); + _controller.clear(); + widget.onChanged(merged); + } +} + class _StepperTile extends StatelessWidget { const _StepperTile({ required this.title, diff --git a/lib/features/viewed/presentation/viewed_controller.dart b/lib/features/viewed/presentation/viewed_controller.dart new file mode 100644 index 0000000..1f1ef71 --- /dev/null +++ b/lib/features/viewed/presentation/viewed_controller.dart @@ -0,0 +1,28 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../backend/backend.dart'; +import '../../../core/utils/result.dart'; + +final viewedControllerProvider = + AsyncNotifierProvider>(ViewedController.new); + +class ViewedController extends AsyncNotifier> { + @override + Future> build() => _load(); + + Future refresh() async { + state = AsyncData(await _load()); + } + + Future clear() async { + await ref.read(viewedHistoryServiceProvider).clearHistory(); + ref.invalidate(viewedKeysProvider); + state = const AsyncData([]); + } + + Future> _load() async { + final result = + await ref.read(viewedHistoryServiceProvider).getViewedPosts(); + return result is Success> ? result.data : const []; + } +} diff --git a/lib/features/viewed/presentation/viewed_screen.dart b/lib/features/viewed/presentation/viewed_screen.dart new file mode 100644 index 0000000..a30c757 --- /dev/null +++ b/lib/features/viewed/presentation/viewed_screen.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../app/app.dart'; +import '../../../app/responsive.dart'; +import '../../../backend/backend.dart'; +import '../../../shared/widgets/adaptive_scaffold.dart'; +import '../../../shared/widgets/empty_view.dart'; +import '../../../shared/widgets/error_view.dart'; +import '../../../shared/widgets/post_masonry_grid.dart'; +import '../../favorites/presentation/favorites_controller.dart'; +import 'viewed_controller.dart'; + +class ViewedScreen extends ConsumerWidget { + const ViewedScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(viewedControllerProvider); + final settings = + ref.watch(appSettingsProvider).value ?? AppSettings.defaults; + final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; + final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; + return AdaptiveScaffold( + title: 'Viewed', + actions: [ + IconButton( + tooltip: 'Clear viewed history', + onPressed: () => ref.read(viewedControllerProvider.notifier).clear(), + icon: const Icon(Icons.history_toggle_off_rounded), + ), + ], + body: state.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => ErrorView(message: error.toString()), + data: (posts) => posts.isEmpty + ? const EmptyView(title: 'No viewed posts yet') + : RefreshIndicator( + onRefresh: () => + ref.read(viewedControllerProvider.notifier).refresh(), + child: PostMasonryGrid( + posts: posts, + columns: Responsive.columnsFor( + context, + mobileColumns: settings.mobileColumns, + desktopColumns: settings.desktopColumns, + ), + blurExplicit: settings.blurExplicitContent, + showBadges: settings.showPostBadges, + nsfwEnabled: settings.nsfwEnabled, + favoriteKeys: favoriteKeys, + viewedKeys: viewedKeys, + onOpen: (post) => context.push( + '/post/${post.providerId}/${post.id}', + extra: post, + ), + onFavorite: (post) async { + if (favoriteKeys.contains(post.cacheKey)) { + await ref + .read(favoriteServiceProvider) + .removeFavorite(post.id, post.providerId); + } else { + await ref.read(favoriteServiceProvider).addFavorite(post); + } + ref.invalidate(favoriteKeysProvider); + }, + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart index 894b4b0..1c6cf88 100644 --- a/lib/shared/widgets/app_search_bar.dart +++ b/lib/shared/widgets/app_search_bar.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../backend/backend.dart'; class AppSearchBar extends StatefulWidget { const AppSearchBar({ @@ -20,29 +23,449 @@ class AppSearchBar extends StatefulWidget { State createState() => _AppSearchBarState(); } -class _AppSearchBarState extends State { +class TagInputSearchBar extends StatefulWidget { + const TagInputSearchBar({ + required this.onSubmitted, + this.onChanged, + this.onSuggestionApplied, + this.initialValue, + this.hintText = 'Search tags', + this.suggestions = const [], + super.key, + }); + + final ValueChanged onSubmitted; + final ValueChanged? onChanged; + final ValueChanged? onSuggestionApplied; + final String? initialValue; + final String hintText; + final List suggestions; + + @override + State createState() => _TagInputSearchBarState(); +} + +class _TagInputSearchBarState extends State { late final TextEditingController _controller; + late final FocusNode _focusNode; Timer? _debounce; + List _tags = []; @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.initialValue); + _controller = TextEditingController(); + _focusNode = FocusNode(); + _focusNode.addListener(_handleFocusChanged); + _setFromQuery(widget.initialValue ?? ''); } @override - void didUpdateWidget(covariant AppSearchBar oldWidget) { + void didUpdateWidget(covariant TagInputSearchBar oldWidget) { super.didUpdateWidget(oldWidget); final next = widget.initialValue ?? ''; - if (oldWidget.initialValue != widget.initialValue && - _controller.text != next) { - _controller.text = next; + if (!_focusNode.hasFocus && oldWidget.initialValue != next) { + _setFromQuery(next); } } @override void dispose() { _debounce?.cancel(); + _focusNode.removeListener(_handleFocusChanged); + _focusNode.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _handleFocusChanged() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).inputDecorationTheme.fillColor ?? + scheme.surfaceContainerHighest.withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _focusNode.hasFocus + ? scheme.primary.withValues(alpha: 0.45) + : Colors.transparent, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _focusNode.requestFocus, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 7), + child: Icon(Icons.search_rounded, + color: scheme.onSurfaceVariant), + ), + const SizedBox(width: 8), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final tag in _tags) _buildTagChip(context, tag), + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 120, + maxWidth: 320, + ), + child: Focus( + onKeyEvent: _handleKeyEvent, + child: TextField( + controller: _controller, + focusNode: _focusNode, + autocorrect: false, + enableSuggestions: false, + textInputAction: TextInputAction.search, + inputFormatters: [ + _TagCommitFormatter(onCommit: _commitDraft), + ], + onSubmitted: (_) => _submit(), + onChanged: (_) => _notifyChangedDebounced(), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + contentPadding: + const EdgeInsets.symmetric(vertical: 8), + hintText: + _tags.isEmpty ? widget.hintText : 'tag', + prefixIcon: null, + suffixIcon: null, + ), + ), + ), + ), + ], + ), + ), + IconButton( + tooltip: 'Clear', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close_rounded), + onPressed: _clear, + ), + ], + ), + ), + ), + ), + if (widget.suggestions.isNotEmpty) ...[ + const SizedBox(height: 8), + _TagSuggestionDropdown( + suggestions: widget.suggestions, + onSelected: _applySuggestion, + ), + ], + ], + ); + } + + Widget _buildTagChip(BuildContext context, String tag) { + final scheme = Theme.of(context).colorScheme; + return InputChip( + label: Text(tag), + avatar: const Icon(Icons.tag_rounded, size: 16), + deleteIcon: const Icon(Icons.close_rounded, size: 16), + onPressed: () => _editTag(tag), + onDeleted: () => _removeTag(tag), + backgroundColor: scheme.primaryContainer.withValues(alpha: 0.54), + labelStyle: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + side: BorderSide(color: scheme.primary.withValues(alpha: 0.18)), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey != LogicalKeyboardKey.backspace) { + return KeyEventResult.ignored; + } + if (_controller.text.isNotEmpty || _tags.isEmpty) { + return KeyEventResult.ignored; + } + final tag = _tags.removeLast(); + _controller.value = TextEditingValue( + text: tag, + selection: TextSelection.collapsed(offset: tag.length), + ); + setState(() {}); + _notifyChanged(); + return KeyEventResult.handled; + } + + void _setFromQuery(String query) { + _tags = _parseTags(query); + _controller.clear(); + } + + void _commitDraft(String value) { + final additions = _parseTags(value); + if (additions.isEmpty) return; + setState(() { + for (final tag in additions) { + if (!_tags.contains(tag)) _tags.add(tag); + } + _controller.clear(); + }); + _notifyChanged(); + } + + void _submit() { + _debounce?.cancel(); + _commitDraft(_controller.text); + widget.onSubmitted(_query); + } + + void _clear() { + _debounce?.cancel(); + setState(() { + _tags = []; + _controller.clear(); + }); + _notifyChanged(); + _focusNode.requestFocus(); + } + + void _editTag(String tag) { + setState(() { + _tags = _tags.where((item) => item != tag).toList(growable: true); + _controller.value = TextEditingValue( + text: tag, + selection: TextSelection.collapsed(offset: tag.length), + ); + }); + _notifyChanged(); + _focusNode.requestFocus(); + } + + void _removeTag(String tag) { + setState(() { + _tags = _tags.where((item) => item != tag).toList(growable: true); + }); + _notifyChanged(); + } + + void _applySuggestion(String suggestion) { + final draft = _controller.text.trim(); + setState(() { + if (draft.isNotEmpty) { + final draftTags = _parseTags(draft); + _tags.removeWhere(draftTags.contains); + } + if (!_tags.contains(suggestion)) _tags.add(suggestion); + _controller.clear(); + }); + _notifyChanged(); + widget.onSuggestionApplied?.call(_query); + _focusNode.requestFocus(); + } + + void _notifyChangedDebounced() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 250), _notifyChanged); + } + + void _notifyChanged() { + widget.onChanged?.call(_query); + } + + List _parseTags(String query) { + final seen = {}; + final result = []; + for (final raw in query.trim().split(RegExp(r'\s+'))) { + final tag = raw.trim(); + if (tag.isEmpty || !seen.add(tag)) continue; + result.add(tag); + } + return result; + } + + String get _query { + final draft = _controller.text.trim(); + final all = [..._tags, if (draft.isNotEmpty) draft]; + return all.join(' '); + } +} + +class _TagCommitFormatter extends TextInputFormatter { + _TagCommitFormatter({required this.onCommit}); + + final ValueChanged onCommit; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.endsWith(' ') || newValue.text.endsWith('\n')) { + final value = newValue.text.trim(); + WidgetsBinding.instance.addPostFrameCallback((_) => onCommit(value)); + return const TextEditingValue(); + } + return newValue; + } +} + +class _TagSuggestionDropdown extends StatelessWidget { + const _TagSuggestionDropdown({ + required this.suggestions, + required this.onSelected, + }); + + final List suggestions; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 620), + child: Material( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.18), + color: scheme.surface, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: suggestions.length > 8 ? 8 : suggestions.length, + separatorBuilder: (_, __) => Divider( + height: 1, + indent: 52, + color: scheme.outlineVariant.withValues(alpha: 0.55), + ), + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + return InkWell( + onTap: () => onSelected(suggestion.name), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + Icon(Icons.tag_rounded, color: scheme.primary, size: 20), + const SizedBox(width: 14), + Expanded( + child: Text( + suggestion.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + const SizedBox(width: 10), + _TagSuggestionBadge(suggestion.categoryLabel), + if (suggestion.postCount > 0) ...[ + const SizedBox(width: 8), + Text( + _compactCount(suggestion.postCount), + style: + Theme.of(context).textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ); + }, + ), + ), + ), + ); + } + + String _compactCount(int value) { + if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M'; + if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K'; + return '$value'; + } +} + +class _TagSuggestionBadge extends StatelessWidget { + const _TagSuggestionBadge(this.label); + + final String label; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +class _AppSearchBarState extends State { + late final TextEditingController _controller; + late final FocusNode _focusNode; + Timer? _debounce; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + _focusNode = FocusNode(); + } + + @override + void didUpdateWidget(covariant AppSearchBar oldWidget) { + super.didUpdateWidget(oldWidget); + final next = widget.initialValue ?? ''; + if (_focusNode.hasFocus) return; + if (oldWidget.initialValue != widget.initialValue && + _controller.text != next) { + _controller.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length), + ); + } + } + + @override + void dispose() { + _debounce?.cancel(); + _focusNode.dispose(); _controller.dispose(); super.dispose(); } @@ -51,8 +474,14 @@ class _AppSearchBarState extends State { Widget build(BuildContext context) { return TextField( controller: _controller, + focusNode: _focusNode, + autocorrect: false, + enableSuggestions: false, textInputAction: TextInputAction.search, - onSubmitted: widget.onSubmitted, + onSubmitted: (value) { + _debounce?.cancel(); + widget.onSubmitted(value); + }, onChanged: (value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 250), () { @@ -66,8 +495,10 @@ class _AppSearchBarState extends State { tooltip: 'Clear', icon: const Icon(Icons.close_rounded), onPressed: () { + _debounce?.cancel(); _controller.clear(); widget.onChanged?.call(''); + _focusNode.requestFocus(); }, ), ), diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 80b14c3..ef79778 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -13,6 +13,7 @@ class AppShell extends StatelessWidget { _Destination('Feed', Icons.dashboard_rounded, '/'), _Destination('Search', Icons.search_rounded, '/search'), _Destination('Favorites', Icons.favorite_rounded, '/favorites'), + _Destination('Viewed', Icons.history_rounded, '/viewed'), _Destination( 'Collections', Icons.collections_bookmark_rounded, '/collections'), _Destination('Providers', Icons.hub_rounded, '/providers'), diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index 6fe101a..2e5f760 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -12,6 +12,7 @@ class PostCard extends StatefulWidget { required this.blurExplicit, required this.showBadges, required this.isFavorite, + required this.isViewed, required this.onOpen, required this.onFavorite, this.onAddToCollection, @@ -22,6 +23,7 @@ class PostCard extends StatefulWidget { final bool blurExplicit; final bool showBadges; final bool isFavorite; + final bool isViewed; final VoidCallback onOpen; final VoidCallback onFavorite; final VoidCallback? onAddToCollection; @@ -110,6 +112,12 @@ class _PostCardState extends State { bottom: 8, child: _MediaBadge(fileType: post.fileType), ), + if (widget.isViewed) + const Positioned( + right: 8, + bottom: 8, + child: _SeenBadge(), + ), ], if (mobile) Positioned( @@ -279,6 +287,34 @@ class _MediaBadge extends StatelessWidget { }; } +class _SeenBadge extends StatelessWidget { + const _SeenBadge(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(6), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.visibility_rounded, size: 12, color: Colors.white), + SizedBox(width: 4), + Text( + 'seen', + style: TextStyle(fontSize: 11, color: Colors.white), + ), + ], + ), + ), + ); + } +} + Future showAddToCollectionPicker( BuildContext context, { required List collections, diff --git a/lib/shared/widgets/post_masonry_grid.dart b/lib/shared/widgets/post_masonry_grid.dart index b094453..eadbfc0 100644 --- a/lib/shared/widgets/post_masonry_grid.dart +++ b/lib/shared/widgets/post_masonry_grid.dart @@ -17,6 +17,7 @@ class PostMasonryGrid extends StatelessWidget { required this.onFavorite, this.onAddToCollection, this.favoriteKeys = const {}, + this.viewedKeys = const {}, this.loading = false, this.controller, super.key, @@ -31,6 +32,7 @@ class PostMasonryGrid extends StatelessWidget { final ValueChanged onFavorite; final ValueChanged? onAddToCollection; final Set favoriteKeys; + final Set viewedKeys; final bool loading; final ScrollController? controller; @@ -52,6 +54,7 @@ class PostMasonryGrid extends StatelessWidget { blurExplicit: blurExplicit && !nsfwEnabled, showBadges: showBadges, isFavorite: favoriteKeys.contains(post.cacheKey), + isViewed: viewedKeys.contains(post.cacheKey), onOpen: () => onOpen(post), onFavorite: () => onFavorite(post), onAddToCollection: diff --git a/pubspec.lock b/pubspec.lock index 0d21dec..0cef062 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1236,6 +1236,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_windows: + dependency: "direct main" + description: + name: webview_windows + sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730" + url: "https://pub.dev" + source: hosted + version: "0.4.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dce01c0..2c06a25 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: file_selector: ^1.0.3 path: ^1.9.0 media_kit_libs_android_video: ^1.3.8 + webview_windows: ^0.4.0 dev_dependencies: flutter_test: diff --git a/test/backend/feed_tag_filter_test.dart b/test/backend/feed_tag_filter_test.dart new file mode 100644 index 0000000..c625926 --- /dev/null +++ b/test/backend/feed_tag_filter_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/post.dart'; +import 'package:gel_rule_app/backend/services/feed_service.dart'; +import 'package:gel_rule_app/backend/services/settings_service.dart'; + +void main() { + group('feed tag filters', () { + test('blacklist hides matching posts', () { + final post = _post(tags: ['solo', 'blocked_tag']); + final settings = AppSettings.defaults.copyWith( + blacklistedTags: ['blocked_tag'], + ); + + expect(postPassesTagFilters(post, settings), isFalse); + }); + + test('whitelist allows only matching posts', () { + final settings = AppSettings.defaults.copyWith( + whitelistedTags: ['favorite_tag'], + ); + + expect( + postPassesTagFilters(_post(tags: ['favorite_tag']), settings), + isTrue, + ); + expect( + postPassesTagFilters(_post(tags: ['other_tag']), settings), + isFalse, + ); + }); + + test('tag groups participate in filtering', () { + final post = _post( + tags: ['general'], + tagGroups: { + 'artist': ['artist_name'], + }, + ); + final settings = AppSettings.defaults.copyWith( + blacklistedTags: ['artist_name'], + ); + + expect(postPassesTagFilters(post, settings), isFalse); + }); + + test('requested search tags are all required', () { + final post = _post(tags: ['touhou', 'hakurei_reimu']); + + expect(postMatchesRequestedTags(post, ['touhou']), isTrue); + expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse); + }); + }); +} + +Post _post({ + required List tags, + Map> tagGroups = const {}, +}) { + return Post( + id: '1', + providerId: 'test', + providerName: 'Test', + previewUrl: 'https://example.test/preview.jpg', + sampleUrl: 'https://example.test/sample.jpg', + fileUrl: 'https://example.test/file.jpg', + tags: tags, + rating: 'general', + width: 100, + height: 100, + createdAt: DateTime(2026), + fileType: 'photo', + score: 1, + tagGroups: tagGroups, + ); +} diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart index 077026a..19eebc1 100644 --- a/test/backend/isar_integration_test.dart +++ b/test/backend/isar_integration_test.dart @@ -6,8 +6,10 @@ import 'package:gel_rule_app/backend/repositories/collection_repository.dart'; import 'package:gel_rule_app/backend/repositories/favorite_repository.dart'; import 'package:gel_rule_app/backend/repositories/post_repository.dart'; import 'package:gel_rule_app/backend/repositories/provider_repository.dart'; +import 'package:gel_rule_app/backend/repositories/viewed_post_repository.dart'; import 'package:gel_rule_app/backend/services/collection_service.dart'; import 'package:gel_rule_app/backend/services/settings_service.dart'; +import 'package:gel_rule_app/backend/services/viewed_history_service.dart'; import 'package:gel_rule_app/core/cache/cache_service.dart'; import 'package:gel_rule_app/core/database/app_database.dart'; import 'package:gel_rule_app/core/database/database_service.dart'; @@ -60,8 +62,9 @@ void main() { await repository.ensureSeedProviders(); final result = await repository.getProviders(); + final providers = (result as Success).data; - expect((result as Success).data.map((provider) => provider.id), [ + expect(providers.map((provider) => provider.id), [ 'gelbooru', 'rule34', 'safebooru', @@ -144,6 +147,23 @@ void main() { expect(cached.data, hasLength(2)); }); + + test('viewed history persists keys and clears history', () async { + final service = ViewedHistoryService( + ViewedPostRepository(databaseService), + postRepository, + ); + final item = post('seen'); + + await service.markViewed(item); + expect((await service.isViewed(item.id, item.providerId) as Success).data, + isTrue); + expect((await service.getViewedKeys() as Success).data, + contains(item.cacheKey)); + + await service.clearHistory(); + expect((await service.getViewedKeys() as Success).data, isEmpty); + }); } Post post(String id) => Post( diff --git a/test/backend/provider_manager_test.dart b/test/backend/provider_manager_test.dart index 855945d..3333bce 100644 --- a/test/backend/provider_manager_test.dart +++ b/test/backend/provider_manager_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:gel_rule_app/backend/models/content_provider_config.dart'; import 'package:gel_rule_app/backend/models/post.dart'; +import 'package:gel_rule_app/backend/models/provider_diagnostics.dart'; import 'package:gel_rule_app/backend/models/provider_health.dart'; import 'package:gel_rule_app/backend/models/top_period_filter.dart'; import 'package:gel_rule_app/backend/providers/content_provider.dart'; @@ -12,6 +13,7 @@ import 'package:gel_rule_app/core/utils/result.dart'; class FakeProviderRepository implements ProviderRepository { final configs = {}; final health = {}; + final diagnostics = {}; @override Future> ensureSeedProviders() async => const Success(null); @@ -55,6 +57,12 @@ class FakeProviderRepository implements ProviderRepository { return Success(health[providerId]); } + @override + Future> saveDiagnostics(ProviderDiagnostics value) async { + diagnostics[value.providerId] = value; + return const Success(null); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -179,6 +187,8 @@ void main() { as Success>; expect(result.data.map((item) => item.providerId), ['a', 'b']); expect(repository.health['c']?.status, ProviderStatus.offline); + expect(repository.diagnostics['a']?.lastResultCount, 1); + expect(repository.diagnostics['c']?.lastErrorMessage, 'Search failed'); }); test('enable disable provider persists config', () async { diff --git a/test/backend/smart_blacklist_test.dart b/test/backend/smart_blacklist_test.dart new file mode 100644 index 0000000..a599695 --- /dev/null +++ b/test/backend/smart_blacklist_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/backend.dart'; + +void main() { + test('matches simple tag and multi tag AND rules', () { + final item = post(tags: ['blue_hair', 'smile']); + + expect(SmartBlacklistMatcher.matches(item, 'blue_hair'), isTrue); + expect(SmartBlacklistMatcher.matches(item, 'blue_hair smile'), isTrue); + expect(SmartBlacklistMatcher.matches(item, 'blue_hair red_eyes'), isFalse); + }); + + test('matches provider rating type score and tag groups', () { + final item = post( + providerId: 'e621', + rating: 'explicit', + fileType: 'video', + score: 42, + tagGroups: const { + 'artist': ['artist_name'], + 'character': ['char_name'], + }, + ); + + expect(SmartBlacklistMatcher.matches(item, 'provider:e621'), isTrue); + expect(SmartBlacklistMatcher.matches(item, 'rating:explicit'), isTrue); + expect(SmartBlacklistMatcher.matches(item, 'type:video'), isTrue); + expect(SmartBlacklistMatcher.matches(item, 'score:>40'), isTrue); + expect(SmartBlacklistMatcher.matches(item, 'artist:artist_name'), isTrue); + expect(SmartBlacklistMatcher.matches(item, 'character:other'), isFalse); + }); + + test('similar tags prefer grouped identity tags', () { + final item = post( + tags: ['general_a', 'general_b'], + tagGroups: const { + 'artist': ['artist_name'], + 'copyright': ['series_name'], + }, + ); + + expect(similarTagsFor(item), ['artist_name', 'series_name']); + }); +} + +Post post({ + String providerId = 'gelbooru', + String rating = 'safe', + String fileType = 'image', + int score = 0, + List tags = const [], + Map> tagGroups = const {}, +}) { + return Post( + id: '1', + providerId: providerId, + providerName: providerId, + previewUrl: '', + sampleUrl: '', + fileUrl: '', + tags: tags, + tagGroups: tagGroups, + rating: rating, + width: 0, + height: 0, + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + fileType: fileType, + score: score, + ); +} diff --git a/test/shared/tag_input_search_bar_test.dart b/test/shared/tag_input_search_bar_test.dart new file mode 100644 index 0000000..bdd110f --- /dev/null +++ b/test/shared/tag_input_search_bar_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/backend.dart'; +import 'package:gel_rule_app/shared/widgets/app_search_bar.dart'; + +void main() { + testWidgets('commits typed tag with space', (tester) async { + String? changed; + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + onSubmitted: (_) {}, + onChanged: (value) { + changed = value; + }), + )); + + await tester.enterText(find.byType(TextField), 'touhou '); + await tester.pump(); + await tester.pump(); + + expect(find.text('touhou'), findsOneWidget); + expect(changed, 'touhou'); + }); + + testWidgets('submits committed tags on search action', (tester) async { + String? submitted; + await tester.pumpWidget(_Harness( + child: TagInputSearchBar(onSubmitted: (value) { + submitted = value; + }), + )); + + await tester.enterText(find.byType(TextField), 'touhou '); + await tester.pump(); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(submitted, 'touhou'); + }); + + testWidgets('deleting chip updates query', (tester) async { + String? changed; + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'touhou video', + onSubmitted: (_) {}, + onChanged: (value) { + changed = value; + }, + ), + )); + + await tester.tap(find.byIcon(Icons.close_rounded).first); + await tester.pump(); + + expect(changed, 'video'); + }); + + testWidgets('suggestion appends active tag as a chip', (tester) async { + String? applied; + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + suggestions: const [ + TagSuggestion( + name: 'touhou', + category: TagCategory.copyright, + postCount: 1200, + providerId: 'danbooru', + ), + ], + onSubmitted: (_) {}, + onSuggestionApplied: (value) { + applied = value; + }, + ), + )); + + await tester.enterText(find.byType(TextField), 'tou'); + await tester.pump(); + await tester.tap(find.text('touhou')); + await tester.pump(); + + expect(find.text('touhou'), findsWidgets); + expect(applied, 'touhou'); + }); +} + +class _Harness extends StatelessWidget { + const _Harness({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + } +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index d0ab5f7..c959211 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -31,6 +31,7 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") # Use Unicode for all projects. add_definitions(-DUNICODE -D_UNICODE) +add_definitions(-D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS) # Compilation settings that should be applied to most targets. # diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 35b6858..b3e212f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { ConnectivityPlusWindowsPluginRegisterWithRegistrar( @@ -29,4 +30,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("UrlLauncherWindows")); VolumeControllerPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); + WebviewWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebviewWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2b8213a..31a0acb 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video url_launcher_windows volume_controller + webview_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST