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
This commit is contained in:
Paweł Orzech 2026-03-20 00:48:01 +01:00
parent 74dac1db6f
commit c55881e7a8
4 changed files with 132 additions and 3 deletions

View file

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

View file

@ -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,

View file

@ -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
)
}
}
}
}

View file

@ -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<String, String>? {
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<String> = try {
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.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
)
}