Compare commits

...
5 Commits
23 changed files with 1058 additions and 384 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-2.3.0-blueviolet?style=for-the-badge" alt="Version 2.3.0" />
<img src="https://img.shields.io/badge/Version-2.3.1-blueviolet?style=for-the-badge" alt="Version 2.3.1" />
<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/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.3.0';
const appBuildNumber = 40;
const appDisplayVersion = '2.3.1';
const appBuildNumber = 41;
const appReleaseChannel = 'Stable';
+49
View File
@@ -1,4 +1,53 @@
const lunarisChangelog = [
LunarisChange(
version: '2.3.5',
title: 'Fullscreen Video Auto-Rotation & Orientation Controls',
bullets: [
'Smart Fullscreen Auto-Rotation: entering fullscreen mode on horizontal/landscape videos automatically snaps the device into landscape orientation for maximum viewing area, while vertical/portrait videos stay in portrait mode.',
'Manual Orientation Toggle: added a quick-access rotation button (Icons.screen_lock_portrait / landscape) directly in the fullscreen top bar next to the close button, allowing 1-tap switching between landscape and portrait.',
'Clean Orientation Recovery: exiting fullscreen seamlessly restores system navigation bars (Edge-to-Edge) and restores unrestricted device sensor orientations.',
],
),
LunarisChange(
version: '2.3.4',
title: 'OverlayPortal Architecture & Search UI Unblocking',
bullets: [
'OverlayPortal Migration: replaced manual root OverlayEntry and translucent backdrop with Flutter native OverlayPortal and TapRegion. Completely eliminates ghost overlay leaks and touch interception.',
'Unblocked Toolbar & Search Bar GUI: buttons (Refresh / Обновить, Clear Filters / Сбросить, random post, filter chips) and search chip crosses now respond immediately to taps without being swallowed by overlay backdrops.',
'Instant Chip Removal: deleting a tag chip (via its X cross icon) now updates the search immediately in real time, synchronizing the feed without restoring deleted tags on rebuild.',
'Search Clear Synchronization: tapping the search bar clear button (X) resets the query and clears active filters immediately.',
'Non-Blocking Outside Tap: tapping outside the suggestion dropdown smoothly dismisses suggestions while allowing the touch event to pass through directly to the underlying button or post.',
],
),
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',
bullets: [
'Official Gelbooru DAPI Alignment: fixed tag suggestions pattern to use SQL LIKE wildcard (%) instead of literal asterisk (*), restoring accurate tag autocompletion.',
'Popularity Tag Sorting: added orderby=count and order=DESC to Gelbooru tag search so the most relevant and widely used tags appear first.',
'Batch Tag Categorization: migrated post tag metadata queries to the official "names" parameter, resolving categories for up to 80 tags in a single batch request instead of dozens of sequential requests.',
'Throttling & Auth Guidance: enhanced API credentials helper text and informative error messages with direct instructions to configure API Key & User ID from Gelbooru account options.',
],
),
LunarisChange(
version: '2.3.1',
title: 'Cloud Mirrors & Author Announcements Fixes',
bullets: [
'Collapsible Cloud Mirrors: the cloud drives section is now folded by default into a sleek header card matching the Tags and Comments cards, expandable on tap.',
'Tag Panel Sanitation: filtered out cloud drive links, JSON metadata, and URLs from appearing in the post tags panel and tag counters.',
'Author Announcements HTML Cleaning: stripped all raw <p style="">, <strong>, and formatting artifacts from artist announcements, rendering clean readable text and clickable service link chips.',
],
),
LunarisChange(
version: '2.3.0',
title: 'Artist Cloud Drives & External Video Mirrors',
+1
View File
@@ -34,5 +34,6 @@ export 'services/settings_service.dart';
export 'services/update_service.dart';
export 'services/viewed_history_service.dart';
export 'services/backup_service.dart';
export 'services/cloud_link_extractor.dart';
export 'utils/smart_blacklist.dart';
export 'utils/media_quality.dart';
+20
View File
@@ -60,6 +60,26 @@ class Post {
return null;
}
List<String> get cleanTags {
return tags.where((tag) {
final t = tag.trim();
if (t.isEmpty) return false;
if (t == 'cloud_mirror') return false;
if (t.startsWith('{') && t.endsWith('}')) return false;
if (t.contains('"url"') || t.contains('"service"')) return false;
if (t.startsWith('http://') || t.startsWith('https://')) return false;
if (t.contains('mega.nz') ||
t.contains('drive.google.com') ||
t.contains('dropbox.com') ||
t.contains('pixeldrain.com') ||
t.contains('catbox.moe') ||
t.contains('mediafire.com')) {
return false;
}
return true;
}).toList(growable: false);
}
Post copyWith({
String? id,
String? providerId,
+82 -8
View File
@@ -65,6 +65,7 @@ class GelbooruProvider
if (rating != null && rating.isNotEmpty) 'rating:$rating',
..._topTags(topPeriod),
];
try {
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
@@ -78,15 +79,20 @@ class GelbooruProvider
..._queryParameters,
},
);
_checkResponse(response);
return GelbooruMapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
} on DioException catch (e) {
_handleDioError(e);
}
}
@override
Future<Post?> getPost(String id) async {
try {
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
@@ -98,12 +104,16 @@ class GelbooruProvider
..._queryParameters,
},
);
_checkResponse(response);
final posts = GelbooruMapper.postsFromResponse(
response.data,
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
} on DioException catch (e) {
_handleDioError(e);
}
}
@override
@@ -142,6 +152,7 @@ class GelbooruProvider
@override
Future<List<PostComment>> getComments(String postId) async {
try {
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
@@ -154,6 +165,9 @@ class GelbooruProvider
},
);
return _commentsFromResponse(response.data, postId);
} catch (_) {
return const [];
}
}
@override
@@ -161,6 +175,7 @@ class GelbooruProvider
{int limit = 20}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
try {
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
@@ -168,7 +183,9 @@ class GelbooruProvider
's': 'tag',
'q': 'index',
'json': '1',
'name_pattern': '$trimmed*',
'name_pattern': '$trimmed%',
'orderby': 'count',
'order': 'DESC',
'limit': limit.clamp(1, 100),
..._queryParameters,
},
@@ -188,6 +205,9 @@ class GelbooruProvider
})
.where((tag) => tag.name.isNotEmpty)
.toList(growable: false);
} catch (_) {
return const [];
}
}
@override
@@ -201,11 +221,43 @@ class GelbooruProvider
if (cleaned.isEmpty) return const {};
final groups = <String, List<String>>{};
for (final tag in cleaned) {
final tagToCategory = <String, TagCategory>{};
// Batch query tag metadata using Gelbooru DAPI "names" parameter
try {
final suggestions = await suggestTags(tag, limit: 3);
final exact = suggestions.where((item) => item.name == tag).firstOrNull;
final category = exact?.category ?? TagCategory.general;
for (var i = 0; i < cleaned.length; i += 50) {
final chunk = cleaned.sublist(
i,
(i + 50 > cleaned.length) ? cleaned.length : i + 50,
);
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'tag',
'q': 'index',
'json': '1',
'names': chunk.join(' '),
'limit': chunk.length.clamp(1, 100),
..._queryParameters,
},
);
for (final item in _tagItems(response.data).whereType<Map>()) {
final json = Map<String, dynamic>.from(item);
final name = (json['name'] ?? '').toString().trim();
if (name.isNotEmpty) {
tagToCategory[name] = tagCategoryFromString(
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
);
}
}
}
} catch (_) {
// Gracefully continue to fallback categorization
}
for (final tag in cleaned) {
final category = tagToCategory[tag] ?? TagCategory.general;
final key = switch (category) {
TagCategory.artist => 'artist',
TagCategory.copyright => 'copyright',
@@ -215,13 +267,35 @@ class GelbooruProvider
TagCategory.general || TagCategory.unknown => 'general',
};
groups.putIfAbsent(key, () => <String>[]).add(tag);
} catch (_) {
groups.putIfAbsent('general', () => <String>[]).add(tag);
}
}
return groups;
}
void _checkResponse(Response<dynamic> response) {
if (response.data is String) {
final text = (response.data as String).toLowerCase();
if (text.contains('throttled') ||
text.contains('authentication required') ||
text.contains('requires authentication')) {
unavailable(
'$name requires authentication or is throttled. '
'Please configure API Key & User ID in Settings -> Providers.',
);
}
}
}
Never _handleDioError(DioException error) {
final status = error.response?.statusCode;
if (status == 401 || status == 403 || status == 429) {
unavailable(
'$name requires authentication or rate limit exceeded ($status). '
'Please enter your API Key and User ID in Settings -> Providers.',
);
}
throw error;
}
Never unavailable(String message) =>
throw ProviderUnavailableException(message);
+118
View File
@@ -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,6 +39,7 @@ class Rule34Provider extends GelbooruProvider {
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
TopPeriodFilter.allTime => const ['sort:score:desc'],
};
try {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
@@ -45,6 +49,7 @@ class Rule34Provider extends GelbooruProvider {
'json': '1',
'pid': page,
'limit': limit,
'fields': 'tag_info',
'tags': [
...tags,
if (rating != null) 'rating:$rating',
@@ -53,10 +58,123 @@ class Rule34Provider extends GelbooruProvider {
...queryParameters,
},
);
_checkRule34Response(response);
return Rule34Mapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
} on DioException catch (e) {
_handleRule34DioError(e);
}
}
@override
Future<Post?> getPost(String id) async {
try {
final response = await dio.get<dynamic>(
'/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<List<TagSuggestion>> 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<dynamic>(
'/autocomplete.php',
queryParameters: {
'q': trimmed,
},
);
if (response.data is List) {
final list = response.data as List;
final suggestions = list
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.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<dynamic> 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;
}
}
+35 -5
View File
@@ -292,19 +292,49 @@ class CloudLinkExtractor {
return null;
}
/// Extracts clean text (stripping HTML tags) suitable for author commentary display.
/// Extracts clean, readable text (stripping HTML tags and formatting links) suitable for author commentary and announcements.
static String cleanCommentary(String? rawHtml) {
if (rawHtml == null || rawHtml.trim().isEmpty) return '';
var text = rawHtml
var text = rawHtml;
// Format anchor tags: <a href="URL">TEXT</a>
text = text.replaceAllMapped(
RegExp(r"""<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""",
caseSensitive: false, dotAll: true),
(match) {
final href = match.group(1)?.trim() ?? '';
final label = match.group(2)?.replaceAll(RegExp(r'<[^>]*>'), '').trim() ?? '';
if (label.isEmpty || label == href || href.contains(label)) {
return href;
}
return '$label ($href)';
},
);
// Convert line breaks and paragraph endings
text = text
.replaceAll(RegExp(r'<br\s*\/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<\/p>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'<[^>]*>'), '')
.replaceAll(RegExp(r'<\/p\s*>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'<\/div\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<li\s*>', caseSensitive: false), '• ')
.replaceAll(RegExp(r'<\/li\s*>', caseSensitive: false), '\n');
// Strip remaining HTML tags
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
// Decode HTML entities
text = text
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'");
// Normalize multiple consecutive blank lines
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return text.trim();
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/responsive.dart';
@@ -230,6 +231,9 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
separatorBuilder: (_, __) => const Divider(height: 24),
itemBuilder: (context, index) {
final a = _announcements[index];
final cleanContent = CloudLinkExtractor.cleanCommentary(a.content);
final detectedLinks = CloudLinkExtractor.extractLinks(content: a.content);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -244,9 +248,35 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
),
const SizedBox(height: 6),
SelectableText(
a.content,
style: theme.textTheme.bodyMedium,
cleanContent.isNotEmpty ? cleanContent : a.content,
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.45,
),
),
if (detectedLinks.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 6,
children: detectedLinks.map((link) {
return ActionChip(
avatar: Icon(
link.iconData,
size: 16,
color: link.brandColor,
),
label: Text(
link.title.isNotEmpty ? link.title : link.serviceName,
style: const TextStyle(fontSize: 12),
),
onPressed: () => launchUrl(
Uri.parse(link.url),
mode: LaunchMode.externalApplication,
),
);
}).toList(),
),
],
],
);
},
@@ -98,6 +98,14 @@ class _FeedToolbarState extends State<FeedToolbar> {
_query = value;
widget.onSuggestionTap(value);
},
onTagRemoved: (value) {
_query = value;
widget.onSearch(value);
},
onCleared: () {
_query = '';
widget.onClearFilters();
},
),
),
const SizedBox(width: 8),
@@ -221,6 +229,14 @@ class _FeedToolbarState extends State<FeedToolbar> {
_query = value;
widget.onSuggestionTap(value);
},
onTagRemoved: (value) {
_query = value;
widget.onSearch(value);
},
onCleared: () {
_query = '';
widget.onClearFilters();
},
),
),
const SizedBox(width: 6),
@@ -339,7 +339,7 @@ class PostDetailsScreen extends ConsumerWidget {
),
const SizedBox(width: 8),
Text(
'${strings.tags} (${post.tags.length})',
'${strings.tags} (${post.cleanTags.length})',
style: Theme.of(context)
.textTheme
.titleMedium
@@ -515,7 +515,7 @@ class PostDetailsScreen extends ConsumerWidget {
color: Theme.of(context).colorScheme.primary,
),
title: Text(
'${strings.tags} (${post.tags.length})',
'${strings.tags} (${post.cleanTags.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -13,6 +13,7 @@ class CloudMirrorsCard extends StatelessWidget {
this.commentary,
this.onPlayStream,
this.onDownloadStream,
this.initiallyExpanded = false,
});
final List<CloudMediaLink> links;
@@ -20,6 +21,7 @@ class CloudMirrorsCard extends StatelessWidget {
final String? commentary;
final ValueChanged<String>? onPlayStream;
final ValueChanged<String>? onDownloadStream;
final bool initiallyExpanded;
@override
Widget build(BuildContext context) {
@@ -36,57 +38,40 @@ class CloudMirrorsCard extends StatelessWidget {
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.2),
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
clipBehavior: Clip.antiAlias,
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
initiallyExpanded: initiallyExpanded,
shape: const Border(),
collapsedShape: const Border(),
leading: Icon(
Icons.cloud_sync_rounded,
size: 20,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
title: Row(
children: [
Text(
Expanded(
child: Text(
strings.ru
? 'Облачные диски и зеркала'
: 'Cloud Mirrors & Drives',
style: theme.textTheme.titleMedium?.copyWith(
? 'Облачные диски и зеркала (${links.length})'
: 'Cloud Mirrors & Drives (${links.length})',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
strings.ru
? '${links.length} внешних источников'
: '${links.length} external sources',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (links.any((l) => l.isStreamable))
if (links.any((l) => l.isStreamable)) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
@@ -96,24 +81,25 @@ class CloudMirrorsCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_circle_fill_rounded,
size: 14, color: Colors.green),
size: 13, color: Colors.green),
const SizedBox(width: 4),
Text(
strings.ru ? 'Плеер доступен' : 'Streamable',
strings.ru ? 'Плеер' : 'Stream',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
],
],
),
children: [
// Archive Password Banner
if (detectedPassword.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
@@ -166,22 +152,22 @@ class CloudMirrorsCard extends StatelessWidget {
],
),
),
const SizedBox(height: 10),
],
// Links list
if (links.isNotEmpty) ...[
const SizedBox(height: 14),
...links.map((link) => _buildLinkTile(context, link, strings)),
],
// Commentary / Description
if (commentary != null && commentary!.trim().isNotEmpty) ...[
const SizedBox(height: 14),
const SizedBox(height: 10),
Divider(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
height: 1,
),
const SizedBox(height: 12),
const SizedBox(height: 10),
Row(
children: [
Icon(
@@ -701,6 +701,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
late bool _muted;
late bool _loopVideo;
late bool _halfVolume;
late bool _isLandscape;
Timer? _hideTimer;
@override
@@ -710,13 +711,30 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
_muted = widget.muted;
_loopVideo = widget.loopVideo;
_halfVolume = widget.halfVolume;
_isLandscape = widget.aspectRatio > 1.05;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_applyOrientation();
_scheduleControlsHide();
}
void _applyOrientation() {
if (_isLandscape) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
_scheduleControlsHide();
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
}
void _toggleOrientation() {
setState(() => _isLandscape = !_isLandscape);
_applyOrientation();
_showControls();
}
@override
@@ -814,7 +832,20 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
top: 12,
right: 12,
child: SafeArea(
child: IconButton.filledTonal(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: _isLandscape
? 'Портретная ориентация'
: 'Альбомная ориентация',
onPressed: _toggleOrientation,
icon: Icon(_isLandscape
? Icons.screen_lock_portrait_rounded
: Icons.screen_lock_landscape_rounded),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Close fullscreen',
onPressed: () => Navigator.of(
context,
@@ -822,6 +853,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
).pop(_snapshot()),
icon: const Icon(Icons.close_rounded),
),
],
),
),
),
],
@@ -219,6 +219,14 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
);
}
static const _ignoredTagGroupKeys = {
'cloud_links',
'description',
'content',
'links',
'external_links',
};
Map<String, List<String>> _groups(Post post) {
if (post.tagGroups.isNotEmpty) {
final ordered = <String, List<String>>{};
@@ -230,17 +238,40 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
'meta',
'general',
]) {
final tags = post.tagGroups[key];
if (tags != null && tags.isNotEmpty) ordered[key] = tags;
final tags = _filterValidTags(post.tagGroups[key]);
if (tags.isNotEmpty) ordered[key] = tags;
}
for (final entry in post.tagGroups.entries) {
if (entry.value.isNotEmpty) {
ordered.putIfAbsent(entry.key, () => entry.value);
if (_ignoredTagGroupKeys.contains(entry.key.toLowerCase())) continue;
final tags = _filterValidTags(entry.value);
if (tags.isNotEmpty) {
ordered.putIfAbsent(entry.key, () => tags);
}
}
return ordered;
}
return post.tags.isEmpty ? const {} : {'general': post.tags};
final filtered = _filterValidTags(post.tags);
return filtered.isEmpty ? const {} : {'general': filtered};
}
List<String> _filterValidTags(List<String>? rawTags) {
if (rawTags == null || rawTags.isEmpty) return const [];
return rawTags.where((tag) {
final t = tag.trim();
if (t.isEmpty) return false;
if (t.startsWith('{') && t.endsWith('}')) return false;
if (t.contains('"url"') || t.contains('"service"')) return false;
if (t.startsWith('http://') || t.startsWith('https://')) return false;
if (t.contains('mega.nz') ||
t.contains('drive.google.com') ||
t.contains('dropbox.com') ||
t.contains('pixeldrain.com') ||
t.contains('catbox.moe') ||
t.contains('mediafire.com')) {
return false;
}
return true;
}).toList(growable: false);
}
String _label(String key) {
@@ -129,7 +129,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _apiKey,
decoration: const InputDecoration(
labelText: 'API key',
helperText: 'Gelbooru: api_key, Danbooru: api_key',
helperText:
'Gelbooru/Rule34/Danbooru API key (Account -> Options)',
),
),
const SizedBox(height: 12),
@@ -137,7 +138,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _userId,
decoration: const InputDecoration(
labelText: 'User ID',
helperText: 'Gelbooru-compatible APIs may require user_id',
helperText:
'Gelbooru/Rule34 User ID (required for API access, see Options)',
),
),
const SizedBox(height: 12),
@@ -105,6 +105,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
onSuggestionApplied: (query) => ref
.read(searchControllerProvider.notifier)
.updateQuery(query),
onTagRemoved: (query) => ref
.read(searchControllerProvider.notifier)
.updateQuery(query),
onCleared: () => ref
.read(searchControllerProvider.notifier)
.updateQuery(''),
),
const SizedBox(height: 12),
SingleChildScrollView(
+168 -96
View File
@@ -28,6 +28,8 @@ class TagInputSearchBar extends StatefulWidget {
required this.onSubmitted,
this.onChanged,
this.onSuggestionApplied,
this.onTagRemoved,
this.onCleared,
this.initialValue,
this.hintText = 'Search tags',
this.suggestions = const [],
@@ -37,6 +39,8 @@ class TagInputSearchBar extends StatefulWidget {
final ValueChanged<String> onSubmitted;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSuggestionApplied;
final ValueChanged<String>? onTagRemoved;
final VoidCallback? onCleared;
final String? initialValue;
final String hintText;
final List<TagSuggestion> suggestions;
@@ -48,10 +52,10 @@ class TagInputSearchBar extends StatefulWidget {
class _TagInputSearchBarState extends State<TagInputSearchBar> {
final _fieldKey = GlobalKey();
final _layerLink = LayerLink();
final _portalController = OverlayPortalController();
late final TextEditingController _controller;
late final FocusNode _focusNode;
late final ScrollController _tagScrollController;
OverlayEntry? _suggestionsOverlay;
Timer? _debounce;
List<String> _tags = [];
String _lastExternalValue = '';
@@ -64,10 +68,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_focusNode = FocusNode();
_tagScrollController = ScrollController();
_focusNode.addListener(_handleFocusChanged);
_controller.addListener(_handleControllerChanged);
_lastExternalValue = widget.initialValue ?? '';
_setFromQuery(_lastExternalValue);
}
void _handleControllerChanged() {
if (_controller.text.trim().isEmpty && _portalController.isShowing) {
_portalController.hide();
}
}
@override
void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -80,15 +91,18 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = false;
}
if (oldWidget.suggestions != widget.suggestions) {
_syncSuggestionsOverlay();
_syncSuggestions();
}
}
@override
void dispose() {
_debounce?.cancel();
_removeSuggestionsOverlay();
if (_portalController.isShowing) {
_portalController.hide();
}
_tagScrollController.dispose();
_controller.removeListener(_handleControllerChanged);
_focusNode.removeListener(_handleFocusChanged);
_focusNode.dispose();
_controller.dispose();
@@ -97,24 +111,115 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleFocusChanged() {
if (!mounted) return;
if (!_focusNode.hasFocus && !_localDirty) {
if (!_focusNode.hasFocus) {
if (_portalController.isShowing) {
_portalController.hide();
}
if (!_localDirty) {
final next = widget.initialValue ?? '';
if (next != _lastExternalValue) {
_setFromQuery(next);
_lastExternalValue = next;
}
}
} else {
_syncSuggestions();
}
setState(() {});
_syncSuggestionsOverlay();
}
void _syncSuggestions() {
if (!mounted) return;
final token = _activeToken;
if (!_focusNode.hasFocus || token.isEmpty) {
if (_portalController.isShowing) {
_portalController.hide();
}
return;
}
final matches = _matchingSuggestions;
if (matches.isEmpty) {
if (_portalController.isShowing) {
_portalController.hide();
}
return;
}
if (!_portalController.isShowing) {
_portalController.show();
}
}
List<TagSuggestion> get _matchingSuggestions {
final token = _activeToken;
if (token.isEmpty) return const [];
return widget.suggestions
.where((item) => item.name.toLowerCase().startsWith(token))
.toList(growable: false);
}
void _hideSuggestions() {
if (_portalController.isShowing) {
_portalController.hide();
}
_focusNode.unfocus();
}
void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_tagScrollController.hasClients) {
_tagScrollController.animateTo(
_tagScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _syncSuggestionsOverlay();
});
return CompositedTransformTarget(
return TapRegion(
groupId: _fieldKey,
onTapOutside: (_) => _hideSuggestions(),
child: OverlayPortal(
controller: _portalController,
overlayChildBuilder: (context) {
final token = _activeToken;
final suggestions = _matchingSuggestions;
if (suggestions.isEmpty || token.isEmpty) {
return const SizedBox.shrink();
}
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
final width =
renderBox?.hasSize == true ? renderBox!.size.width : 360.0;
final height =
renderBox?.hasSize == true ? renderBox!.size.height : 48.0;
return CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: Offset(0, height + 6),
child: Align(
alignment: Alignment.topLeft,
child: TapRegion(
groupId: _fieldKey,
child: SizedBox(
width: width.clamp(280.0, 620.0),
child: _TagSuggestionDropdown(
suggestions: suggestions,
query: token,
onSelected: _applySuggestion,
),
),
),
),
);
},
child: CompositedTransformTarget(
link: _layerLink,
child: DecoratedBox(
key: _fieldKey,
@@ -133,11 +238,13 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
borderRadius: BorderRadius.circular(22),
onTap: _focusNode.requestFocus,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant),
Icon(Icons.search_rounded,
color: scheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
@@ -167,7 +274,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
focusNode: _focusNode,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.search,
textInputAction:
TextInputAction.search,
onSubmitted: (_) => _submit(),
onChanged: _handleDraftChanged,
decoration: InputDecoration(
@@ -176,11 +284,13 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
contentPadding: const EdgeInsets.symmetric(
contentPadding:
const EdgeInsets.symmetric(
vertical: 8,
),
hintText:
_tags.isEmpty ? widget.hintText : 'tag',
hintText: _tags.isEmpty
? widget.hintText
: 'tag',
prefixIcon: null,
suffixIcon: null,
),
@@ -204,41 +314,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
),
),
),
);
}
void _syncSuggestionsOverlay() {
final token = _activeToken;
final suggestions = widget.suggestions
.where((item) => item.name.toLowerCase().startsWith(token))
.toList(growable: false);
if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) {
_removeSuggestionsOverlay();
return;
}
final overlay = Overlay.maybeOf(context);
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (overlay == null || renderBox == null || !renderBox.hasSize) return;
final size = renderBox.size;
_removeSuggestionsOverlay();
_suggestionsOverlay = OverlayEntry(
builder: (context) => _TagSuggestionOverlay(
link: _layerLink,
width: size.width,
yOffset: size.height + 8,
suggestions: suggestions,
query: token,
onSelected: _applySuggestion,
),
),
);
overlay.insert(_suggestionsOverlay!);
}
void _removeSuggestionsOverlay() {
_suggestionsOverlay?.remove();
_suggestionsOverlay = null;
}
Widget _buildTagChip(BuildContext context, String tag) {
@@ -302,10 +380,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleDraftChanged(String value) {
_localDirty = true;
if (value.trim().isEmpty) {
_debounce?.cancel();
if (_portalController.isShowing) {
_portalController.hide();
}
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;
}
@@ -329,31 +414,47 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
_controller.clear();
});
if (_portalController.isShowing) {
_portalController.hide();
}
_scrollToEnd();
_notifyChanged();
}
void _submit() {
_debounce?.cancel();
if (_portalController.isShowing) {
_portalController.hide();
}
_commitDraft(_controller.text);
_removeSuggestionsOverlay();
_lastExternalValue = _query;
_localDirty = false;
_focusNode.unfocus();
widget.onSubmitted(_query);
}
void _clear() {
_debounce?.cancel();
_localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
_localDirty = false;
setState(() {
_tags = [];
_controller.clear();
});
_lastExternalValue = '';
_notifyChanged();
_focusNode.requestFocus();
_focusNode.unfocus();
widget.onCleared?.call();
widget.onSubmitted('');
}
void _editTag(String tag) {
_localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true);
_controller.value = TextEditingValue(
@@ -367,10 +468,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _removeTag(String tag) {
_localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true);
});
_lastExternalValue = _query;
_notifyChanged();
if (widget.onTagRemoved != null) {
widget.onTagRemoved!(_query);
}
}
void _applySuggestion(String suggestion) {
@@ -393,8 +501,12 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
_controller.clear();
});
if (_portalController.isShowing) {
_portalController.hide();
}
_lastExternalValue = _query;
_scrollToEnd();
_notifyChanged();
_removeSuggestionsOverlay();
widget.onSuggestionApplied?.call(_query);
_focusNode.requestFocus();
}
@@ -407,7 +519,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _notifyChanged() {
_localDirty = true;
widget.onChanged?.call(_query);
_syncSuggestionsOverlay();
_syncSuggestions();
}
List<String> _parseTags(String query) {
@@ -438,46 +550,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
bool get _isEditing => _focusNode.hasFocus || _localDirty;
}
class _TagSuggestionOverlay extends StatelessWidget {
const _TagSuggestionOverlay({
required this.link,
required this.width,
required this.yOffset,
required this.suggestions,
required this.query,
required this.onSelected,
});
final LayerLink link;
final double width;
final double yOffset;
final List<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> onSelected;
@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,
),
),
),
),
);
}
}
class _TagSuggestionDropdown extends StatelessWidget {
const _TagSuggestionDropdown({
required this.suggestions,
+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.3.0+40
version: 2.3.5+45
environment:
sdk: ">=3.3.0 <4.0.0"
@@ -85,6 +85,17 @@ void main() {
expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!');
});
test('cleans HTML commentary and announcements correctly without style artifacts', () {
const html =
'<p style=""><strong>Thank you!</strong> </p><p style="">Here\'s the link to the archive</p><p style=""><a href="https://www.patreon.com/posts/full-fugtrup-14581297" rel="noopener noreferrer nofollow">https://www.patreon.com/posts/full-fugtrup-14581297</a></p><p style="">and the link to my <strong>discord server</strong>, if you\'d like to join it <a href="https://discord.com/invite/xR2AbkX" rel="noopener noreferrer nofollow">https://discord.com/invite/xR2AbkX</a></p>';
final clean = CloudLinkExtractor.cleanCommentary(html);
expect(clean.contains('<p'), isFalse);
expect(clean.contains('style='), isFalse);
expect(clean.contains('<strong>'), isFalse);
expect(clean.contains('Thank you!'), isTrue);
expect(clean.contains('https://discord.com/invite/xR2AbkX'), isTrue);
});
test('encodes and decodes CloudMediaLink to and from JSON', () {
const link = CloudMediaLink(
url: 'https://mega.nz/file/123#abc',
+45
View File
@@ -0,0 +1,45 @@
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/gelbooru_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/core/http/dio_client.dart';
void main() {
test('ProviderFactory creates GelbooruProvider with query credentials', () {
final factory = ProviderFactory();
final config = ContentProviderConfig(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
apiType: 'gelbooru',
enabled: true,
priority: 10,
timeoutSeconds: 20,
customHeaders: const {
'query.api_key': 'test_api_key',
'query.user_id': '123456',
},
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
final provider = factory.create(config);
expect(provider, isA<GelbooruProvider>());
final gelbooru = provider as GelbooruProvider;
expect(gelbooru.id, 'gelbooru');
expect(gelbooru.queryParameters['api_key'], 'test_api_key');
expect(gelbooru.queryParameters['user_id'], '123456');
});
test('GelbooruProvider mediaHeaders contains Referer and User-Agent', () {
final client = DioClient(baseUrl: 'https://gelbooru.com');
final provider = GelbooruProvider(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
dioClient: client,
);
expect(provider.baseUrl, 'https://gelbooru.com');
});
}
+33
View File
@@ -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<Rule34Provider>());
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');
});
}
+3 -3
View File
@@ -35,6 +35,7 @@ void main() {
strings: const AppStrings('ru'),
commentary: 'Special Patreon bonus animation for all patrons.',
onPlayStream: (stream) => playedStream = stream,
initiallyExpanded: true,
),
),
),
@@ -42,9 +43,8 @@ void main() {
);
// Verify header and counts
expect(find.text('Облачные диски и зеркала'), findsOneWidget);
expect(find.text('2 внешних источников'), findsOneWidget);
expect(find.text('Плеер доступен'), findsOneWidget);
expect(find.text('Облачные диски и зеркала (2)'), findsOneWidget);
expect(find.text('Плеер'), findsOneWidget);
// Verify password banner
expect(find.textContaining('archive_pass_42'), findsOneWidget);
+117
View File
@@ -339,6 +339,123 @@ void main() {
expect(find.text('AND'), findsOneWidget);
expect(find.text('dog'), findsOneWidget);
});
testWidgets('deleting chip triggers onTagRemoved', (tester) async {
String? removedQuery;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'cat dog',
onSubmitted: (_) {},
onTagRemoved: (query) => removedQuery = query,
),
));
expect(find.text('cat'), findsOneWidget);
expect(find.text('dog'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close_rounded).first);
await tester.pump();
expect(removedQuery, 'dog');
expect(find.text('cat'), findsNothing);
expect(find.text('dog'), findsOneWidget);
});
testWidgets('clear button triggers onCleared and onSubmitted with empty string',
(tester) async {
bool cleared = false;
String? submitted;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'cat dog',
onSubmitted: (query) => submitted = query,
onCleared: () => cleared = true,
),
));
await tester.tap(find.byTooltip('Clear'));
await tester.pump();
expect(cleared, isTrue);
expect(submitted, '');
expect(find.text('cat'), findsNothing);
expect(find.text('dog'), findsNothing);
});
testWidgets(
'tapping external button while suggestions are visible triggers button and hides suggestions',
(tester) async {
bool externalButtonPressed = false;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'genshin',
category: TagCategory.copyright,
postCount: 100,
providerId: 'test',
),
],
onSubmitted: (_) {},
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => externalButtonPressed = true,
child: const Text('Обновить'),
),
],
),
),
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'gen');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsOneWidget);
// Tap external 'Обновить' button directly while suggestions are up
await tester.tap(find.text('Обновить'));
await tester.pumpAndSettle();
expect(externalButtonPressed, isTrue);
expect(find.text('genshin'), findsNothing);
});
testWidgets('erasing draft immediately hides suggestions', (tester) async {
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'genshin',
category: TagCategory.copyright,
postCount: 100,
providerId: 'test',
),
],
onSubmitted: (_) {},
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'gen');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsOneWidget);
await tester.enterText(find.byType(TextField), '');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsNothing);
});
}
class _Harness extends StatelessWidget {