Polish mobile UX downloads and comments

This commit is contained in:
2026-05-23 12:15:20 +03:00
parent 63da741295
commit f9938e393d
24 changed files with 571 additions and 61 deletions
+1 -1
View File
@@ -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),
+2
View File
@@ -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';
+5
View File
@@ -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();
});
+17
View File
@@ -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);
}
+11 -1
View File
@@ -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 {
+34 -1
View File
@@ -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 [],
+50 -1
View File
@@ -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();
}
}
+14 -10
View File
@@ -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,
),
};
);
}
}
}
+31 -5
View File
@@ -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,
+45 -4
View File
@@ -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: