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