Fix feed tag search and prepare 2.0.1+22

This commit is contained in:
2026-06-03 13:10:57 +03:00
parent af62ddf0da
commit f0bf2ea515
46 changed files with 4262 additions and 4154 deletions
+5 -11
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
channel: "stable"
project_type: app
@@ -13,17 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: linux
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: windows
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
# User provided section
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.1';
const appBuildNumber = 21;
const appBuildNumber = 22;
const appReleaseChannel = 'Stable';
+44 -12
View File
@@ -32,7 +32,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
final _scrollController = ScrollController();
final Set<String> _selectedKeys = {};
bool _selectionMode = false;
bool _usedInitialQuery = false;
String? _appliedInitialQuery;
Timer? _scrollSaveDebounce;
@override
@@ -70,10 +70,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
if (nextQuery != null &&
nextQuery.isNotEmpty &&
nextQuery != oldWidget.initialQuery) {
_usedInitialQuery = true;
_appliedInitialQuery = nextQuery;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(feedControllerProvider.notifier).search(nextQuery);
_applySearchQuery(nextQuery);
});
}
}
@@ -93,10 +93,16 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) {
_usedInitialQuery = true;
final initialQuery = widget.initialQuery?.trim();
final currentTags = feed.value?.selectedTags.join(' ');
if (initialQuery != null &&
initialQuery.isNotEmpty &&
_appliedInitialQuery != initialQuery &&
currentTags != initialQuery) {
_appliedInitialQuery = initialQuery;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(feedControllerProvider.notifier).search(widget.initialQuery!);
if (!mounted) return;
_applySearchQuery(initialQuery);
});
}
@@ -159,17 +165,14 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
onSearchChanged: (query) => ref
.read(feedControllerProvider.notifier)
.updateTagSuggestions(query),
onSuggestionTap: (query) =>
ref.read(feedControllerProvider.notifier).search(query),
onSuggestionTap: _submitSearch,
onTopPeriodChanged: (period) => ref
.read(feedControllerProvider.notifier)
.setTopPeriod(period),
onSearch: (query) =>
ref.read(feedControllerProvider.notifier).search(query),
onSearch: _submitSearch,
onRefresh: () =>
ref.read(feedControllerProvider.notifier).refresh(),
onClearFilters: () =>
ref.read(feedControllerProvider.notifier).clearFilters(),
onClearFilters: _clearFilters,
selectionMode: _selectionMode,
onToggleSelectionMode: () {
setState(() {
@@ -279,6 +282,35 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
);
}
void _submitSearch(String query) {
final trimmed = query.trim();
if (trimmed.isEmpty) {
_clearFilters();
return;
}
final location = '/?q=${Uri.encodeQueryComponent(trimmed)}';
if (widget.initialQuery?.trim() == trimmed) {
_applySearchQuery(trimmed);
return;
}
context.go(location);
}
Future<void> _applySearchQuery(String query) async {
await ref.read(feedControllerProvider.notifier).search(query);
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
}
Future<void> _clearFilters() async {
_appliedInitialQuery = null;
await ref.read(feedControllerProvider.notifier).clearFilters();
if (mounted && widget.initialQuery != null) {
context.go('/');
}
}
void _toggleSelected(Post post) {
setState(() {
_selectionMode = true;
@@ -3,33 +3,57 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
// Меняем FutureProvider на StreamProvider
final postDetailsControllerProvider =
FutureProvider.family<Post?, PostDetailsArgs>((ref, args) async {
StreamProvider.family<Post?, PostDetailsArgs>((ref, args) async* {
// Правильно собираем зависимости на самом верхнем уровне (фиксим антипаттерн)
final providerManager = ref.watch(providerManagerProvider);
final cacheService = ref.watch(cacheServiceProvider);
final postRepository = ref.watch(postRepositoryProvider);
// Вспомогательная функция для загрузки тегов
Future<Post?> enrich(Post? post) async {
if (post == null) return null;
final shouldEnrich = post.tagGroups.isEmpty ||
(post.tagGroups.length == 1 && post.tagGroups.containsKey('general'));
if (!shouldEnrich) return post;
final enriched =
await ref.watch(providerManagerProvider).enrichPostTags(post);
final enriched = await providerManager.enrichPostTags(post);
if (enriched is Success<Post>) {
await ref.watch(cacheServiceProvider).cachePosts([enriched.data]);
await cacheService.cachePosts([enriched.data]);
return enriched.data;
}
return post;
}
if (args.initialPost != null) return enrich(args.initialPost);
final cached = await ref
.watch(postRepositoryProvider)
.getCachedPost(args.postId, args.providerId);
// СЦЕНАРИЙ 1: Пост уже передан из ленты (Самый частый случай)
if (args.initialPost != null) {
yield args
.initialPost; // МГНОВЕННО отдаем пост в UI, экран сразу рендерится!
final enriched = await enrich(args.initialPost);
yield enriched; // Чуть позже отдаем версию с загруженными тегами
return;
}
// СЦЕНАРИЙ 2: Прямой переход (например, по ссылке), проверяем локальный кэш
final cached =
await postRepository.getCachedPost(args.postId, args.providerId);
if (cached is Success<Post?> && cached.data != null) {
return enrich(cached.data);
yield cached.data; // Нашли в кэше — сразу показываем контент
final enriched = await enrich(cached.data);
yield enriched; // Догружаем теги на фоне
return;
}
// СЦЕНАРИЙ 3: Полный фоллбэк, если вообще ничего нет — идем в сеть за самим постом
final remote = await providerManager.getPost(args.providerId, args.postId);
if (remote is Success<Post?> && remote.data != null) {
yield remote.data;
final enriched = await enrich(remote.data);
yield enriched;
} else {
yield null;
}
final remote = await ref
.watch(providerManagerProvider)
.getPost(args.providerId, args.postId);
return remote is Success<Post?> ? enrich(remote.data) : null;
});
class PostDetailsArgs {
@@ -178,8 +178,7 @@ class PostDetailsScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton.filledTonal(
tooltip:
strings.ru ? 'Предыдущий' : 'Previous',
tooltip: strings.ru ? 'Предыдущий' : 'Previous',
onPressed: previous == null
? null
: () => _openPost(context, previous),
@@ -205,7 +204,7 @@ class PostDetailsScreen extends ConsumerWidget {
),
),
IconButton.filledTonal(
tooltip: strings.ru ? 'Следующий' : 'Next',
tooltip: strings.ru ? 'Следующий' : 'Next',
onPressed: next == null
? null
: () => _openPost(context, next),
@@ -402,9 +401,7 @@ class PostDetailsScreen extends ConsumerWidget {
final strings = ref.read(appStringsProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(strings.ru
? 'Скачивание началось'
: 'Download started'),
content: Text(strings.ru ? 'Скачивание началось' : 'Download started'),
),
);
Future<void>.delayed(const Duration(seconds: 2), () {
@@ -518,7 +515,7 @@ class PostDetailsScreen extends ConsumerWidget {
collection: strings.collection,
similar: strings.similar,
openOriginal: strings.open,
copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link',
copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link',
download: strings.download,
deleteLocalFile: strings.deleteLocalFile,
hidePost: strings.hidePost,
@@ -602,12 +599,10 @@ class _MobilePostPager extends StatefulWidget {
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_controller = PageController(initialPage: widget.initialIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
@@ -617,10 +612,10 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialIndex != _currentIndex &&
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
_currentIndex = widget.initialIndex;
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
}
@@ -642,7 +637,6 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
controller: _controller,
itemCount: widget.posts.length,
onPageChanged: (index) {
_currentIndex = index;
_prefetchAround(index);
},
itemBuilder: (context, index) {
+22 -14
View File
@@ -165,10 +165,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f
sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.2.0"
code_builder:
dependency: transitive
description:
@@ -289,6 +289,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@@ -675,10 +683,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -731,18 +739,18 @@ packages:
dependency: transitive
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
version: "10.1.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "4.1.0"
path:
dependency: "direct main"
description:
@@ -1056,10 +1064,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
time:
dependency: transitive
description:
@@ -1200,10 +1208,10 @@ packages:
dependency: transitive
description:
name: wakelock_plus
sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
version: "1.6.1"
wakelock_plus_platform_interface:
dependency: transitive
description:
@@ -1256,10 +1264,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "5.15.0"
version: "6.3.0"
xdg_directories:
dependency: transitive
description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 2.0.1+21
version: 2.0.1+22
environment:
sdk: ">=3.3.0 <4.0.0"
+10
View File
@@ -1,3 +1,4 @@
import 'dart:ffi' show Abi;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
@@ -15,6 +16,7 @@ import 'package:gel_rule_app/core/cache/cache_service.dart';
import 'package:gel_rule_app/core/database/app_database.dart';
import 'package:gel_rule_app/core/database/database_service.dart';
import 'package:gel_rule_app/core/utils/result.dart';
import 'package:isar/isar.dart';
void main() {
late Directory directory;
@@ -24,6 +26,14 @@ void main() {
late File copiedIsarDll;
setUpAll(() async {
if (Platform.isLinux) {
await Isar.initializeIsarCore(
libraries: {
Abi.linuxX64:
'${Directory.current.path}/third_party/isar_flutter_libs/linux/libisar.so',
},
);
}
copiedIsarDll =
File('${Directory.current.path}${Platform.pathSeparator}isar.dll');
if (Platform.isWindows && !copiedIsarDll.existsSync()) {
@@ -57,6 +57,48 @@ void main() {
expect(changed, 'video');
});
testWidgets('clear button removes all committed tags', (tester) async {
String? changed;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'touhou video',
onSubmitted: (_) {},
onChanged: (value) {
changed = value;
},
),
));
await tester.tap(find.byTooltip('Clear'));
await tester.pump();
expect(find.text('touhou'), findsNothing);
expect(find.text('video'), findsNothing);
expect(changed, '');
});
testWidgets('external initial value replaces committed tags', (tester) async {
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag',
onSubmitted: (_) {},
),
));
expect(find.text('old_tag'), findsOneWidget);
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'new_tag',
onSubmitted: (_) {},
),
));
await tester.pump();
expect(find.text('old_tag'), findsNothing);
expect(find.text('new_tag'), findsOneWidget);
});
testWidgets('suggestion appends active tag as a chip', (tester) async {
String? applied;
await tester.pumpWidget(_Harness(