feat(qol): add smart tag actions, universal zoom, scroll-to-top, desktop hotkeys, and Pawchive provider

This commit is contained in:
2026-09-03 17:37:15 +03:00
parent ec6d8156e0
commit cc5a1a4f82
12 changed files with 789 additions and 14 deletions
@@ -125,8 +125,12 @@ class PostDetailsScreen extends ConsumerWidget {
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.arrowLeft):
const _PreviousPostIntent(),
LogicalKeySet(LogicalKeyboardKey.keyA):
const _PreviousPostIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowRight):
const _NextPostIntent(),
LogicalKeySet(LogicalKeyboardKey.keyD):
const _NextPostIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF):
const _ToggleFavoriteIntent(),
LogicalKeySet(LogicalKeyboardKey.keyC):
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
@@ -218,13 +219,10 @@ class _PostMediaViewerState extends State<PostMediaViewer>
);
},
);
final allowPinchZoom = MediaQuery.sizeOf(context).width < 700;
final child = allowPinchZoom
? _ZoomableImage(
onGestureLockChanged: widget.onMediaGestureLockChanged,
child: image,
)
: image;
final child = _ZoomableImage(
onGestureLockChanged: widget.onMediaGestureLockChanged,
child: image,
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) {
@@ -1083,6 +1081,29 @@ class _ZoomableImageState extends State<_ZoomableImage> {
)
: widget.child;
return Listener(
onPointerSignal: (signal) {
if (signal is PointerScrollEvent) {
final scaleDelta = signal.scrollDelta.dy < 0 ? 1.15 : 0.85;
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale * scaleDelta).clamp(1.0, 6.0);
if (newScale <= 1.02) {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
} else {
final focalPoint = signal.localPosition;
_controller.value = Matrix4.identity()
..translateByDouble(
-focalPoint.dx * (newScale - 1),
-focalPoint.dy * (newScale - 1),
0,
1)
..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = true);
_setLocked(true);
}
}
},
onPointerDown: (_) {
_pointerCount++;
if (_pointerCount >= 2 || _zoomed) {
@@ -1,19 +1,24 @@
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 '../../../../app/app.dart';
import '../../../../backend/backend.dart';
import '../../../../shared/widgets/tag_chip.dart';
import '../../../feed/presentation/feed_controller.dart';
import '../../../settings/presentation/settings_controller.dart';
class PostTagsPanel extends StatefulWidget {
class PostTagsPanel extends ConsumerStatefulWidget {
const PostTagsPanel({required this.post, super.key});
final Post post;
@override
State<PostTagsPanel> createState() => _PostTagsPanelState();
ConsumerState<PostTagsPanel> createState() => _PostTagsPanelState();
}
class _PostTagsPanelState extends State<PostTagsPanel> {
class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
final _expandedGroups = <String>{};
@override
@@ -26,6 +31,7 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
for (final entry in groups.entries)
_TagGroupBlock(
label: _label(entry.key),
group: entry.key,
tags: entry.value,
expanded: _expandedGroups.contains(entry.key),
onToggleExpanded: () {
@@ -36,11 +42,183 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
});
},
onTap: (tag) => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
onLongPress: (tag) =>
_showTagActionSheet(context, tag: tag, group: entry.key),
),
],
);
}
void _showTagActionSheet(
BuildContext context, {
required String tag,
required String group,
}) {
final currentTags =
ref.read(feedControllerProvider).value?.selectedTags ?? const [];
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final isBlacklisted = settings.blacklistedTags.contains(tag);
final isArtist = group == 'artist';
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (modalContext) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_label(group),
style:
Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
tag,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.search_rounded),
title: const Text('Искать только этот тег'),
onTap: () {
Navigator.pop(modalContext);
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
},
),
ListTile(
leading: const Icon(Icons.add_circle_outline_rounded),
title: const Text('Добавить к поиску (+тег)'),
subtitle: currentTags.isNotEmpty
? Text('Текущие: ${currentTags.join(', ')}')
: null,
onTap: () {
Navigator.pop(modalContext);
final nextQuery = currentTags.contains(tag)
? currentTags.join(' ')
: [...currentTags, tag].join(' ');
context.go('/?q=${Uri.encodeQueryComponent(nextQuery)}');
},
),
ListTile(
leading: const Icon(Icons.remove_circle_outline_rounded),
title: const Text('Исключить из поиска (-тег)'),
onTap: () {
Navigator.pop(modalContext);
final filtered = currentTags
.where((t) => t != tag && t != '-$tag')
.toList();
final nextQuery = [...filtered, '-$tag'].join(' ');
context.go('/?q=${Uri.encodeQueryComponent(nextQuery)}');
},
),
ListTile(
leading: Icon(
isBlacklisted
? Icons.block_flipped
: Icons.block_rounded,
color: isBlacklisted
? null
: Theme.of(context).colorScheme.error,
),
title: Text(
isBlacklisted
? 'Удалить из чёрного списка'
: 'В чёрный список (Blacklist)',
),
onTap: () async {
Navigator.pop(modalContext);
final updatedList = isBlacklisted
? settings.blacklistedTags
.where((t) => t != tag)
.toList()
: [...settings.blacklistedTags, tag];
final nextSettings =
settings.copyWith(blacklistedTags: updatedList);
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(nextSettings);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isBlacklisted
? 'Тег "$tag" удалён из чёрного списка'
: 'Тег "$tag" добавлен в чёрный список',
),
behavior: SnackBarBehavior.floating,
),
);
}
},
),
ListTile(
leading: const Icon(Icons.copy_rounded),
title: const Text('Скопировать тег'),
onTap: () {
Navigator.pop(modalContext);
Clipboard.setData(ClipboardData(text: tag));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Тег "$tag" скопирован в буфер'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
),
if (isArtist)
ListTile(
leading: const Icon(Icons.palette_rounded),
title: const Text('Все работы этого автора'),
onTap: () {
Navigator.pop(modalContext);
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
},
),
],
),
),
),
);
},
);
}
Map<String, List<String>> _groups(Post post) {
if (post.tagGroups.isNotEmpty) {
final ordered = <String, List<String>>{};
@@ -81,19 +259,23 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
class _TagGroupBlock extends StatelessWidget {
const _TagGroupBlock({
required this.label,
required this.group,
required this.tags,
required this.expanded,
required this.onToggleExpanded,
required this.onTap,
required this.onLongPress,
});
static const _collapsedLimit = 36;
final String label;
final String group;
final List<String> tags;
final bool expanded;
final VoidCallback onToggleExpanded;
final ValueChanged<String> onTap;
final ValueChanged<String> onLongPress;
@override
Widget build(BuildContext context) {
@@ -126,6 +308,7 @@ class _TagGroupBlock extends StatelessWidget {
TagChip(
tag: tag,
onTap: () => onTap(tag),
onLongPress: () => onLongPress(tag),
),
if (tags.length > _collapsedLimit)
ActionChip(