Release Lunaris 2.0.1 polish update

This commit is contained in:
2026-05-26 18:47:07 +03:00
parent f65efe7cf7
commit af62ddf0da
37 changed files with 2607 additions and 143 deletions
@@ -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,