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
@@ -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,
),
);
}
}
}