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
+73
View File
@@ -0,0 +1,73 @@
import 'package:isar/isar.dart';
import '../database/app_database.dart';
import '../database/database_service.dart';
import '../utils/result.dart';
import '../../backend/models/post.dart';
class CacheService {
CacheService(this._databaseService);
final DatabaseService _databaseService;
Future<Result<void>> cachePosts(
List<Post> posts, {
int? maxItems,
}) {
return _databaseService.safeWrite((isar) async {
await isar.cachedPostEntitys.putAll(
posts.map((post) => CachedPostEntity.fromModel(post)).toList(),
);
if (maxItems != null && maxItems > 0) {
await _evictOverflow(isar, maxItems);
}
});
}
Future<Result<List<Post>>> getCachedPosts({
Duration maxAge = const Duration(hours: 24),
}) {
return _databaseService.safeRead((isar) async {
final cutoff = DateTime.now().subtract(maxAge);
final entities = await isar.cachedPostEntitys
.filter()
.cachedAtGreaterThan(cutoff)
.findAll();
entities.sort((a, b) => b.cachedAt.compareTo(a.cachedAt));
return entities.map((entity) => entity.toModel()).toList();
});
}
Future<Result<void>> prune({
Duration? olderThan,
int? maxItems,
}) {
return _databaseService.safeWrite((isar) async {
if (olderThan != null) {
final cutoff = DateTime.now().subtract(olderThan);
await isar.cachedPostEntitys
.filter()
.cachedAtLessThan(cutoff)
.deleteAll();
}
if (maxItems != null && maxItems > 0) {
await _evictOverflow(isar, maxItems);
}
});
}
Future<Result<void>> clear() {
return _databaseService.safeWrite((isar) async {
await isar.cachedPostEntitys.clear();
});
}
Future<void> _evictOverflow(Isar isar, int maxItems) async {
final entities = await isar.cachedPostEntitys.where().findAll();
if (entities.length <= maxItems) return;
entities.sort((a, b) => b.cachedAt.compareTo(a.cachedAt));
final overflowIds =
entities.skip(maxItems).map((entity) => entity.isarId).toList();
await isar.cachedPostEntitys.deleteAll(overflowIds);
}
}
+7
View File
@@ -0,0 +1,7 @@
import '../../backend/models/post.dart';
class ImageCacheService {
String previewCacheKey(Post post) => 'preview:${post.providerId}:${post.id}';
String sampleCacheKey(Post post) => 'sample:${post.providerId}:${post.id}';
String fileCacheKey(Post post) => 'file:${post.providerId}:${post.id}';
}
+264
View File
@@ -0,0 +1,264 @@
import 'dart:convert';
import 'package:isar/isar.dart' hide Collection;
import 'package:path_provider/path_provider.dart';
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_health.dart';
import '../../backend/models/search_history.dart';
part 'app_database.g.dart';
class AppDatabase {
AppDatabase(this.isar);
final Isar isar;
static Future<AppDatabase> open({String? directory}) async {
final dir = directory ?? (await getApplicationDocumentsDirectory()).path;
final isar = await Isar.open(
[
ProviderConfigEntitySchema,
ProviderHealthEntitySchema,
CachedPostEntitySchema,
FavoriteEntitySchema,
CollectionEntitySchema,
CollectionPostEntitySchema,
SearchHistoryEntitySchema,
AppSettingEntitySchema,
],
directory: dir,
name: 'gel_rule_app',
);
return AppDatabase(isar);
}
Future<void> close() => isar.close();
}
@collection
class ProviderConfigEntity {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String providerId;
late String name;
late String baseUrl;
late String apiType;
late bool enabled;
late int priority;
late int timeoutSeconds;
late String customHeadersJson;
late DateTime createdAt;
late DateTime updatedAt;
ContentProviderConfig toModel() => ContentProviderConfig(
id: providerId,
name: name,
baseUrl: baseUrl,
apiType: apiType,
enabled: enabled,
priority: priority,
timeoutSeconds: timeoutSeconds,
customHeaders: Map<String, String>.from(
jsonDecode(customHeadersJson) as Map,
),
createdAt: createdAt,
updatedAt: updatedAt,
);
static ProviderConfigEntity fromModel(ContentProviderConfig model) {
return ProviderConfigEntity()
..providerId = model.id
..name = model.name
..baseUrl = model.baseUrl
..apiType = model.apiType
..enabled = model.enabled
..priority = model.priority
..timeoutSeconds = model.timeoutSeconds
..customHeadersJson = jsonEncode(model.customHeaders)
..createdAt = model.createdAt
..updatedAt = model.updatedAt;
}
}
@collection
class ProviderHealthEntity {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String providerId;
@enumerated
late ProviderStatus status;
late int pingMs;
late DateTime lastCheckedAt;
String? errorMessage;
String? apiVersion;
ProviderHealth toModel() => ProviderHealth(
providerId: providerId,
status: status,
pingMs: pingMs,
lastCheckedAt: lastCheckedAt,
errorMessage: errorMessage,
apiVersion: apiVersion,
);
static ProviderHealthEntity fromModel(ProviderHealth model) {
return ProviderHealthEntity()
..providerId = model.providerId
..status = model.status
..pingMs = model.pingMs
..lastCheckedAt = model.lastCheckedAt
..errorMessage = model.errorMessage
..apiVersion = model.apiVersion;
}
}
@collection
class CachedPostEntity {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String cacheKey;
@Index(composite: [CompositeIndex('postId')])
late String providerId;
late String postId;
late String providerName;
late String previewUrl;
late String sampleUrl;
late String fileUrl;
late List<String> tags;
late String rating;
late int width;
late int height;
String? source;
late DateTime createdAt;
late String fileType;
late int score;
late DateTime cachedAt;
Post toModel() => Post(
id: postId,
providerId: providerId,
providerName: providerName,
previewUrl: previewUrl,
sampleUrl: sampleUrl,
fileUrl: fileUrl,
tags: tags,
rating: rating,
width: width,
height: height,
source: source,
createdAt: createdAt,
fileType: fileType,
score: score,
);
static CachedPostEntity fromModel(Post model, {DateTime? cachedAt}) {
return CachedPostEntity()
..cacheKey = model.cacheKey
..providerId = model.providerId
..postId = model.id
..providerName = model.providerName
..previewUrl = model.previewUrl
..sampleUrl = model.sampleUrl
..fileUrl = model.fileUrl
..tags = model.tags
..rating = model.rating
..width = model.width
..height = model.height
..source = model.source
..createdAt = model.createdAt
..fileType = model.fileType
..score = model.score
..cachedAt = cachedAt ?? DateTime.now();
}
}
@collection
class FavoriteEntity {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String favoriteKey;
late String favoriteId;
late String postId;
late String providerId;
late DateTime savedAt;
Favorite toModel() => Favorite(
id: favoriteId,
postId: postId,
providerId: providerId,
savedAt: savedAt,
);
}
@collection
class CollectionEntity {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String collectionId;
late String name;
String? description;
String? coverUrl;
late DateTime createdAt;
late DateTime updatedAt;
Collection toModel() => Collection(
id: collectionId,
name: name,
description: description,
coverUrl: coverUrl,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
@collection
class CollectionPostEntity {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String linkKey;
@Index()
late String collectionId;
late String postId;
late String providerId;
late DateTime addedAt;
}
@collection
class SearchHistoryEntity {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String historyId;
late String query;
late List<String> tags;
late DateTime searchedAt;
late int resultCount;
SearchHistory toModel() => SearchHistory(
id: historyId,
query: query,
tags: tags,
searchedAt: searchedAt,
resultCount: resultCount,
);
}
@collection
class AppSettingEntity {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String key;
late String jsonValue;
late DateTime updatedAt;
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
import 'package:isar/isar.dart';
import '../errors/app_exception.dart';
import '../utils/result.dart';
import 'app_database.dart';
class DatabaseService {
DatabaseService(this.database);
final AppDatabase database;
Isar get isar => database.isar;
Future<Result<T>> safeRead<T>(Future<T> Function(Isar isar) action) async {
try {
return Success(await action(isar));
} catch (error) {
return Error(
DatabaseException(error.toString(), details: error).toFailure());
}
}
Future<Result<T>> safeWrite<T>(Future<T> Function(Isar isar) action) async {
try {
final value = await isar.writeTxn(() => action(isar));
return Success(value);
} catch (error) {
return Error(
DatabaseException(error.toString(), details: error).toFailure());
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'failure.dart';
sealed class AppException implements Exception {
const AppException(this.message, {this.details});
final String message;
final Object? details;
String get code;
Failure toFailure() =>
Failure(code: code, message: message, details: details);
}
class NetworkException extends AppException {
const NetworkException(super.message, {super.details});
@override
String get code => 'network';
}
class RequestTimeoutException extends AppException {
const RequestTimeoutException(super.message, {super.details});
@override
String get code => 'timeout';
}
class ProviderUnavailableException extends AppException {
const ProviderUnavailableException(super.message, {super.details});
@override
String get code => 'provider_unavailable';
}
class BadResponseException extends AppException {
const BadResponseException(super.message, {super.details});
@override
String get code => 'bad_response';
}
class DatabaseException extends AppException {
const DatabaseException(super.message, {super.details});
@override
String get code => 'database';
}
class UnknownException extends AppException {
const UnknownException(super.message, {super.details});
@override
String get code => 'unknown';
}
Failure failureFromObject(Object error) {
if (error is AppException) return error.toFailure();
return UnknownException(error.toString(), details: error).toFailure();
}
+14
View File
@@ -0,0 +1,14 @@
class Failure {
const Failure({
required this.code,
required this.message,
this.details,
});
final String code;
final String message;
final Object? details;
@override
String toString() => 'Failure($code, $message)';
}
+117
View File
@@ -0,0 +1,117 @@
import 'dart:async';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../errors/app_exception.dart';
import '../utils/result.dart';
class DioClient {
DioClient({
String? baseUrl,
Duration timeout = const Duration(seconds: 20),
Map<String, String>? headers,
Dio? dio,
}) : dio = dio ?? Dio(_options(baseUrl, timeout, headers)) {
this.dio.interceptors.add(_RetryInterceptor(this.dio));
if (kDebugMode) {
this.dio.interceptors.add(
LogInterceptor(
requestBody: false,
responseBody: false,
requestHeader: false,
responseHeader: false,
),
);
}
}
final Dio dio;
static BaseOptions _options(
String? baseUrl,
Duration timeout,
Map<String, String>? headers,
) {
return BaseOptions(
baseUrl: baseUrl ?? '',
connectTimeout: timeout,
receiveTimeout: timeout,
sendTimeout: timeout,
headers: {
'User-Agent': 'GelRuleApp/0.1 Flutter local booru browser',
'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8',
...?headers,
},
);
}
Future<Result<T>> safeRequest<T>(Future<T> Function(Dio dio) request) async {
try {
return Success(await request(dio));
} on DioException catch (error) {
return Error(_mapDioException(error).toFailure());
} on AppException catch (error) {
return Error(error.toFailure());
} catch (error) {
return Error(
UnknownException(error.toString(), details: error).toFailure());
}
}
static AppException _mapDioException(DioException error) {
return switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
RequestTimeoutException('Request timed out', details: error),
DioExceptionType.badResponse => BadResponseException(
'Provider returned HTTP ${error.response?.statusCode}',
details: error,
),
DioExceptionType.connectionError => NetworkException(
'Network connection failed',
details: error,
),
_ =>
UnknownException(error.message ?? 'Unknown HTTP error', details: error),
};
}
}
class _RetryInterceptor extends Interceptor {
_RetryInterceptor(this._dio);
final Dio _dio;
static const _maxAttempts = 2;
@override
Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async {
final attempt = (err.requestOptions.extra['retryAttempt'] as int?) ?? 0;
if (!_shouldRetry(err) || attempt >= _maxAttempts) {
handler.next(err);
return;
}
final delayMs = 350 * pow(2, attempt).toInt();
await Future<void>.delayed(Duration(milliseconds: delayMs));
try {
final options = err.requestOptions;
options.extra['retryAttempt'] = attempt + 1;
final response = await _dio.fetch<dynamic>(options);
handler.resolve(response);
} on DioException catch (error) {
handler.next(error);
}
}
bool _shouldRetry(DioException error) {
final status = error.response?.statusCode;
return error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.receiveTimeout ||
status == 429 ||
(status != null && status >= 500);
}
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:connectivity_plus/connectivity_plus.dart';
class NetworkInfo {
NetworkInfo(this._connectivity);
final Connectivity _connectivity;
Future<bool> get isConnected async {
final results = await _connectivity.checkConnectivity();
return results.any((result) => result != ConnectivityResult.none);
}
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter/foundation.dart';
class AppLogger {
const AppLogger();
void debug(String message, [Object? error, StackTrace? stackTrace]) {
if (kDebugMode) {
debugPrint('[GelRule] $message');
if (error != null) debugPrint(' error: $error');
if (stackTrace != null) debugPrint(' stack: $stackTrace');
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import '../errors/failure.dart';
sealed class Result<T> {
const Result();
bool get isSuccess => this is Success<T>;
bool get isError => this is Error<T>;
R fold<R>({
required R Function(T data) onSuccess,
required R Function(Failure failure) onError,
}) {
final current = this;
return switch (current) {
Success<T>(:final data) => onSuccess(data),
Error<T>(:final failure) => onError(failure),
};
}
}
class Success<T> extends Result<T> {
const Success(this.data);
final T data;
}
class Error<T> extends Result<T> {
const Error(this.failure);
final Failure failure;
}