feat(qol): add smart tag actions, universal zoom, scroll-to-top, desktop hotkeys, and Pawchive provider
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user