Release Lunaris 2.0.1 polish update
This commit is contained in:
@@ -82,6 +82,8 @@ class ArtistPostsScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
@@ -94,7 +96,12 @@ class ArtistPostsScreen extends ConsumerWidget {
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
|
||||
@@ -46,6 +46,8 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
@@ -57,7 +59,12 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
|
||||
@@ -19,8 +19,15 @@ class FavoritesScreen extends ConsumerWidget {
|
||||
final state = ref.watch(favoritesControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final posts = state.value?.posts ?? const <Post>[];
|
||||
final downloaded = ref
|
||||
.watch(downloadedMediaByKeysProvider(
|
||||
posts.map((post) => post.cacheKey).toList(growable: false),
|
||||
))
|
||||
.value ??
|
||||
const <String, DownloadedMedia>{};
|
||||
return AdaptiveScaffold(
|
||||
title: 'Favorites',
|
||||
title: settings.languageCode == 'ru' ? 'Избранное' : 'Favorites',
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
@@ -36,7 +43,10 @@ class FavoritesScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(),
|
||||
downloadedKeys: downloaded.keys.toSet(),
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
@@ -9,8 +11,12 @@ final feedControllerProvider =
|
||||
AsyncNotifierProvider<FeedController, FeedState>(FeedController.new);
|
||||
|
||||
class FeedController extends AsyncNotifier<FeedState> {
|
||||
Timer? _providerDebounce;
|
||||
int _loadRequestId = 0;
|
||||
|
||||
@override
|
||||
Future<FeedState> build() async {
|
||||
ref.onDispose(() => _providerDebounce?.cancel());
|
||||
final providers = await _loadProviders();
|
||||
final settings = await _settings();
|
||||
final providerIds = providers.map((provider) => provider.id).toSet();
|
||||
@@ -32,23 +38,29 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
final requestId = ++_loadRequestId;
|
||||
state = const AsyncLoading();
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
state = AsyncData(current.copyWith(posts: posts, clearError: true));
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
final requestId = ++_loadRequestId;
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false));
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
||||
final requestId = ++_loadRequestId;
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: false, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
final existingKeys = current.posts.map((post) => post.cacheKey).toSet();
|
||||
final newPosts = posts
|
||||
.where((post) => existingKeys.add(post.cacheKey))
|
||||
@@ -98,16 +110,21 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
|
||||
Future<void> setProviders(List<String> providerIds) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(selectedProviderIds: providerIds, posts: []));
|
||||
final enabledIds = current.providers.map((provider) => provider.id).toSet();
|
||||
final selected = providerIds.where(enabledIds.contains).toList();
|
||||
state =
|
||||
AsyncData(current.copyWith(selectedProviderIds: selected, posts: []));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(
|
||||
selectedFeedProviderIds: providerIds,
|
||||
lastFeedProviderIds: providerIds,
|
||||
selectedFeedProviderIds: selected,
|
||||
lastFeedProviderIds: selected,
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
_providerDebounce?.cancel();
|
||||
_providerDebounce = Timer(const Duration(milliseconds: 320), () {
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setTopPeriod(TopPeriodFilter period) async {
|
||||
|
||||
@@ -235,6 +235,8 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode: MediaQualityMode.fromName(
|
||||
settings.mediaQualityMode),
|
||||
loading: state.isLoadingMore,
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
@@ -309,6 +311,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
for (final post in _selectedPosts(posts)) {
|
||||
if (!favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await _maybeAutoDownloadFavorite(ref, post);
|
||||
}
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
@@ -398,6 +401,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await _maybeAutoDownloadFavorite(ref, post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
@@ -426,6 +430,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
|
||||
await ref.read(downloadManagerServiceProvider).start(post);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefreshIntent extends Intent {
|
||||
@@ -448,13 +459,17 @@ class _FeedTitle extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Feed',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Builder(builder: (context) {
|
||||
return Consumer(
|
||||
builder: (context, ref, _) => Text(
|
||||
ref.watch(appStringsProvider).feed,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_strings.dart';
|
||||
import '../../../app/motion.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
@@ -55,14 +56,15 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
final post = ref.watch(postDetailsControllerProvider(args));
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
final feedPosts =
|
||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: 'Post',
|
||||
title: strings.post,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
tooltip: strings.close,
|
||||
onPressed: () => _close(context),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
@@ -99,6 +101,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
settings,
|
||||
favoriteKeys,
|
||||
qualityMode,
|
||||
strings,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -109,6 +112,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
settings,
|
||||
favoriteKeys,
|
||||
qualityMode,
|
||||
strings,
|
||||
);
|
||||
}
|
||||
return Shortcuts(
|
||||
@@ -174,7 +178,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Previous',
|
||||
tooltip:
|
||||
strings.ru ? 'Предыдущий' : 'Previous',
|
||||
onPressed: previous == null
|
||||
? null
|
||||
: () => _openPost(context, previous),
|
||||
@@ -200,7 +205,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Next',
|
||||
tooltip: strings.ru ? 'Следующий' : 'Next',
|
||||
onPressed: next == null
|
||||
? null
|
||||
: () => _openPost(context, next),
|
||||
@@ -219,6 +224,12 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
PostActionBar(
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
labels: _postActionLabels(strings),
|
||||
downloaded: ref
|
||||
.watch(
|
||||
downloadedMediaByKeyProvider(post.cacheKey))
|
||||
.value !=
|
||||
null,
|
||||
onFavorite: () =>
|
||||
_toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
@@ -230,6 +241,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
onDownload: settings.allowDownloads
|
||||
? () => _download(context, ref, post)
|
||||
: null,
|
||||
onDeleteLocalFile: () =>
|
||||
_deleteLocalFile(context, ref, post),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
@@ -242,13 +255,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
Chip(label: Text('${post.width} x ${post.height}')),
|
||||
if (post.source != null && post.source!.isNotEmpty)
|
||||
ActionChip(
|
||||
label: const Text('Source'),
|
||||
label: Text(strings.source),
|
||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Tags', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(strings.tags,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
PostTagsPanel(post: post),
|
||||
const SizedBox(height: 16),
|
||||
@@ -270,6 +284,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
AppSettings settings,
|
||||
Set<String> favoriteKeys,
|
||||
MediaQualityMode qualityMode,
|
||||
AppStrings strings,
|
||||
) {
|
||||
final isVideo = MediaUrlSelector.isVideo(post);
|
||||
return GestureDetector(
|
||||
@@ -310,6 +325,10 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 12),
|
||||
PostActionBar(
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
labels: _postActionLabels(strings),
|
||||
downloaded:
|
||||
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value !=
|
||||
null,
|
||||
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
@@ -319,6 +338,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
onDownload: settings.allowDownloads
|
||||
? () => _download(context, ref, post)
|
||||
: null,
|
||||
onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
@@ -330,7 +350,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
Chip(label: Text('${post.width} x ${post.height}')),
|
||||
if (post.source != null && post.source!.isNotEmpty)
|
||||
ActionChip(
|
||||
label: const Text('Source'),
|
||||
label: Text(strings.source),
|
||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||
),
|
||||
],
|
||||
@@ -339,7 +359,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
initiallyExpanded: false,
|
||||
title: Text('Tags', style: Theme.of(context).textTheme.titleMedium),
|
||||
title: Text(strings.tags,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -378,9 +399,17 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
if (!context.mounted) return;
|
||||
await ref.read(downloadManagerServiceProvider).start(post);
|
||||
if (!context.mounted) return;
|
||||
final strings = ref.read(appStringsProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Download started')),
|
||||
SnackBar(
|
||||
content: Text(strings.ru
|
||||
? 'Скачивание началось'
|
||||
: 'Download started'),
|
||||
),
|
||||
);
|
||||
Future<void>.delayed(const Duration(seconds: 2), () {
|
||||
ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showMobileQuickActions(
|
||||
@@ -453,11 +482,49 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await _maybeAutoDownloadFavorite(ref, post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
}
|
||||
|
||||
Future<void> _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
|
||||
await ref.read(downloadManagerServiceProvider).start(post);
|
||||
}
|
||||
|
||||
Future<void> _deleteLocalFile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
) async {
|
||||
final strings = ref.read(appStringsProvider);
|
||||
await ref.read(downloadedMediaServiceProvider).deleteLocalFile(
|
||||
post.cacheKey,
|
||||
);
|
||||
ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(strings.deleteLocalFile)),
|
||||
);
|
||||
}
|
||||
|
||||
PostActionLabels _postActionLabels(AppStrings strings) {
|
||||
return PostActionLabels(
|
||||
favorite: strings.favorite,
|
||||
unfavorite: strings.removeFavorite,
|
||||
collection: strings.collection,
|
||||
similar: strings.similar,
|
||||
openOriginal: strings.open,
|
||||
copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link',
|
||||
download: strings.download,
|
||||
deleteLocalFile: strings.deleteLocalFile,
|
||||
hidePost: strings.hidePost,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _hidePost(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -610,7 +677,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
||||
post.fileUrl.contains('gelbooru.com') ||
|
||||
@@ -742,6 +809,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
final comments = _expanded
|
||||
? ref.watch(
|
||||
postCommentsProvider(
|
||||
@@ -756,27 +824,28 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
||||
tilePadding: EdgeInsets.zero,
|
||||
initiallyExpanded: false,
|
||||
onExpansionChanged: (value) => setState(() => _expanded = value),
|
||||
title: Text('Comments', style: Theme.of(context).textTheme.titleMedium),
|
||||
title: Text(strings.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(
|
||||
error: (_, __) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text('Comments unavailable'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(strings.commentsUnavailable),
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const Align(
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text('No comments'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(strings.noComments),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -788,7 +857,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
comment.authorName.isEmpty
|
||||
? 'Anonymous'
|
||||
? strings.anonymous
|
||||
: comment.authorName,
|
||||
),
|
||||
subtitle: Text(comment.body),
|
||||
|
||||
@@ -94,6 +94,8 @@ class SimilarPostsScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favorites,
|
||||
viewedKeys: viewed,
|
||||
onOpen: (post) => context.push(
|
||||
@@ -123,7 +125,13 @@ class SimilarPostsScreen extends ConsumerWidget {
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager: ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(viewedControllerProvider);
|
||||
|
||||
@@ -8,6 +8,9 @@ class PostActionBar extends StatelessWidget {
|
||||
required this.onCopy,
|
||||
required this.isFavorite,
|
||||
this.onDownload,
|
||||
this.onDeleteLocalFile,
|
||||
this.downloaded = false,
|
||||
this.labels,
|
||||
this.onSimilar,
|
||||
this.onHide,
|
||||
super.key,
|
||||
@@ -19,11 +22,15 @@ class PostActionBar extends StatelessWidget {
|
||||
final VoidCallback onCopy;
|
||||
final bool isFavorite;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onDeleteLocalFile;
|
||||
final VoidCallback? onSimilar;
|
||||
final VoidCallback? onHide;
|
||||
final bool downloaded;
|
||||
final PostActionLabels? labels;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = labels ?? const PostActionLabels();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
@@ -33,38 +40,46 @@ class PostActionBar extends StatelessWidget {
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
),
|
||||
label: Text(isFavorite ? 'Unfavorite' : 'Favorite'),
|
||||
label: Text(isFavorite ? text.unfavorite : text.favorite),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCollection,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Collection'),
|
||||
label: Text(text.collection),
|
||||
),
|
||||
if (onSimilar != null)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onSimilar,
|
||||
icon: const Icon(Icons.auto_awesome_rounded),
|
||||
label: const Text('Similar'),
|
||||
label: Text(text.similar),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Open original',
|
||||
tooltip: text.openOriginal,
|
||||
onPressed: onOpen,
|
||||
icon: const Icon(Icons.open_in_new_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Copy link',
|
||||
tooltip: text.copyLink,
|
||||
onPressed: onCopy,
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
),
|
||||
if (onDownload != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Download',
|
||||
tooltip: text.download,
|
||||
onPressed: onDownload,
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
icon: Icon(
|
||||
downloaded ? Icons.download_done_rounded : Icons.download_rounded,
|
||||
),
|
||||
),
|
||||
if (downloaded && onDeleteLocalFile != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.deleteLocalFile,
|
||||
onPressed: onDeleteLocalFile,
|
||||
icon: const Icon(Icons.delete_sweep_rounded),
|
||||
),
|
||||
if (onHide != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Hide post locally',
|
||||
tooltip: text.hidePost,
|
||||
onPressed: onHide,
|
||||
icon: const Icon(Icons.visibility_off_rounded),
|
||||
),
|
||||
@@ -72,3 +87,27 @@ class PostActionBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostActionLabels {
|
||||
const PostActionLabels({
|
||||
this.favorite = 'Favorite',
|
||||
this.unfavorite = 'Unfavorite',
|
||||
this.collection = 'Collection',
|
||||
this.similar = 'Similar',
|
||||
this.openOriginal = 'Open original',
|
||||
this.copyLink = 'Copy link',
|
||||
this.download = 'Download',
|
||||
this.deleteLocalFile = 'Delete local file',
|
||||
this.hidePost = 'Hide post locally',
|
||||
});
|
||||
|
||||
final String favorite;
|
||||
final String unfavorite;
|
||||
final String collection;
|
||||
final String similar;
|
||||
final String openOriginal;
|
||||
final String copyLink;
|
||||
final String download;
|
||||
final String deleteLocalFile;
|
||||
final String hidePost;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
},
|
||||
);
|
||||
final allowPinchZoom = MediaQuery.sizeOf(context).width < 700;
|
||||
return allowPinchZoom ? InteractiveViewer(child: image) : image;
|
||||
return allowPinchZoom ? _ZoomableImage(child: image) : image;
|
||||
}
|
||||
|
||||
void _initializeVideo() {
|
||||
@@ -348,7 +348,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
||||
post.fileUrl.contains('gelbooru.com') ||
|
||||
@@ -1170,6 +1170,61 @@ class VideoErrorOverlay extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ZoomableImage extends StatefulWidget {
|
||||
const _ZoomableImage({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_ZoomableImage> createState() => _ZoomableImageState();
|
||||
}
|
||||
|
||||
class _ZoomableImageState extends State<_ZoomableImage> {
|
||||
final _controller = TransformationController();
|
||||
TapDownDetails? _doubleTapDetails;
|
||||
bool _zoomed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onDoubleTapDown: (details) => _doubleTapDetails = details,
|
||||
onDoubleTap: _toggleZoom,
|
||||
child: InteractiveViewer(
|
||||
transformationController: _controller,
|
||||
minScale: 1,
|
||||
maxScale: 5,
|
||||
panEnabled: _zoomed,
|
||||
scaleEnabled: true,
|
||||
clipBehavior: Clip.none,
|
||||
onInteractionEnd: (_) {
|
||||
final scale = _controller.value.getMaxScaleOnAxis();
|
||||
if (mounted) setState(() => _zoomed = scale > 1.03);
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleZoom() {
|
||||
final tap = _doubleTapDetails?.localPosition ?? Offset.zero;
|
||||
if (_zoomed) {
|
||||
_controller.value = Matrix4.identity();
|
||||
setState(() => _zoomed = false);
|
||||
return;
|
||||
}
|
||||
_controller.value = Matrix4.identity()
|
||||
..translateByDouble(-tap.dx * 1.3, -tap.dy * 1.3, 0, 1)
|
||||
..scaleByDouble(2.3, 2.3, 1, 1);
|
||||
setState(() => _zoomed = true);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoundControlButton extends StatelessWidget {
|
||||
const _RoundControlButton({
|
||||
required this.tooltip,
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../app/app_version.dart';
|
||||
import '../../../app/changelog.dart';
|
||||
import '../../../app/app_strings.dart';
|
||||
import '../../../app/motion.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
@@ -21,8 +22,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsControllerProvider);
|
||||
final strings = AppStrings(
|
||||
settings.value?.languageCode ?? AppSettings.defaults.languageCode);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Settings',
|
||||
title: strings.settings,
|
||||
body: settings.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
@@ -40,6 +43,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAndroid = Platform.isAndroid;
|
||||
final strings = AppStrings(settings.languageCode);
|
||||
final deviceInfo =
|
||||
isAndroid ? ref.watch(motionDeviceInfoProvider).value : null;
|
||||
final detectedHz =
|
||||
@@ -55,13 +59,13 @@ class _SettingsContent extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SettingsSection(
|
||||
title: 'General',
|
||||
title: strings.general,
|
||||
icon: Icons.tune_rounded,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: settings.themeMode,
|
||||
decoration: InputDecoration(
|
||||
labelText: settings.languageCode == 'ru' ? 'Тема' : 'Theme',
|
||||
labelText: strings.theme,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'dark', child: Text('Dark')),
|
||||
@@ -73,37 +77,46 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.nsfwEnabled,
|
||||
title: const Text('Allow NSFW content'),
|
||||
title: Text(strings.allowNsfw),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(nsfwEnabled: value)),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.blurExplicitContent,
|
||||
title: const Text('Blur sensitive previews'),
|
||||
title: Text(strings.blurSensitive),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(blurExplicitContent: value)),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showPostBadges,
|
||||
title: const Text('Show post badges'),
|
||||
title: Text(strings.showPostBadges),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(showPostBadges: value)),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.allowDownloads,
|
||||
title: const Text('Allow manual downloads'),
|
||||
title: Text(strings.allowDownloads),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(allowDownloads: value)),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.autoDownloadFavorites,
|
||||
title: Text(strings.autoDownloadFavorites),
|
||||
subtitle: Text(strings.autoDownloadFavoritesHint),
|
||||
onChanged: (value) => _update(
|
||||
ref,
|
||||
settings.copyWith(autoDownloadFavorites: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: settings.languageCode == 'ru' ? 'Внешний вид' : 'Appearance',
|
||||
title: strings.appearance,
|
||||
icon: Icons.palette_rounded,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: settings.languageCode,
|
||||
decoration: const InputDecoration(labelText: 'Language / Язык'),
|
||||
decoration: InputDecoration(labelText: strings.language),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'ru', child: Text('Русский')),
|
||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||
@@ -127,10 +140,12 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.allowExperimentalUpdates,
|
||||
title: const Text('Receive beta / experimental updates'),
|
||||
subtitle: const Text(
|
||||
'When enabled, the updater also sees prerelease Gitea builds.',
|
||||
),
|
||||
title: Text(settings.languageCode == 'ru'
|
||||
? 'Получать beta / experimental обновления'
|
||||
: 'Receive beta / experimental updates'),
|
||||
subtitle: Text(settings.languageCode == 'ru'
|
||||
? 'Обновлятор будет видеть prerelease-сборки Gitea.'
|
||||
: 'When enabled, the updater also sees prerelease Gitea builds.'),
|
||||
onChanged: (value) => _update(
|
||||
ref,
|
||||
settings.copyWith(allowExperimentalUpdates: value),
|
||||
@@ -139,12 +154,12 @@ class _SettingsContent extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'Feed & Layout',
|
||||
title: strings.feedLayout,
|
||||
icon: Icons.dashboard_customize_rounded,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: settings.mediaQualityMode,
|
||||
decoration: const InputDecoration(labelText: 'Media quality'),
|
||||
decoration: InputDecoration(labelText: strings.mediaQuality),
|
||||
items: [
|
||||
for (final mode in MediaQualityMode.values)
|
||||
DropdownMenuItem(value: mode.name, child: Text(mode.label)),
|
||||
@@ -185,7 +200,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
_StepperTile(
|
||||
title: 'Desktop columns',
|
||||
title: strings.desktopColumns,
|
||||
value: settings.desktopColumns,
|
||||
min: 3,
|
||||
max: 8,
|
||||
@@ -195,7 +210,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
_StepperTile(
|
||||
title: 'Mobile columns',
|
||||
title: strings.mobileColumns,
|
||||
value: settings.mobileColumns,
|
||||
min: 1,
|
||||
max: 3,
|
||||
@@ -207,17 +222,17 @@ class _SettingsContent extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'Filters',
|
||||
title: strings.filters,
|
||||
icon: Icons.filter_alt_rounded,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.hideViewedPosts,
|
||||
title: const Text('Hide viewed posts'),
|
||||
title: Text(strings.hideViewed),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(hideViewedPosts: value)),
|
||||
),
|
||||
_TagListEditor(
|
||||
title: 'Smart blacklist',
|
||||
title: strings.smartBlacklist,
|
||||
icon: Icons.visibility_off_rounded,
|
||||
tags: settings.smartBlacklistRules,
|
||||
helper:
|
||||
@@ -229,7 +244,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_TagListEditor(
|
||||
title: 'Whitelisted tags',
|
||||
title: strings.whitelistedTags,
|
||||
icon: Icons.verified_rounded,
|
||||
tags: settings.whitelistedTags,
|
||||
onChanged: (tags) => _update(
|
||||
@@ -240,11 +255,11 @@ class _SettingsContent extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'Storage',
|
||||
title: strings.storage,
|
||||
icon: Icons.storage_rounded,
|
||||
children: [
|
||||
_StepperTile(
|
||||
title: 'Cache max items',
|
||||
title: strings.cacheMaxItems,
|
||||
value: settings.cacheMaxItems,
|
||||
min: 100,
|
||||
max: 10000,
|
||||
@@ -259,14 +274,14 @@ class _SettingsContent extends ConsumerWidget {
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.clearCache(),
|
||||
icon: const Icon(Icons.cleaning_services_rounded),
|
||||
label: const Text('Clear cache'),
|
||||
label: Text(strings.clearCache),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.clearViewedHistory(),
|
||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
||||
label: const Text('Clear viewed'),
|
||||
label: Text(strings.clearViewed),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => ref
|
||||
@@ -318,7 +333,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'About',
|
||||
title: strings.about,
|
||||
icon: Icons.info_rounded,
|
||||
children: [
|
||||
_ActionGrid(
|
||||
@@ -326,7 +341,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _checkUpdates(context, ref),
|
||||
icon: const Icon(Icons.system_update_alt_rounded),
|
||||
label: const Text('Check updates'),
|
||||
label: Text(strings.checkUpdates),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _showChangelog(context),
|
||||
@@ -336,12 +351,12 @@ class _SettingsContent extends ConsumerWidget {
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _exportJson(context, ref),
|
||||
icon: const Icon(Icons.upload_file_rounded),
|
||||
label: const Text('Export JSON'),
|
||||
label: Text(strings.exportJson),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _importDialog(context, ref),
|
||||
icon: const Icon(Icons.download_for_offline_rounded),
|
||||
label: const Text('Import JSON'),
|
||||
label: Text(strings.importJson),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -23,7 +23,7 @@ class ViewedScreen extends ConsumerWidget {
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: 'Viewed',
|
||||
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear viewed history',
|
||||
@@ -49,6 +49,8 @@ class ViewedScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
@@ -61,7 +63,12 @@ class ViewedScreen extends ConsumerWidget {
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user