Compare commits

..
6 Commits
27 changed files with 2350 additions and 317 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Version-2.2.0-blueviolet?style=for-the-badge" alt="Version 2.2.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/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" />
+1
View File
@@ -36,6 +36,7 @@ class AppStrings {
String get downloading => ru ? 'Скачивается' : 'Downloading'; String get downloading => ru ? 'Скачивается' : 'Downloading';
String get failed => ru ? 'Ошибка' : 'Failed'; String get failed => ru ? 'Ошибка' : 'Failed';
String get open => ru ? 'Открыть' : 'Open'; String get open => ru ? 'Открыть' : 'Open';
String get copyLink => ru ? 'Копировать ссылку' : 'Copy link';
String get hidePost => ru ? 'Скрыть пост' : 'Hide post'; String get hidePost => ru ? 'Скрыть пост' : 'Hide post';
String get hiddenLocally => String get hiddenLocally =>
ru ? 'Пост скрыт локально' : 'Post hidden locally'; ru ? 'Пост скрыт локально' : 'Post hidden locally';
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.2.0'; const appDisplayVersion = '2.3.1';
const appBuildNumber = 39; const appBuildNumber = 41;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+61
View File
@@ -1,4 +1,65 @@
const lunarisChangelog = [ 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',
bullets: [
'Cloud Mirrors & Drives: automatic detection and parsing of external links to Google Drive, MEGA, Dropbox, Pixeldrain, Catbox, MediaFire, GoFile, Bunkr, and direct video files from post descriptions, HTML, and embed data.',
'Direct Video Playback & Download: resolves direct streaming URLs for Dropbox (?raw=1), Google Drive, Pixeldrain API, and Catbox with 1-tap playback and direct downloads.',
'Cloud Mirrors Card: styled cards with service branding colors (MEGA Red, Google Drive Blue, Dropbox Blue, Pixeldrain Cyan) showing link type, direct stream button, external open, and copy link.',
'Link-Only Posts Support: artist posts that only contain cloud drive links without direct server files are now displayed with an elegant CloudMediaHero banner instead of an empty view.',
'Archive Password Detection: automatically extracts and displays archive passwords (Pass: ..., Password: ...) with 1-tap copy button.',
'Artist Commentary Card: formatted display of author notes, instructions, and text below post details.',
],
),
LunarisChange( LunarisChange(
version: '2.2.0', version: '2.2.0',
title: 'Offline Mode Overhaul & Post Details Screen Redesign', title: 'Offline Mode Overhaul & Post Details Screen Redesign',
+2
View File
@@ -7,6 +7,7 @@ export 'models/artist_work_query.dart';
export 'models/artist_tag.dart'; export 'models/artist_tag.dart';
export 'models/artist_link.dart'; export 'models/artist_link.dart';
export 'models/artist_announcement.dart'; export 'models/artist_announcement.dart';
export 'models/cloud_media_link.dart';
export 'models/download_task.dart'; export 'models/download_task.dart';
export 'models/downloaded_media.dart'; export 'models/downloaded_media.dart';
export 'models/favorite.dart'; export 'models/favorite.dart';
@@ -33,5 +34,6 @@ export 'services/settings_service.dart';
export 'services/update_service.dart'; export 'services/update_service.dart';
export 'services/viewed_history_service.dart'; export 'services/viewed_history_service.dart';
export 'services/backup_service.dart'; export 'services/backup_service.dart';
export 'services/cloud_link_extractor.dart';
export 'utils/smart_blacklist.dart'; export 'utils/smart_blacklist.dart';
export 'utils/media_quality.dart'; export 'utils/media_quality.dart';
+155
View File
@@ -0,0 +1,155 @@
import 'dart:convert';
import 'package:flutter/material.dart';
enum CloudServiceType {
googleDrive,
mega,
dropbox,
pixeldrain,
catbox,
mediafire,
bunkr,
gofile,
terabox,
genericVideo,
other,
}
class CloudMediaLink {
const CloudMediaLink({
required this.url,
required this.service,
required this.title,
this.directStreamUrl,
this.isFolder = false,
this.isStreamable = false,
this.detectedPassword,
});
final String url;
final CloudServiceType service;
final String title;
final String? directStreamUrl;
final bool isFolder;
final bool isStreamable;
final String? detectedPassword;
String get serviceName {
switch (service) {
case CloudServiceType.googleDrive:
return 'Google Drive';
case CloudServiceType.mega:
return 'MEGA';
case CloudServiceType.dropbox:
return 'Dropbox';
case CloudServiceType.pixeldrain:
return 'Pixeldrain';
case CloudServiceType.catbox:
return 'Catbox';
case CloudServiceType.mediafire:
return 'MediaFire';
case CloudServiceType.bunkr:
return 'Bunkr';
case CloudServiceType.gofile:
return 'GoFile';
case CloudServiceType.terabox:
return 'TeraBox';
case CloudServiceType.genericVideo:
return 'Direct Video';
case CloudServiceType.other:
return 'External Mirror';
}
}
Color get brandColor {
switch (service) {
case CloudServiceType.googleDrive:
return const Color(0xFF4285F4); // Google Blue
case CloudServiceType.mega:
return const Color(0xFFD9272E); // Mega Red
case CloudServiceType.dropbox:
return const Color(0xFF0061FF); // Dropbox Blue
case CloudServiceType.pixeldrain:
return const Color(0xFF00B4D8); // Cyan
case CloudServiceType.catbox:
return const Color(0xFFFF6F00); // Amber/Orange
case CloudServiceType.mediafire:
return const Color(0xFF1E88E5); // MediaFire Blue
case CloudServiceType.bunkr:
return const Color(0xFF9C27B0); // Purple
case CloudServiceType.gofile:
return const Color(0xFF26A69A); // Teal
case CloudServiceType.terabox:
return const Color(0xFF29B6F6); // Light Blue
case CloudServiceType.genericVideo:
return const Color(0xFFE91E63); // Pink
case CloudServiceType.other:
return const Color(0xFF78909C); // Slate
}
}
IconData get iconData {
if (isFolder) return Icons.folder_zip_rounded;
if (isStreamable || service == CloudServiceType.genericVideo) {
return Icons.play_circle_filled_rounded;
}
switch (service) {
case CloudServiceType.googleDrive:
return Icons.add_to_drive_rounded;
case CloudServiceType.mega:
return Icons.cloud_circle_rounded;
case CloudServiceType.dropbox:
return Icons.cloud_done_rounded;
case CloudServiceType.pixeldrain:
case CloudServiceType.catbox:
case CloudServiceType.mediafire:
case CloudServiceType.bunkr:
case CloudServiceType.gofile:
case CloudServiceType.terabox:
return Icons.cloud_download_rounded;
case CloudServiceType.genericVideo:
return Icons.videocam_rounded;
case CloudServiceType.other:
return Icons.link_rounded;
}
}
Map<String, dynamic> toJson() => {
'url': url,
'service': service.name,
'title': title,
if (directStreamUrl != null) 'directStreamUrl': directStreamUrl,
'isFolder': isFolder,
'isStreamable': isStreamable,
if (detectedPassword != null) 'detectedPassword': detectedPassword,
};
factory CloudMediaLink.fromJson(Map<String, dynamic> json) {
final serviceStr = (json['service'] ?? '').toString();
final service = CloudServiceType.values.firstWhere(
(e) => e.name == serviceStr,
orElse: () => CloudServiceType.other,
);
return CloudMediaLink(
url: (json['url'] ?? '').toString(),
service: service,
title: (json['title'] ?? '').toString(),
directStreamUrl: json['directStreamUrl']?.toString(),
isFolder: json['isFolder'] == true,
isStreamable: json['isStreamable'] == true,
detectedPassword: json['detectedPassword']?.toString(),
);
}
String encode() => jsonEncode(toJson());
static CloudMediaLink? tryDecode(String str) {
try {
final decoded = jsonDecode(str);
if (decoded is Map<String, dynamic>) {
return CloudMediaLink.fromJson(decoded);
}
} catch (_) {}
return null;
}
}
+45
View File
@@ -1,3 +1,6 @@
import 'cloud_media_link.dart';
import '../services/cloud_link_extractor.dart';
class Post { class Post {
const Post({ const Post({
required this.id, required this.id,
@@ -35,6 +38,48 @@ class Post {
String get cacheKey => '$providerId:$id'; String get cacheKey => '$providerId:$id';
List<CloudMediaLink> get cloudLinks {
final list = tagGroups['cloud_links'];
if (list != null && list.isNotEmpty) {
return list
.map((e) => CloudMediaLink.tryDecode(e))
.whereType<CloudMediaLink>()
.toList(growable: false);
}
if (source != null && source!.isNotEmpty) {
return CloudLinkExtractor.extractLinks(source: source);
}
return const [];
}
String? get commentary {
final list = tagGroups['description'];
if (list != null && list.isNotEmpty) {
return list.first;
}
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({ Post copyWith({
String? id, String? id,
String? providerId, String? providerId,
+82 -8
View File
@@ -65,6 +65,7 @@ class GelbooruProvider
if (rating != null && rating.isNotEmpty) 'rating:$rating', if (rating != null && rating.isNotEmpty) 'rating:$rating',
..._topTags(topPeriod), ..._topTags(topPeriod),
]; ];
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -78,15 +79,20 @@ class GelbooruProvider
..._queryParameters, ..._queryParameters,
}, },
); );
_checkResponse(response);
return GelbooruMapper.postsFromResponse( return GelbooruMapper.postsFromResponse(
response.data, response.data,
providerId: id, providerId: id,
providerName: name, providerName: name,
); );
} on DioException catch (e) {
_handleDioError(e);
}
} }
@override @override
Future<Post?> getPost(String id) async { Future<Post?> getPost(String id) async {
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -98,12 +104,16 @@ class GelbooruProvider
..._queryParameters, ..._queryParameters,
}, },
); );
_checkResponse(response);
final posts = GelbooruMapper.postsFromResponse( final posts = GelbooruMapper.postsFromResponse(
response.data, response.data,
providerId: this.id, providerId: this.id,
providerName: name, providerName: name,
); );
return posts.isEmpty ? null : posts.first; return posts.isEmpty ? null : posts.first;
} on DioException catch (e) {
_handleDioError(e);
}
} }
@override @override
@@ -142,6 +152,7 @@ class GelbooruProvider
@override @override
Future<List<PostComment>> getComments(String postId) async { Future<List<PostComment>> getComments(String postId) async {
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -154,6 +165,9 @@ class GelbooruProvider
}, },
); );
return _commentsFromResponse(response.data, postId); return _commentsFromResponse(response.data, postId);
} catch (_) {
return const [];
}
} }
@override @override
@@ -161,6 +175,7 @@ class GelbooruProvider
{int limit = 20}) async { {int limit = 20}) async {
final trimmed = query.trim(); final trimmed = query.trim();
if (trimmed.isEmpty) return const []; if (trimmed.isEmpty) return const [];
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -168,7 +183,9 @@ class GelbooruProvider
's': 'tag', 's': 'tag',
'q': 'index', 'q': 'index',
'json': '1', 'json': '1',
'name_pattern': '$trimmed*', 'name_pattern': '$trimmed%',
'orderby': 'count',
'order': 'DESC',
'limit': limit.clamp(1, 100), 'limit': limit.clamp(1, 100),
..._queryParameters, ..._queryParameters,
}, },
@@ -188,6 +205,9 @@ class GelbooruProvider
}) })
.where((tag) => tag.name.isNotEmpty) .where((tag) => tag.name.isNotEmpty)
.toList(growable: false); .toList(growable: false);
} catch (_) {
return const [];
}
} }
@override @override
@@ -201,11 +221,43 @@ class GelbooruProvider
if (cleaned.isEmpty) return const {}; if (cleaned.isEmpty) return const {};
final groups = <String, List<String>>{}; final groups = <String, List<String>>{};
for (final tag in cleaned) { final tagToCategory = <String, TagCategory>{};
// Batch query tag metadata using Gelbooru DAPI "names" parameter
try { try {
final suggestions = await suggestTags(tag, limit: 3); for (var i = 0; i < cleaned.length; i += 50) {
final exact = suggestions.where((item) => item.name == tag).firstOrNull; final chunk = cleaned.sublist(
final category = exact?.category ?? TagCategory.general; 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) { final key = switch (category) {
TagCategory.artist => 'artist', TagCategory.artist => 'artist',
TagCategory.copyright => 'copyright', TagCategory.copyright => 'copyright',
@@ -215,13 +267,35 @@ class GelbooruProvider
TagCategory.general || TagCategory.unknown => 'general', TagCategory.general || TagCategory.unknown => 'general',
}; };
groups.putIfAbsent(key, () => <String>[]).add(tag); groups.putIfAbsent(key, () => <String>[]).add(tag);
} catch (_) {
groups.putIfAbsent('general', () => <String>[]).add(tag);
}
} }
return groups; 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) => Never unavailable(String message) =>
throw ProviderUnavailableException(message); throw ProviderUnavailableException(message);
+57 -2
View File
@@ -10,6 +10,7 @@ import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
import '../models/provider_health.dart'; import '../models/provider_health.dart';
import '../models/top_period_filter.dart'; import '../models/top_period_filter.dart';
import '../services/cloud_link_extractor.dart';
import 'content_provider.dart'; import 'content_provider.dart';
class KemonoProvider class KemonoProvider
@@ -361,6 +362,18 @@ class KemonoProvider
_dateFromAny(json['added']) ?? _dateFromAny(json['added']) ??
_dateFromAny(json['edited']) ?? _dateFromAny(json['edited']) ??
DateTime.fromMillisecondsSinceEpoch(0); DateTime.fromMillisecondsSinceEpoch(0);
final postSource =
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId';
final rawContent = (json['content'] ?? '').toString();
final embed = json['embed'] is Map ? Map<String, dynamic>.from(json['embed']) : null;
final cloudLinks = CloudLinkExtractor.extractLinks(
content: rawContent,
source: postSource,
embed: embed,
);
final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false);
final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent);
final files = <Map<String, dynamic>>[]; final files = <Map<String, dynamic>>[];
final file = json['file']; final file = json['file'];
if (file is Map && file.isNotEmpty) { if (file is Map && file.isNotEmpty) {
@@ -370,6 +383,47 @@ class KemonoProvider
if (attachments is List) { if (attachments is List) {
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from)); files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
} }
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) {
final stableId = '${query.service}:${query.artistId}:$postId:0';
final firstStreamable =
cloudLinks.where((l) => l.isStreamable).firstOrNull;
final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null;
final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? '';
final tags = [
query.service,
query.artistName,
if (title.trim().isNotEmpty) title.trim(),
'cloud_mirror',
];
return [
Post(
id: stableId,
providerId: id,
providerName: name,
previewUrl: '',
sampleUrl: '',
fileUrl: fileUrl,
tags: tags,
rating: 'unknown',
width: 0,
height: 0,
source: postSource,
createdAt: published,
fileType: firstStreamable != null ? 'video' : 'link',
score: 0,
tagGroups: {
'artist': [query.artistName],
'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
},
),
];
}
var index = 0; var index = 0;
return files return files
.map((file) { .map((file) {
@@ -396,8 +450,7 @@ class KemonoProvider
rating: 'unknown', rating: 'unknown',
width: 0, width: 0,
height: 0, height: 0,
source: source: postSource,
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
createdAt: published, createdAt: published,
fileType: type, fileType: type,
score: 0, score: 0,
@@ -405,6 +458,8 @@ class KemonoProvider
'artist': [query.artistName], 'artist': [query.artistName],
'meta': [query.service], 'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()], if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
}, },
); );
}) })
+56 -2
View File
@@ -12,6 +12,7 @@ import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
import '../models/provider_health.dart'; import '../models/provider_health.dart';
import '../models/top_period_filter.dart'; import '../models/top_period_filter.dart';
import '../services/cloud_link_extractor.dart';
import 'content_provider.dart'; import 'content_provider.dart';
class PawchiveProvider class PawchiveProvider
@@ -394,6 +395,18 @@ class PawchiveProvider
_dateFromAny(json['added']) ?? _dateFromAny(json['added']) ??
_dateFromAny(json['edited']) ?? _dateFromAny(json['edited']) ??
DateTime.fromMillisecondsSinceEpoch(0); DateTime.fromMillisecondsSinceEpoch(0);
final postSource =
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId';
final rawContent = (json['content'] ?? '').toString();
final embed = json['embed'] is Map ? Map<String, dynamic>.from(json['embed']) : null;
final cloudLinks = CloudLinkExtractor.extractLinks(
content: rawContent,
source: postSource,
embed: embed,
);
final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false);
final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent);
final files = <Map<String, dynamic>>[]; final files = <Map<String, dynamic>>[];
final file = json['file']; final file = json['file'];
if (file is Map && file.isNotEmpty) { if (file is Map && file.isNotEmpty) {
@@ -404,6 +417,46 @@ class PawchiveProvider
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from)); files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
} }
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) {
final stableId = '${query.service}:${query.artistId}:$postId:0';
final firstStreamable =
cloudLinks.where((l) => l.isStreamable).firstOrNull;
final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null;
final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? '';
final tags = [
query.service,
query.artistName,
if (title.trim().isNotEmpty) title.trim(),
'cloud_mirror',
];
return [
Post(
id: stableId,
providerId: id,
providerName: name,
previewUrl: '',
sampleUrl: '',
fileUrl: fileUrl,
tags: tags,
rating: 'unknown',
width: 0,
height: 0,
source: postSource,
createdAt: published,
fileType: firstStreamable != null ? 'video' : 'link',
score: 0,
tagGroups: {
'artist': [query.artistName],
'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
},
),
];
}
var index = 0; var index = 0;
return files return files
.map((fileMap) { .map((fileMap) {
@@ -436,8 +489,7 @@ class PawchiveProvider
rating: 'unknown', rating: 'unknown',
width: 0, width: 0,
height: 0, height: 0,
source: source: postSource,
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
createdAt: published, createdAt: published,
fileType: type, fileType: type,
score: 0, score: 0,
@@ -445,6 +497,8 @@ class PawchiveProvider
'artist': [query.artistName], 'artist': [query.artistName],
'meta': [query.service], 'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()], if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
}, },
); );
}) })
+118
View File
@@ -1,5 +1,8 @@
import 'package:dio/dio.dart';
import '../mappers/rule34_mapper.dart'; import '../mappers/rule34_mapper.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart'; import '../models/top_period_filter.dart';
import 'gelbooru_provider.dart'; import 'gelbooru_provider.dart';
@@ -36,6 +39,7 @@ class Rule34Provider extends GelbooruProvider {
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[], TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
TopPeriodFilter.allTime => const ['sort:score:desc'], TopPeriodFilter.allTime => const ['sort:score:desc'],
}; };
try {
final response = await dio.get<dynamic>( final response = await dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -45,6 +49,7 @@ class Rule34Provider extends GelbooruProvider {
'json': '1', 'json': '1',
'pid': page, 'pid': page,
'limit': limit, 'limit': limit,
'fields': 'tag_info',
'tags': [ 'tags': [
...tags, ...tags,
if (rating != null) 'rating:$rating', if (rating != null) 'rating:$rating',
@@ -53,10 +58,123 @@ class Rule34Provider extends GelbooruProvider {
...queryParameters, ...queryParameters,
}, },
); );
_checkRule34Response(response);
return Rule34Mapper.postsFromResponse( return Rule34Mapper.postsFromResponse(
response.data, response.data,
providerId: id, providerId: id,
providerName: name, 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;
} }
} }
@@ -0,0 +1,340 @@
import '../models/cloud_media_link.dart';
class CloudLinkExtractor {
static final _urlRegex = RegExp(
r'''https?://[^\s<>"{}|\\^`\[\]]+''',
caseSensitive: false,
);
static final _htmlLinkRegex = RegExp(
r"""<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""",
caseSensitive: false,
dotAll: true,
);
static final _passwordRegex = RegExp(
r'(?:pass(?:word)?|pw|пароль|пас)(?:[\s:=_-]+)([^\s,;<>"\n\r]{2,50})',
caseSensitive: false,
);
/// Extracts any archive password mentioned in the raw text/HTML.
static String? extractPassword(String? text) {
if (text == null || text.trim().isEmpty) return null;
final match = _passwordRegex.firstMatch(text);
if (match != null && match.groupCount >= 1) {
final pass = match.group(1)?.trim();
if (pass != null &&
pass.isNotEmpty &&
!pass.startsWith('http') &&
pass.toLowerCase() != 'none') {
return pass;
}
}
return null;
}
/// Extracts all cloud media and video mirror links from text, HTML, and embeds.
static List<CloudMediaLink> extractLinks({
String? content,
String? source,
Map<String, dynamic>? embed,
}) {
final results = <CloudMediaLink>[];
final seenUrls = <String>{};
final detectedPass = extractPassword(content);
void addLink(String rawUrl, {String? customTitle}) {
final cleanUrl = _cleanUrl(rawUrl);
if (cleanUrl == null || seenUrls.contains(cleanUrl)) return;
seenUrls.add(cleanUrl);
final link = _classifyLink(cleanUrl, customTitle: customTitle, password: detectedPass);
if (link != null) {
results.add(link);
}
}
// 1. Process HTML anchors first to preserve anchor text / custom title
if (content != null && content.contains('<a ')) {
for (final match in _htmlLinkRegex.allMatches(content)) {
final href = match.group(1);
final rawTitle = match.group(2);
final cleanTitle = _stripHtml(rawTitle);
if (href != null && href.isNotEmpty) {
addLink(href, customTitle: cleanTitle.isNotEmpty ? cleanTitle : null);
}
}
}
// 2. Process all URLs in content text
if (content != null) {
for (final match in _urlRegex.allMatches(content)) {
final url = match.group(0);
if (url != null) addLink(url);
}
}
// 3. Process source
if (source != null && source.trim().isNotEmpty) {
for (final match in _urlRegex.allMatches(source)) {
final url = match.group(0);
if (url != null) addLink(url);
}
}
// 4. Process embed map
if (embed != null) {
final embedUrl = embed['url']?.toString();
if (embedUrl != null && embedUrl.isNotEmpty) {
final title = embed['title']?.toString();
addLink(embedUrl, customTitle: title);
}
}
return results;
}
static String? _cleanUrl(String raw) {
var url = raw.trim();
// Trim trailing punctuation like ), ., ,, ;, etc.
while (url.isNotEmpty && (url.endsWith(')') || url.endsWith('.') || url.endsWith(',') || url.endsWith(';') || url.endsWith('>'))) {
url = url.substring(0, url.length - 1);
}
final parsed = Uri.tryParse(url);
if (parsed == null || !parsed.hasScheme || (!parsed.scheme.startsWith('http'))) {
return null;
}
return url;
}
static String _stripHtml(String? html) {
if (html == null) return '';
return html.replaceAll(RegExp(r'<[^>]*>'), '').trim();
}
static CloudMediaLink? _classifyLink(
String url, {
String? customTitle,
String? password,
}) {
final uri = Uri.tryParse(url);
if (uri == null) return null;
final host = uri.host.toLowerCase();
final path = uri.path.toLowerCase();
// Google Drive
if (host.contains('drive.google.com')) {
final isFolder = path.contains('/folders/') || uri.queryParameters.containsKey('folder');
String? directStreamUrl;
final fileIdMatch = RegExp(r'/file/d/([a-zA-Z0-9_-]+)').firstMatch(uri.path);
final idParam = fileIdMatch?.group(1) ?? uri.queryParameters['id'];
if (idParam != null && !isFolder) {
directStreamUrl = 'https://drive.google.com/uc?export=download&id=$idParam';
}
return CloudMediaLink(
url: url,
service: CloudServiceType.googleDrive,
title: customTitle ?? (isFolder ? 'Google Drive Folder' : 'Google Drive File'),
directStreamUrl: directStreamUrl,
isFolder: isFolder,
isStreamable: directStreamUrl != null,
detectedPassword: password,
);
}
// MEGA
if (host.contains('mega.nz') || host.contains('mega.co.nz')) {
final isFolder = url.contains('/folder/') || url.contains('/#F!') || url.contains('#F!');
return CloudMediaLink(
url: url,
service: CloudServiceType.mega,
title: customTitle ?? (isFolder ? 'MEGA Cloud Folder' : 'MEGA Video / Archive'),
directStreamUrl: null, // End-to-end encrypted; launches in MEGA app/browser
isFolder: isFolder,
isStreamable: false,
detectedPassword: password,
);
}
// Dropbox
if (host.contains('dropbox.com')) {
final isFolder = path.contains('/sh/') || path.contains('/folder');
var direct = url;
if (direct.contains('?dl=0')) {
direct = direct.replaceAll('?dl=0', '?raw=1');
} else if (!direct.contains('?raw=1') && !isFolder) {
direct = direct.contains('?') ? '$direct&raw=1' : '$direct?raw=1';
}
return CloudMediaLink(
url: url,
service: CloudServiceType.dropbox,
title: customTitle ?? (isFolder ? 'Dropbox Folder' : 'Dropbox Media'),
directStreamUrl: isFolder ? null : direct,
isFolder: isFolder,
isStreamable: !isFolder,
detectedPassword: password,
);
}
// Pixeldrain
if (host.contains('pixeldrain.com')) {
final fileMatch = RegExp(r'/u/([a-zA-Z0-9_-]+)').firstMatch(uri.path);
final isList = path.contains('/l/');
final fileId = fileMatch?.group(1);
final direct = fileId != null ? 'https://pixeldrain.com/api/file/$fileId' : null;
return CloudMediaLink(
url: url,
service: CloudServiceType.pixeldrain,
title: customTitle ?? (isList ? 'Pixeldrain Gallery/List' : 'Pixeldrain Video'),
directStreamUrl: direct,
isFolder: isList,
isStreamable: direct != null,
detectedPassword: password,
);
}
// Catbox / Litterbox
if (host.contains('catbox.moe')) {
final isVideo = path.endsWith('.mp4') || path.endsWith('.webm') || path.endsWith('.mov') || path.endsWith('.m4v');
return CloudMediaLink(
url: url,
service: CloudServiceType.catbox,
title: customTitle ?? 'Catbox Upload',
directStreamUrl: url,
isFolder: false,
isStreamable: isVideo,
detectedPassword: password,
);
}
// MediaFire
if (host.contains('mediafire.com')) {
final isFolder = path.contains('/folder/') || path.contains('/?');
return CloudMediaLink(
url: url,
service: CloudServiceType.mediafire,
title: customTitle ?? (isFolder ? 'MediaFire Folder' : 'MediaFire Download'),
directStreamUrl: null,
isFolder: isFolder,
isStreamable: false,
detectedPassword: password,
);
}
// Bunkr
if (host.contains('bunkr.')) {
final isAlbum = path.contains('/a/') || path.contains('/v/');
return CloudMediaLink(
url: url,
service: CloudServiceType.bunkr,
title: customTitle ?? (isAlbum ? 'Bunkr Album' : 'Bunkr Media'),
directStreamUrl: null,
isFolder: isAlbum,
isStreamable: false,
detectedPassword: password,
);
}
// GoFile
if (host.contains('gofile.io')) {
return CloudMediaLink(
url: url,
service: CloudServiceType.gofile,
title: customTitle ?? 'GoFile Folder / Archive',
directStreamUrl: null,
isFolder: true,
isStreamable: false,
detectedPassword: password,
);
}
// TeraBox
if (host.contains('terabox') || host.contains('1024tera') || host.contains('dubox')) {
return CloudMediaLink(
url: url,
service: CloudServiceType.terabox,
title: customTitle ?? 'TeraBox Cloud Share',
directStreamUrl: null,
isFolder: true,
isStreamable: false,
detectedPassword: password,
);
}
// Direct Video Link (MP4, WEBM, MKV, MOV)
if (path.endsWith('.mp4') || path.endsWith('.webm') || path.endsWith('.mov') || path.endsWith('.m4v') || path.endsWith('.mkv')) {
final filename = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : 'Video Stream';
return CloudMediaLink(
url: url,
service: CloudServiceType.genericVideo,
title: customTitle ?? filename,
directStreamUrl: url,
isFolder: false,
isStreamable: true,
detectedPassword: password,
);
}
// Ignore known booru and search engine domains
if (host.contains('kemono') ||
host.contains('coomer') ||
host.contains('danbooru') ||
host.contains('gelbooru') ||
host.contains('google.com') && !host.contains('drive') ||
host.contains('twitter.com') ||
host.contains('x.com') ||
host.contains('patreon.com') ||
host.contains('fanbox.cc') ||
host.contains('pixiv.net')) {
return null;
}
return null;
}
/// 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;
// 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\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('&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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart'; import '../../../app/app.dart';
import '../../../app/responsive.dart'; import '../../../app/responsive.dart';
@@ -230,6 +231,9 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
separatorBuilder: (_, __) => const Divider(height: 24), separatorBuilder: (_, __) => const Divider(height: 24),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final a = _announcements[index]; final a = _announcements[index];
final cleanContent = CloudLinkExtractor.cleanCommentary(a.content);
final detectedLinks = CloudLinkExtractor.extractLinks(content: a.content);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -244,9 +248,35 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
SelectableText( SelectableText(
a.content, cleanContent.isNotEmpty ? cleanContent : a.content,
style: theme.textTheme.bodyMedium, 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; _query = value;
widget.onSuggestionTap(value); widget.onSuggestionTap(value);
}, },
onTagRemoved: (value) {
_query = value;
widget.onSearch(value);
},
onCleared: () {
_query = '';
widget.onClearFilters();
},
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -221,6 +229,14 @@ class _FeedToolbarState extends State<FeedToolbar> {
_query = value; _query = value;
widget.onSuggestionTap(value); widget.onSuggestionTap(value);
}, },
onTagRemoved: (value) {
_query = value;
widget.onSearch(value);
},
onCleared: () {
_query = '';
widget.onClearFilters();
},
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -23,6 +23,7 @@ import '../../favorites/presentation/favorites_controller.dart';
import '../../feed/presentation/feed_controller.dart'; import '../../feed/presentation/feed_controller.dart';
import '../../viewed/presentation/viewed_controller.dart'; import '../../viewed/presentation/viewed_controller.dart';
import 'post_details_controller.dart'; import 'post_details_controller.dart';
import 'widgets/cloud_mirrors_card.dart';
import 'widgets/post_action_bar.dart'; import 'widgets/post_action_bar.dart';
import 'widgets/post_media_viewer.dart'; import 'widgets/post_media_viewer.dart';
import 'widgets/post_tags_panel.dart'; import 'widgets/post_tags_panel.dart';
@@ -294,6 +295,24 @@ class PostDetailsScreen extends ConsumerWidget {
localMedia: localMedia, localMedia: localMedia,
fileSizeBytes: fileSizeBytes, fileSizeBytes: fileSizeBytes,
), ),
if (post.cloudLinks.isNotEmpty ||
(post.commentary != null &&
post.commentary!.trim().isNotEmpty)) ...[
const SizedBox(height: 16),
CloudMirrorsCard(
links: post.cloudLinks,
strings: strings,
commentary: post.commentary,
onPlayStream: (streamUrl) => launchUrl(
Uri.parse(streamUrl),
mode: LaunchMode.externalApplication,
),
onDownloadStream: settings.allowDownloads
? (streamUrl) =>
_downloadUrl(context, ref, post, streamUrl)
: null,
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -320,7 +339,7 @@ class PostDetailsScreen extends ConsumerWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'${strings.tags} (${post.tags.length})', '${strings.tags} (${post.cleanTags.length})',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
@@ -443,6 +462,23 @@ class PostDetailsScreen extends ConsumerWidget {
localMedia: localMedia, localMedia: localMedia,
fileSizeBytes: fileSizeBytes, fileSizeBytes: fileSizeBytes,
), ),
if (post.cloudLinks.isNotEmpty ||
(post.commentary != null &&
post.commentary!.trim().isNotEmpty)) ...[
const SizedBox(height: 12),
CloudMirrorsCard(
links: post.cloudLinks,
strings: strings,
commentary: post.commentary,
onPlayStream: (streamUrl) => launchUrl(
Uri.parse(streamUrl),
mode: LaunchMode.externalApplication,
),
onDownloadStream: settings.allowDownloads
? (streamUrl) => _downloadUrl(context, ref, post, streamUrl)
: null,
),
],
if (feedPosts != null && feedPosts.length > 1) ...[ if (feedPosts != null && feedPosts.length > 1) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_NeighborStrip( _NeighborStrip(
@@ -479,7 +515,7 @@ class PostDetailsScreen extends ConsumerWidget {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
title: Text( title: Text(
'${strings.tags} (${post.tags.length})', '${strings.tags} (${post.cleanTags.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -535,6 +571,16 @@ class PostDetailsScreen extends ConsumerWidget {
}); });
} }
Future<void> _downloadUrl(
BuildContext context,
WidgetRef ref,
Post post,
String targetUrl,
) async {
final customPost = post.copyWith(fileUrl: targetUrl, sampleUrl: targetUrl);
await _download(context, ref, customPost);
}
Future<void> _sharePost(BuildContext context, WidgetRef ref, Post post) async { Future<void> _sharePost(BuildContext context, WidgetRef ref, Post post) async {
final strings = ref.read(appStringsProvider); final strings = ref.read(appStringsProvider);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -0,0 +1,360 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../app/app_strings.dart';
import '../../../../backend/models/cloud_media_link.dart';
class CloudMirrorsCard extends StatelessWidget {
const CloudMirrorsCard({
super.key,
required this.links,
required this.strings,
this.commentary,
this.onPlayStream,
this.onDownloadStream,
this.initiallyExpanded = false,
});
final List<CloudMediaLink> links;
final AppStrings strings;
final String? commentary;
final ValueChanged<String>? onPlayStream;
final ValueChanged<String>? onDownloadStream;
final bool initiallyExpanded;
@override
Widget build(BuildContext context) {
if (links.isEmpty && (commentary == null || commentary!.trim().isEmpty)) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
final detectedPassword = links
.map((l) => l.detectedPassword)
.whereType<String>()
.firstWhere((p) => p.isNotEmpty, orElse: () => '');
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
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,
color: theme.colorScheme.primary,
),
title: Row(
children: [
Expanded(
child: Text(
strings.ru
? 'Облачные диски и зеркала (${links.length})'
: 'Cloud Mirrors & Drives (${links.length})',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (links.any((l) => l.isStreamable)) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_circle_fill_rounded,
size: 13, color: Colors.green),
const SizedBox(width: 4),
Text(
strings.ru ? 'Плеер' : 'Stream',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
],
],
),
children: [
// Archive Password Banner
if (detectedPassword.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Icon(Icons.key_rounded, size: 16, color: Colors.amber),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
text: strings.ru ? 'Пароль к архиву: ' : 'Archive Password: ',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface,
),
children: [
TextSpan(
text: detectedPassword,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
),
),
),
IconButton(
icon: const Icon(Icons.copy_rounded, size: 16),
visualDensity: VisualDensity.compact,
tooltip: strings.ru ? 'Копировать пароль' : 'Copy Password',
onPressed: () {
Clipboard.setData(ClipboardData(text: detectedPassword));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
strings.ru
? 'Пароль скопирован: $detectedPassword'
: 'Password copied: $detectedPassword',
),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
),
],
),
),
const SizedBox(height: 10),
],
// Links list
if (links.isNotEmpty) ...[
...links.map((link) => _buildLinkTile(context, link, strings)),
],
// Commentary / Description
if (commentary != null && commentary!.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Divider(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
height: 1,
),
const SizedBox(height: 10),
Row(
children: [
Icon(
Icons.notes_rounded,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 6),
Text(
strings.ru ? 'Описание от автора' : 'Artist Commentary',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
SelectableText(
commentary!.trim(),
style: theme.textTheme.bodySmall?.copyWith(
height: 1.45,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
);
}
Widget _buildLinkTile(
BuildContext context,
CloudMediaLink link,
AppStrings strings,
) {
final theme = Theme.of(context);
final brandColor = link.brandColor;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: brandColor.withValues(alpha: 0.25),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: brandColor.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: brandColor.withValues(alpha: 0.35)),
),
child: Icon(link.iconData, size: 20, color: brandColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
link.serviceName,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
if (link.isFolder)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
strings.ru ? 'Папка' : 'Folder',
style: theme.textTheme.labelSmall?.copyWith(
fontSize: 10,
color: theme.colorScheme.onSecondaryContainer,
),
),
),
],
),
const SizedBox(height: 2),
Text(
link.title,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 10),
// Action buttons row
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Play button (if streamable)
if (link.isStreamable && onPlayStream != null) ...[
FilledButton.tonalIcon(
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10),
),
icon: const Icon(Icons.play_arrow_rounded, size: 16),
label: Text(strings.ru ? 'Смотреть' : 'Play'),
onPressed: () {
final target = link.directStreamUrl ?? link.url;
onPlayStream!(target);
},
),
const SizedBox(width: 8),
],
// Download button (if direct streamable and callback exists)
if (link.isStreamable &&
link.directStreamUrl != null &&
onDownloadStream != null) ...[
IconButton.filledTonal(
visualDensity: VisualDensity.compact,
tooltip: strings.download,
icon: const Icon(Icons.download_rounded, size: 16),
onPressed: () => onDownloadStream!(link.directStreamUrl!),
),
const SizedBox(width: 8),
],
// Copy link button
IconButton.outlined(
visualDensity: VisualDensity.compact,
tooltip: strings.copyLink,
icon: const Icon(Icons.copy_rounded, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: link.url));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
strings.ru
? 'Ссылка скопирована: ${link.serviceName}'
: 'Link copied: ${link.serviceName}',
),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
),
const SizedBox(width: 8),
// Open in browser / app button
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: brandColor,
foregroundColor: Colors.white,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
icon: const Icon(Icons.open_in_new_rounded, size: 15),
label: Text(strings.ru ? 'Открыть' : 'Open'),
onPressed: () => launchUrl(
Uri.parse(link.url),
mode: LaunchMode.externalApplication,
),
),
],
),
],
),
);
}
}
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../app/motion.dart'; import '../../../../app/motion.dart';
import '../../../../backend/backend.dart'; import '../../../../backend/backend.dart';
@@ -210,7 +211,21 @@ class _PostMediaViewerState extends State<PostMediaViewer>
); );
} }
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; if (_imageUrls.isEmpty) {
return _CloudMediaHero(
post: widget.post,
onOpenPrimary: () {
final links = widget.post.cloudLinks;
final first = links.isNotEmpty ? links.first : null;
if (first != null) {
launchUrl(Uri.parse(first.url),
mode: LaunchMode.externalApplication);
}
},
);
}
final url = _imageUrls[_imageIndex];
final isLocal = url.startsWith('/') || url.startsWith('file://'); final isLocal = url.startsWith('/') || url.startsWith('file://');
final headers = _headersFor(widget.post); final headers = _headersFor(widget.post);
final Widget image; final Widget image;
@@ -419,7 +434,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
} }
List<String> _buildVideoUrls(Post post) { List<String> _buildVideoUrls(Post post) {
final list = MediaUrlSelector.video(post); final list = List<String>.from(MediaUrlSelector.video(post));
final cloudStreams = post.cloudLinks
.where((l) => l.isStreamable && l.directStreamUrl != null)
.map((l) => l.directStreamUrl!);
for (final stream in cloudStreams) {
if (!list.contains(stream)) list.add(stream);
}
final local = widget.localFilePath; final local = widget.localFilePath;
if (local != null && local.isNotEmpty && File(local).existsSync()) { if (local != null && local.isNotEmpty && File(local).existsSync()) {
return [local, ...list]; return [local, ...list];
@@ -428,6 +449,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
} }
bool _isVideo(Post post) { bool _isVideo(Post post) {
if (post.cloudLinks.any((l) => l.isStreamable)) return true;
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
return value.contains('video') || return value.contains('video') ||
value.contains('.webm') || value.contains('.webm') ||
@@ -679,6 +701,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
late bool _muted; late bool _muted;
late bool _loopVideo; late bool _loopVideo;
late bool _halfVolume; late bool _halfVolume;
late bool _isLandscape;
Timer? _hideTimer; Timer? _hideTimer;
@override @override
@@ -688,13 +711,30 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
_muted = widget.muted; _muted = widget.muted;
_loopVideo = widget.loopVideo; _loopVideo = widget.loopVideo;
_halfVolume = widget.halfVolume; _halfVolume = widget.halfVolume;
_isLandscape = widget.aspectRatio > 1.05;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_applyOrientation();
_scheduleControlsHide();
}
void _applyOrientation() {
if (_isLandscape) {
SystemChrome.setPreferredOrientations([ SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight, DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]); ]);
_scheduleControlsHide(); } else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
}
void _toggleOrientation() {
setState(() => _isLandscape = !_isLandscape);
_applyOrientation();
_showControls();
} }
@override @override
@@ -792,7 +832,20 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
top: 12, top: 12,
right: 12, right: 12,
child: SafeArea( 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', tooltip: 'Close fullscreen',
onPressed: () => Navigator.of( onPressed: () => Navigator.of(
context, context,
@@ -800,6 +853,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
).pop(_snapshot()), ).pop(_snapshot()),
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
), ),
],
),
), ),
), ),
], ],
@@ -2004,3 +2059,117 @@ class _VideoControls extends StatelessWidget {
return '$minutes:$seconds'; return '$minutes:$seconds';
} }
} }
class _CloudMediaHero extends StatelessWidget {
const _CloudMediaHero({
required this.post,
this.onOpenPrimary,
});
final Post post;
final VoidCallback? onOpenPrimary;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final links = post.cloudLinks;
final primaryColor = links.isNotEmpty
? links.first.brandColor
: theme.colorScheme.primary;
return Center(
child: Container(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
constraints: const BoxConstraints(maxWidth: 460),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: primaryColor.withValues(alpha: 0.35),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.12),
blurRadius: 28,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(
color: primaryColor.withValues(alpha: 0.4),
width: 2,
),
),
child: Icon(
links.isNotEmpty ? links.first.iconData : Icons.cloud_queue_rounded,
size: 34,
color: primaryColor,
),
),
const SizedBox(height: 16),
Text(
isRu
? 'Контент на внешнем диске'
: 'Cloud Drive Media',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
links.isNotEmpty
? (isRu
? 'Автор опубликовал медиа на ${links.map((l) => l.serviceName).toSet().join(', ')}.'
: 'Author hosted media on ${links.map((l) => l.serviceName).toSet().join(', ')}.')
: (isRu
? 'В данном посте нет медиафайла на сервере.'
: 'No media file hosted directly on the server.'),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (links.isNotEmpty) ...[
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 22,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.open_in_new_rounded, size: 16),
label: Text(
isRu
? 'Открыть ${links.first.serviceName}'
: 'Open ${links.first.serviceName}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
onPressed: onOpenPrimary,
),
],
],
),
),
);
}
}
@@ -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) { Map<String, List<String>> _groups(Post post) {
if (post.tagGroups.isNotEmpty) { if (post.tagGroups.isNotEmpty) {
final ordered = <String, List<String>>{}; final ordered = <String, List<String>>{};
@@ -230,17 +238,40 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
'meta', 'meta',
'general', 'general',
]) { ]) {
final tags = post.tagGroups[key]; final tags = _filterValidTags(post.tagGroups[key]);
if (tags != null && tags.isNotEmpty) ordered[key] = tags; if (tags.isNotEmpty) ordered[key] = tags;
} }
for (final entry in post.tagGroups.entries) { for (final entry in post.tagGroups.entries) {
if (entry.value.isNotEmpty) { if (_ignoredTagGroupKeys.contains(entry.key.toLowerCase())) continue;
ordered.putIfAbsent(entry.key, () => entry.value); final tags = _filterValidTags(entry.value);
if (tags.isNotEmpty) {
ordered.putIfAbsent(entry.key, () => tags);
} }
} }
return ordered; 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) { String _label(String key) {
@@ -129,7 +129,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _apiKey, controller: _apiKey,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'API key', labelText: 'API key',
helperText: 'Gelbooru: api_key, Danbooru: api_key', helperText:
'Gelbooru/Rule34/Danbooru API key (Account -> Options)',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -137,7 +138,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _userId, controller: _userId,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'User ID', 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), const SizedBox(height: 12),
@@ -105,6 +105,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
onSuggestionApplied: (query) => ref onSuggestionApplied: (query) => ref
.read(searchControllerProvider.notifier) .read(searchControllerProvider.notifier)
.updateQuery(query), .updateQuery(query),
onTagRemoved: (query) => ref
.read(searchControllerProvider.notifier)
.updateQuery(query),
onCleared: () => ref
.read(searchControllerProvider.notifier)
.updateQuery(''),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SingleChildScrollView( SingleChildScrollView(
+168 -96
View File
@@ -28,6 +28,8 @@ class TagInputSearchBar extends StatefulWidget {
required this.onSubmitted, required this.onSubmitted,
this.onChanged, this.onChanged,
this.onSuggestionApplied, this.onSuggestionApplied,
this.onTagRemoved,
this.onCleared,
this.initialValue, this.initialValue,
this.hintText = 'Search tags', this.hintText = 'Search tags',
this.suggestions = const [], this.suggestions = const [],
@@ -37,6 +39,8 @@ class TagInputSearchBar extends StatefulWidget {
final ValueChanged<String> onSubmitted; final ValueChanged<String> onSubmitted;
final ValueChanged<String>? onChanged; final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSuggestionApplied; final ValueChanged<String>? onSuggestionApplied;
final ValueChanged<String>? onTagRemoved;
final VoidCallback? onCleared;
final String? initialValue; final String? initialValue;
final String hintText; final String hintText;
final List<TagSuggestion> suggestions; final List<TagSuggestion> suggestions;
@@ -48,10 +52,10 @@ class TagInputSearchBar extends StatefulWidget {
class _TagInputSearchBarState extends State<TagInputSearchBar> { class _TagInputSearchBarState extends State<TagInputSearchBar> {
final _fieldKey = GlobalKey(); final _fieldKey = GlobalKey();
final _layerLink = LayerLink(); final _layerLink = LayerLink();
final _portalController = OverlayPortalController();
late final TextEditingController _controller; late final TextEditingController _controller;
late final FocusNode _focusNode; late final FocusNode _focusNode;
late final ScrollController _tagScrollController; late final ScrollController _tagScrollController;
OverlayEntry? _suggestionsOverlay;
Timer? _debounce; Timer? _debounce;
List<String> _tags = []; List<String> _tags = [];
String _lastExternalValue = ''; String _lastExternalValue = '';
@@ -64,10 +68,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_focusNode = FocusNode(); _focusNode = FocusNode();
_tagScrollController = ScrollController(); _tagScrollController = ScrollController();
_focusNode.addListener(_handleFocusChanged); _focusNode.addListener(_handleFocusChanged);
_controller.addListener(_handleControllerChanged);
_lastExternalValue = widget.initialValue ?? ''; _lastExternalValue = widget.initialValue ?? '';
_setFromQuery(_lastExternalValue); _setFromQuery(_lastExternalValue);
} }
void _handleControllerChanged() {
if (_controller.text.trim().isEmpty && _portalController.isShowing) {
_portalController.hide();
}
}
@override @override
void didUpdateWidget(covariant TagInputSearchBar oldWidget) { void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -80,15 +91,18 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = false; _localDirty = false;
} }
if (oldWidget.suggestions != widget.suggestions) { if (oldWidget.suggestions != widget.suggestions) {
_syncSuggestionsOverlay(); _syncSuggestions();
} }
} }
@override @override
void dispose() { void dispose() {
_debounce?.cancel(); _debounce?.cancel();
_removeSuggestionsOverlay(); if (_portalController.isShowing) {
_portalController.hide();
}
_tagScrollController.dispose(); _tagScrollController.dispose();
_controller.removeListener(_handleControllerChanged);
_focusNode.removeListener(_handleFocusChanged); _focusNode.removeListener(_handleFocusChanged);
_focusNode.dispose(); _focusNode.dispose();
_controller.dispose(); _controller.dispose();
@@ -97,24 +111,115 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleFocusChanged() { void _handleFocusChanged() {
if (!mounted) return; if (!mounted) return;
if (!_focusNode.hasFocus && !_localDirty) { if (!_focusNode.hasFocus) {
if (_portalController.isShowing) {
_portalController.hide();
}
if (!_localDirty) {
final next = widget.initialValue ?? ''; final next = widget.initialValue ?? '';
if (next != _lastExternalValue) { if (next != _lastExternalValue) {
_setFromQuery(next); _setFromQuery(next);
_lastExternalValue = next; _lastExternalValue = next;
} }
} }
} else {
_syncSuggestions();
}
setState(() {}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _syncSuggestionsOverlay(); return TapRegion(
}); groupId: _fieldKey,
return CompositedTransformTarget( 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, link: _layerLink,
child: DecoratedBox( child: DecoratedBox(
key: _fieldKey, key: _fieldKey,
@@ -133,11 +238,13 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
onTap: _focusNode.requestFocus, onTap: _focusNode.requestFocus,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant), Icon(Icons.search_rounded,
color: scheme.onSurfaceVariant),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: SizedBox( child: SizedBox(
@@ -167,7 +274,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
focusNode: _focusNode, focusNode: _focusNode,
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
textInputAction: TextInputAction.search, textInputAction:
TextInputAction.search,
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),
onChanged: _handleDraftChanged, onChanged: _handleDraftChanged,
decoration: InputDecoration( decoration: InputDecoration(
@@ -176,11 +284,13 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
enabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none, focusedBorder: InputBorder.none,
filled: false, filled: false,
contentPadding: const EdgeInsets.symmetric( contentPadding:
const EdgeInsets.symmetric(
vertical: 8, vertical: 8,
), ),
hintText: hintText: _tags.isEmpty
_tags.isEmpty ? widget.hintText : 'tag', ? widget.hintText
: 'tag',
prefixIcon: null, prefixIcon: null,
suffixIcon: 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) { Widget _buildTagChip(BuildContext context, String tag) {
@@ -302,10 +380,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleDraftChanged(String value) { void _handleDraftChanged(String value) {
_localDirty = true; _localDirty = true;
if (value.trim().isEmpty) {
_debounce?.cancel();
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() {});
_notifyChanged();
return;
}
final composing = _controller.value.composing; final composing = _controller.value.composing;
if (!composing.isValid && if (!composing.isValid && RegExp(r'\s$').hasMatch(value)) {
value.isNotEmpty &&
RegExp(r'\s$').hasMatch(value)) {
_commitDraft(value); _commitDraft(value);
return; return;
} }
@@ -329,31 +414,47 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
_controller.clear(); _controller.clear();
}); });
if (_portalController.isShowing) {
_portalController.hide();
}
_scrollToEnd();
_notifyChanged(); _notifyChanged();
} }
void _submit() { void _submit() {
_debounce?.cancel(); _debounce?.cancel();
if (_portalController.isShowing) {
_portalController.hide();
}
_commitDraft(_controller.text); _commitDraft(_controller.text);
_removeSuggestionsOverlay();
_lastExternalValue = _query; _lastExternalValue = _query;
_localDirty = false; _localDirty = false;
_focusNode.unfocus();
widget.onSubmitted(_query); widget.onSubmitted(_query);
} }
void _clear() { void _clear() {
_debounce?.cancel(); _debounce?.cancel();
_localDirty = true; if (_portalController.isShowing) {
_portalController.hide();
}
_localDirty = false;
setState(() { setState(() {
_tags = []; _tags = [];
_controller.clear(); _controller.clear();
}); });
_lastExternalValue = '';
_notifyChanged(); _notifyChanged();
_focusNode.requestFocus(); _focusNode.unfocus();
widget.onCleared?.call();
widget.onSubmitted('');
} }
void _editTag(String tag) { void _editTag(String tag) {
_localDirty = true; _localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() { setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true); _tags = _tags.where((item) => item != tag).toList(growable: true);
_controller.value = TextEditingValue( _controller.value = TextEditingValue(
@@ -367,10 +468,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _removeTag(String tag) { void _removeTag(String tag) {
_localDirty = true; _localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() { setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true); _tags = _tags.where((item) => item != tag).toList(growable: true);
}); });
_lastExternalValue = _query;
_notifyChanged(); _notifyChanged();
if (widget.onTagRemoved != null) {
widget.onTagRemoved!(_query);
}
} }
void _applySuggestion(String suggestion) { void _applySuggestion(String suggestion) {
@@ -393,8 +501,12 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
_controller.clear(); _controller.clear();
}); });
if (_portalController.isShowing) {
_portalController.hide();
}
_lastExternalValue = _query;
_scrollToEnd();
_notifyChanged(); _notifyChanged();
_removeSuggestionsOverlay();
widget.onSuggestionApplied?.call(_query); widget.onSuggestionApplied?.call(_query);
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
@@ -407,7 +519,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _notifyChanged() { void _notifyChanged() {
_localDirty = true; _localDirty = true;
widget.onChanged?.call(_query); widget.onChanged?.call(_query);
_syncSuggestionsOverlay(); _syncSuggestions();
} }
List<String> _parseTags(String query) { List<String> _parseTags(String query) {
@@ -438,46 +550,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
bool get _isEditing => _focusNode.hasFocus || _localDirty; 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 { class _TagSuggestionDropdown extends StatelessWidget {
const _TagSuggestionDropdown({ const _TagSuggestionDropdown({
required this.suggestions, required this.suggestions,
+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.2.0+39 version: 2.3.5+45
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/cloud_media_link.dart';
import 'package:gel_rule_app/backend/services/cloud_link_extractor.dart';
void main() {
group('CloudLinkExtractor tests', () {
test('extracts and parses Google Drive links with direct stream URLs', () {
const html = '''
<p>New animation release!</p>
<p>Download here: <a href="https://drive.google.com/file/d/1AbC-DeFgHiJkLmNoPqRsTuVwXyZ123/view?usp=sharing">Google Drive 4K</a></p>
<p>Or browse the full folder: https://drive.google.com/drive/folders/9ZyX-WvUtSrQpOnMlKjIhGfEdCbA456</p>
''';
final links = CloudLinkExtractor.extractLinks(content: html);
expect(links.length, 2);
final fileLink = links.firstWhere((l) => l.service == CloudServiceType.googleDrive && !l.isFolder);
expect(fileLink.title, 'Google Drive 4K');
expect(fileLink.isStreamable, isTrue);
expect(fileLink.directStreamUrl, 'https://drive.google.com/uc?export=download&id=1AbC-DeFgHiJkLmNoPqRsTuVwXyZ123');
final folderLink = links.firstWhere((l) => l.service == CloudServiceType.googleDrive && l.isFolder);
expect(folderLink.isFolder, isTrue);
expect(folderLink.isStreamable, isFalse);
});
test('extracts MEGA links and detects folders', () {
const content = '''
Archive mirrors:
Mega file: https://mega.nz/file/xyz123#abcdefg
Mega folder: https://mega.nz/folder/abc456#1234567
''';
final links = CloudLinkExtractor.extractLinks(content: content);
expect(links.length, 2);
final fileLink = links.firstWhere((l) => !l.isFolder);
expect(fileLink.service, CloudServiceType.mega);
expect(fileLink.serviceName, 'MEGA');
expect(fileLink.isFolder, isFalse);
final folderLink = links.firstWhere((l) => l.isFolder);
expect(folderLink.service, CloudServiceType.mega);
expect(folderLink.isFolder, isTrue);
});
test('transforms Dropbox links into direct stream URLs with ?raw=1', () {
const content = 'https://www.dropbox.com/s/sample123/video.mp4?dl=0';
final links = CloudLinkExtractor.extractLinks(content: content);
expect(links.length, 1);
final link = links.first;
expect(link.service, CloudServiceType.dropbox);
expect(link.isStreamable, isTrue);
expect(link.directStreamUrl, 'https://www.dropbox.com/s/sample123/video.mp4?raw=1');
});
test('extracts Pixeldrain file IDs and generates API endpoints', () {
const content = 'Pixeldrain mirror: https://pixeldrain.com/u/abc123xy';
final links = CloudLinkExtractor.extractLinks(content: content);
expect(links.length, 1);
final link = links.first;
expect(link.service, CloudServiceType.pixeldrain);
expect(link.isStreamable, isTrue);
expect(link.directStreamUrl, 'https://pixeldrain.com/api/file/abc123xy');
});
test('extracts archive passwords from content', () {
expect(
CloudLinkExtractor.extractPassword('Here is the zip! Password: SuperSecret123'),
'SuperSecret123',
);
expect(
CloudLinkExtractor.extractPassword('Pass = pass_word_2026!'),
'pass_word_2026!',
);
expect(
CloudLinkExtractor.extractPassword('Пароль: mypass777'),
'mypass777',
);
});
test('cleans HTML commentary correctly', () {
const html = '<p>Hello world!<br/>Check out the new video.</p><p>&amp; enjoy!</p>';
final clean = CloudLinkExtractor.cleanCommentary(html);
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',
service: CloudServiceType.mega,
title: 'Full 4K Video',
isFolder: false,
isStreamable: false,
detectedPassword: 'pass',
);
final encoded = link.encode();
final decoded = CloudMediaLink.tryDecode(encoded);
expect(decoded, isNotNull);
expect(decoded!.url, link.url);
expect(decoded.service, CloudServiceType.mega);
expect(decoded.title, 'Full 4K Video');
expect(decoded.detectedPassword, 'pass');
});
});
}
+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');
});
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/app/app_strings.dart';
import 'package:gel_rule_app/backend/models/cloud_media_link.dart';
import 'package:gel_rule_app/features/post/presentation/widgets/cloud_mirrors_card.dart';
void main() {
testWidgets('CloudMirrorsCard renders mirrors, password banner, and commentary', (tester) async {
const links = [
CloudMediaLink(
url: 'https://mega.nz/file/xyz123#abc',
service: CloudServiceType.mega,
title: 'MEGA 4K Archive',
isFolder: false,
isStreamable: false,
detectedPassword: 'archive_pass_42',
),
CloudMediaLink(
url: 'https://drive.google.com/file/d/123/view',
service: CloudServiceType.googleDrive,
title: 'Google Drive Stream',
directStreamUrl: 'https://drive.google.com/uc?export=download&id=123',
isFolder: false,
isStreamable: true,
),
];
var playedStream = '';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: CloudMirrorsCard(
links: links,
strings: const AppStrings('ru'),
commentary: 'Special Patreon bonus animation for all patrons.',
onPlayStream: (stream) => playedStream = stream,
initiallyExpanded: true,
),
),
),
),
);
// Verify header and counts
expect(find.text('Облачные диски и зеркала (2)'), findsOneWidget);
expect(find.text('Плеер'), findsOneWidget);
// Verify password banner
expect(find.textContaining('archive_pass_42'), findsOneWidget);
// Verify link titles and service names
expect(find.text('MEGA'), findsOneWidget);
expect(find.text('MEGA 4K Archive'), findsOneWidget);
expect(find.text('Google Drive'), findsOneWidget);
expect(find.text('Google Drive Stream'), findsOneWidget);
// Verify commentary
expect(find.text('Описание от автора'), findsOneWidget);
expect(find.text('Special Patreon bonus animation for all patrons.'), findsOneWidget);
// Verify play button triggers callback
final playBtn = find.text('Смотреть');
expect(playBtn, findsOneWidget);
await tester.tap(playBtn);
expect(playedStream, 'https://drive.google.com/uc?export=download&id=123');
});
testWidgets('CloudMirrorsCard renders nothing if no links and no commentary', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: CloudMirrorsCard(
links: [],
strings: AppStrings('ru'),
),
),
),
);
expect(find.byType(Container), findsNothing);
});
}
+117
View File
@@ -339,6 +339,123 @@ void main() {
expect(find.text('AND'), findsOneWidget); expect(find.text('AND'), findsOneWidget);
expect(find.text('dog'), 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 { class _Harness extends StatelessWidget {