From c55881e7a88198db8fe0bb34900d87606f8f1c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:48:01 +0100 Subject: [PATCH] feat: display file attachments in Feed and Detail screens - Extend FeedPost with fileUrl and fileName fields - Parse mobiledoc file cards in FeedViewModel.extractFileCardFromMobiledoc() - Map LocalPost file fields to FeedPost in toFeedPost() - Create FileAttachmentCard composable with file type icon colors and tap-to-download - Integrate file card into PostCardContent (FeedScreen) and DetailScreen --- .../microblog/data/model/GhostModels.kt | 4 +- .../microblog/ui/detail/DetailScreen.kt | 17 ++++ .../swoosh/microblog/ui/feed/FeedScreen.kt | 80 +++++++++++++++++++ .../swoosh/microblog/ui/feed/FeedViewModel.kt | 34 +++++++- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index c99eef3..45dca66 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -143,7 +143,9 @@ data class FeedPost( val createdAt: String?, val updatedAt: String?, val isLocal: Boolean = false, - val queueStatus: QueueStatus = QueueStatus.NONE + val queueStatus: QueueStatus = QueueStatus.NONE, + val fileUrl: String? = null, + val fileName: String? = null ) @Stable diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index 8fb0070..dcfa1f1 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -54,6 +54,7 @@ import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.ui.animation.SwooshMotion import com.swoosh.microblog.ui.components.ConfirmationDialog +import com.swoosh.microblog.ui.feed.FileAttachmentCard import com.swoosh.microblog.ui.feed.FullScreenGallery import com.swoosh.microblog.ui.feed.StatusBadge import com.swoosh.microblog.ui.feed.formatRelativeTime @@ -348,6 +349,22 @@ fun DetailScreen( } } + // File attachment + if (post.fileUrl != null) { + AnimatedVisibility( + visible = sectionVisible[4].value, + enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle()) + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + FileAttachmentCard( + fileUrl = post.fileUrl, + fileName = post.fileName ?: "File" + ) + } + } + } + // Section 5 — PostStatsSection AnimatedVisibility( visible = sectionVisible[5].value, diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index a29db61..8395ce4 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import android.content.Intent import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -1591,6 +1592,15 @@ fun PostCardContent( } } + // File attachment + if (post.fileUrl != null) { + Spacer(modifier = Modifier.height(12.dp)) + FileAttachmentCard( + fileUrl = post.fileUrl, + fileName = post.fileName ?: "File" + ) + } + // Tags display if (post.tags.isNotEmpty()) { Spacer(modifier = Modifier.height(10.dp)) @@ -2176,3 +2186,73 @@ fun StatusBadge(post: FeedPost) { border = null ) } + +/** + * Returns an appropriate icon tint color based on file extension. + */ +@Composable +private fun fileTypeColorFromName(fileName: String): Color { + val lower = fileName.lowercase() + return when { + lower.endsWith(".pdf") -> Color(0xFFD32F2F) // red + lower.endsWith(".doc") || lower.endsWith(".docx") -> Color(0xFF1565C0) // blue + lower.endsWith(".txt") || lower.endsWith(".csv") -> Color(0xFF757575) // gray + lower.endsWith(".xls") || lower.endsWith(".xlsx") -> Color(0xFF2E7D32) // green + lower.endsWith(".ppt") || lower.endsWith(".pptx") -> Color(0xFFE65100) // orange + lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".gz") -> Color(0xFF6A1B9A) // purple + else -> MaterialTheme.colorScheme.onSurfaceVariant + } +} + +/** + * Card displaying a file attachment in Feed/Detail screens. + * Tapping opens the file URL in an external viewer. + */ +@Composable +fun FileAttachmentCard( + fileUrl: String, + fileName: String +) { + val context = LocalContext.current + val iconTint = fileTypeColorFromName(fileName) + + OutlinedCard( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse(fileUrl)) + context.startActivity(intent) + } catch (_: Exception) { + // No handler available for this file type + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.InsertDriveFile, + contentDescription = "File attachment", + modifier = Modifier.size(28.dp), + tint = iconTint + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = fileName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Tap to download", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 744a3cb..0173054 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -535,6 +535,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { allImages.add(url) } } + val fileData = extractFileCardFromMobiledoc(mobiledoc) return FeedPost( ghostId = id, slug = slug, @@ -555,7 +556,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { publishedAt = published_at, createdAt = created_at, updatedAt = updated_at, - isLocal = false + isLocal = false, + fileUrl = fileData?.first, + fileName = fileData?.second ) } @@ -580,6 +583,31 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * Extracts file card data from Ghost mobiledoc JSON. + * File cards have the format: ["file", {"src": "url", "fileName": "name"}] + * Returns a Pair of (fileUrl, fileName) or null if no file card. + */ + private fun extractFileCardFromMobiledoc(mobiledoc: String?): Pair? { + if (mobiledoc == null) return null + return try { + val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject + val cards = json.getAsJsonArray("cards") ?: return null + for (card in cards) { + val cardArray = card.asJsonArray + if (cardArray.size() >= 2 && cardArray[0].asString == "file") { + val cardData = cardArray[1].asJsonObject + val src = cardData.get("src")?.asString ?: continue + val name = cardData.get("fileName")?.asString ?: "file" + return Pair(src, name) + } + } + null + } catch (e: Exception) { + null + } + } + private fun LocalPost.toFeedPost(): FeedPost { val tagNames: List = try { Gson().fromJson(tags, object : TypeToken>() {}.type) ?: emptyList() @@ -615,7 +643,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { createdAt = null, updatedAt = null, isLocal = true, - queueStatus = queueStatus + queueStatus = queueStatus, + fileUrl = uploadedFileUrl ?: fileUri, + fileName = fileName ) }