fix: preserve AND tag operator during search and suggestion draft completions (v2.0.10)

This commit is contained in:
2026-09-03 22:17:55 +03:00
parent 5560332b66
commit 7e384b1be7
8 changed files with 128 additions and 26 deletions
+4 -4
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Version-2.0.9-blueviolet?style=for-the-badge" alt="Version 2.0.9" /> <img src="https://img.shields.io/badge/Version-2.0.10-blueviolet?style=for-the-badge" alt="Version 2.0.10" />
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" /> <img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" /> <img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" /> <img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
@@ -69,9 +69,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.9.apk` - **Android**: `Lunaris-v2.0.10.apk`
- **Linux (x64)**: `Lunaris-v2.0.9-linux-x64.tar.gz` - **Linux (x64)**: `Lunaris-v2.0.10-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.9.exe` / `LunarisPortable-v2.0.9.zip` - **Windows**: `LunarisSetup-v2.0.10.exe` / `LunarisPortable-v2.0.10.zip`
--- ---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.9'; const appDisplayVersion = '2.0.10';
const appBuildNumber = 31; const appBuildNumber = 32;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+10
View File
@@ -1,4 +1,14 @@
const lunarisChangelog = [ 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( LunarisChange(
version: '2.0.9', version: '2.0.9',
title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting', title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting',
+9 -7
View File
@@ -28,13 +28,15 @@ class SearchService {
} }
List<String> parseTags(String query) { List<String> parseTags(String query) {
return query final seen = <String>{};
.trim() final result = <String>[];
.split(RegExp(r'\s+')) for (final raw in query.trim().split(RegExp(r'\s+'))) {
.map(sanitizeToken) final tag = raw.trim();
.where((tag) => tag.isNotEmpty && tag != 'and') if (tag.isEmpty) continue;
.toSet() if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
.toList(); result.add(tag);
}
return result;
} }
Future<Result<void>> saveSearch( Future<Result<void>> saveSearch(
+40 -12
View File
@@ -242,18 +242,34 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
Widget _buildTagChip(BuildContext context, String tag) { Widget _buildTagChip(BuildContext context, String tag) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final isAnd = tag.toLowerCase() == 'and';
return InputChip( return InputChip(
label: Text(tag), label: Text(
avatar: const Icon(Icons.tag_rounded, size: 16), 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), deleteIcon: const Icon(Icons.close_rounded, size: 16),
onPressed: () => _editTag(tag), onPressed: () => _editTag(tag),
onDeleted: () => _removeTag(tag), onDeleted: () => _removeTag(tag),
backgroundColor: scheme.primaryContainer.withValues(alpha: 0.54), backgroundColor: isAnd
labelStyle: TextStyle( ? scheme.secondaryContainer.withValues(alpha: 0.7)
color: scheme.onPrimaryContainer, : scheme.primaryContainer.withValues(alpha: 0.54),
fontWeight: FontWeight.w700, 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, visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
); );
@@ -306,7 +322,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = true; _localDirty = true;
setState(() { setState(() {
for (final tag in additions) { for (final tag in additions) {
if (!_tags.contains(tag)) _tags.add(tag); if (tag.toLowerCase() == 'and' || !_tags.contains(tag)) {
_tags.add(tag);
}
} }
_controller.clear(); _controller.clear();
}); });
@@ -359,10 +377,19 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = true; _localDirty = true;
setState(() { setState(() {
if (draft.isNotEmpty) { if (draft.isNotEmpty) {
final draftTags = _parseTags(draft); final tokens = _parseTags(draft);
_tags.removeWhere(draftTags.contains); 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(); _controller.clear();
}); });
_notifyChanged(); _notifyChanged();
@@ -387,7 +414,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
final result = <String>[]; final result = <String>[];
for (final raw in query.trim().split(RegExp(r'\s+'))) { for (final raw in query.trim().split(RegExp(r'\s+'))) {
final tag = raw.trim(); 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); result.add(tag);
} }
return result; return result;
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.0.9+31 version: 2.0.10+32
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+11
View File
@@ -43,6 +43,17 @@ void main() {
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']); 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 { test('recent searches are ordered by searchedAt', () async {
await service.saveSearch('old', 1); await service.saveSearch('old', 1);
await Future<void>.delayed(const Duration(milliseconds: 1)); await Future<void>.delayed(const Duration(milliseconds: 1));
@@ -288,6 +288,57 @@ void main() {
expect(size.height, lessThan(96)); expect(size.height, lessThan(96));
expect(find.text('touhou'), findsOneWidget); 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 { class _Harness extends StatelessWidget {