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
@@ -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,