From 7e384b1be700f5013b3ea58a12667463c015aaad Mon Sep 17 00:00:00 2001 From: RarDog Date: Thu, 3 Sep 2026 22:17:55 +0300 Subject: [PATCH] fix: preserve AND tag operator during search and suggestion draft completions (v2.0.10) --- README.md | 8 ++-- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 10 +++++ lib/backend/services/search_service.dart | 16 ++++--- lib/shared/widgets/app_search_bar.dart | 52 +++++++++++++++++----- pubspec.yaml | 2 +- test/backend/search_service_test.dart | 11 +++++ test/shared/tag_input_search_bar_test.dart | 51 +++++++++++++++++++++ 8 files changed, 128 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2371714..04cbf35 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.0.9 + Version 2.0.10 Flutter Platforms Gitea @@ -69,9 +69,9 @@ 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** -- **Android**: `Lunaris-v2.0.9.apk` -- **Linux (x64)**: `Lunaris-v2.0.9-linux-x64.tar.gz` -- **Windows**: `LunarisSetup-v2.0.9.exe` / `LunarisPortable-v2.0.9.zip` +- **Android**: `Lunaris-v2.0.10.apk` +- **Linux (x64)**: `Lunaris-v2.0.10-linux-x64.tar.gz` +- **Windows**: `LunarisSetup-v2.0.10.exe` / `LunarisPortable-v2.0.10.zip` --- diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 85e5d90..96f8c5a 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.0.9'; -const appBuildNumber = 31; +const appDisplayVersion = '2.0.10'; +const appBuildNumber = 32; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 9917a12..1a999df 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.0.10', + title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling', + bullets: [ + 'Fixed "and" operator disappearing when submitting search or applying autocompletions.', + 'Preserved preceding draft tokens (e.g. typing "cat and d" then picking "dog" now retains "cat", "AND", and "dog").', + 'Supported multiple "and" separators without accidental deduplication.', + 'Distinct visual styling for AND separator chips with secondary theme container and alt-route icon.', + ], + ), LunarisChange( version: '2.0.9', title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting', diff --git a/lib/backend/services/search_service.dart b/lib/backend/services/search_service.dart index 62e73e8..48c83e8 100644 --- a/lib/backend/services/search_service.dart +++ b/lib/backend/services/search_service.dart @@ -28,13 +28,15 @@ class SearchService { } List parseTags(String query) { - return query - .trim() - .split(RegExp(r'\s+')) - .map(sanitizeToken) - .where((tag) => tag.isNotEmpty && tag != 'and') - .toSet() - .toList(); + final seen = {}; + final result = []; + for (final raw in query.trim().split(RegExp(r'\s+'))) { + final tag = raw.trim(); + if (tag.isEmpty) continue; + if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue; + result.add(tag); + } + return result; } Future> saveSearch( diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart index 56c9f60..ed08437 100644 --- a/lib/shared/widgets/app_search_bar.dart +++ b/lib/shared/widgets/app_search_bar.dart @@ -242,18 +242,34 @@ class _TagInputSearchBarState extends State { Widget _buildTagChip(BuildContext context, String tag) { final scheme = Theme.of(context).colorScheme; + final isAnd = tag.toLowerCase() == 'and'; return InputChip( - label: Text(tag), - avatar: const Icon(Icons.tag_rounded, size: 16), + label: Text( + isAnd ? 'AND' : tag, + style: TextStyle( + color: isAnd + ? scheme.onSecondaryContainer + : scheme.onPrimaryContainer, + fontWeight: isAnd ? FontWeight.w900 : FontWeight.w700, + letterSpacing: isAnd ? 0.8 : 0, + ), + ), + avatar: Icon( + isAnd ? Icons.alt_route_rounded : Icons.tag_rounded, + size: 16, + color: isAnd ? scheme.secondary : scheme.primary, + ), deleteIcon: const Icon(Icons.close_rounded, size: 16), onPressed: () => _editTag(tag), onDeleted: () => _removeTag(tag), - backgroundColor: scheme.primaryContainer.withValues(alpha: 0.54), - labelStyle: TextStyle( - color: scheme.onPrimaryContainer, - fontWeight: FontWeight.w700, + backgroundColor: isAnd + ? scheme.secondaryContainer.withValues(alpha: 0.7) + : scheme.primaryContainer.withValues(alpha: 0.54), + side: BorderSide( + color: isAnd + ? scheme.secondary.withValues(alpha: 0.4) + : scheme.primary.withValues(alpha: 0.18), ), - side: BorderSide(color: scheme.primary.withValues(alpha: 0.18)), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); @@ -306,7 +322,9 @@ class _TagInputSearchBarState extends State { _localDirty = true; setState(() { for (final tag in additions) { - if (!_tags.contains(tag)) _tags.add(tag); + if (tag.toLowerCase() == 'and' || !_tags.contains(tag)) { + _tags.add(tag); + } } _controller.clear(); }); @@ -359,10 +377,19 @@ class _TagInputSearchBarState extends State { _localDirty = true; setState(() { if (draft.isNotEmpty) { - final draftTags = _parseTags(draft); - _tags.removeWhere(draftTags.contains); + final tokens = _parseTags(draft); + if (tokens.isNotEmpty) { + final prefixTokens = tokens.sublist(0, tokens.length - 1); + for (final token in prefixTokens) { + if (token.toLowerCase() == 'and' || !_tags.contains(token)) { + _tags.add(token); + } + } + } + } + if (!_tags.contains(suggestion)) { + _tags.add(suggestion); } - if (!_tags.contains(suggestion)) _tags.add(suggestion); _controller.clear(); }); _notifyChanged(); @@ -387,7 +414,8 @@ class _TagInputSearchBarState extends State { final result = []; for (final raw in query.trim().split(RegExp(r'\s+'))) { final tag = raw.trim(); - if (tag.isEmpty || !seen.add(tag)) continue; + if (tag.isEmpty) continue; + if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue; result.add(tag); } return result; diff --git a/pubspec.yaml b/pubspec.yaml index 92c30f0..f6cfaed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.9+31 +version: 2.0.10+32 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/search_service_test.dart b/test/backend/search_service_test.dart index 89a5af6..4241322 100644 --- a/test/backend/search_service_test.dart +++ b/test/backend/search_service_test.dart @@ -43,6 +43,17 @@ void main() { expect(service.parseTags(' cat cute cat '), ['cat', 'cute']); }); + test('parses query preserving and operators and grouping', () { + expect( + service.parseTags('cat and dog and fox'), + ['cat', 'and', 'dog', 'and', 'fox'], + ); + expect( + service.parseTags('(cat and dog) anthro'), + ['(cat', 'and', 'dog)', 'anthro'], + ); + }); + test('recent searches are ordered by searchedAt', () async { await service.saveSearch('old', 1); await Future.delayed(const Duration(milliseconds: 1)); diff --git a/test/shared/tag_input_search_bar_test.dart b/test/shared/tag_input_search_bar_test.dart index ad73833..b39fecd 100644 --- a/test/shared/tag_input_search_bar_test.dart +++ b/test/shared/tag_input_search_bar_test.dart @@ -288,6 +288,57 @@ void main() { expect(size.height, lessThan(96)); expect(find.text('touhou'), findsOneWidget); }); + + testWidgets('preserves AND operator chips when submitted', (tester) async { + String submitted = ''; + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'cat and dog', + onSubmitted: (query) => submitted = query, + ), + )); + + expect(find.text('cat'), findsOneWidget); + expect(find.text('AND'), findsOneWidget); + expect(find.text('dog'), findsOneWidget); + + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + + expect(submitted, 'cat and dog'); + }); + + testWidgets('applying suggestion preserves preceding tokens and AND operator', + (tester) async { + String applied = ''; + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + suggestions: const [ + TagSuggestion( + name: 'dog', + category: TagCategory.general, + postCount: 50, + providerId: 'test', + ), + ], + onSuggestionApplied: (query) => applied = query, + onSubmitted: (_) {}, + ), + )); + + await tester.enterText(find.byType(TextField), 'cat and d'); + await tester.pumpAndSettle(); + + expect(find.text('dog'), findsOneWidget); + await tester.tap(find.text('dog')); + await tester.pumpAndSettle(); + + expect(applied, 'cat and dog'); + expect(find.text('cat'), findsOneWidget); + expect(find.text('AND'), findsOneWidget); + expect(find.text('dog'), findsOneWidget); + }); } class _Harness extends StatelessWidget {