diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 74f6cf0..1f77b38 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.3.3', + title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment', + bullets: [ + 'Tag Suggestions Hanging Fix: eliminated the bug where the tag suggestion card remained stuck on screen after clearing text or tapping outside. Added immediate dismissal on empty input, outside-tap backdrop dismissal, controller listener, and unfocus on submission.', + 'Rule34 Dedicated Autocomplete: connected Rule34 directly to its official fast /autocomplete.php?q= endpoint, displaying real-time tag suggestions with accurate post counts (e.g. braid (216,984)).', + 'Rule34 Tag Types (fields=tag_info): added tag_info field to post query for accurate tag categories per post.', + 'Rule34 Authentication & Outage Handling: added detection for "missing authentication", rate limits, and server overload/search down ("success": false) with informative guidance to enter API Key & User ID.', + ], + ), LunarisChange( version: '2.3.2', title: 'Gelbooru DAPI Compliance & Tag Query Optimization', diff --git a/lib/backend/providers/rule34_provider.dart b/lib/backend/providers/rule34_provider.dart index c75bb0a..f36ed03 100644 --- a/lib/backend/providers/rule34_provider.dart +++ b/lib/backend/providers/rule34_provider.dart @@ -1,5 +1,8 @@ +import 'package:dio/dio.dart'; + import '../mappers/rule34_mapper.dart'; import '../models/post.dart'; +import '../models/tag_suggestion.dart'; import '../models/top_period_filter.dart'; import 'gelbooru_provider.dart'; @@ -36,27 +39,142 @@ class Rule34Provider extends GelbooruProvider { TopPeriodFilter.month || TopPeriodFilter.year => const [], TopPeriodFilter.allTime => const ['sort:score:desc'], }; - final response = await dio.get( - '/index.php', - queryParameters: { - 'page': 'dapi', - 's': 'post', - 'q': 'index', - 'json': '1', - 'pid': page, - 'limit': limit, - 'tags': [ - ...tags, - if (rating != null) 'rating:$rating', - ...topTags, - ].join(' '), - ...queryParameters, - }, - ); - return Rule34Mapper.postsFromResponse( - response.data, - providerId: id, - providerName: name, - ); + try { + final response = await dio.get( + '/index.php', + queryParameters: { + 'page': 'dapi', + 's': 'post', + 'q': 'index', + 'json': '1', + 'pid': page, + 'limit': limit, + 'fields': 'tag_info', + 'tags': [ + ...tags, + if (rating != null) 'rating:$rating', + ...topTags, + ].join(' '), + ...queryParameters, + }, + ); + _checkRule34Response(response); + return Rule34Mapper.postsFromResponse( + response.data, + providerId: id, + providerName: name, + ); + } on DioException catch (e) { + _handleRule34DioError(e); + } + } + + @override + Future getPost(String id) async { + try { + final response = await dio.get( + '/index.php', + queryParameters: { + 'page': 'dapi', + 's': 'post', + 'q': 'index', + 'json': '1', + 'id': id, + 'fields': 'tag_info', + ...queryParameters, + }, + ); + _checkRule34Response(response); + final posts = Rule34Mapper.postsFromResponse( + response.data, + providerId: this.id, + providerName: name, + ); + return posts.isEmpty ? null : posts.first; + } on DioException catch (e) { + _handleRule34DioError(e); + } + } + + @override + Future> suggestTags(String query, + {int limit = 20}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + try { + // Official Rule34 autocomplete endpoint (fast, public, with exact post count) + final response = await dio.get( + '/autocomplete.php', + queryParameters: { + 'q': trimmed, + }, + ); + if (response.data is List) { + final list = response.data as List; + final suggestions = list + .whereType() + .map((item) { + final json = Map.from(item); + final rawLabel = (json['label'] ?? '').toString(); + final rawValue = (json['value'] ?? '').toString(); + // Parse label "braid (216984)" -> name: "braid", postCount: 216984 + final match = + RegExp(r'^(.*?)\s*\((\d+)\)$').firstMatch(rawLabel); + final name = (match?.group(1) ?? rawValue).trim(); + final postCount = int.tryParse(match?.group(2) ?? '') ?? 0; + return TagSuggestion( + name: name.isNotEmpty ? name : rawValue, + category: TagCategory.general, + postCount: postCount, + providerId: id, + ); + }) + .where((tag) => tag.name.isNotEmpty) + .take(limit) + .toList(growable: false); + if (suggestions.isNotEmpty) return suggestions; + } + } catch (_) { + // Gracefully fall back to standard DAPI tag search + } + return super.suggestTags(query, limit: limit); + } + + void _checkRule34Response(Response response) { + if (response.data is String) { + final text = (response.data as String).toLowerCase(); + if (text.contains('missing authentication') || + text.contains('throttled') || + text.contains('authentication required')) { + unavailable( + '$name requires authentication. ' + 'Please configure your API Key and User ID in Settings -> Providers ' + '(obtain from https://rule34.xxx/index.php?page=account&s=options).', + ); + } + if (text.contains('search down') || + text.contains('server is overloaded')) { + unavailable( + '$name searcher is temporarily down or overloaded. Please try again in a few minutes.', + ); + } + } else if (response.data is Map) { + final map = response.data as Map; + if (map['success'] == false || map['success'] == 'false') { + final msg = map['message'] ?? 'search down'; + unavailable('$name error: $msg'); + } + } + } + + Never _handleRule34DioError(DioException error) { + final status = error.response?.statusCode; + if (status == 401 || status == 403 || status == 429) { + unavailable( + '$name requires authentication or rate limit reached ($status). ' + 'Please enter your API Key and User ID in Settings -> Providers.', + ); + } + throw error; } } diff --git a/lib/features/providers/presentation/provider_form_screen.dart b/lib/features/providers/presentation/provider_form_screen.dart index a7c0210..16faacd 100644 --- a/lib/features/providers/presentation/provider_form_screen.dart +++ b/lib/features/providers/presentation/provider_form_screen.dart @@ -130,7 +130,7 @@ class _ProviderFormScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'API key', helperText: - 'Gelbooru/Danbooru API key (Gelbooru: Account -> Options)', + 'Gelbooru/Rule34/Danbooru API key (Account -> Options)', ), ), const SizedBox(height: 12), @@ -139,7 +139,7 @@ class _ProviderFormScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'User ID', helperText: - 'Gelbooru User ID (required when throttled, see profile)', + 'Gelbooru/Rule34 User ID (required for API access, see Options)', ), ), const SizedBox(height: 12), diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart index 073979a..e25259d 100644 --- a/lib/shared/widgets/app_search_bar.dart +++ b/lib/shared/widgets/app_search_bar.dart @@ -64,10 +64,17 @@ class _TagInputSearchBarState extends State { _focusNode = FocusNode(); _tagScrollController = ScrollController(); _focusNode.addListener(_handleFocusChanged); + _controller.addListener(_handleControllerChanged); _lastExternalValue = widget.initialValue ?? ''; _setFromQuery(_lastExternalValue); } + void _handleControllerChanged() { + if (_controller.text.trim().isEmpty && _suggestionsOverlay != null) { + _removeSuggestionsOverlay(); + } + } + @override void didUpdateWidget(covariant TagInputSearchBar oldWidget) { super.didUpdateWidget(oldWidget); @@ -89,6 +96,7 @@ class _TagInputSearchBarState extends State { _debounce?.cancel(); _removeSuggestionsOverlay(); _tagScrollController.dispose(); + _controller.removeListener(_handleControllerChanged); _focusNode.removeListener(_handleFocusChanged); _focusNode.dispose(); _controller.dispose(); @@ -97,15 +105,19 @@ class _TagInputSearchBarState extends State { void _handleFocusChanged() { if (!mounted) return; - if (!_focusNode.hasFocus && !_localDirty) { - final next = widget.initialValue ?? ''; - if (next != _lastExternalValue) { - _setFromQuery(next); - _lastExternalValue = next; + if (!_focusNode.hasFocus) { + _removeSuggestionsOverlay(); + if (!_localDirty) { + final next = widget.initialValue ?? ''; + if (next != _lastExternalValue) { + _setFromQuery(next); + _lastExternalValue = next; + } } + } else { + _syncSuggestionsOverlay(); } setState(() {}); - _syncSuggestionsOverlay(); } @override @@ -209,17 +221,24 @@ class _TagInputSearchBarState extends State { void _syncSuggestionsOverlay() { final token = _activeToken; + if (!_focusNode.hasFocus || token.isEmpty) { + _removeSuggestionsOverlay(); + return; + } final suggestions = widget.suggestions .where((item) => item.name.toLowerCase().startsWith(token)) .toList(growable: false); - if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) { + if (suggestions.isEmpty) { _removeSuggestionsOverlay(); return; } final overlay = Overlay.maybeOf(context); final renderBox = _fieldKey.currentContext?.findRenderObject() as RenderBox?; - if (overlay == null || renderBox == null || !renderBox.hasSize) return; + if (overlay == null || renderBox == null || !renderBox.hasSize) { + _removeSuggestionsOverlay(); + return; + } final size = renderBox.size; _removeSuggestionsOverlay(); @@ -231,14 +250,22 @@ class _TagInputSearchBarState extends State { suggestions: suggestions, query: token, onSelected: _applySuggestion, + onDismiss: () { + _removeSuggestionsOverlay(); + _focusNode.unfocus(); + }, ), ); overlay.insert(_suggestionsOverlay!); } void _removeSuggestionsOverlay() { - _suggestionsOverlay?.remove(); - _suggestionsOverlay = null; + if (_suggestionsOverlay != null) { + if (_suggestionsOverlay!.mounted) { + _suggestionsOverlay!.remove(); + } + _suggestionsOverlay = null; + } } Widget _buildTagChip(BuildContext context, String tag) { @@ -302,10 +329,15 @@ class _TagInputSearchBarState extends State { void _handleDraftChanged(String value) { _localDirty = true; + if (value.trim().isEmpty) { + _debounce?.cancel(); + _removeSuggestionsOverlay(); + setState(() {}); + _notifyChanged(); + return; + } final composing = _controller.value.composing; - if (!composing.isValid && - value.isNotEmpty && - RegExp(r'\s$').hasMatch(value)) { + if (!composing.isValid && RegExp(r'\s$').hasMatch(value)) { _commitDraft(value); return; } @@ -334,22 +366,24 @@ class _TagInputSearchBarState extends State { void _submit() { _debounce?.cancel(); - _commitDraft(_controller.text); _removeSuggestionsOverlay(); + _commitDraft(_controller.text); _lastExternalValue = _query; _localDirty = false; + _focusNode.unfocus(); widget.onSubmitted(_query); } void _clear() { _debounce?.cancel(); + _removeSuggestionsOverlay(); _localDirty = true; setState(() { _tags = []; _controller.clear(); }); _notifyChanged(); - _focusNode.requestFocus(); + _focusNode.unfocus(); } void _editTag(String tag) { @@ -446,6 +480,7 @@ class _TagSuggestionOverlay extends StatelessWidget { required this.suggestions, required this.query, required this.onSelected, + required this.onDismiss, }); final LayerLink link; @@ -454,26 +489,37 @@ class _TagSuggestionOverlay extends StatelessWidget { final List suggestions; final String query; final ValueChanged onSelected; + final VoidCallback onDismiss; @override Widget build(BuildContext context) { - return Positioned.fill( - child: CompositedTransformFollower( - link: link, - showWhenUnlinked: false, - offset: Offset(0, yOffset), - child: Align( - alignment: Alignment.topLeft, - child: SizedBox( - width: width.clamp(280, 620).toDouble(), - child: _TagSuggestionDropdown( - suggestions: suggestions, - query: query, - onSelected: onSelected, + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onDismiss, + ), + ), + Positioned.fill( + child: CompositedTransformFollower( + link: link, + showWhenUnlinked: false, + offset: Offset(0, yOffset), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width.clamp(280, 620).toDouble(), + child: _TagSuggestionDropdown( + suggestions: suggestions, + query: query, + onSelected: onSelected, + ), + ), ), ), ), - ), + ], ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 11dadc8..c4d1376 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.3.2+42 +version: 2.3.3+43 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/rule34_provider_test.dart b/test/backend/rule34_provider_test.dart new file mode 100644 index 0000000..af7bbef --- /dev/null +++ b/test/backend/rule34_provider_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/content_provider_config.dart'; +import 'package:gel_rule_app/backend/providers/provider_factory.dart'; +import 'package:gel_rule_app/backend/providers/rule34_provider.dart'; + +void main() { + test('ProviderFactory creates Rule34Provider with credentials', () { + final factory = ProviderFactory(); + final config = ContentProviderConfig( + id: 'rule34', + name: 'Rule34', + baseUrl: 'https://api.rule34.xxx', + apiType: 'rule34', + enabled: true, + priority: 1, + timeoutSeconds: 20, + customHeaders: const { + 'query.api_key': 'test_key', + 'query.user_id': '99999', + }, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + final provider = factory.create(config); + expect(provider, isA()); + final r34 = provider as Rule34Provider; + expect(r34.id, 'rule34'); + expect(r34.baseUrl, 'https://api.rule34.xxx'); + expect(r34.queryParameters['api_key'], 'test_key'); + expect(r34.queryParameters['user_id'], '99999'); + }); +}