From 6f902740c1414e6743c213413f9f6ee193a5c62e Mon Sep 17 00:00:00 2001 From: RarDog Date: Fri, 4 Sep 2026 17:46:32 +0300 Subject: [PATCH] feat: clickable social and creator links in announcements and commentary (v3.0.5) --- README.md | 2 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 9 + lib/backend/backend.dart | 1 + lib/backend/models/creator_link.dart | 332 ++++++++++++++++++ .../presentation/artist_posts_screen.dart | 19 +- .../widgets/cloud_mirrors_card.dart | 19 +- .../widgets/formatted_content_text.dart | 261 ++++++++++++++ pubspec.yaml | 2 +- test/backend/creator_link_test.dart | 67 ++++ 10 files changed, 705 insertions(+), 11 deletions(-) create mode 100644 lib/backend/models/creator_link.dart create mode 100644 lib/shared/widgets/formatted_content_text.dart create mode 100644 test/backend/creator_link_test.dart diff --git a/README.md b/README.md index 6f3b7f3..e99bf8c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.0.4 + Version 3.0.5 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 627de63..5854b66 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.0.4'; -const appBuildNumber = 55; +const appDisplayVersion = '3.0.5'; +const appBuildNumber = 56; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 93d1f64..2c8ac75 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,13 @@ const lunarisChangelog = [ + LunarisChange( + version: '3.0.5', + title: 'Интерактивные ссылки на соцсети и авторов в объявлениях и описаниях', + bullets: [ + 'Кликабельные ссылки в объявлениях автора и описании постов: все URL в тексте подсвечиваются и открываются во внешнем браузере по клику.', + 'Красивые бейджи соцсетей (CreatorLinkChips): автоматическое распознавание и стилизация ссылок на YouTube, X (Twitter), Instagram, TikTok, Discord, Patreon, Fanbox, Fantia, Boosty, Pixiv, Twitch, Telegram, Gumroad и другие платформы с фирменными иконками и цветами.', + 'Корректная очистка пунктуации: знаки препинания в конце URL (!, ?, :, ., ,, ;, >) больше не ломают ссылки.', + ], + ), LunarisChange( version: '3.0.0', title: 'Lunaris 3.0 — Глобальное обновление: Дизайн 2.0, Интеграция Pawchive и умная синхронизация', diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index 62365a9..8f279ed 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -8,6 +8,7 @@ export 'models/artist_tag.dart'; export 'models/artist_link.dart'; export 'models/artist_announcement.dart'; export 'models/cloud_media_link.dart'; +export 'models/creator_link.dart'; export 'models/download_task.dart'; export 'models/downloaded_media.dart'; export 'models/favorite.dart'; diff --git a/lib/backend/models/creator_link.dart b/lib/backend/models/creator_link.dart new file mode 100644 index 0000000..8f41799 --- /dev/null +++ b/lib/backend/models/creator_link.dart @@ -0,0 +1,332 @@ +import 'package:flutter/material.dart'; + +enum CreatorLinkType { + youtube, + twitter, + instagram, + tiktok, + discord, + pixiv, + fanbox, + patreon, + fantia, + boosty, + twitch, + telegram, + gumroad, + subscribestar, + artstation, + deviantart, + linkhub, + other, +} + +class CreatorLink { + const CreatorLink({ + required this.url, + required this.type, + required this.title, + this.customLabel, + }); + + final String url; + final CreatorLinkType type; + final String title; + final String? customLabel; + + String get serviceName { + switch (type) { + case CreatorLinkType.youtube: + return 'YouTube'; + case CreatorLinkType.twitter: + return 'X (Twitter)'; + case CreatorLinkType.instagram: + return 'Instagram'; + case CreatorLinkType.tiktok: + return 'TikTok'; + case CreatorLinkType.discord: + return 'Discord'; + case CreatorLinkType.pixiv: + return 'Pixiv'; + case CreatorLinkType.fanbox: + return 'Fanbox'; + case CreatorLinkType.patreon: + return 'Patreon'; + case CreatorLinkType.fantia: + return 'Fantia'; + case CreatorLinkType.boosty: + return 'Boosty'; + case CreatorLinkType.twitch: + return 'Twitch'; + case CreatorLinkType.telegram: + return 'Telegram'; + case CreatorLinkType.gumroad: + return 'Gumroad'; + case CreatorLinkType.subscribestar: + return 'SubscribeStar'; + case CreatorLinkType.artstation: + return 'ArtStation'; + case CreatorLinkType.deviantart: + return 'DeviantArt'; + case CreatorLinkType.linkhub: + return 'Links Hub'; + case CreatorLinkType.other: + return title.isNotEmpty ? title : 'Web Link'; + } + } + + Color get brandColor { + switch (type) { + case CreatorLinkType.youtube: + return const Color(0xFFFF0000); // YouTube Red + case CreatorLinkType.twitter: + return const Color(0xFF1D9BF0); // Twitter Blue + case CreatorLinkType.instagram: + return const Color(0xFFE1306C); // Instagram Gradient Magenta + case CreatorLinkType.tiktok: + return const Color(0xFF00F2FE); // TikTok Cyan + case CreatorLinkType.discord: + return const Color(0xFF5865F2); // Discord Blurple + case CreatorLinkType.pixiv: + return const Color(0xFF0096FA); // Pixiv Blue + case CreatorLinkType.fanbox: + return const Color(0xFFF1A12F); // Fanbox Orange + case CreatorLinkType.patreon: + return const Color(0xFFFF424D); // Patreon Coral + case CreatorLinkType.fantia: + return const Color(0xFFE0245E); // Fantia Pink + case CreatorLinkType.boosty: + return const Color(0xFFF15A24); // Boosty Orange + case CreatorLinkType.twitch: + return const Color(0xFF9146FF); // Twitch Purple + case CreatorLinkType.telegram: + return const Color(0xFF2AABEE); // Telegram Blue + case CreatorLinkType.gumroad: + return const Color(0xFFFF90E8); // Gumroad Pink + case CreatorLinkType.subscribestar: + return const Color(0xFF00ADB5); // SubscribeStar Teal + case CreatorLinkType.artstation: + return const Color(0xFF13AFF0); // ArtStation Blue + case CreatorLinkType.deviantart: + return const Color(0xFF05CC47); // DeviantArt Green + case CreatorLinkType.linkhub: + return const Color(0xFF43E660); // Linktree Green + case CreatorLinkType.other: + return const Color(0xFF5C6BC0); // Indigo + } + } + + IconData get iconData { + switch (type) { + case CreatorLinkType.youtube: + return Icons.smart_display_rounded; + case CreatorLinkType.twitter: + return Icons.alternate_email_rounded; + case CreatorLinkType.instagram: + return Icons.camera_alt_rounded; + case CreatorLinkType.tiktok: + return Icons.music_note_rounded; + case CreatorLinkType.discord: + return Icons.forum_rounded; + case CreatorLinkType.pixiv: + return Icons.palette_rounded; + case CreatorLinkType.fanbox: + return Icons.favorite_rounded; + case CreatorLinkType.patreon: + return Icons.volunteer_activism_rounded; + case CreatorLinkType.fantia: + return Icons.stars_rounded; + case CreatorLinkType.boosty: + return Icons.rocket_launch_rounded; + case CreatorLinkType.twitch: + return Icons.live_tv_rounded; + case CreatorLinkType.telegram: + return Icons.send_rounded; + case CreatorLinkType.gumroad: + return Icons.shopping_bag_rounded; + case CreatorLinkType.subscribestar: + return Icons.star_rounded; + case CreatorLinkType.artstation: + case CreatorLinkType.deviantart: + return Icons.brush_rounded; + case CreatorLinkType.linkhub: + return Icons.link_rounded; + case CreatorLinkType.other: + return Icons.language_rounded; + } + } + + static final _urlRegex = RegExp( + r'''https?://[^\s<>"{}|\\^`\[\]]+''', + caseSensitive: false, + ); + + static final _htmlLinkRegex = RegExp( + r"""]*href=["']([^"']+)["'][^>]*>(.*?)""", + caseSensitive: false, + dotAll: true, + ); + + static const _cloudDriveHosts = { + 'drive.google.com', + 'mega.nz', + 'mega.co.nz', + 'dropbox.com', + 'pixeldrain.com', + 'mediafire.com', + 'catbox.moe', + 'bunkr.', + 'gofile.io', + 'terabox.com', + '1024tera.com', + 'dubox.com', + }; + + /// Extracts all social media, creator profiles and external web links from text. + static List extractLinks( + String? text, { + bool excludeCloudDrives = true, + }) { + if (text == null || text.trim().isEmpty) return const []; + final results = []; + final seenUrls = {}; + + void processUrl(String rawUrl, {String? customTitle}) { + var url = rawUrl.trim(); + while (url.isNotEmpty && + (url.endsWith(')') || + url.endsWith('.') || + url.endsWith(',') || + url.endsWith(';') || + url.endsWith('!') || + url.endsWith('?') || + url.endsWith(':') || + url.endsWith('>'))) { + url = url.substring(0, url.length - 1); + } + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || !uri.scheme.startsWith('http')) { + return; + } + if (seenUrls.contains(url)) return; + seenUrls.add(url); + + final host = uri.host.toLowerCase(); + if (excludeCloudDrives) { + if (_cloudDriveHosts.any((ch) => host.contains(ch))) { + return; + } + } + + final (type, defaultTitle) = _classifyHost(uri); + results.add(CreatorLink( + url: url, + type: type, + title: (customTitle != null && customTitle.isNotEmpty && customTitle != url) + ? customTitle + : defaultTitle, + )); + } + + // 1. Process HTML anchors + if (text.contains(']*>'), '').trim() ?? ''; + if (href != null && href.isNotEmpty) { + processUrl(href, customTitle: cleanTitle.isNotEmpty ? cleanTitle : null); + } + } + } + + // 2. Process all plain URLs + for (final match in _urlRegex.allMatches(text)) { + final url = match.group(0); + if (url != null) processUrl(url); + } + + return results; + } + + static (CreatorLinkType, String) _classifyHost(Uri uri) { + final host = uri.host.toLowerCase(); + + // YouTube + if (host.contains('youtube.com') || host.contains('youtu.be')) { + return (CreatorLinkType.youtube, 'YouTube'); + } + // X / Twitter + if (host.contains('twitter.com') || host.contains('x.com') || host == 't.co') { + return (CreatorLinkType.twitter, 'X (Twitter)'); + } + // Instagram + if (host.contains('instagram.com') || host.contains('instagr.am')) { + return (CreatorLinkType.instagram, 'Instagram'); + } + // TikTok + if (host.contains('tiktok.com')) { + return (CreatorLinkType.tiktok, 'TikTok'); + } + // Discord + if (host.contains('discord.gg') || host.contains('discord.com')) { + return (CreatorLinkType.discord, 'Discord'); + } + // Pixiv + if (host.contains('pixiv.net')) { + return (CreatorLinkType.pixiv, 'Pixiv'); + } + // Fanbox + if (host.contains('fanbox.cc')) { + return (CreatorLinkType.fanbox, 'Fanbox'); + } + // Patreon + if (host.contains('patreon.com')) { + return (CreatorLinkType.patreon, 'Patreon'); + } + // Fantia + if (host.contains('fantia.jp')) { + return (CreatorLinkType.fantia, 'Fantia'); + } + // Boosty + if (host.contains('boosty.to')) { + return (CreatorLinkType.boosty, 'Boosty'); + } + // Twitch + if (host.contains('twitch.tv')) { + return (CreatorLinkType.twitch, 'Twitch'); + } + // Telegram + if (host.contains('t.me') || host.contains('telegram.me')) { + return (CreatorLinkType.telegram, 'Telegram'); + } + // Gumroad + if (host.contains('gumroad.com')) { + return (CreatorLinkType.gumroad, 'Gumroad'); + } + // SubscribeStar + if (host.contains('subscribestar.adult') || host.contains('subscribestar.com')) { + return (CreatorLinkType.subscribestar, 'SubscribeStar'); + } + // ArtStation + if (host.contains('artstation.com')) { + return (CreatorLinkType.artstation, 'ArtStation'); + } + // DeviantArt + if (host.contains('deviantart.com')) { + return (CreatorLinkType.deviantart, 'DeviantArt'); + } + // Link hubs + if (host.contains('linktr.ee') || host.contains('carrd.co') || host.contains('lit.link')) { + return (CreatorLinkType.linkhub, 'Links Hub'); + } + + // Default clean host name + var cleanHost = host.replaceFirst(RegExp(r'^www\.'), ''); + if (cleanHost.length > 25) { + cleanHost = cleanHost.substring(0, 25); + } + return (CreatorLinkType.other, cleanHost.isNotEmpty ? cleanHost : 'Web Link'); + } +} diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 4dc9046..cbe0142 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -12,6 +12,7 @@ import '../../../core/utils/result.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/empty_view.dart'; import '../../../shared/widgets/error_view.dart'; +import '../../../shared/widgets/formatted_content_text.dart'; import '../../../shared/widgets/post_masonry_grid.dart'; import '../../favorites/presentation/favorites_controller.dart'; import '../../settings/presentation/settings_controller.dart'; @@ -235,7 +236,8 @@ class _ArtistPostsScreenState extends ConsumerState { itemBuilder: (context, index) { final a = _announcements[index]; final cleanContent = CloudLinkExtractor.cleanCommentary(a.content); - final detectedLinks = CloudLinkExtractor.extractLinks(content: a.content); + final detectedCloudLinks = CloudLinkExtractor.extractLinks(content: a.content); + final creatorLinks = CreatorLink.extractLinks(a.content); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -250,18 +252,25 @@ class _ArtistPostsScreenState extends ConsumerState { ), ), const SizedBox(height: 6), - SelectableText( - cleanContent.isNotEmpty ? cleanContent : a.content, + FormattedContentText( + text: cleanContent.isNotEmpty ? cleanContent : a.content, style: theme.textTheme.bodyMedium?.copyWith( height: 1.45, ), ), - if (detectedLinks.isNotEmpty) ...[ + if (creatorLinks.isNotEmpty) ...[ + const SizedBox(height: 10), + CreatorLinkChips( + links: creatorLinks, + title: 'Ссылки автора', + ), + ], + if (detectedCloudLinks.isNotEmpty) ...[ const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 6, - children: detectedLinks.map((link) { + children: detectedCloudLinks.map((link) { return ActionChip( avatar: Icon( link.iconData, diff --git a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart index cf2f15f..4ee267d 100644 --- a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart +++ b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart @@ -4,6 +4,8 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../../app/app_strings.dart'; import '../../../../backend/models/cloud_media_link.dart'; +import '../../../../backend/models/creator_link.dart'; +import '../../../../shared/widgets/formatted_content_text.dart'; class CloudMirrorsCard extends StatelessWidget { const CloudMirrorsCard({ @@ -186,13 +188,26 @@ class CloudMirrorsCard extends StatelessWidget { ], ), const SizedBox(height: 8), - SelectableText( - commentary!.trim(), + FormattedContentText( + text: commentary!.trim(), style: theme.textTheme.bodySmall?.copyWith( height: 1.45, color: theme.colorScheme.onSurfaceVariant, ), ), + Builder( + builder: (context) { + final creatorLinks = CreatorLink.extractLinks(commentary); + if (creatorLinks.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 10), + child: CreatorLinkChips( + links: creatorLinks, + title: strings.ru ? 'Ссылки автора' : 'Author Links', + ), + ); + }, + ), ], ], ), diff --git a/lib/shared/widgets/formatted_content_text.dart b/lib/shared/widgets/formatted_content_text.dart new file mode 100644 index 0000000..5dff8f1 --- /dev/null +++ b/lib/shared/widgets/formatted_content_text.dart @@ -0,0 +1,261 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../backend/models/creator_link.dart'; + +class FormattedContentText extends StatefulWidget { + const FormattedContentText({ + super.key, + required this.text, + this.style, + this.linkColor, + this.selectable = true, + }); + + final String text; + final TextStyle? style; + final Color? linkColor; + final bool selectable; + + @override + State createState() => _FormattedContentTextState(); +} + +class _FormattedContentTextState extends State { + final List _recognizers = []; + + @override + void dispose() { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + super.dispose(); + } + + void _clearRecognizers() { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + } + + Future _launch(String url) async { + final uri = Uri.tryParse(url); + if (uri != null) { + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Не удалось открыть ссылку: $url')), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + _clearRecognizers(); + + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final defaultStyle = widget.style ?? theme.textTheme.bodyMedium ?? const TextStyle(); + final linkStyle = defaultStyle.copyWith( + color: widget.linkColor ?? scheme.primary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + decorationColor: (widget.linkColor ?? scheme.primary).withValues(alpha: 0.45), + ); + + final text = widget.text; + if (text.isEmpty) return const SizedBox.shrink(); + + final urlRegex = RegExp(r'''https?://[^\s<>"{}|\\^`\[\]]+''', caseSensitive: false); + final spans = []; + var lastIndex = 0; + + for (final match in urlRegex.allMatches(text)) { + if (match.start > lastIndex) { + spans.add(TextSpan( + text: text.substring(lastIndex, match.start), + style: defaultStyle, + )); + } + + var rawUrl = match.group(0)!; + var cleanUrl = rawUrl; + var trailing = ''; + while (cleanUrl.isNotEmpty && + (cleanUrl.endsWith(')') || + cleanUrl.endsWith('.') || + cleanUrl.endsWith(',') || + cleanUrl.endsWith(';') || + cleanUrl.endsWith('!') || + cleanUrl.endsWith('?') || + cleanUrl.endsWith(':') || + cleanUrl.endsWith('>'))) { + trailing = cleanUrl[cleanUrl.length - 1] + trailing; + cleanUrl = cleanUrl.substring(0, cleanUrl.length - 1); + } + + final recognizer = TapGestureRecognizer() + ..onTap = () => _launch(cleanUrl); + _recognizers.add(recognizer); + + spans.add(TextSpan( + text: cleanUrl, + style: linkStyle, + recognizer: recognizer, + )); + + if (trailing.isNotEmpty) { + spans.add(TextSpan( + text: trailing, + style: defaultStyle, + )); + } + + lastIndex = match.end; + } + + if (lastIndex < text.length) { + spans.add(TextSpan( + text: text.substring(lastIndex), + style: defaultStyle, + )); + } + + if (widget.selectable) { + return SelectableText.rich( + TextSpan(children: spans), + style: defaultStyle, + ); + } + + return Text.rich( + TextSpan(children: spans), + style: defaultStyle, + ); + } +} + +class CreatorLinkChips extends StatelessWidget { + const CreatorLinkChips({ + super.key, + required this.links, + this.title, + }); + + final List links; + final String? title; + + Future _openLink(BuildContext context, String url) async { + final uri = Uri.tryParse(url); + if (uri != null) { + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Не удалось открыть ссылку: $url')), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + if (links.isEmpty) return const SizedBox.shrink(); + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null && title!.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Icon( + Icons.link_rounded, + size: 15, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + title!, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ], + ), + ), + ], + Wrap( + spacing: 8, + runSpacing: 6, + children: links.map((link) { + final brand = link.brandColor; + final labelText = link.title.isNotEmpty ? link.title : link.serviceName; + + return Material( + color: brand.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => _openLink(context, link.url), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: brand.withValues(alpha: 0.35), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + link.iconData, + size: 16, + color: brand, + ), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: Text( + labelText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.arrow_outward_rounded, + size: 12, + color: brand.withValues(alpha: 0.75), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 3e67b74..9dd188f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 3.0.4+55 +version: 3.0.5+56 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/creator_link_test.dart b/test/backend/creator_link_test.dart new file mode 100644 index 0000000..da42c13 --- /dev/null +++ b/test/backend/creator_link_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/creator_link.dart'; + +void main() { + group('CreatorLink extractor and classification tests', () { + test('extracts and classifies social and creator links accurately', () { + const text = ''' +Check out my latest speedpaint on YouTube: https://youtu.be/dQw4w9WgXcQ +Also follow my X account https://x.com/artist_handle and Instagram: https://instagram.com/p/C1234567/! +Support me on Patreon: https://www.patreon.com/my_art or Fanbox: https://artist.fanbox.cc +Join my Discord: https://discord.gg/invitelink +Other links: https://carrd.co/myportfolio and https://example.com/blog. +Cloud links like https://drive.google.com/file/d/123/view and https://mega.nz/file/abc should be excluded by default. +'''; + + final links = CreatorLink.extractLinks(text); + + expect(links.length, 8); + + final yt = links.firstWhere((l) => l.type == CreatorLinkType.youtube); + expect(yt.serviceName, 'YouTube'); + expect(yt.url, 'https://youtu.be/dQw4w9WgXcQ'); + + final twitter = links.firstWhere((l) => l.type == CreatorLinkType.twitter); + expect(twitter.serviceName, 'X (Twitter)'); + expect(twitter.url, 'https://x.com/artist_handle'); + + final insta = links.firstWhere((l) => l.type == CreatorLinkType.instagram); + expect(insta.serviceName, 'Instagram'); + expect(insta.url, 'https://instagram.com/p/C1234567/'); + + final patreon = links.firstWhere((l) => l.type == CreatorLinkType.patreon); + expect(patreon.serviceName, 'Patreon'); + + final fanbox = links.firstWhere((l) => l.type == CreatorLinkType.fanbox); + expect(fanbox.serviceName, 'Fanbox'); + + final discord = links.firstWhere((l) => l.type == CreatorLinkType.discord); + expect(discord.serviceName, 'Discord'); + + final hub = links.firstWhere((l) => l.type == CreatorLinkType.linkhub); + expect(hub.serviceName, 'Links Hub'); + + final web = links.firstWhere((l) => l.type == CreatorLinkType.other); + expect(web.url, 'https://example.com/blog'); + + // Verify cloud storage was excluded + expect(links.any((l) => l.url.contains('drive.google.com')), isFalse); + expect(links.any((l) => l.url.contains('mega.nz')), isFalse); + }); + + test('handles HTML anchor links with custom titles', () { + const html = ''' +

Visit my Twitter Profile and watch on YouTube Stream!

+'''; + + final links = CreatorLink.extractLinks(html); + expect(links.length, 2); + + final tw = links.firstWhere((l) => l.type == CreatorLinkType.twitter); + expect(tw.title, 'Twitter Profile'); + + final yt = links.firstWhere((l) => l.type == CreatorLinkType.youtube); + expect(yt.title, 'YouTube Stream'); + }); + }); +}