From 67c69943118e35f0ab5e26c212b1a5951b58a5d9 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 5 Sep 2026 10:24:20 +0300 Subject: [PATCH] feat: redesign artist messages/announcements & native text-only posts (v3.3.0) --- README.md | 2 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 11 + lib/backend/models/post.dart | 12 +- lib/backend/providers/kemono_provider.dart | 38 +- lib/backend/providers/pawchive_provider.dart | 19 +- .../presentation/artist_posts_screen.dart | 326 ++++++++++++++---- .../widgets/post_media_viewer.dart | 192 ++++++++++- lib/shared/widgets/post_card.dart | 203 +++++++++-- pubspec.yaml | 2 +- 10 files changed, 689 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 6fdd1d9..c9619c2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.2.0 + Version 3.3.0 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 9564949..f17701f 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.2.0'; -const appBuildNumber = 71; +const appDisplayVersion = '3.3.0'; +const appBuildNumber = 72; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 7341d82..eb2090b 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,15 @@ const lunarisChangelog = [ + LunarisChange( + version: '3.3.0', + title: 'Редизайн личных сообщений и рассылок артиста, нативная поддержка текстовых публикаций', + bullets: [ + 'Центр сообщений и рассылок автора: полностью переработан диалог по кнопке-рупору на экране артиста — аккуратные карточки сообщений в стиле мессенджера, бейджи сервисов, точное время публикации и кнопка быстрого копирования текста в буфер обмена.', + 'Нативная поддержка текстовых постов: публикации без медиафайлов больше не вызывают ошибок и пустых плейсхолдеров — в сетке они отображаются как стильные заметки с заголовком и выдержкой текста.', + 'Удобное чтение статей и заметок: при открытии текстового поста отображается комфортный ридер с форматированным текстом, ссылками автора и тегами.', + 'Фильтрация по типу контента: на экране автора добавлен фильтр «Текст» с подсчетом количества публикаций наряду с фото, видео и GIF.', + 'Поддержка анонсов в Kemono и Coomer: API анонсов и персональных сообщений теперь полноценно работает для всех поддерживаемых провайдеров.', + ], + ), LunarisChange( version: '3.2.0', title: 'Стабильный полноэкранный режим: сохранение воспроизведения, устранение вылетов плеера и чистый интерфейс', diff --git a/lib/backend/models/post.dart b/lib/backend/models/post.dart index 55e4810..930a35e 100644 --- a/lib/backend/models/post.dart +++ b/lib/backend/models/post.dart @@ -60,11 +60,21 @@ class Post { return null; } + String? get description => commentary; + + String? get title { + final list = tagGroups['copyright']; + if (list != null && list.isNotEmpty) { + return list.first; + } + return null; + } + List get cleanTags { return tags.where((tag) { final t = tag.trim(); if (t.isEmpty) return false; - if (t == 'cloud_mirror') return false; + if (t == 'cloud_mirror' || t == 'text_post') 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; diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart index e6143c9..daba43d 100644 --- a/lib/backend/providers/kemono_provider.dart +++ b/lib/backend/providers/kemono_provider.dart @@ -230,8 +230,23 @@ class KemonoProvider @override Future> getArtistAnnouncements( - String service, String artistId) async => - const []; + String service, String artistId) async { + final path = '/api/v1/$service/user/$artistId/announcements'; + for (final apiBase in _apiBaseCandidates()) { + try { + final response = await _getFromApiBase(apiBase, path); + final items = response.data; + if (items is List) { + return items + .whereType() + .map((item) => + ArtistAnnouncement.fromJson(Map.from(item))) + .toList(growable: false); + } + } catch (_) {} + } + return const []; + } String _creatorPath() => _isCoomer ? '/coomer' : '/kemono'; @@ -385,17 +400,23 @@ class KemonoProvider } // Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.) - if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) { + // or text-only posts where author wrote an announcement, status or note + if (files.isEmpty && + (cloudLinks.isNotEmpty || + cleanDesc.isNotEmpty || + rawContent.trim().isNotEmpty || + title.trim().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 isTextOnly = cloudLinks.isEmpty; final tags = [ query.service, query.artistName, if (title.trim().isNotEmpty) title.trim(), - 'cloud_mirror', + if (!isTextOnly) 'cloud_mirror' else 'text_post', ]; return [ Post( @@ -411,14 +432,19 @@ class KemonoProvider height: 0, source: postSource, createdAt: published, - fileType: firstStreamable != null ? 'video' : 'link', + fileType: firstStreamable != null + ? 'video' + : (isTextOnly ? 'text' : '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], + if (cleanDesc.isNotEmpty) + 'description': [cleanDesc] + else if (rawContent.trim().isNotEmpty) + 'description': [rawContent.trim()], }, ), ]; diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart index 016faa5..0a1555b 100644 --- a/lib/backend/providers/pawchive_provider.dart +++ b/lib/backend/providers/pawchive_provider.dart @@ -418,17 +418,23 @@ class PawchiveProvider } // Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.) - if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) { + // or text-only posts where author wrote an announcement, status or note + if (files.isEmpty && + (cloudLinks.isNotEmpty || + cleanDesc.isNotEmpty || + rawContent.trim().isNotEmpty || + title.trim().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 isTextOnly = cloudLinks.isEmpty; final tags = [ query.service, query.artistName, if (title.trim().isNotEmpty) title.trim(), - 'cloud_mirror', + if (!isTextOnly) 'cloud_mirror' else 'text_post', ]; return [ Post( @@ -444,14 +450,19 @@ class PawchiveProvider height: 0, source: postSource, createdAt: published, - fileType: firstStreamable != null ? 'video' : 'link', + fileType: firstStreamable != null + ? 'video' + : (isTextOnly ? 'text' : '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], + if (cleanDesc.isNotEmpty) + 'description': [cleanDesc] + else if (rawContent.trim().isNotEmpty) + 'description': [rawContent.trim()], }, ), ]; diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index cbe0142..7e7adfc 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -191,11 +192,13 @@ class _ArtistPostsScreenState extends ConsumerState { if (_selectedTypes.isEmpty) return true; final isVid = MediaUrlSelector.isVideo(post); final isGif = MediaUrlSelector.isGif(post); - final isPhoto = !isVid && !isGif; + final isText = post.fileType == 'text'; + final isPhoto = !isVid && !isGif && !isText; if (_selectedTypes.contains('video') && isVid) return true; if (_selectedTypes.contains('gif') && isGif) return true; if (_selectedTypes.contains('photo') && isPhoto) return true; + if (_selectedTypes.contains('text') && isText) return true; return false; } @@ -205,94 +208,246 @@ class _ArtistPostsScreenState extends ConsumerState { context: context, isScrollControlled: true, showDragHandle: true, + backgroundColor: theme.colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), builder: (ctx) => DraggableScrollableSheet( expand: false, - initialChildSize: 0.6, - maxChildSize: 0.9, + initialChildSize: 0.65, + maxChildSize: 0.92, builder: (_, scroll) => Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + padding: const EdgeInsets.fromLTRB(20, 4, 16, 12), child: Row( children: [ - Icon(Icons.campaign_rounded, color: theme.colorScheme.primary), - const SizedBox(width: 10), - Text( - 'Анонсы автора (${_announcements.length})', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.campaign_rounded, + color: theme.colorScheme.onPrimaryContainer, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Личные сообщения и анонсы', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 2), + Text( + '${widget.artistName} • ${widget.service.toUpperCase()}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_announcements.length}', + style: TextStyle( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + fontSize: 12, + ), ), ), ], ), ), - const Divider(), + Divider( + height: 1, + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4), + ), Expanded( - child: ListView.separated( - controller: scroll, - padding: const EdgeInsets.all(16), - itemCount: _announcements.length, - separatorBuilder: (_, __) => const Divider(height: 24), - itemBuilder: (context, index) { - final a = _announcements[index]; - final cleanContent = CloudLinkExtractor.cleanCommentary(a.content); - final detectedCloudLinks = CloudLinkExtractor.extractLinks(content: a.content); - final creatorLinks = CreatorLink.extractLinks(a.content); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (a.added != null) - Text( - '${a.added!.year}-${a.added!.month.toString().padLeft(2, '0')}-${a.added!.day.toString().padLeft(2, '0')}', - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, + child: _announcements.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.mark_email_read_outlined, + size: 48, + color: theme.colorScheme.outline, ), - ), - const SizedBox(height: 6), - FormattedContentText( - text: cleanContent.isNotEmpty ? cleanContent : a.content, - style: theme.textTheme.bodyMedium?.copyWith( - height: 1.45, - ), + const SizedBox(height: 12), + Text( + 'Нет сообщений или рассылок', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], ), - if (creatorLinks.isNotEmpty) ...[ - const SizedBox(height: 10), - CreatorLinkChips( - links: creatorLinks, - title: 'Ссылки автора', - ), - ], - if (detectedCloudLinks.isNotEmpty) ...[ - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 6, - children: detectedCloudLinks.map((link) { - return ActionChip( - avatar: Icon( - link.iconData, - size: 16, - color: link.brandColor, + ) + : ListView.separated( + controller: scroll, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + itemCount: _announcements.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final a = _announcements[index]; + final cleanContent = + CloudLinkExtractor.cleanCommentary(a.content); + final detectedCloudLinks = + CloudLinkExtractor.extractLinks(content: a.content); + final creatorLinks = + CreatorLink.extractLinks(a.content); + final displayContent = cleanContent.isNotEmpty + ? cleanContent + : a.content; + + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: theme.colorScheme.outlineVariant + .withValues(alpha: 0.45), + ), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + (a.service.isNotEmpty + ? a.service + : widget.service) + .toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + letterSpacing: 0.5, + ), + ), + ), + if (a.added != null) ...[ + const SizedBox(width: 8), + Icon( + Icons.access_time_rounded, + size: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '${a.added!.day.toString().padLeft(2, '0')}.${a.added!.month.toString().padLeft(2, '0')}.${a.added!.year} ${a.added!.hour.toString().padLeft(2, '0')}:${a.added!.minute.toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 11.5, + color: + theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], + const Spacer(), + IconButton( + icon: const Icon(Icons.copy_rounded, + size: 17), + tooltip: 'Скопировать текст', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Clipboard.setData( + ClipboardData(text: displayContent), + ); + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: + Text('Текст сообщения скопирован'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + ], ), - label: Text( - link.title.isNotEmpty ? link.title : link.serviceName, - style: const TextStyle(fontSize: 12), + const SizedBox(height: 10), + FormattedContentText( + text: displayContent, + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.5, + letterSpacing: 0.15, + ), ), - onPressed: () => launchUrl( - Uri.parse(link.url), - mode: LaunchMode.externalApplication, - ), - ); - }).toList(), - ), - ], - ], - ); - }, - ), + if (creatorLinks.isNotEmpty) ...[ + const SizedBox(height: 12), + CreatorLinkChips( + links: creatorLinks, + title: 'Ссылки из сообщения', + ), + ], + if (detectedCloudLinks.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 6, + children: detectedCloudLinks.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(), + ), + ], + ], + ), + ); + }, + ), ), ], ), @@ -303,10 +458,14 @@ class _ArtistPostsScreenState extends ConsumerState { Widget _buildMediaTypeFilters() { final scheme = Theme.of(context).colorScheme; final photoCount = _posts - .where((p) => !MediaUrlSelector.isVideo(p) && !MediaUrlSelector.isGif(p)) + .where((p) => + p.fileType != 'text' && + !MediaUrlSelector.isVideo(p) && + !MediaUrlSelector.isGif(p)) .length; final videoCount = _posts.where(MediaUrlSelector.isVideo).length; final gifCount = _posts.where(MediaUrlSelector.isGif).length; + final textCount = _posts.where((p) => p.fileType == 'text').length; return Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 4), @@ -376,6 +535,27 @@ class _ArtistPostsScreenState extends ConsumerState { }); }, ), + if (textCount > 0) ...[ + const SizedBox(width: 8), + FilterChip( + selected: _selectedTypes.contains('text'), + avatar: Icon(Icons.article_outlined, + size: 16, + color: _selectedTypes.contains('text') + ? scheme.onPrimaryContainer + : scheme.onSurfaceVariant), + label: Text('Текст ($textCount)'), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add('text'); + } else { + _selectedTypes.remove('text'); + } + }); + }, + ), + ], ], ), ), diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 237c041..d819664 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -13,6 +13,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../../app/motion.dart'; import '../../../../backend/backend.dart'; +import '../../../../shared/widgets/formatted_content_text.dart'; final Map _playbackMemory = {}; @@ -251,17 +252,20 @@ class _PostMediaViewerState extends State } 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); - } - }, - ); + if (widget.post.cloudLinks.isNotEmpty) { + 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); + } + }, + ); + } + return _TextArticleHero(post: widget.post); } final url = _imageUrls[_imageIndex]; @@ -2510,3 +2514,169 @@ class _CloudMediaHero extends StatelessWidget { } } +class _TextArticleHero extends StatelessWidget { + const _TextArticleHero({required this.post}); + + final Post post; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final title = (post.title ?? '').trim(); + final cleanContent = + CloudLinkExtractor.cleanCommentary(post.description ?? ''); + final displayContent = cleanContent.isNotEmpty + ? cleanContent + : (title.isNotEmpty ? '' : 'Публикация автора без текста и вложений'); + final creatorLinks = CreatorLink.extractLinks(post.description ?? ''); + final cleanTags = post.cleanTags; + + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Container( + constraints: const BoxConstraints(maxWidth: 680), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.article_rounded, + color: theme.colorScheme.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Текстовая публикация', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + letterSpacing: 0.5, + ), + ), + Text( + '${post.providerName} • ${post.createdAt.day.toString().padLeft(2, '0')}.${post.createdAt.month.toString().padLeft(2, '0')}.${post.createdAt.year}', + style: TextStyle( + fontSize: 11.5, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded, size: 18), + tooltip: 'Скопировать текст', + onPressed: () { + final textToCopy = [ + if (title.isNotEmpty) title, + if (displayContent.isNotEmpty) displayContent, + ].join('\n\n'); + Clipboard.setData(ClipboardData(text: textToCopy)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Текст скопирован в буфер обмена'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + ], + ), + if (title.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -0.3, + ), + ), + ], + const SizedBox(height: 14), + Divider( + height: 1, + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + const SizedBox(height: 16), + if (displayContent.isNotEmpty) + FormattedContentText( + text: displayContent, + style: theme.textTheme.bodyLarge?.copyWith( + height: 1.6, + letterSpacing: 0.15, + ), + ), + if (creatorLinks.isNotEmpty) ...[ + const SizedBox(height: 18), + CreatorLinkChips( + links: creatorLinks, + title: 'Ссылки из публикации', + ), + ], + if (cleanTags.isNotEmpty) ...[ + const SizedBox(height: 18), + Wrap( + spacing: 6, + runSpacing: 6, + children: cleanTags.take(8).map((t) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3.5, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '#$t', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ), + ); + } +} + diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index 2e979f1..bb95076 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -117,9 +117,14 @@ class _PostCardState extends ConsumerState Widget build(BuildContext context) { final post = _resolvedPost ?? widget.post; final sensitive = _isSensitive(post.rating); + final isTextPost = post.fileType == 'text' || + (post.previewUrl.trim().isEmpty && + post.sampleUrl.trim().isEmpty && + post.fileUrl.trim().isEmpty && + !MediaUrlSelector.isVideo(post)); final aspect = post.width > 0 && post.height > 0 ? post.width / post.height - : _fallbackAspect(post.fileType); + : _fallbackAspect(isTextPost ? 'text' : post.fileType); final mobile = MediaQuery.sizeOf(context).width < 700; final urls = _feedUrls(post, mobile: mobile); final imageUrl = urls.isEmpty ? post.previewUrl : urls.first; @@ -202,6 +207,12 @@ class _PostCardState extends ConsumerState enabled: widget.blurExplicit && sensitive, child: LayoutBuilder( builder: (context, constraints) { + if (isTextPost) { + return _TextPostCardPreview( + post: post, + hovered: _hovered && !mobile, + ); + } final dpr = MediaQuery.devicePixelRatioOf(context); final cacheWidth = (constraints.maxWidth * dpr).round().clamp(320, 1800); @@ -239,27 +250,28 @@ class _PostCardState extends ConsumerState ), ), ), - // Bottom cinematic gradient overlay for badges and favorite - Positioned( - left: 0, - right: 0, - bottom: 0, - height: 64, - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black.withValues(alpha: 0.72), - Colors.transparent, - ], + // Bottom cinematic gradient overlay for badges and favorite (only on media posts) + if (!isTextPost) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 64, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withValues(alpha: 0.72), + Colors.transparent, + ], + ), ), ), ), ), - ), if (widget.showBadges) ...[ Positioned( left: 8, @@ -269,11 +281,14 @@ class _PostCardState extends ConsumerState if (post.fileType.toLowerCase().contains('video') || post.fileType.toLowerCase().contains('webm') || post.fileType.toLowerCase().contains('mp4') || - post.fileType.toLowerCase().contains('gif')) + post.fileType.toLowerCase().contains('gif') || + isTextPost) Positioned( right: 8, top: 8, - child: _MediaBadge(fileType: post.fileType), + child: _MediaBadge( + fileType: isTextPost ? 'text' : post.fileType, + ), ), Positioned( left: 8, @@ -434,6 +449,7 @@ class _PostCardState extends ConsumerState double _fallbackAspect(String fileType) { final normalized = fileType.toLowerCase(); + if (normalized == 'text') return 0.95; if (normalized.contains('video') || normalized.contains('webm')) { return 16 / 9; } @@ -775,7 +791,7 @@ class _MediaBadge extends StatelessWidget { Icon(_icon(type), size: 13, color: Colors.white), const SizedBox(width: 3.5), Text( - type, + _label(type), style: const TextStyle( fontSize: 10.5, color: Colors.white, @@ -796,14 +812,159 @@ class _MediaBadge extends StatelessWidget { return 'video'; } if (value.contains('gif')) return 'gif'; + if (value.contains('text')) return 'text'; return 'photo'; } IconData _icon(String type) => switch (type) { 'video' => Icons.play_arrow_rounded, 'gif' => Icons.gif_box_rounded, + 'text' => Icons.article_rounded, _ => Icons.image_rounded, }; + + String _label(String type) => switch (type) { + 'video' => 'VIDEO', + 'gif' => 'GIF', + 'text' => 'ТЕКСТ', + _ => 'PHOTO', + }; +} + +class _TextPostCardPreview extends StatelessWidget { + const _TextPostCardPreview({ + required this.post, + required this.hovered, + }); + + final Post post; + final bool hovered; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final title = (post.title ?? '').trim(); + final cleanContent = + CloudLinkExtractor.cleanCommentary(post.description ?? ''); + final hasTitle = title.isNotEmpty; + final snippet = cleanContent.isNotEmpty + ? cleanContent + : (hasTitle ? '' : 'Текстовая публикация автора без вложений'); + final cloudCount = post.cloudLinks.length; + + return Container( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.surfaceContainerHigh, + theme.colorScheme.surfaceContainer, + ], + ), + border: Border.all( + color: hovered + ? theme.colorScheme.primary.withValues(alpha: 0.5) + : theme.colorScheme.outlineVariant.withValues(alpha: 0.35), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + child: Icon( + Icons.notes_rounded, + size: 15, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${post.createdAt.day.toString().padLeft(2, '0')}.${post.createdAt.month.toString().padLeft(2, '0')}.${post.createdAt.year}', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + if (hasTitle) ...[ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + height: 1.25, + ), + ), + const SizedBox(height: 6), + ], + if (snippet.isNotEmpty) + Expanded( + child: Text( + snippet, + maxLines: hasTitle ? 5 : 7, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.42, + ), + ), + ) + else + const Spacer(), + if (cloudCount > 0) ...[ + const SizedBox(height: 6), + Row( + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cloud_download_outlined, + size: 12, + color: theme.colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 4), + Text( + 'Вложения ($cloudCount)', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ), + ], + ), + ], + ], + ), + ); + } } class _SeenBadge extends StatelessWidget { diff --git a/pubspec.yaml b/pubspec.yaml index c3b6731..62d86a6 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.2.0+71 +version: 3.3.0+72 environment: sdk: ">=3.3.0 <4.0.0"