Initial Flutter booru app
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user