Add viewed history, diagnostics, tag chips and SWF viewer

This commit is contained in:
2026-05-24 11:35:43 +03:00
parent b02c776628
commit 361d0b3910
49 changed files with 4956 additions and 446 deletions
+6
View File
@@ -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.
+5
View File
@@ -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<GoRouter>((ref) {
@@ -43,6 +44,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/favorites',
builder: (context, state) => const FavoritesScreen(),
),
GoRoute(
path: '/viewed',
builder: (context, state) => const ViewedScreen(),
),
GoRoute(
path: '/collections',
builder: (context, state) => const CollectionsScreen(),
+4
View File
@@ -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';
+20
View File
@@ -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<AppDatabase>((ref) async {
final database = await AppDatabase.open();
@@ -54,6 +57,10 @@ final searchRepositoryProvider = Provider<SearchRepository>((ref) {
return SearchRepository(ref.watch(databaseServiceProvider));
});
final viewedPostRepositoryProvider = Provider<ViewedPostRepository>((ref) {
return ViewedPostRepository(ref.watch(databaseServiceProvider));
});
final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
return FavoriteRepository(
ref.watch(databaseServiceProvider),
@@ -88,9 +95,22 @@ final feedServiceProvider = Provider<FeedService>((ref) {
ref.watch(providerManagerProvider),
ref.watch(cacheServiceProvider),
ref.watch(settingsServiceProvider),
ref.watch(viewedHistoryServiceProvider),
);
});
final viewedHistoryServiceProvider = Provider<ViewedHistoryService>((ref) {
return ViewedHistoryService(
ref.watch(viewedPostRepositoryProvider),
ref.watch(postRepositoryProvider),
);
});
final viewedKeysProvider = FutureProvider<Set<String>>((ref) async {
final result = await ref.watch(viewedHistoryServiceProvider).getViewedKeys();
return result is Success<Set<String>> ? result.data : <String>{};
});
final searchServiceProvider = Provider<SearchService>((ref) {
return SearchService(
ref.watch(searchRepositoryProvider),
+22 -2
View File
@@ -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<String, List<String>> _tagGroups(List<String> tags) {
return tags.isEmpty ? const {} : {'general': tags};
}
static Map<String, List<String>> _tagGroupsFromJson(
Map<String, dynamic> json,
) {
final groups = <String, List<String>>{};
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;
}
}
@@ -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;
}
+16
View File
@@ -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';
}
+22 -5
View File
@@ -43,7 +43,7 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
final response = await _dio.get<dynamic>(
'/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>()
.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<dynamic> _commentItems(dynamic data) {
if (data is List) return data;
if (data is! Map) return const [];
final json = Map<String, dynamic>.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<String> _topTags(TopPeriodFilter period) {
return switch (period) {
TopPeriodFilter.none => const [],
+35 -2
View File
@@ -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<dynamic>(
'/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<List<PostComment>> getComments(String postId) async {
final response = await _dio.get<dynamic>(
'/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>()
.map((item) {
final json = Map<String, dynamic>.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',
+72 -8
View File
@@ -148,13 +148,7 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
}
List<PostComment> _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>()
.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<dynamic> _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<String, dynamic>.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<Map<String, dynamic>> _commentItemsFromText(String text) {
final items = <Map<String, dynamic>>[];
for (final match in RegExp(r'<comment\b([^>]*)>(.*?)</comment>',
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'<comment\b([^>]*)/?>', caseSensitive: false, dotAll: true)
.allMatches(text)) {
final attrs = _attributes(match.group(1) ?? '');
if (attrs.isNotEmpty) items.add(attrs);
}
return items;
}
Map<String, dynamic> _attributes(String source) {
final result = <String, dynamic>{};
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[^>]*>(.*?)</$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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
}
}
+38 -2
View File
@@ -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<dynamic>(
'/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<List<PostComment>> getComments(String postId) async {
final response = await _dio.get<dynamic>(
'/comment.json',
queryParameters: {
'post_id': postId,
'limit': 50,
..._queryParameters,
},
);
final items = response.data is List ? response.data as List : const [];
return items
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.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<String> _topTags(TopPeriodFilter period) {
return switch (period) {
TopPeriodFilter.none => const [],
+20 -1
View File
@@ -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 = <Post>[];
final seen = <String>{};
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,
@@ -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<Result<void>> 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<Result<void>> saveDiagnostics(ProviderDiagnostics diagnostics) {
return _databaseService.safeWrite((isar) async {
await isar.providerDiagnosticsEntitys.put(
ProviderDiagnosticsEntity.fromModel(diagnostics),
);
});
}
Future<Result<List<ProviderDiagnostics>>> getDiagnostics() {
return _databaseService.safeRead((isar) async {
final items = await isar.providerDiagnosticsEntitys.where().findAll();
return items.map((item) => item.toModel()).toList();
});
}
}
@@ -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<Result<void>> 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<Result<bool>> 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<Result<Set<String>>> keys() {
return _databaseService.safeRead((isar) async {
final items = await isar.viewedPostEntitys.where().findAll();
return items.map((item) => item.viewedKey).toSet();
});
}
Future<Result<List<ViewedPost>>> 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<Result<void>> clear() {
return _databaseService.safeWrite((isar) async {
await isar.viewedPostEntitys.clear();
});
}
}
+69 -6
View File
@@ -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<String, int> _pages = {};
String _key(
@@ -61,10 +69,65 @@ class FeedService {
final posts = (result as Success<List<Post>>).data;
_pages[key] = page + 1;
final settingsResult = await _settingsService.getSettings();
final maxItems = settingsResult is Success<AppSettings>
? settingsResult.data.cacheMaxItems
: null;
await _cacheService.cachePosts(posts, maxItems: maxItems);
return Success(posts);
final settings = settingsResult is Success<AppSettings>
? settingsResult.data
: AppSettings.defaults;
final viewedKeysResult = settings.hideViewedPosts
? await _viewedHistoryService.getViewedKeys()
: const Success(<String>{});
final viewedKeys = viewedKeysResult is Success<Set<String>>
? viewedKeysResult.data
: <String>{};
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<String> 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<String> _postTagSet(Post post) {
return <String>{
...post.tags,
for (final group in post.tagGroups.values) ...group,
}
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty)
.toSet();
}
@@ -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<String> selectedFeedProviderIds;
final bool showPostBadges;
final String defaultTopPeriodFilter;
final List<String> blacklistedTags;
final List<String> whitelistedTags;
final List<String> 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<String>? selectedFeedProviderIds,
bool? showPostBadges,
String? defaultTopPeriodFilter,
List<String>? blacklistedTags,
List<String>? whitelistedTags,
List<String>? 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<String>.from(
(json['blacklistedTags'] as List?) ?? defaults.blacklistedTags,
),
whitelistedTags: List<String>.from(
(json['whitelistedTags'] as List?) ?? defaults.whitelistedTags,
),
smartBlacklistRules: List<String>.from(
(json['smartBlacklistRules'] as List?) ??
(json['blacklistedTags'] as List?) ??
defaults.smartBlacklistRules,
),
hideViewedPosts:
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
defaultRatingFilter: json['defaultRatingFilter'] as String?,
);
}
@@ -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<Result<void>> markViewed(Post post) async {
final cacheResult = await _postRepository.cachePosts([post]);
if (cacheResult is Error<void>) return cacheResult;
return _repository.markViewed(post.providerId, post.id);
}
Future<Result<bool>> isViewed(String postId, String providerId) {
return _repository.exists(providerId, postId);
}
Future<Result<Set<String>>> getViewedKeys() {
return _repository.keys();
}
Future<Result<List<Post>>> getViewedPosts() async {
final viewedResult = await _repository.recent();
if (viewedResult is Error<List<ViewedPost>>) {
return Error(viewedResult.failure);
}
final viewed = (viewedResult as Success<List<ViewedPost>>).data;
return _postRepository.getCachedPostsByKeys(
viewed.map((item) => item.viewedKey),
);
}
Future<Result<void>> clearHistory() {
return _repository.clear();
}
}
+78
View File
@@ -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<String> _allTags(Post post) {
return {
...post.tags,
for (final group in post.tagGroups.values) ...group,
}.map((tag) => tag.toLowerCase()).toSet();
}
}
List<String> similarTagsFor(Post post) {
final priority = [
...post.tagGroups['artist'] ?? const <String>[],
...post.tagGroups['character'] ?? const <String>[],
...post.tagGroups['copyright'] ?? const <String>[],
].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();
}
+57
View File
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
@@ -47,9 +47,13 @@ class FeedController extends AsyncNotifier<FeedState> {
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<FeedState> {
Future<void> 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<FeedState> {
final result = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
final normalizedToken = lastToken.trim().toLowerCase();
final suggestions = result is Success<List<TagSuggestion>>
? result.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: const <TagSuggestion>[];
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
}
@@ -138,6 +149,7 @@ class FeedController extends AsyncNotifier<FeedState> {
? <String?>[null]
: current.selectedProviderIds;
final posts = <Post>[];
final seen = <String>{};
for (final providerId in providerIds) {
final result = refresh
? await service.refresh(
@@ -152,7 +164,11 @@ class FeedController extends AsyncNotifier<FeedState> {
providerId: providerId,
topPeriod: current.topPeriodFilter,
);
if (result is Success<List<Post>>) posts.addAll(result.data);
if (result is Success<List<Post>>) {
for (final post in result.data) {
if (seen.add(post.cacheKey)) posts.add(post);
}
}
}
return posts;
}
@@ -52,6 +52,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) {
_usedInitialQuery = true;
@@ -165,6 +166,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
nsfwEnabled: settings.nsfwEnabled,
loading: state.isLoadingMore,
favoriteKeys: favoriteKeys,
viewedKeys: viewedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
@@ -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<FeedToolbar> {
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<FeedToolbar> {
],
),
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<FeedToolbar> {
],
),
),
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<FeedToolbar> {
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<FeedToolbar> {
),
),
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<FeedToolbar> {
],
),
),
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
? <String>[]
: _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),
),
),
],
);
}
}
@@ -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,7 +106,54 @@ class PostDetailsScreen extends ConsumerWidget {
favoriteKeys,
);
}
return ListView(
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(
@@ -122,20 +176,31 @@ class PostDetailsScreen extends ConsumerWidget {
),
IconButton.filledTonal(
tooltip: 'Next',
onPressed:
next == null ? null : () => _openPost(context, 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),
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,
@@ -163,6 +228,9 @@ class PostDetailsScreen extends ConsumerWidget {
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<String, String> _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<Post> posts;
final int currentIndex;
final ValueChanged<Post> 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'),
),
);
}
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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),
);
},
@@ -90,6 +90,9 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
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) =>
@@ -38,3 +38,14 @@ class ProvidersController extends AsyncNotifier<List<ContentProviderConfig>> {
final providerHealthProvider =
StateProvider<Map<String, ProviderHealth>>((ref) => {});
final providerDiagnosticsProvider =
FutureProvider<Map<String, ProviderDiagnostics>>((ref) async {
final result = await ref.watch(providerRepositoryProvider).getDiagnostics();
if (result is Success<List<ProviderDiagnostics>>) {
return {
for (final item in result.data) item.providerId: item,
};
}
return {};
});
@@ -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(
@@ -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 ? <String>[] : query.trim().split(RegExp(r'\s+'));
if (tokens.isEmpty) {
tokens.add(suggestion);
} else {
tokens[tokens.length - 1] = suggestion;
}
return tokens.toSet().join(' ');
}
}
@@ -25,6 +25,11 @@ class SettingsController extends AsyncNotifier<AppSettings> {
await ref.read(cacheServiceProvider).clear();
}
Future<void> clearViewedHistory() async {
await ref.read(viewedHistoryServiceProvider).clearHistory();
ref.invalidate(viewedKeysProvider);
}
Future<String> exportJson() async {
final result =
await ref.read(settingsServiceProvider).exportSettingsToJson();
@@ -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<String> tags;
final ValueChanged<List<String>> 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 = <String>{...widget.tags, ...incoming}.toList()..sort();
_controller.clear();
widget.onChanged(merged);
}
}
class _StepperTile extends StatelessWidget {
const _StepperTile({
required this.title,
@@ -0,0 +1,28 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final viewedControllerProvider =
AsyncNotifierProvider<ViewedController, List<Post>>(ViewedController.new);
class ViewedController extends AsyncNotifier<List<Post>> {
@override
Future<List<Post>> build() => _load();
Future<void> refresh() async {
state = AsyncData(await _load());
}
Future<void> clear() async {
await ref.read(viewedHistoryServiceProvider).clearHistory();
ref.invalidate(viewedKeysProvider);
state = const AsyncData([]);
}
Future<List<Post>> _load() async {
final result =
await ref.read(viewedHistoryServiceProvider).getViewedPosts();
return result is Success<List<Post>> ? result.data : const [];
}
}
@@ -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 ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
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);
},
),
),
),
);
}
}
+438 -7
View File
@@ -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<AppSearchBar> createState() => _AppSearchBarState();
}
class _AppSearchBarState extends State<AppSearchBar> {
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<String> onSubmitted;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSuggestionApplied;
final String? initialValue;
final String hintText;
final List<TagSuggestion> suggestions;
@override
State<TagInputSearchBar> createState() => _TagInputSearchBarState();
}
class _TagInputSearchBarState extends State<TagInputSearchBar> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
Timer? _debounce;
List<String> _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<String> _parseTags(String query) {
final seen = <String>{};
final result = <String>[];
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<String> 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<TagSuggestion> suggestions;
final ValueChanged<String> 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<AppSearchBar> {
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<AppSearchBar> {
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<AppSearchBar> {
tooltip: 'Clear',
icon: const Icon(Icons.close_rounded),
onPressed: () {
_debounce?.cancel();
_controller.clear();
widget.onChanged?.call('');
_focusNode.requestFocus();
},
),
),
+1
View File
@@ -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'),
+36
View File
@@ -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<PostCard> {
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<void> showAddToCollectionPicker(
BuildContext context, {
required List<Collection> collections,
@@ -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<Post> onFavorite;
final ValueChanged<Post>? onAddToCollection;
final Set<String> favoriteKeys;
final Set<String> 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:
+8
View File
@@ -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:
+1
View File
@@ -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:
+75
View File
@@ -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<String> tags,
Map<String, List<String>> 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,
);
}
+21 -1
View File
@@ -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(
+10
View File
@@ -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 = <String, ContentProviderConfig>{};
final health = <String, ProviderHealth>{};
final diagnostics = <String, ProviderDiagnostics>{};
@override
Future<Result<void>> ensureSeedProviders() async => const Success(null);
@@ -55,6 +57,12 @@ class FakeProviderRepository implements ProviderRepository {
return Success(health[providerId]);
}
@override
Future<Result<void>> 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<List<Post>>;
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 {
+70
View File
@@ -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<String> tags = const [],
Map<String, List<String>> 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,
);
}
+105
View File
@@ -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,
),
),
);
}
}
+1
View File
@@ -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.
#
@@ -13,6 +13,7 @@
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <volume_controller/volume_controller_plugin_c_api.h>
#include <webview_windows/webview_windows_plugin.h>
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"));
}
+1
View File
@@ -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