Initial Flutter booru app

This commit is contained in:
2026-05-23 11:46:52 +03:00
commit 63da741295
187 changed files with 24113 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
export 'di/backend_providers.dart';
export 'models/collection.dart';
export 'models/content_provider_config.dart';
export 'models/favorite.dart';
export 'models/post.dart';
export 'models/provider_health.dart';
export 'models/search_history.dart';
export 'models/top_period_filter.dart';
export 'providers/content_provider.dart';
export 'providers/provider_manager.dart';
export 'services/collection_service.dart';
export 'services/favorite_service.dart';
export 'services/feed_service.dart';
export 'services/provider_check_service.dart';
export 'services/search_service.dart';
export 'services/settings_service.dart';
+121
View File
@@ -0,0 +1,121 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/cache/cache_service.dart';
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 '../providers/provider_factory.dart';
import '../providers/provider_manager.dart';
import '../repositories/collection_repository.dart';
import '../repositories/favorite_repository.dart';
import '../repositories/post_repository.dart';
import '../repositories/provider_repository.dart';
import '../repositories/search_repository.dart';
import '../services/collection_service.dart';
import '../services/favorite_service.dart';
import '../services/feed_service.dart';
import '../services/provider_check_service.dart';
import '../services/search_service.dart';
import '../services/settings_service.dart';
final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
final database = await AppDatabase.open();
ref.onDispose(database.close);
return database;
});
final databaseServiceProvider = Provider<DatabaseService>((ref) {
final database = ref.watch(appDatabaseProvider).requireValue;
return DatabaseService(database);
});
final connectivityProvider = Provider<Connectivity>((ref) => Connectivity());
final networkInfoProvider = Provider<NetworkInfo>((ref) {
return NetworkInfo(ref.watch(connectivityProvider));
});
final providerFactoryProvider = Provider<ProviderFactory>((ref) {
return ProviderFactory();
});
final postRepositoryProvider = Provider<PostRepository>((ref) {
return PostRepository(ref.watch(databaseServiceProvider));
});
final providerRepositoryProvider = Provider<ProviderRepository>((ref) {
return ProviderRepository(ref.watch(databaseServiceProvider));
});
final searchRepositoryProvider = Provider<SearchRepository>((ref) {
return SearchRepository(ref.watch(databaseServiceProvider));
});
final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
return FavoriteRepository(
ref.watch(databaseServiceProvider),
ref.watch(postRepositoryProvider),
);
});
final collectionRepositoryProvider = Provider<CollectionRepository>((ref) {
return CollectionRepository(
ref.watch(databaseServiceProvider),
ref.watch(postRepositoryProvider),
);
});
final cacheServiceProvider = Provider<CacheService>((ref) {
return CacheService(ref.watch(databaseServiceProvider));
});
final imageCacheServiceProvider = Provider<ImageCacheService>((ref) {
return ImageCacheService();
});
final providerManagerProvider = Provider<ProviderManager>((ref) {
return ProviderManager(
ref.watch(providerRepositoryProvider),
ref.watch(providerFactoryProvider),
);
});
final feedServiceProvider = Provider<FeedService>((ref) {
return FeedService(
ref.watch(providerManagerProvider),
ref.watch(cacheServiceProvider),
ref.watch(settingsServiceProvider),
);
});
final searchServiceProvider = Provider<SearchService>((ref) {
return SearchService(ref.watch(searchRepositoryProvider));
});
final providerCheckServiceProvider = Provider<ProviderCheckService>((ref) {
return ProviderCheckService(
ref.watch(providerRepositoryProvider),
ref.watch(providerFactoryProvider),
ref.watch(providerManagerProvider),
);
});
final favoriteServiceProvider = Provider<FavoriteService>((ref) {
return FavoriteService(
ref.watch(favoriteRepositoryProvider),
ref.watch(postRepositoryProvider),
);
});
final collectionServiceProvider = Provider<CollectionService>((ref) {
return CollectionService(ref.watch(collectionRepositoryProvider));
});
final settingsServiceProvider = Provider<SettingsService>((ref) {
return SettingsService(
ref.watch(databaseServiceProvider),
providerRepository: ref.watch(providerRepositoryProvider),
);
});
+80
View File
@@ -0,0 +1,80 @@
import '../models/post.dart';
class DanbooruMapper {
static List<Post> postsFromResponse(
dynamic data, {
required String providerId,
required String providerName,
}) {
final items = data is List ? data : const [];
return items
.whereType<Map>()
.map((item) => postFromJson(
Map<String, dynamic>.from(item),
providerId: providerId,
providerName: providerName,
))
.toList();
}
static Post postFromJson(
Map<String, dynamic> json, {
required String providerId,
required String providerName,
}) {
final fileUrl = _string(json['file_url'] ?? json['large_file_url']);
final sampleUrl =
_string(json['large_file_url'] ?? json['sample_file_url'] ?? fileUrl);
final previewUrl = _string(json['preview_file_url'] ?? sampleUrl);
return Post(
id: _string(json['id']),
providerId: providerId,
providerName: providerName,
previewUrl: previewUrl,
sampleUrl: sampleUrl,
fileUrl: fileUrl,
tags: _tags(json['tag_string'] ?? json['tags']),
rating: _string(json['rating'], fallback: 'unknown'),
width: _int(json['image_width'] ?? json['width']),
height: _int(json['image_height'] ?? json['height']),
source: _nullableString(json['source']),
createdAt:
DateTime.tryParse(_string(json['created_at'])) ?? DateTime.now(),
fileType: _fileType(fileUrl),
score: _int(json['score']),
);
}
static List<String> _tags(dynamic value) {
if (value is List) return value.map((tag) => tag.toString()).toList();
return _string(value)
.split(RegExp(r'\s+'))
.where((tag) => tag.trim().isNotEmpty)
.toSet()
.toList();
}
static int _int(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
static String _string(dynamic value, {String fallback = ''}) {
final text = value?.toString();
return text == null || text.isEmpty ? fallback : text;
}
static String? _nullableString(dynamic value) {
final text = value?.toString();
return text == null || text.isEmpty ? null : text;
}
static String _fileType(String url) {
final lower = url.toLowerCase();
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
if (lower.endsWith('.gif')) return 'gif';
if (lower.isEmpty) return 'unknown';
return 'image';
}
}
+99
View File
@@ -0,0 +1,99 @@
import '../models/post.dart';
class GelbooruMapper {
static List<Post> postsFromResponse(
dynamic data, {
required String providerId,
required String providerName,
}) {
final items = _extractItems(data);
return items
.whereType<Map>()
.map((item) => postFromJson(
Map<String, dynamic>.from(item),
providerId: providerId,
providerName: providerName,
))
.toList();
}
static Post postFromJson(
Map<String, dynamic> json, {
required String providerId,
required String providerName,
}) {
final id = _string(json['id']);
final fileUrl = _string(json['file_url'] ?? json['fileUrl']);
final sampleUrl = _string(json['sample_url'] ??
json['sampleUrl'] ??
json['large_file_url'] ??
fileUrl);
final previewUrl =
_string(json['preview_url'] ?? json['previewUrl'] ?? sampleUrl);
return Post(
id: id,
providerId: providerId,
providerName: providerName,
previewUrl: previewUrl,
sampleUrl: sampleUrl,
fileUrl: fileUrl,
tags: _tags(json['tags'] ?? json['tag_string']),
rating: _string(json['rating'], fallback: 'unknown'),
width: _int(json['width']),
height: _int(json['height']),
source: _nullableString(json['source']),
createdAt: _date(json['created_at'] ?? json['createdAt']),
fileType: _fileType(fileUrl),
score: _int(json['score']),
);
}
static List<dynamic> _extractItems(dynamic data) {
if (data is List) return data;
if (data is Map) {
final posts = data['post'] ?? data['posts'];
if (posts is List) return posts;
if (posts is Map && posts['post'] is List) return posts['post'] as List;
if (posts is Map) return [posts];
}
return const [];
}
static List<String> _tags(dynamic value) {
if (value is List) return value.map((tag) => tag.toString()).toList();
return _string(value)
.split(RegExp(r'\s+'))
.where((tag) => tag.trim().isNotEmpty)
.toSet()
.toList();
}
static int _int(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
static String _string(dynamic value, {String fallback = ''}) {
final text = value?.toString();
return text == null || text.isEmpty ? fallback : text;
}
static String? _nullableString(dynamic value) {
final text = value?.toString();
return text == null || text.isEmpty ? null : text;
}
static DateTime _date(dynamic value) {
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value * 1000);
return DateTime.tryParse(value?.toString() ?? '') ?? DateTime.now();
}
static String _fileType(String url) {
final lower = url.toLowerCase();
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
if (lower.endsWith('.gif')) return 'gif';
if (lower.isEmpty) return 'unknown';
return 'image';
}
}
+16
View File
@@ -0,0 +1,16 @@
import '../models/post.dart';
import 'gelbooru_mapper.dart';
class Rule34Mapper {
static List<Post> postsFromResponse(
dynamic data, {
required String providerId,
required String providerName,
}) {
return GelbooruMapper.postsFromResponse(
data,
providerId: providerId,
providerName: providerName,
);
}
}
+34
View File
@@ -0,0 +1,34 @@
class Collection {
const Collection({
required this.id,
required this.name,
this.description,
this.coverUrl,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String name;
final String? description;
final String? coverUrl;
final DateTime createdAt;
final DateTime updatedAt;
Collection copyWith({
String? id,
String? name,
String? description,
String? coverUrl,
DateTime? createdAt,
DateTime? updatedAt,
}) =>
Collection(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
coverUrl: coverUrl ?? this.coverUrl,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@@ -0,0 +1,81 @@
class ContentProviderConfig {
const ContentProviderConfig({
required this.id,
required this.name,
required this.baseUrl,
required this.apiType,
required this.enabled,
required this.priority,
required this.timeoutSeconds,
required this.customHeaders,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String name;
final String baseUrl;
final String apiType;
final bool enabled;
final int priority;
final int timeoutSeconds;
final Map<String, String> customHeaders;
final DateTime createdAt;
final DateTime updatedAt;
ContentProviderConfig copyWith({
String? id,
String? name,
String? baseUrl,
String? apiType,
bool? enabled,
int? priority,
int? timeoutSeconds,
Map<String, String>? customHeaders,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return ContentProviderConfig(
id: id ?? this.id,
name: name ?? this.name,
baseUrl: baseUrl ?? this.baseUrl,
apiType: apiType ?? this.apiType,
enabled: enabled ?? this.enabled,
priority: priority ?? this.priority,
timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds,
customHeaders: customHeaders ?? this.customHeaders,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'baseUrl': baseUrl,
'apiType': apiType,
'enabled': enabled,
'priority': priority,
'timeoutSeconds': timeoutSeconds,
'customHeaders': customHeaders,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
factory ContentProviderConfig.fromJson(Map<String, dynamic> json) {
return ContentProviderConfig(
id: json['id'] as String,
name: json['name'] as String,
baseUrl: json['baseUrl'] as String,
apiType: json['apiType'] as String,
enabled: (json['enabled'] as bool?) ?? true,
priority: (json['priority'] as num?)?.toInt() ?? 0,
timeoutSeconds: (json['timeoutSeconds'] as num?)?.toInt() ?? 20,
customHeaders: Map<String, String>.from(
(json['customHeaders'] as Map?) ?? const {},
),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
class Favorite {
const Favorite({
required this.id,
required this.postId,
required this.providerId,
required this.savedAt,
});
final String id;
final String postId;
final String providerId;
final DateTime savedAt;
}
+70
View File
@@ -0,0 +1,70 @@
class Post {
const Post({
required this.id,
required this.providerId,
required this.providerName,
required this.previewUrl,
required this.sampleUrl,
required this.fileUrl,
required this.tags,
required this.rating,
required this.width,
required this.height,
this.source,
required this.createdAt,
required this.fileType,
required this.score,
});
final String id;
final String providerId;
final String providerName;
final String previewUrl;
final String sampleUrl;
final String fileUrl;
final List<String> tags;
final String rating;
final int width;
final int height;
final String? source;
final DateTime createdAt;
final String fileType;
final int score;
String get cacheKey => '$providerId:$id';
Map<String, dynamic> toJson() => {
'id': id,
'providerId': providerId,
'providerName': providerName,
'previewUrl': previewUrl,
'sampleUrl': sampleUrl,
'fileUrl': fileUrl,
'tags': tags,
'rating': rating,
'width': width,
'height': height,
'source': source,
'createdAt': createdAt.toIso8601String(),
'fileType': fileType,
'score': score,
};
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json['id'].toString(),
providerId: json['providerId'] as String,
providerName: json['providerName'] as String,
previewUrl: (json['previewUrl'] as String?) ?? '',
sampleUrl: (json['sampleUrl'] as String?) ?? '',
fileUrl: (json['fileUrl'] as String?) ?? '',
tags: List<String>.from((json['tags'] as List?) ?? const []),
rating: (json['rating'] as String?) ?? 'unknown',
width: (json['width'] as num?)?.toInt() ?? 0,
height: (json['height'] as num?)?.toInt() ?? 0,
source: json['source'] as String?,
createdAt: DateTime.tryParse((json['createdAt'] as String?) ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
fileType: (json['fileType'] as String?) ?? 'unknown',
score: (json['score'] as num?)?.toInt() ?? 0,
);
}
+42
View File
@@ -0,0 +1,42 @@
enum ProviderStatus { unknown, online, offline }
class ProviderHealth {
const ProviderHealth({
required this.providerId,
required this.status,
required this.pingMs,
required this.lastCheckedAt,
this.errorMessage,
this.apiVersion,
});
final String providerId;
final ProviderStatus status;
final int pingMs;
final DateTime lastCheckedAt;
final String? errorMessage;
final String? apiVersion;
bool get isOnline => status == ProviderStatus.online;
Map<String, dynamic> toJson() => {
'providerId': providerId,
'status': status.name,
'pingMs': pingMs,
'lastCheckedAt': lastCheckedAt.toIso8601String(),
'errorMessage': errorMessage,
'apiVersion': apiVersion,
};
factory ProviderHealth.fromJson(Map<String, dynamic> json) => ProviderHealth(
providerId: json['providerId'] as String,
status: ProviderStatus.values.firstWhere(
(value) => value.name == json['status'],
orElse: () => ProviderStatus.unknown,
),
pingMs: (json['pingMs'] as num?)?.toInt() ?? 0,
lastCheckedAt: DateTime.parse(json['lastCheckedAt'] as String),
errorMessage: json['errorMessage'] as String?,
apiVersion: json['apiVersion'] as String?,
);
}
+15
View File
@@ -0,0 +1,15 @@
class SearchHistory {
const SearchHistory({
required this.id,
required this.query,
required this.tags,
required this.searchedAt,
required this.resultCount,
});
final String id;
final String query;
final List<String> tags;
final DateTime searchedAt;
final int resultCount;
}
+15
View File
@@ -0,0 +1,15 @@
enum TopPeriodFilter {
none,
month,
year,
allTime;
String get label {
return switch (this) {
TopPeriodFilter.none => 'Latest',
TopPeriodFilter.month => 'Top month',
TopPeriodFilter.year => 'Top year',
TopPeriodFilter.allTime => 'Top all time',
};
}
}
@@ -0,0 +1,21 @@
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
abstract class ContentProvider {
String get id;
String get name;
String get baseUrl;
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
});
Future<Post?> getPost(String id);
Future<ProviderHealth> checkHealth();
}
@@ -0,0 +1,84 @@
import '../../core/errors/app_exception.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class CustomProvider implements ContentProvider {
CustomProvider(this._delegate);
final ContentProvider _delegate;
@override
String get id => _delegate.id;
@override
String get name => _delegate.name;
@override
String get baseUrl => _delegate.baseUrl;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) =>
_delegate.searchPosts(
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
);
@override
Future<Post?> getPost(String id) => _delegate.getPost(id);
@override
Future<ProviderHealth> checkHealth() => _delegate.checkHealth();
}
class UnsupportedCustomProvider implements ContentProvider {
UnsupportedCustomProvider({
required this.id,
required this.name,
required this.baseUrl,
required this.apiType,
});
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final String apiType;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
throw ProviderUnavailableException(
'Unsupported provider apiType: $apiType');
}
@override
Future<Post?> getPost(String id) async {
throw ProviderUnavailableException(
'Unsupported provider apiType: $apiType');
}
@override
Future<ProviderHealth> checkHealth() async => ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: 0,
lastCheckedAt: DateTime.now(),
errorMessage: 'Unsupported provider apiType: $apiType',
);
}
@@ -0,0 +1,106 @@
import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../mappers/danbooru_mapper.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class DanbooruProvider implements ContentProvider {
DanbooruProvider({
required this.id,
required this.name,
required this.baseUrl,
required DioClient dioClient,
Map<String, String> queryParameters = const {},
}) : _queryParameters = queryParameters,
_dio = dioClient.dio;
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final Dio _dio;
final Map<String, String> _queryParameters;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final queryTags = [
...tags,
if (rating != null && rating.isNotEmpty) 'rating:$rating',
..._topTags(topPeriod),
];
final response = await _dio.get<dynamic>(
'/posts.json',
queryParameters: {
'page': page <= 0 ? 1 : page,
'limit': limit,
'tags': queryTags.join(' '),
..._queryParameters,
},
);
return DanbooruMapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
}
@override
Future<Post?> getPost(String id) async {
final response = await _dio.get<dynamic>(
'/posts/$id.json',
queryParameters: _queryParameters,
);
final posts = DanbooruMapper.postsFromResponse(
[response.data],
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
await _dio.get<dynamic>(
'/posts.json',
queryParameters: {'limit': 1, ..._queryParameters},
);
return ProviderHealth(
providerId: id,
status: ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: 'danbooru',
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
apiVersion: 'danbooru',
);
}
}
List<String> _topTags(TopPeriodFilter period) {
return switch (period) {
TopPeriodFilter.none => const [],
TopPeriodFilter.month || TopPeriodFilter.year => const ['order:rank'],
TopPeriodFilter.allTime => const ['order:score'],
};
}
}
@@ -0,0 +1,132 @@
import 'package:dio/dio.dart';
import '../../core/errors/app_exception.dart';
import '../../core/http/dio_client.dart';
import '../mappers/gelbooru_mapper.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class GelbooruProvider implements ContentProvider {
GelbooruProvider({
required this.id,
required this.name,
required this.baseUrl,
required DioClient dioClient,
Map<String, String> queryParameters = const {},
}) : _queryParameters = queryParameters,
_dio = dioClient.dio;
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final Dio _dio;
final Map<String, String> _queryParameters;
Dio get dio => _dio;
Map<String, String> get queryParameters => _queryParameters;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final queryTags = [
...tags,
if (rating != null && rating.isNotEmpty) 'rating:$rating',
..._topTags(topPeriod),
];
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'json': '1',
'pid': page,
'limit': limit,
'tags': queryTags.join(' '),
..._queryParameters,
},
);
return GelbooruMapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
}
@override
Future<Post?> getPost(String id) async {
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'json': '1',
'id': id,
..._queryParameters,
},
);
final posts = GelbooruMapper.postsFromResponse(
response.data,
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
await _dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'json': '1',
'limit': 1,
..._queryParameters,
},
);
return ProviderHealth(
providerId: id,
status: ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: 'gelbooru-compatible',
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
apiVersion: 'gelbooru-compatible',
);
}
}
Never unavailable(String message) =>
throw ProviderUnavailableException(message);
List<String> _topTags(TopPeriodFilter period) {
return switch (period) {
TopPeriodFilter.none => const [],
TopPeriodFilter.month ||
TopPeriodFilter.year ||
TopPeriodFilter.allTime =>
const ['sort:score:desc'],
};
}
}
@@ -0,0 +1,53 @@
import '../../core/http/dio_client.dart';
import '../models/content_provider_config.dart';
import 'content_provider.dart';
import 'custom_provider.dart';
import 'danbooru_provider.dart';
import 'gelbooru_provider.dart';
import 'rule34_provider.dart';
class ProviderFactory {
ContentProvider create(ContentProviderConfig config) {
final headers = Map<String, String>.from(config.customHeaders)
..removeWhere((key, _) => key.startsWith('query.'));
final queryParameters = {
for (final entry in config.customHeaders.entries)
if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty)
entry.key.substring('query.'.length): entry.value.trim(),
};
final client = DioClient(
baseUrl: config.baseUrl,
timeout: Duration(seconds: config.timeoutSeconds),
headers: headers,
);
return switch (config.apiType.toLowerCase()) {
'gelbooru' => GelbooruProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
queryParameters: queryParameters,
),
'rule34' => Rule34Provider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
queryParameters: queryParameters,
),
'danbooru' => DanbooruProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
queryParameters: queryParameters,
),
_ => UnsupportedCustomProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
apiType: config.apiType,
),
};
}
}
+172
View File
@@ -0,0 +1,172 @@
import 'dart:async';
import '../../core/errors/failure.dart';
import '../../core/utils/result.dart';
import '../models/content_provider_config.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import '../repositories/provider_repository.dart';
import 'content_provider.dart';
import 'provider_factory.dart';
class ProviderManager {
ProviderManager(this._repository, this._factory);
final ProviderRepository _repository;
final ProviderFactory _factory;
Future<Result<List<ContentProviderConfig>>> loadConfigs({
bool enabledOnly = true,
}) async {
await _repository.ensureSeedProviders();
return _repository.getProviders(enabledOnly: enabledOnly);
}
Future<Result<List<ContentProvider>>> activeProviders() async {
final result = await loadConfigs();
if (result is Error<List<ContentProviderConfig>>) {
return Error(result.failure);
}
final configs = (result as Success<List<ContentProviderConfig>>).data
..sort((a, b) => a.priority.compareTo(b.priority));
return Success(configs.map(_factory.create).toList());
}
Future<Result<void>> enableProvider(String id, bool enabled) async {
final result = await _repository.getProvider(id);
return result.fold(
onSuccess: (config) {
if (config == null) {
return const Error<void>(
Failure(code: 'not_found', message: 'Provider not found'),
);
}
return _repository.saveProvider(
config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
);
},
onError: Error<void>.new,
);
}
Future<Result<void>> addCustomProvider(ContentProviderConfig config) {
return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now()));
}
Future<Result<void>> deleteProvider(String id) =>
_repository.deleteProvider(id);
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).data;
if (providers.isEmpty) return const Success([]);
final results = await _runLimited(
providers,
concurrency,
(provider) async {
final health = await provider.checkHealth();
await _repository.saveHealth(health);
return health;
},
);
return Success(results);
}
Future<Result<List<Post>>> searchAcrossProviders({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
String? providerId,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
var providers = (providersResult as Success<List<ContentProvider>>).data;
if (providerId != null) {
providers =
providers.where((provider) => provider.id == providerId).toList();
}
final posts = <Post>[];
for (final provider in providers) {
final health = await _repository.getHealth(provider.id);
final offline = health is Success<ProviderHealth?> &&
health.data?.status == ProviderStatus.offline;
if (offline) continue;
try {
final providerPosts = await provider.searchPosts(
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
);
posts.addAll(providerPosts);
} catch (_) {
await _repository.saveHealth(
ProviderHealth(
providerId: provider.id,
status: ProviderStatus.offline,
pingMs: 0,
lastCheckedAt: DateTime.now(),
errorMessage: 'Search failed',
),
);
}
}
return Success(posts);
}
Future<Result<Post?>> getPost(String providerId, String postId) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).data;
final matches = providers.where((provider) => provider.id == providerId);
if (matches.isEmpty) {
return const Error(
Failure(code: 'not_found', message: 'Provider not found'));
}
try {
return Success(await matches.first.getPost(postId));
} catch (error) {
return Error(
Failure(
code: 'provider_get_post',
message: 'Could not load post',
details: error,
),
);
}
}
Future<List<R>> _runLimited<T, R>(
List<T> items,
int concurrency,
Future<R> Function(T item) action,
) async {
final results = <R>[];
var index = 0;
Future<void> worker() async {
while (index < items.length) {
final current = items[index++];
results.add(await action(current));
}
}
await Future.wait(
List.generate(concurrency.clamp(1, items.length), (_) => worker()),
);
return results;
}
}
@@ -0,0 +1,53 @@
import '../mappers/rule34_mapper.dart';
import '../models/post.dart';
import '../models/top_period_filter.dart';
import 'gelbooru_provider.dart';
class Rule34Provider extends GelbooruProvider {
Rule34Provider({
required super.id,
required super.name,
required super.baseUrl,
required super.dioClient,
super.queryParameters,
});
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final topTags = switch (topPeriod) {
TopPeriodFilter.none => const <String>[],
TopPeriodFilter.month ||
TopPeriodFilter.year ||
TopPeriodFilter.allTime =>
const ['sort:score:desc'],
};
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'json': '1',
'pid': page,
'limit': limit,
'tags': [
...tags,
if (rating != null) 'rating:$rating',
...topTags,
].join(' '),
...queryParameters,
},
);
return Rule34Mapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
}
}
@@ -0,0 +1,105 @@
import 'package:isar/isar.dart' hide Collection;
import '../../core/database/app_database.dart';
import '../../core/database/database_service.dart';
import '../../core/utils/result.dart';
import '../models/collection.dart';
import '../models/post.dart';
import 'post_repository.dart';
class CollectionRepository {
CollectionRepository(this._databaseService, this._postRepository);
final DatabaseService _databaseService;
final PostRepository _postRepository;
Future<Result<Collection>> save(Collection collection) {
return _databaseService.safeWrite((isar) async {
await isar.collectionEntitys.put(
CollectionEntity()
..collectionId = collection.id
..name = collection.name
..description = collection.description
..coverUrl = collection.coverUrl
..createdAt = collection.createdAt
..updatedAt = collection.updatedAt,
);
return collection;
});
}
Future<Result<void>> delete(String id) {
return _databaseService.safeWrite((isar) async {
await isar.collectionPostEntitys
.filter()
.collectionIdEqualTo(id)
.deleteAll();
await isar.collectionEntitys.filter().collectionIdEqualTo(id).deleteAll();
});
}
Future<Result<List<Collection>>> all() {
return _databaseService.safeRead((isar) async {
final items = await isar.collectionEntitys.where().findAll();
items.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return items.map((entity) => entity.toModel()).toList();
});
}
Future<Result<Collection?>> getById(String id) {
return _databaseService.safeRead((isar) async {
final entity = await isar.collectionEntitys
.filter()
.collectionIdEqualTo(id)
.findFirst();
return entity?.toModel();
});
}
Future<Result<void>> addPost(String collectionId, Post post) async {
await _postRepository.cachePosts([post]);
return _databaseService.safeWrite((isar) async {
await isar.collectionPostEntitys.put(
CollectionPostEntity()
..linkKey = '$collectionId:${post.providerId}:${post.id}'
..collectionId = collectionId
..postId = post.id
..providerId = post.providerId
..addedAt = DateTime.now(),
);
});
}
Future<Result<void>> removePost(
String collectionId,
String postId,
String providerId,
) {
return _databaseService.safeWrite((isar) async {
await isar.collectionPostEntitys
.filter()
.linkKeyEqualTo('$collectionId:$providerId:$postId')
.deleteAll();
});
}
Future<Result<List<Post>>> posts(String collectionId) {
return _databaseService.safeRead((isar) async {
final links = await isar.collectionPostEntitys
.filter()
.collectionIdEqualTo(collectionId)
.findAll();
links.sort((a, b) => b.addedAt.compareTo(a.addedAt));
final posts = <Post>[];
for (final link in links) {
final entity = await isar.cachedPostEntitys
.filter()
.providerIdEqualTo(link.providerId)
.postIdEqualTo(link.postId)
.findFirst();
if (entity != null) posts.add(entity.toModel());
}
return posts;
});
}
}
@@ -0,0 +1,63 @@
import 'package:isar/isar.dart';
import '../../core/database/app_database.dart';
import '../../core/database/database_service.dart';
import '../../core/utils/result.dart';
import '../models/favorite.dart';
import '../models/post.dart';
import 'post_repository.dart';
class FavoriteRepository {
FavoriteRepository(this._databaseService, this._postRepository);
final DatabaseService _databaseService;
final PostRepository _postRepository;
Future<Result<void>> add(Post post) async {
await _postRepository.cachePosts([post]);
return _databaseService.safeWrite((isar) async {
final key = '${post.providerId}:${post.id}';
await isar.favoriteEntitys.put(
FavoriteEntity()
..favoriteKey = key
..favoriteId = key
..postId = post.id
..providerId = post.providerId
..savedAt = DateTime.now(),
);
});
}
Future<Result<void>> remove(String postId, String providerId) {
return _databaseService.safeWrite((isar) async {
await isar.favoriteEntitys
.filter()
.favoriteKeyEqualTo('$providerId:$postId')
.deleteAll();
});
}
Future<Result<bool>> exists(String postId, String providerId) {
return _databaseService.safeRead((isar) async {
return await isar.favoriteEntitys
.filter()
.favoriteKeyEqualTo('$providerId:$postId')
.count() >
0;
});
}
Future<Result<List<Favorite>>> all() {
return _databaseService.safeRead((isar) async {
final items = await isar.favoriteEntitys.where().findAll();
items.sort((a, b) => b.savedAt.compareTo(a.savedAt));
return items.map((entity) => entity.toModel()).toList();
});
}
Future<Result<void>> clear() {
return _databaseService.safeWrite((isar) async {
await isar.favoriteEntitys.clear();
});
}
}
@@ -0,0 +1,45 @@
import 'package:isar/isar.dart';
import '../../core/database/app_database.dart';
import '../../core/database/database_service.dart';
import '../../core/utils/result.dart';
import '../models/post.dart';
class PostRepository {
PostRepository(this._databaseService);
final DatabaseService _databaseService;
Future<Result<void>> cachePosts(List<Post> posts) {
return _databaseService.safeWrite((isar) async {
await isar.cachedPostEntitys.putAll(
posts.map((post) => CachedPostEntity.fromModel(post)).toList(),
);
});
}
Future<Result<Post?>> getCachedPost(String postId, String providerId) {
return _databaseService.safeRead((isar) async {
final entity = await isar.cachedPostEntitys
.filter()
.providerIdEqualTo(providerId)
.postIdEqualTo(postId)
.findFirst();
return entity?.toModel();
});
}
Future<Result<List<Post>>> getCachedPostsByKeys(Iterable<String> keys) {
return _databaseService.safeRead((isar) async {
final posts = <Post>[];
for (final key in keys) {
final entity = await isar.cachedPostEntitys
.filter()
.cacheKeyEqualTo(key)
.findFirst();
if (entity != null) posts.add(entity.toModel());
}
return posts;
});
}
}
@@ -0,0 +1,127 @@
import 'package:isar/isar.dart';
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_health.dart';
class ProviderRepository {
ProviderRepository(this._databaseService);
final DatabaseService _databaseService;
static List<ContentProviderConfig> seedProviders() {
final now = DateTime.now();
return [
ContentProviderConfig(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
apiType: 'gelbooru',
enabled: true,
priority: 0,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'rule34',
name: 'Rule34',
baseUrl: 'https://api.rule34.xxx',
apiType: 'rule34',
enabled: true,
priority: 1,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'safebooru',
name: 'Safebooru',
baseUrl: 'https://safebooru.org',
apiType: 'gelbooru',
enabled: true,
priority: 2,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
),
];
}
Future<Result<void>> ensureSeedProviders() {
return _databaseService.safeWrite((isar) async {
final count = await isar.providerConfigEntitys.count();
if (count > 0) return;
await isar.providerConfigEntitys.putAll(
seedProviders().map(ProviderConfigEntity.fromModel).toList(),
);
});
}
Future<Result<List<ContentProviderConfig>>> getProviders({
bool enabledOnly = false,
}) {
return _databaseService.safeRead((isar) async {
final providers = enabledOnly
? await isar.providerConfigEntitys
.filter()
.enabledEqualTo(true)
.findAll()
: await isar.providerConfigEntitys.where().findAll();
providers.sort((a, b) => a.priority.compareTo(b.priority));
return providers.map((entity) => entity.toModel()).toList();
});
}
Future<Result<ContentProviderConfig?>> getProvider(String id) {
return _databaseService.safeRead((isar) async {
final entity = await isar.providerConfigEntitys
.filter()
.providerIdEqualTo(id)
.findFirst();
return entity?.toModel();
});
}
Future<Result<void>> saveProvider(ContentProviderConfig config) {
return _databaseService.safeWrite((isar) async {
await isar.providerConfigEntitys
.put(ProviderConfigEntity.fromModel(config));
});
}
Future<Result<void>> deleteProvider(String id) {
return _databaseService.safeWrite((isar) async {
await isar.providerConfigEntitys
.filter()
.providerIdEqualTo(id)
.deleteAll();
await isar.providerHealthEntitys
.filter()
.providerIdEqualTo(id)
.deleteAll();
});
}
Future<Result<void>> saveHealth(ProviderHealth health) {
return _databaseService.safeWrite((isar) async {
await isar.providerHealthEntitys
.put(ProviderHealthEntity.fromModel(health));
});
}
Future<Result<ProviderHealth?>> getHealth(String providerId) {
return _databaseService.safeRead((isar) async {
final entity = await isar.providerHealthEntitys
.filter()
.providerIdEqualTo(providerId)
.findFirst();
return entity?.toModel();
});
}
}
@@ -0,0 +1,39 @@
import 'package:isar/isar.dart';
import '../../core/database/app_database.dart';
import '../../core/database/database_service.dart';
import '../../core/utils/result.dart';
import '../models/search_history.dart';
class SearchRepository {
SearchRepository(this._databaseService);
final DatabaseService _databaseService;
Future<Result<void>> save(SearchHistory history) {
return _databaseService.safeWrite((isar) async {
await isar.searchHistoryEntitys.put(
SearchHistoryEntity()
..historyId = history.id
..query = history.query
..tags = history.tags
..searchedAt = history.searchedAt
..resultCount = history.resultCount,
);
});
}
Future<Result<List<SearchHistory>>> recent({int limit = 20}) {
return _databaseService.safeRead((isar) async {
final items = await isar.searchHistoryEntitys.where().findAll();
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
return items.take(limit).map((entity) => entity.toModel()).toList();
});
}
Future<Result<void>> clear() {
return _databaseService.safeWrite((isar) async {
await isar.searchHistoryEntitys.clear();
});
}
}
@@ -0,0 +1,71 @@
import '../../core/utils/result.dart';
import '../../core/errors/failure.dart';
import '../models/collection.dart';
import '../models/post.dart';
import '../repositories/collection_repository.dart';
class CollectionService {
CollectionService(this._repository);
final CollectionRepository _repository;
Future<Result<Collection>> createCollection(
String name, String? description) {
final now = DateTime.now();
return _repository.save(
Collection(
id: now.microsecondsSinceEpoch.toString(),
name: name,
description: description,
createdAt: now,
updatedAt: now,
),
);
}
Future<Result<Collection>> updateCollection(
String id, {
required String name,
String? description,
String? coverUrl,
}) async {
final existingResult = await _repository.getById(id);
if (existingResult is Error<Collection?>) {
return Error(existingResult.failure);
}
final existing = (existingResult as Success<Collection?>).data;
if (existing == null) {
return const Error(
Failure(code: 'not_found', message: 'Collection not found'),
);
}
return _repository.save(
Collection(
id: id,
name: name,
description: description,
coverUrl: coverUrl,
createdAt: existing.createdAt,
updatedAt: DateTime.now(),
),
);
}
Future<Result<void>> deleteCollection(String id) => _repository.delete(id);
Future<Result<void>> addPostToCollection(String collectionId, Post post) {
return _repository.addPost(collectionId, post);
}
Future<Result<void>> removePostFromCollection(
String collectionId,
String postId,
String providerId,
) {
return _repository.removePost(collectionId, postId, providerId);
}
Future<Result<List<Collection>>> getCollections() => _repository.all();
Future<Result<List<Post>>> getCollectionPosts(String collectionId) {
return _repository.posts(collectionId);
}
}
@@ -0,0 +1,37 @@
import '../../core/utils/result.dart';
import '../models/favorite.dart';
import '../models/post.dart';
import '../repositories/favorite_repository.dart';
import '../repositories/post_repository.dart';
class FavoriteService {
FavoriteService(this._repository, [this._postRepository]);
final FavoriteRepository _repository;
final PostRepository? _postRepository;
Future<Result<void>> addFavorite(Post post) => _repository.add(post);
Future<Result<void>> removeFavorite(String postId, String providerId) {
return _repository.remove(postId, providerId);
}
Future<Result<bool>> isFavorite(String postId, String providerId) {
return _repository.exists(postId, providerId);
}
Future<Result<List<Favorite>>> getFavorites() => _repository.all();
Future<Result<List<Post>>> getFavoritePosts() async {
final postRepository = _postRepository;
if (postRepository == null) return const Success([]);
final favoritesResult = await _repository.all();
if (favoritesResult is Error<List<Favorite>>) {
return Error(favoritesResult.failure);
}
final favorites = (favoritesResult as Success<List<Favorite>>).data;
return postRepository.getCachedPostsByKeys(
favorites.map((favorite) => '${favorite.providerId}:${favorite.postId}'),
);
}
Future<Result<void>> clearFavorites() => _repository.clear();
}
+70
View File
@@ -0,0 +1,70 @@
import '../../core/cache/cache_service.dart';
import '../../core/utils/result.dart';
import '../models/post.dart';
import '../models/top_period_filter.dart';
import '../providers/provider_manager.dart';
import 'settings_service.dart';
class FeedService {
FeedService(this._providerManager, this._cacheService, this._settingsService);
final ProviderManager _providerManager;
final CacheService _cacheService;
final SettingsService _settingsService;
final Map<String, int> _pages = {};
String _key(
List<String> tags,
String? rating,
String? providerId,
TopPeriodFilter topPeriod,
) {
final sorted = [...tags]..sort();
return '${sorted.join(' ')}|${rating ?? ''}|${providerId ?? ''}|${topPeriod.name}';
}
Future<Result<List<Post>>> refresh({
List<String> tags = const [],
String? rating,
String? providerId,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
int limit = 50,
}) {
_pages[_key(tags, rating, providerId, topPeriod)] = 0;
return loadNextPage(
tags: tags,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
limit: limit,
);
}
Future<Result<List<Post>>> loadNextPage({
List<String> tags = const [],
String? rating,
String? providerId,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
int limit = 50,
}) async {
final key = _key(tags, rating, providerId, topPeriod);
final page = _pages[key] ?? 0;
final result = await _providerManager.searchAcrossProviders(
tags: tags,
page: page,
limit: limit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
if (result is Error<List<Post>>) return Error(result.failure);
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);
}
}
@@ -0,0 +1,35 @@
import '../../core/errors/failure.dart';
import '../../core/utils/result.dart';
import '../models/content_provider_config.dart';
import '../models/provider_health.dart';
import '../providers/provider_factory.dart';
import '../providers/provider_manager.dart';
import '../repositories/provider_repository.dart';
class ProviderCheckService {
ProviderCheckService(this._repository, this._factory, this._manager);
final ProviderRepository _repository;
final ProviderFactory _factory;
final ProviderManager _manager;
Future<Result<ProviderHealth>> checkOne(String providerId) async {
final configResult = await _repository.getProvider(providerId);
if (configResult is Error<ContentProviderConfig?>) {
return Error(configResult.failure);
}
final config = (configResult as Success<ContentProviderConfig?>).data;
if (config == null) {
return const Error(
Failure(code: 'not_found', message: 'Provider not found'),
);
}
final health = await _factory.create(config).checkHealth();
await _repository.saveHealth(health);
return Success(health);
}
Future<Result<List<ProviderHealth>>> checkAll() {
return _manager.checkAll(concurrency: 3);
}
}
+55
View File
@@ -0,0 +1,55 @@
import '../../core/utils/result.dart';
import '../models/search_history.dart';
import '../repositories/search_repository.dart';
class SearchService {
SearchService(this._repository);
final SearchRepository _repository;
List<String> parseTags(String query) {
return query
.trim()
.split(RegExp(r'\s+'))
.where((tag) => tag.isNotEmpty)
.toSet()
.toList();
}
Future<Result<void>> saveSearch(String query, int resultCount) {
final tags = parseTags(query);
return _repository.save(
SearchHistory(
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
query: query.trim(),
tags: tags,
searchedAt: DateTime.now(),
resultCount: resultCount,
),
);
}
Future<Result<List<SearchHistory>>> recentSearches({int limit = 20}) {
return _repository.recent(limit: limit);
}
Future<Result<List<String>>> autocomplete(String prefix,
{int limit = 10}) async {
final recent = await _repository.recent(limit: 100);
return recent.fold(
onSuccess: (items) {
final needle = prefix.toLowerCase();
final values = <String>{};
for (final item in items) {
for (final tag in item.tags) {
if (tag.toLowerCase().startsWith(needle)) values.add(tag);
}
}
return Success(values.take(limit).toList());
},
onError: Error<List<String>>.new,
);
}
Future<Result<void>> clearHistory() => _repository.clear();
}
+254
View File
@@ -0,0 +1,254 @@
import 'dart:convert';
import 'package:isar/isar.dart';
import '../../core/database/app_database.dart';
import '../../core/database/database_service.dart';
import '../../core/errors/failure.dart';
import '../../core/utils/result.dart';
import '../models/content_provider_config.dart';
import '../repositories/provider_repository.dart';
class AppSettings {
const AppSettings({
required this.enabledProviderIds,
required this.nsfwEnabled,
required this.cacheTtlHours,
required this.cacheMaxItems,
required this.providerPriority,
required this.themeMode,
required this.desktopColumns,
required this.mobileColumns,
required this.blurExplicitContent,
required this.allowDownloads,
required this.selectedFeedProviderIds,
required this.showPostBadges,
required this.defaultTopPeriodFilter,
this.defaultRatingFilter,
});
final List<String> enabledProviderIds;
final bool nsfwEnabled;
final int cacheTtlHours;
final int cacheMaxItems;
final Map<String, int> providerPriority;
final String themeMode;
final int desktopColumns;
final int mobileColumns;
final bool blurExplicitContent;
final bool allowDownloads;
final List<String> selectedFeedProviderIds;
final bool showPostBadges;
final String defaultTopPeriodFilter;
final String? defaultRatingFilter;
static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
nsfwEnabled: true,
cacheTtlHours: 24,
cacheMaxItems: 2000,
providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2},
themeMode: 'dark',
desktopColumns: 5,
mobileColumns: 2,
blurExplicitContent: true,
allowDownloads: true,
selectedFeedProviderIds: [],
showPostBadges: true,
defaultTopPeriodFilter: 'none',
defaultRatingFilter: null,
);
AppSettings copyWith({
List<String>? enabledProviderIds,
bool? nsfwEnabled,
int? cacheTtlHours,
int? cacheMaxItems,
Map<String, int>? providerPriority,
String? themeMode,
int? desktopColumns,
int? mobileColumns,
bool? blurExplicitContent,
bool? allowDownloads,
List<String>? selectedFeedProviderIds,
bool? showPostBadges,
String? defaultTopPeriodFilter,
String? defaultRatingFilter,
}) {
return AppSettings(
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
cacheMaxItems: cacheMaxItems ?? this.cacheMaxItems,
providerPriority: providerPriority ?? this.providerPriority,
themeMode: themeMode ?? this.themeMode,
desktopColumns: desktopColumns ?? this.desktopColumns,
mobileColumns: mobileColumns ?? this.mobileColumns,
blurExplicitContent: blurExplicitContent ?? this.blurExplicitContent,
allowDownloads: allowDownloads ?? this.allowDownloads,
selectedFeedProviderIds:
selectedFeedProviderIds ?? this.selectedFeedProviderIds,
showPostBadges: showPostBadges ?? this.showPostBadges,
defaultTopPeriodFilter:
defaultTopPeriodFilter ?? this.defaultTopPeriodFilter,
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
);
}
Map<String, dynamic> toJson() => {
'enabledProviderIds': enabledProviderIds,
'nsfwEnabled': nsfwEnabled,
'cacheTtlHours': cacheTtlHours,
'cacheMaxItems': cacheMaxItems,
'providerPriority': providerPriority,
'themeMode': themeMode,
'desktopColumns': desktopColumns,
'mobileColumns': mobileColumns,
'blurExplicitContent': blurExplicitContent,
'allowDownloads': allowDownloads,
'selectedFeedProviderIds': selectedFeedProviderIds,
'showPostBadges': showPostBadges,
'defaultTopPeriodFilter': defaultTopPeriodFilter,
'defaultRatingFilter': defaultRatingFilter,
};
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
enabledProviderIds: List<String>.from(
(json['enabledProviderIds'] as List?) ?? defaults.enabledProviderIds,
),
nsfwEnabled: (json['nsfwEnabled'] as bool?) ?? defaults.nsfwEnabled,
cacheTtlHours:
(json['cacheTtlHours'] as num?)?.toInt() ?? defaults.cacheTtlHours,
cacheMaxItems:
(json['cacheMaxItems'] as num?)?.toInt() ?? defaults.cacheMaxItems,
providerPriority: Map<String, int>.from(
(json['providerPriority'] as Map?) ?? defaults.providerPriority,
),
themeMode: (json['themeMode'] as String?) ?? defaults.themeMode,
desktopColumns: (json['desktopColumns'] as num?)?.toInt() ??
defaults.desktopColumns,
mobileColumns:
(json['mobileColumns'] as num?)?.toInt() ?? defaults.mobileColumns,
blurExplicitContent: (json['blurExplicitContent'] as bool?) ??
defaults.blurExplicitContent,
allowDownloads:
(json['allowDownloads'] as bool?) ?? defaults.allowDownloads,
selectedFeedProviderIds: List<String>.from(
(json['selectedFeedProviderIds'] as List?) ??
defaults.selectedFeedProviderIds,
),
showPostBadges:
(json['showPostBadges'] as bool?) ?? defaults.showPostBadges,
defaultTopPeriodFilter: (json['defaultTopPeriodFilter'] as String?) ??
defaults.defaultTopPeriodFilter,
defaultRatingFilter: json['defaultRatingFilter'] as String?,
);
}
class SettingsService {
SettingsService(
this._databaseService, {
ProviderRepository? providerRepository,
}) : _providerRepository = providerRepository;
final DatabaseService _databaseService;
final ProviderRepository? _providerRepository;
static const _settingsKey = 'app_settings';
Future<Result<AppSettings>> getSettings() {
return _databaseService.safeRead((isar) async {
final entity = await isar.appSettingEntitys
.filter()
.keyEqualTo(_settingsKey)
.findFirst();
if (entity == null) return AppSettings.defaults;
return AppSettings.fromJson(
jsonDecode(entity.jsonValue) as Map<String, dynamic>);
});
}
Future<Result<void>> updateSettings(AppSettings settings) {
return _databaseService.safeWrite((isar) async {
await isar.appSettingEntitys.put(
AppSettingEntity()
..key = _settingsKey
..jsonValue = jsonEncode(settings.toJson())
..updatedAt = DateTime.now(),
);
});
}
Future<Result<void>> saveEnabledProviders(List<String> providerIds) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
final updateResult = await updateSettings(
settings.copyWith(enabledProviderIds: providerIds));
if (updateResult is Error<void>) return updateResult;
final repository = _providerRepository;
if (repository == null) return updateResult;
final providersResult = await repository.getProviders();
if (providersResult is Error<List<ContentProviderConfig>>) {
return Error(providersResult.failure);
}
final enabled = providerIds.toSet();
for (final provider
in (providersResult as Success<List<ContentProviderConfig>>).data) {
await repository.saveProvider(
provider.copyWith(
enabled: enabled.contains(provider.id),
updatedAt: DateTime.now(),
),
);
}
return updateResult;
}
Future<Result<void>> saveNsfwFilter(bool enabled) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
return updateSettings(settings.copyWith(nsfwEnabled: enabled));
}
Future<Result<void>> saveCacheSettings({
required int ttlHours,
required int maxItems,
}) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
return updateSettings(
settings.copyWith(cacheTtlHours: ttlHours, cacheMaxItems: maxItems),
);
}
Future<Result<String>> exportSettingsToJson() async {
final result = await getSettings();
return result.fold(
onSuccess: (settings) => Success(jsonEncode(settings.toJson())),
onError: Error<String>.new,
);
}
Future<Result<void>> importSettingsFromJson(String json) async {
try {
final settings =
AppSettings.fromJson(jsonDecode(json) as Map<String, dynamic>);
final result = await updateSettings(settings);
if (result is Error<void>) return result;
await saveEnabledProviders(settings.enabledProviderIds);
return result;
} catch (error) {
return Error(
Failure(
code: 'settings_import',
message: 'Invalid settings JSON',
details: error,
),
);
}
}
}