mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
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:
parent
74dac1db6f
commit
c55881e7a8
4 changed files with 132 additions and 3 deletions
|
|
@ -143,7 +143,9 @@ data class FeedPost(
|
||||||
val createdAt: String?,
|
val createdAt: String?,
|
||||||
val updatedAt: String?,
|
val updatedAt: String?,
|
||||||
val isLocal: Boolean = false,
|
val isLocal: Boolean = false,
|
||||||
val queueStatus: QueueStatus = QueueStatus.NONE
|
val queueStatus: QueueStatus = QueueStatus.NONE,
|
||||||
|
val fileUrl: String? = null,
|
||||||
|
val fileName: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import com.swoosh.microblog.data.model.PostStats
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||||
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
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.FullScreenGallery
|
||||||
import com.swoosh.microblog.ui.feed.StatusBadge
|
import com.swoosh.microblog.ui.feed.StatusBadge
|
||||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
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
|
// Section 5 — PostStatsSection
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = sectionVisible[5].value,
|
visible = sectionVisible[5].value,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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
|
// Tags display
|
||||||
if (post.tags.isNotEmpty()) {
|
if (post.tags.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
@ -2176,3 +2186,73 @@ fun StatusBadge(post: FeedPost) {
|
||||||
border = null
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
allImages.add(url)
|
allImages.add(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val fileData = extractFileCardFromMobiledoc(mobiledoc)
|
||||||
return FeedPost(
|
return FeedPost(
|
||||||
ghostId = id,
|
ghostId = id,
|
||||||
slug = slug,
|
slug = slug,
|
||||||
|
|
@ -555,7 +556,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
publishedAt = published_at,
|
publishedAt = published_at,
|
||||||
createdAt = created_at,
|
createdAt = created_at,
|
||||||
updatedAt = updated_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 {
|
private fun LocalPost.toFeedPost(): FeedPost {
|
||||||
val tagNames: List<String> = try {
|
val tagNames: List<String> = try {
|
||||||
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||||
|
|
@ -615,7 +643,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
createdAt = null,
|
createdAt = null,
|
||||||
updatedAt = null,
|
updatedAt = null,
|
||||||
isLocal = true,
|
isLocal = true,
|
||||||
queueStatus = queueStatus
|
queueStatus = queueStatus,
|
||||||
|
fileUrl = uploadedFileUrl ?: fileUri,
|
||||||
|
fileName = fileName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue