From f983e3c8c06f056b69b439b60cc4b6cd607e0104 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 5 Sep 2026 10:40:14 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=D1=85=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=20(v3.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 9 + .../presentation/post_details_screen.dart | 32 ++- .../widgets/cloud_mirrors_card.dart | 198 ++++++++++++------ pubspec.yaml | 2 +- test/features/cloud_mirrors_card_test.dart | 16 +- 7 files changed, 183 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index c9619c2..0533987 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.3.0 + Version 3.3.1 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index f17701f..6e13e75 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.3.0'; -const appBuildNumber = 72; +const appDisplayVersion = '3.3.1'; +const appBuildNumber = 73; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index eb2090b..633eadd 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,13 @@ const lunarisChangelog = [ + LunarisChange( + version: '3.3.1', + title: 'Улучшенное разделение текстовых постов и облачных зеркал', + bullets: [ + 'Исключение дублирования в текстовых публикациях: для текстовых постов (без медиа) убрана лишняя плашка «Облачные диски и зеркала», исключая повторный вывод того же текста внизу экрана.', + 'Вкладки «Зеркала» и «Описание»: для постов с фото, видео и GIF в блоке облачных дисков теперь доступны удобные переключаемые вкладки, позволяющие отдельно просматривать список ссылок на хранилища и авторское описание.', + 'Чистый и нативный вид карточек: если у медиапоста есть только ссылки или только описание, отображается аккуратная монолитная секция с соответствующим заголовком и иконкой без лишних переключателей.', + ], + ), LunarisChange( version: '3.3.0', title: 'Редизайн личных сообщений и рассылок артиста, нативная поддержка текстовых публикаций', diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 90ff961..62d93df 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -110,6 +110,16 @@ class PostDetailsScreen extends ConsumerWidget { t.status == DownloadTaskStatus.queued), ) ?? false; + final isTextOnly = post.fileType == 'text' || + (post.previewUrl.isEmpty && + post.sampleUrl.isEmpty && + post.fileUrl.isEmpty && + !MediaUrlSelector.isVideo(post)); + final shouldShowCloudCard = isTextOnly + ? post.cloudLinks.isNotEmpty + : (post.cloudLinks.isNotEmpty || + (post.commentary != null && + post.commentary!.trim().isNotEmpty)); if (Responsive.isMobile(context)) { if (currentIndex >= 0 && feedPosts.length > 1) { @@ -341,14 +351,12 @@ class PostDetailsScreen extends ConsumerWidget { localMedia: localMedia, fileSizeBytes: fileSizeBytes, ), - if (post.cloudLinks.isNotEmpty || - (post.commentary != null && - post.commentary!.trim().isNotEmpty)) ...[ + if (shouldShowCloudCard) ...[ const SizedBox(height: 16), CloudMirrorsCard( links: post.cloudLinks, strings: strings, - commentary: post.commentary, + commentary: isTextOnly ? null : post.commentary, onPlayStream: (streamUrl) => launchUrl( Uri.parse(streamUrl), mode: LaunchMode.externalApplication, @@ -438,6 +446,16 @@ class PostDetailsScreen extends ConsumerWidget { t.status == DownloadTaskStatus.queued), ) ?? false; + final isTextOnly = post.fileType == 'text' || + (post.previewUrl.isEmpty && + post.sampleUrl.isEmpty && + post.fileUrl.isEmpty && + !MediaUrlSelector.isVideo(post)); + final shouldShowCloudCard = isTextOnly + ? post.cloudLinks.isNotEmpty + : (post.cloudLinks.isNotEmpty || + (post.commentary != null && + post.commentary!.trim().isNotEmpty)); return GestureDetector( behavior: HitTestBehavior.translucent, @@ -562,14 +580,12 @@ class PostDetailsScreen extends ConsumerWidget { localMedia: localMedia, fileSizeBytes: fileSizeBytes, ), - if (post.cloudLinks.isNotEmpty || - (post.commentary != null && - post.commentary!.trim().isNotEmpty)) ...[ + if (shouldShowCloudCard) ...[ const SizedBox(height: 12), CloudMirrorsCard( links: post.cloudLinks, strings: strings, - commentary: post.commentary, + commentary: isTextOnly ? null : post.commentary, onPlayStream: (streamUrl) => launchUrl( Uri.parse(streamUrl), mode: LaunchMode.externalApplication, diff --git a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart index 4ee267d..867f3d8 100644 --- a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart +++ b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart @@ -7,7 +7,7 @@ import '../../../../backend/models/cloud_media_link.dart'; import '../../../../backend/models/creator_link.dart'; import '../../../../shared/widgets/formatted_content_text.dart'; -class CloudMirrorsCard extends StatelessWidget { +class CloudMirrorsCard extends StatefulWidget { const CloudMirrorsCard({ super.key, required this.links, @@ -25,9 +25,21 @@ class CloudMirrorsCard extends StatelessWidget { final ValueChanged? onDownloadStream; final bool initiallyExpanded; + @override + State createState() => _CloudMirrorsCardState(); +} + +class _CloudMirrorsCardState extends State { + int _selectedTab = 0; // 0: mirrors, 1: commentary + @override Widget build(BuildContext context) { - if (links.isEmpty && (commentary == null || commentary!.trim().isEmpty)) { + final links = widget.links; + final commentary = widget.commentary; + final hasLinks = links.isNotEmpty; + final hasCommentary = commentary != null && commentary.trim().isNotEmpty; + + if (!hasLinks && !hasCommentary) { return const SizedBox.shrink(); } @@ -37,6 +49,14 @@ class CloudMirrorsCard extends StatelessWidget { .whereType() .firstWhere((p) => p.isNotEmpty, orElse: () => ''); + final cardTitle = hasLinks + ? (widget.strings.ru + ? 'Облачные диски и зеркала (${links.length})' + : 'Cloud Mirrors & Drives (${links.length})') + : (widget.strings.ru ? 'Описание от автора' : 'Artist Commentary'); + + final cardIcon = hasLinks ? Icons.cloud_sync_rounded : Icons.notes_rounded; + return Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerLow, @@ -49,20 +69,18 @@ class CloudMirrorsCard extends StatelessWidget { child: ExpansionTile( tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14), - initiallyExpanded: initiallyExpanded, + initiallyExpanded: widget.initiallyExpanded, shape: const Border(), collapsedShape: const Border(), leading: Icon( - Icons.cloud_sync_rounded, + cardIcon, color: theme.colorScheme.primary, ), title: Row( children: [ Expanded( child: Text( - strings.ru - ? 'Облачные диски и зеркала (${links.length})' - : 'Cloud Mirrors & Drives (${links.length})', + cardTitle, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), @@ -77,7 +95,8 @@ class CloudMirrorsCard extends StatelessWidget { decoration: BoxDecoration( color: Colors.green.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.green.withValues(alpha: 0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -86,7 +105,7 @@ class CloudMirrorsCard extends StatelessWidget { size: 13, color: Colors.green), const SizedBox(width: 4), Text( - strings.ru ? 'Плеер' : 'Stream', + widget.strings.ru ? 'Плеер' : 'Stream', style: theme.textTheme.labelSmall?.copyWith( color: Colors.green, fontWeight: FontWeight.bold, @@ -116,7 +135,9 @@ class CloudMirrorsCard extends StatelessWidget { Expanded( child: Text.rich( TextSpan( - text: strings.ru ? 'Пароль к архиву: ' : 'Archive Password: ', + text: widget.strings.ru + ? 'Пароль к архиву: ' + : 'Archive Password: ', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), @@ -135,13 +156,15 @@ class CloudMirrorsCard extends StatelessWidget { IconButton( icon: const Icon(Icons.copy_rounded, size: 16), visualDensity: VisualDensity.compact, - tooltip: strings.ru ? 'Копировать пароль' : 'Copy Password', + tooltip: widget.strings.ru + ? 'Копировать пароль' + : 'Copy Password', onPressed: () { Clipboard.setData(ClipboardData(text: detectedPassword)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - strings.ru + widget.strings.ru ? 'Пароль скопирован: $detectedPassword' : 'Password copied: $detectedPassword', ), @@ -157,63 +180,111 @@ class CloudMirrorsCard extends StatelessWidget { 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, + if (hasLinks && hasCommentary) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: 0, + icon: const Icon(Icons.cloud_queue_rounded, size: 16), + label: Text( + widget.strings.ru + ? 'Зеркала (${links.length})' + : 'Mirrors (${links.length})', + ), + ), + ButtonSegment( + value: 1, + icon: const Icon(Icons.notes_rounded, size: 16), + label: Text( + widget.strings.ru ? 'Описание' : 'Description', + ), + ), + ], + selected: {_selectedTab}, + onSelectionChanged: (set) => + setState(() => _selectedTab = set.first), ), - 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), - 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', - ), - ); - }, + IndexedStack( + index: _selectedTab, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: links + .map((link) => _buildLinkTile( + context, + link, + widget.strings, + )) + .toList(), + ), + _buildCommentarySection(context, commentary, theme), + ], ), + ] else if (hasLinks) ...[ + ...links.map((link) => _buildLinkTile( + context, + link, + widget.strings, + )), + ] else if (hasCommentary) ...[ + _buildCommentarySection(context, commentary, theme), ], ], ), ); } + Widget _buildCommentarySection( + BuildContext context, + String text, + ThemeData theme, + ) { + final creatorLinks = CreatorLink.extractLinks(text); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.notes_rounded, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + widget.strings.ru ? 'Описание от автора' : 'Artist Commentary', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + FormattedContentText( + text: text.trim(), + style: theme.textTheme.bodySmall?.copyWith( + height: 1.45, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (creatorLinks.isNotEmpty) ...[ + const SizedBox(height: 10), + CreatorLinkChips( + links: creatorLinks, + title: widget.strings.ru ? 'Ссылки автора' : 'Author Links', + ), + ], + ], + ); + } + Widget _buildLinkTile( BuildContext context, CloudMediaLink link, @@ -303,7 +374,7 @@ class CloudMirrorsCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ // Play button (if streamable) - if (link.isStreamable && onPlayStream != null) ...[ + if (link.isStreamable && widget.onPlayStream != null) ...[ FilledButton.tonalIcon( style: FilledButton.styleFrom( visualDensity: VisualDensity.compact, @@ -313,7 +384,7 @@ class CloudMirrorsCard extends StatelessWidget { label: Text(strings.ru ? 'Смотреть' : 'Play'), onPressed: () { final target = link.directStreamUrl ?? link.url; - onPlayStream!(target); + widget.onPlayStream!(target); }, ), const SizedBox(width: 8), @@ -321,12 +392,13 @@ class CloudMirrorsCard extends StatelessWidget { // Download button (if direct streamable and callback exists) if (link.isStreamable && link.directStreamUrl != null && - onDownloadStream != null) ...[ + widget.onDownloadStream != null) ...[ IconButton.filledTonal( visualDensity: VisualDensity.compact, tooltip: strings.download, icon: const Icon(Icons.download_rounded, size: 16), - onPressed: () => onDownloadStream!(link.directStreamUrl!), + onPressed: () => + widget.onDownloadStream!(link.directStreamUrl!), ), const SizedBox(width: 8), ], diff --git a/pubspec.yaml b/pubspec.yaml index 62d86a6..387b786 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.3.0+72 +version: 3.3.1+73 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/features/cloud_mirrors_card_test.dart b/test/features/cloud_mirrors_card_test.dart index ffe9383..62b70d1 100644 --- a/test/features/cloud_mirrors_card_test.dart +++ b/test/features/cloud_mirrors_card_test.dart @@ -55,15 +55,21 @@ void main() { 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 + // Verify play button triggers callback on mirrors tab final playBtn = find.text('Смотреть'); expect(playBtn, findsOneWidget); await tester.tap(playBtn); expect(playedStream, 'https://drive.google.com/uc?export=download&id=123'); + + // Switch to description tab and verify commentary + final descTab = find.text('Описание'); + expect(descTab, findsOneWidget); + await tester.tap(descTab); + await tester.pumpAndSettle(); + + // Verify commentary + expect(find.text('Описание от автора'), findsOneWidget); + expect(find.text('Special Patreon bonus animation for all patrons.'), findsOneWidget); }); testWidgets('CloudMirrorsCard renders nothing if no links and no commentary', (tester) async {