Polish mobile UX downloads and comments
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# GelRuleApp
|
||||
# RuleGel
|
||||
|
||||
GelRuleApp is a single Flutter monolith for browsing booru/gelbooru/rule34-like providers with a Pinterest-style feed. It does not start or require a separate backend server. Provider access, caching, favorites, collections, search history, settings, and health checks all run locally inside the Flutter app.
|
||||
RuleGel is a single Flutter monolith for browsing booru/gelbooru/rule34-like providers with a Pinterest-style feed. It does not start or require a separate backend server. Provider access, caching, favorites, collections, search history, settings, downloads, read-only comments where APIs allow them, and health checks all run locally inside the Flutter app.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -118,8 +118,8 @@ Unsupported types return `ProviderUnavailableException` instead of crashing the
|
||||
|
||||
Search behavior:
|
||||
|
||||
- Offline providers are skipped based on saved health.
|
||||
- A failed provider marks itself offline.
|
||||
- Enabled providers are tried even if an older health check marked them offline.
|
||||
- A failed provider marks itself offline for diagnostics.
|
||||
- Other providers continue returning posts.
|
||||
- Results are combined into a single list.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application
|
||||
android:label="gel_rule_app"
|
||||
android:label="RuleGel"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
package com.example.gel_rule_app
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "rulegel/downloads")
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method != "saveToDownloads") {
|
||||
result.notImplemented()
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
val path = call.argument<String>("path") ?: error("Missing path")
|
||||
val fileName = call.argument<String>("fileName") ?: File(path).name
|
||||
val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream"
|
||||
result.success(saveToDownloads(path, fileName, mimeType))
|
||||
} catch (error: Throwable) {
|
||||
result.error("save_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToDownloads(path: String, fileName: String, mimeType: String): String {
|
||||
val source = File(path)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.Downloads.MIME_TYPE, mimeType)
|
||||
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
}
|
||||
val resolver = applicationContext.contentResolver
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
?: error("Could not create Downloads entry")
|
||||
resolver.openOutputStream(uri)?.use { output ->
|
||||
FileInputStream(source).use { input -> input.copyTo(output) }
|
||||
} ?: error("Could not open Downloads output stream")
|
||||
values.clear()
|
||||
values.put(MediaStore.Downloads.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
return fileName
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
downloads.mkdirs()
|
||||
val destination = File(downloads, fileName)
|
||||
FileInputStream(source).use { input ->
|
||||
FileOutputStream(destination).use { output -> input.copyTo(output) }
|
||||
}
|
||||
return destination.absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ class GelRuleApp extends ConsumerWidget {
|
||||
home: _StartupErrorScreen(error: error),
|
||||
),
|
||||
data: (_) => MaterialApp.router(
|
||||
title: 'GelRule',
|
||||
title: 'RuleGel',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildTheme(Brightness.light),
|
||||
darkTheme: buildTheme(Brightness.dark),
|
||||
|
||||
@@ -3,12 +3,14 @@ export 'models/collection.dart';
|
||||
export 'models/content_provider_config.dart';
|
||||
export 'models/favorite.dart';
|
||||
export 'models/post.dart';
|
||||
export 'models/post_comment.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/download_service.dart';
|
||||
export 'services/favorite_service.dart';
|
||||
export 'services/feed_service.dart';
|
||||
export 'services/provider_check_service.dart';
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../repositories/post_repository.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
import '../repositories/search_repository.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/download_service.dart';
|
||||
import '../services/favorite_service.dart';
|
||||
import '../services/feed_service.dart';
|
||||
import '../services/provider_check_service.dart';
|
||||
@@ -119,3 +120,7 @@ final settingsServiceProvider = Provider<SettingsService>((ref) {
|
||||
providerRepository: ref.watch(providerRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final downloadServiceProvider = Provider<DownloadService>((ref) {
|
||||
return DownloadService();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class PostComment {
|
||||
const PostComment({
|
||||
required this.id,
|
||||
required this.postId,
|
||||
required this.providerId,
|
||||
required this.authorName,
|
||||
required this.body,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String postId;
|
||||
final String providerId;
|
||||
final String authorName;
|
||||
final String body;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
|
||||
@@ -19,3 +20,7 @@ abstract class ContentProvider {
|
||||
|
||||
Future<ProviderHealth> checkHealth();
|
||||
}
|
||||
|
||||
abstract class CommentProvider {
|
||||
Future<List<PostComment>> getComments(String postId);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import '../../core/errors/app_exception.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class CustomProvider implements ContentProvider {
|
||||
class CustomProvider implements ContentProvider, CommentProvider {
|
||||
CustomProvider(this._delegate);
|
||||
|
||||
final ContentProvider _delegate;
|
||||
@@ -37,6 +38,15 @@ class CustomProvider implements ContentProvider {
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() => _delegate.checkHealth();
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) {
|
||||
final delegate = _delegate;
|
||||
if (delegate is CommentProvider) {
|
||||
return (delegate as CommentProvider).getComments(postId);
|
||||
}
|
||||
return Future.value(const []);
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedCustomProvider implements ContentProvider {
|
||||
|
||||
@@ -3,11 +3,12 @@ import 'package:dio/dio.dart';
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/danbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class DanbooruProvider implements ContentProvider {
|
||||
class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
DanbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -96,6 +97,38 @@ class DanbooruProvider implements ContentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/comments.json',
|
||||
queryParameters: {
|
||||
'search[post_id]': postId,
|
||||
'limit': 50,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
final data = response.data;
|
||||
final items = data is List ? data : const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return PostComment(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
postId: (json['post_id'] ?? postId).toString(),
|
||||
providerId: id,
|
||||
authorName: (json['creator_name'] ?? json['creator_id'] ?? 'user')
|
||||
.toString(),
|
||||
body: (json['body'] ?? '').toString(),
|
||||
createdAt:
|
||||
DateTime.tryParse((json['created_at'] ?? '').toString()) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
})
|
||||
.where((comment) => comment.body.trim().isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
|
||||
@@ -4,11 +4,12 @@ import '../../core/errors/app_exception.dart';
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/gelbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class GelbooruProvider implements ContentProvider {
|
||||
class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
GelbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -117,6 +118,22 @@ class GelbooruProvider implements ContentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'comment',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'post_id': postId,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return _commentsFromResponse(response.data, postId);
|
||||
}
|
||||
|
||||
Never unavailable(String message) =>
|
||||
throw ProviderUnavailableException(message);
|
||||
|
||||
@@ -129,4 +146,36 @@ class GelbooruProvider implements ContentProvider {
|
||||
const ['sort:score:desc'],
|
||||
};
|
||||
}
|
||||
|
||||
List<PostComment> _commentsFromResponse(dynamic data, String postId) {
|
||||
final raw = switch (data) {
|
||||
List() => data,
|
||||
{'comments': final List comments} => comments,
|
||||
{'@attributes': _, 'comment': final List comments} => comments,
|
||||
{'comment': final List comments} => comments,
|
||||
_ => const [],
|
||||
};
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return PostComment(
|
||||
id: (json['id'] ?? json['comment_id'] ?? '').toString(),
|
||||
postId: (json['post_id'] ?? postId).toString(),
|
||||
providerId: id,
|
||||
authorName: (json['creator'] ??
|
||||
json['creator_name'] ??
|
||||
json['author'] ??
|
||||
'')
|
||||
.toString(),
|
||||
body: (json['body'] ?? json['comment'] ?? '').toString(),
|
||||
createdAt: DateTime.tryParse(
|
||||
(json['created_at'] ?? json['created'] ?? '').toString(),
|
||||
) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
})
|
||||
.where((comment) => comment.body.trim().isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,34 +20,38 @@ class ProviderFactory {
|
||||
timeout: Duration(seconds: config.timeoutSeconds),
|
||||
headers: headers,
|
||||
);
|
||||
return switch (config.apiType.toLowerCase()) {
|
||||
'gelbooru' => GelbooruProvider(
|
||||
switch (config.apiType.toLowerCase()) {
|
||||
case 'gelbooru':
|
||||
return GelbooruProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
'rule34' => Rule34Provider(
|
||||
);
|
||||
case 'rule34':
|
||||
return Rule34Provider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
'danbooru' => DanbooruProvider(
|
||||
);
|
||||
case 'danbooru':
|
||||
return DanbooruProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
),
|
||||
_ => UnsupportedCustomProvider(
|
||||
);
|
||||
default:
|
||||
return UnsupportedCustomProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
apiType: config.apiType,
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
@@ -96,11 +97,6 @@ class ProviderManager {
|
||||
|
||||
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,
|
||||
@@ -125,6 +121,36 @@ class ProviderManager {
|
||||
return Success(posts);
|
||||
}
|
||||
|
||||
Future<Result<List<PostComment>>> getComments(
|
||||
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'),
|
||||
);
|
||||
}
|
||||
final provider = matches.first;
|
||||
if (provider is! CommentProvider) return const Success([]);
|
||||
try {
|
||||
return Success(await (provider as CommentProvider).getComments(postId));
|
||||
} catch (error) {
|
||||
return Error(
|
||||
Failure(
|
||||
code: 'comments_unavailable',
|
||||
message: 'Comments unavailable',
|
||||
details: error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<Post?>> getPost(String providerId, String postId) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../models/post.dart';
|
||||
|
||||
class DownloadService {
|
||||
DownloadService({Dio? dio}) : _dio = dio ?? Dio();
|
||||
|
||||
static const _channel = MethodChannel('rulegel/downloads');
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<String?> downloadPost(
|
||||
Post post, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
}) async {
|
||||
final url = _downloadUrl(post);
|
||||
if (url == null) {
|
||||
throw StateError('No downloadable URL for this post.');
|
||||
}
|
||||
final fileName = _fileName(post, url);
|
||||
if (Platform.isAndroid) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = p.join(tempDir.path, fileName);
|
||||
await _dio.download(url, tempPath, onReceiveProgress: onProgress);
|
||||
final saved = await _channel.invokeMethod<String>('saveToDownloads', {
|
||||
'path': tempPath,
|
||||
'fileName': fileName,
|
||||
'mimeType': _mimeType(fileName, post.fileType),
|
||||
});
|
||||
return saved ?? fileName;
|
||||
}
|
||||
|
||||
final location = await getSaveLocation(suggestedName: fileName);
|
||||
if (location == null) return null;
|
||||
await _dio.download(url, location.path, onReceiveProgress: onProgress);
|
||||
return location.path;
|
||||
}
|
||||
|
||||
String? _downloadUrl(Post post) {
|
||||
for (final url in [post.sampleUrl, post.fileUrl, post.previewUrl]) {
|
||||
if (url.trim().isNotEmpty) return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _fileName(Post post, String url) {
|
||||
final parsed = Uri.tryParse(url);
|
||||
final fromUrl = parsed == null ? '' : p.basename(parsed.path);
|
||||
if (fromUrl.contains('.') && fromUrl.length > 3) return fromUrl;
|
||||
final extension = _extension(post.fileType);
|
||||
return '${post.providerId}_${post.id}$extension';
|
||||
}
|
||||
|
||||
String _extension(String fileType) {
|
||||
final value = fileType.toLowerCase();
|
||||
if (value.contains('webm')) return '.webm';
|
||||
if (value.contains('mp4') || value.contains('video')) return '.mp4';
|
||||
if (value.contains('gif')) return '.gif';
|
||||
if (value.contains('png')) return '.png';
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
String _mimeType(String fileName, String fileType) {
|
||||
final value = '${fileName.toLowerCase()} ${fileType.toLowerCase()}';
|
||||
if (value.contains('.webm') || value.contains('webm')) return 'video/webm';
|
||||
if (value.contains('.mp4') || value.contains('mp4')) return 'video/mp4';
|
||||
if (value.contains('.gif') || value.contains('gif')) return 'image/gif';
|
||||
if (value.contains('.png') || value.contains('png')) return 'image/png';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
import 'collections_controller.dart';
|
||||
|
||||
class CollectionDetailsScreen extends ConsumerWidget {
|
||||
@@ -21,6 +22,7 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
final posts = ref.watch(collectionPostsProvider(collectionId));
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: 'Collection',
|
||||
body: posts.when(
|
||||
@@ -38,12 +40,22 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
ref.read(favoriteServiceProvider).addFavorite(post),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,16 @@ final favoritesControllerProvider =
|
||||
FavoritesController.new,
|
||||
);
|
||||
|
||||
final favoriteKeysProvider = FutureProvider<Set<String>>((ref) async {
|
||||
final result = await ref.watch(favoriteServiceProvider).getFavorites();
|
||||
return result.fold(
|
||||
onSuccess: (favorites) => favorites
|
||||
.map((favorite) => '${favorite.providerId}:${favorite.postId}')
|
||||
.toSet(),
|
||||
onError: (_) => <String>{},
|
||||
);
|
||||
});
|
||||
|
||||
class FavoritesController extends AsyncNotifier<FavoritesState> {
|
||||
@override
|
||||
Future<FavoritesState> build() async => FavoritesState(posts: await _load());
|
||||
@@ -17,6 +27,7 @@ class FavoritesController extends AsyncNotifier<FavoritesState> {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
state = AsyncData(FavoritesState(posts: await _load()));
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class FavoritesScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(),
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_card.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../collections/presentation/collection_form_dialog.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
import 'feed_controller.dart';
|
||||
import 'widgets/feed_toolbar.dart';
|
||||
|
||||
@@ -50,6 +51,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
final feed = ref.watch(feedControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
|
||||
if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) {
|
||||
_usedInitialQuery = true;
|
||||
@@ -162,13 +164,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
loading: state.isLoadingMore,
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) => ref
|
||||
.read(favoriteServiceProvider)
|
||||
.addFavorite(post),
|
||||
onFavorite: (post) =>
|
||||
_toggleFavorite(ref, post, favoriteKeys),
|
||||
onAddToCollection: (post) =>
|
||||
_addToCollection(context, ref, post),
|
||||
),
|
||||
@@ -202,6 +204,22 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
onCreate: () => showCollectionFormDialog(context, ref),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
Set<String> favoriteKeys,
|
||||
) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefreshIntent extends Intent {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
@@ -17,12 +14,22 @@ import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_card.dart';
|
||||
import '../../../shared/widgets/rating_badge.dart';
|
||||
import '../../collections/presentation/collection_form_dialog.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
import '../../feed/presentation/feed_controller.dart';
|
||||
import 'post_details_controller.dart';
|
||||
import 'widgets/post_action_bar.dart';
|
||||
import 'widgets/post_media_viewer.dart';
|
||||
import 'widgets/post_tags_panel.dart';
|
||||
|
||||
final postCommentsProvider =
|
||||
FutureProvider.family<List<PostComment>, PostDetailsArgs>(
|
||||
(ref, args) async {
|
||||
final result = await ref
|
||||
.watch(providerManagerProvider)
|
||||
.getComments(args.providerId, args.postId);
|
||||
return result is Success<List<PostComment>> ? result.data : const [];
|
||||
});
|
||||
|
||||
class PostDetailsScreen extends ConsumerWidget {
|
||||
const PostDetailsScreen({
|
||||
required this.providerId,
|
||||
@@ -47,6 +54,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final feedPosts =
|
||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: 'Post',
|
||||
actions: [
|
||||
@@ -70,7 +78,28 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
? feedPosts[currentIndex + 1]
|
||||
: null;
|
||||
if (Responsive.isMobile(context)) {
|
||||
return _buildMobileDetails(context, ref, post, settings);
|
||||
if (currentIndex >= 0 && feedPosts.length > 1) {
|
||||
return PageView.builder(
|
||||
controller: PageController(initialPage: currentIndex),
|
||||
itemCount: feedPosts.length,
|
||||
onPageChanged: (index) =>
|
||||
_replacePost(context, feedPosts[index]),
|
||||
itemBuilder: (context, index) => _buildMobileDetails(
|
||||
context,
|
||||
ref,
|
||||
feedPosts[index],
|
||||
settings,
|
||||
favoriteKeys,
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildMobileDetails(
|
||||
context,
|
||||
ref,
|
||||
post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -103,14 +132,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PostActionBar(
|
||||
onFavorite: () =>
|
||||
ref.read(favoriteServiceProvider).addFavorite(post),
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
onCopy: () =>
|
||||
Clipboard.setData(ClipboardData(text: post.fileUrl)),
|
||||
onDownload: settings.allowDownloads
|
||||
? () => _download(context, post)
|
||||
? () => _download(context, ref, post)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -133,6 +162,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
Text('Tags', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
PostTagsPanel(post: post),
|
||||
const SizedBox(height: 16),
|
||||
_CommentsSection(post: post),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -145,6 +176,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
AppSettings settings,
|
||||
Set<String> favoriteKeys,
|
||||
) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
||||
@@ -157,12 +189,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PostActionBar(
|
||||
onFavorite: () => ref.read(favoriteServiceProvider).addFavorite(post),
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
|
||||
onDownload:
|
||||
settings.allowDownloads ? () => _download(context, post) : null,
|
||||
onDownload: settings.allowDownloads
|
||||
? () => _download(context, ref, post)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
@@ -191,6 +225,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
_CommentsSection(post: post),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -216,23 +251,47 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _download(BuildContext context, Post post) async {
|
||||
final filename = p.basename(Uri.parse(post.fileUrl).path);
|
||||
final location = await getSaveLocation(suggestedName: filename);
|
||||
if (location == null) return;
|
||||
Future<void> _download(BuildContext context, WidgetRef ref, Post post) async {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Downloading...')),
|
||||
);
|
||||
await Dio().download(post.fileUrl, location.path);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Download complete')),
|
||||
);
|
||||
try {
|
||||
final saved = await ref.read(downloadServiceProvider).downloadPost(post);
|
||||
if (!context.mounted || saved == null) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Download complete: $saved')),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Download failed: $error')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
Set<String> favoriteKeys,
|
||||
) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
}
|
||||
|
||||
void _openPost(BuildContext context, Post post) {
|
||||
context.go('/post/${post.providerId}/${post.id}', extra: post);
|
||||
_replacePost(context, post);
|
||||
}
|
||||
|
||||
void _replacePost(BuildContext context, Post post) {
|
||||
context.replace('/post/${post.providerId}/${post.id}', extra: post);
|
||||
}
|
||||
|
||||
void _close(BuildContext context) {
|
||||
@@ -243,3 +302,77 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentsSection extends ConsumerStatefulWidget {
|
||||
const _CommentsSection({required this.post});
|
||||
|
||||
final Post post;
|
||||
|
||||
@override
|
||||
ConsumerState<_CommentsSection> createState() => _CommentsSectionState();
|
||||
}
|
||||
|
||||
class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final comments = _expanded
|
||||
? ref.watch(
|
||||
postCommentsProvider(
|
||||
PostDetailsArgs(
|
||||
providerId: widget.post.providerId,
|
||||
postId: widget.post.id,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
return ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
initiallyExpanded: false,
|
||||
onExpansionChanged: (value) => setState(() => _expanded = value),
|
||||
title: Text('Comments', style: Theme.of(context).textTheme.titleMedium),
|
||||
children: [
|
||||
(comments ?? const AsyncValue<List<PostComment>>.data([])).when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (_, __) => const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text('Comments unavailable'),
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text('Comments unavailable'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final comment in items)
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
comment.authorName.isEmpty
|
||||
? 'Anonymous'
|
||||
: comment.authorName,
|
||||
),
|
||||
subtitle: Text(comment.body),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class PostActionBar extends StatelessWidget {
|
||||
required this.onCollection,
|
||||
required this.onOpen,
|
||||
required this.onCopy,
|
||||
required this.isFavorite,
|
||||
this.onDownload,
|
||||
super.key,
|
||||
});
|
||||
@@ -14,6 +15,7 @@ class PostActionBar extends StatelessWidget {
|
||||
final VoidCallback onCollection;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onCopy;
|
||||
final bool isFavorite;
|
||||
final VoidCallback? onDownload;
|
||||
|
||||
@override
|
||||
@@ -24,8 +26,10 @@ class PostActionBar extends StatelessWidget {
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onFavorite,
|
||||
icon: const Icon(Icons.favorite_rounded),
|
||||
label: const Text('Favorite'),
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
),
|
||||
label: Text(isFavorite ? 'Unfavorite' : 'Favorite'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCollection,
|
||||
|
||||
@@ -11,6 +11,7 @@ class PostCard extends StatefulWidget {
|
||||
required this.post,
|
||||
required this.blurExplicit,
|
||||
required this.showBadges,
|
||||
required this.isFavorite,
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
this.onAddToCollection,
|
||||
@@ -20,6 +21,7 @@ class PostCard extends StatefulWidget {
|
||||
final Post post;
|
||||
final bool blurExplicit;
|
||||
final bool showBadges;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback? onAddToCollection;
|
||||
@@ -37,6 +39,7 @@ class _PostCardState extends State<PostCard> {
|
||||
final sensitive = _isSensitive(post.rating);
|
||||
final aspect =
|
||||
post.width > 0 && post.height > 0 ? post.width / post.height : 0.72;
|
||||
final mobile = MediaQuery.sizeOf(context).width < 700;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
@@ -108,11 +111,20 @@ class _PostCardState extends State<PostCard> {
|
||||
child: _MediaBadge(fileType: post.fileType),
|
||||
),
|
||||
],
|
||||
if (mobile)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: _FavoriteButton(
|
||||
isFavorite: widget.isFavorite,
|
||||
onPressed: widget.onFavorite,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !_hovered,
|
||||
ignoring: !_hovered || mobile,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _hovered ? 1 : 0,
|
||||
opacity: _hovered && !mobile ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 140),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
@@ -133,9 +145,15 @@ class _PostCardState extends State<PostCard> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Favorite',
|
||||
tooltip: widget.isFavorite
|
||||
? 'Remove favorite'
|
||||
: 'Favorite',
|
||||
onPressed: widget.onFavorite,
|
||||
icon: const Icon(Icons.favorite_rounded),
|
||||
icon: Icon(
|
||||
widget.isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
@@ -167,6 +185,29 @@ class _PostCardState extends State<PostCard> {
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteButton extends StatelessWidget {
|
||||
const _FavoriteButton({
|
||||
required this.isFavorite,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final bool isFavorite;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
||||
onPressed: onPressed,
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderBadge extends StatelessWidget {
|
||||
const _ProviderBadge({required this.name});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
this.onAddToCollection,
|
||||
this.favoriteKeys = const {},
|
||||
this.loading = false,
|
||||
this.controller,
|
||||
super.key,
|
||||
@@ -29,6 +30,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
final ValueChanged<Post> onOpen;
|
||||
final ValueChanged<Post> onFavorite;
|
||||
final ValueChanged<Post>? onAddToCollection;
|
||||
final Set<String> favoriteKeys;
|
||||
final bool loading;
|
||||
final ScrollController? controller;
|
||||
|
||||
@@ -49,6 +51,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
post: post,
|
||||
blurExplicit: blurExplicit && !nsfwEnabled,
|
||||
showBadges: showBadges,
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
onOpen: () => onOpen(post),
|
||||
onFavorite: () => onFavorite(post),
|
||||
onAddToCollection:
|
||||
|
||||
@@ -155,7 +155,7 @@ void main() {
|
||||
expect(result.data.map((provider) => provider.id), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('offline provider is skipped and partial failures do not break feed',
|
||||
test('saved offline health does not block enabled provider retry',
|
||||
() async {
|
||||
final repository = FakeProviderRepository()
|
||||
..configs['a'] = config('a', 0)
|
||||
@@ -178,7 +178,7 @@ void main() {
|
||||
|
||||
final result = await manager.searchAcrossProviders(tags: [], page: 0)
|
||||
as Success<List<Post>>;
|
||||
expect(result.data.map((item) => item.providerId), ['a']);
|
||||
expect(result.data.map((item) => item.providerId), ['a', 'b']);
|
||||
expect(repository.health['c']?.status, ProviderStatus.offline);
|
||||
});
|
||||
|
||||
|
||||
@@ -90,12 +90,12 @@ BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "com.example" "\0"
|
||||
VALUE "FileDescription", "gel_rule_app" "\0"
|
||||
VALUE "FileDescription", "RuleGel" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "gel_rule_app" "\0"
|
||||
VALUE "InternalName", "RuleGel" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "gel_rule_app.exe" "\0"
|
||||
VALUE "ProductName", "gel_rule_app" "\0"
|
||||
VALUE "ProductName", "RuleGel" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
|
||||
Reference in New Issue
Block a user