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 @@
-
+
@@ -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 {