Add viewed history, diagnostics, tag chips and SWF viewer
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,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<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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user