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 @@
-
+
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 {