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 @@
-
+
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');
+ });
+ });
+}