mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
feat: add file attachment support in Composer, MobiledocBuilder, and PostUploadWorker
- Add file card support to MobiledocBuilder with Ghost's native file card format - Add file card tests to MobiledocBuilderTest - Add file state fields to ComposerUiState (fileUri, fileName, fileSize, fileMimeType, uploadedFileUrl) - Add addFile()/removeFile() methods to ComposerViewModel with 50MB size validation - Add file picker button and FileAttachmentComposerCard in ComposerScreen - Update PostUploadWorker to upload files via repository.uploadFile() and include in mobiledoc
This commit is contained in:
parent
2f9b7dac09
commit
74dac1db6f
5 changed files with 339 additions and 10 deletions
|
|
@ -50,10 +50,11 @@ object MobiledocBuilder {
|
|||
}
|
||||
|
||||
/**
|
||||
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first)
|
||||
* and an optional link preview.
|
||||
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first),
|
||||
* an optional link preview, and an optional file attachment.
|
||||
* Each image becomes an image card in the mobiledoc format.
|
||||
* The bookmark card (link preview) is added after image cards.
|
||||
* The file card is added last, after all other cards.
|
||||
*/
|
||||
fun build(
|
||||
text: String,
|
||||
|
|
@ -61,7 +62,10 @@ object MobiledocBuilder {
|
|||
linkUrl: String?,
|
||||
linkTitle: String?,
|
||||
linkDescription: String?,
|
||||
imageAlt: String?
|
||||
imageAlt: String?,
|
||||
fileUrl: String? = null,
|
||||
fileName: String? = null,
|
||||
fileSize: Long = 0
|
||||
): String {
|
||||
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
||||
|
||||
|
|
@ -86,6 +90,14 @@ object MobiledocBuilder {
|
|||
cardSections.add("[10,${cards.size - 1}]")
|
||||
}
|
||||
|
||||
// Add file card if file is present
|
||||
if (fileUrl != null) {
|
||||
val escapedFileUrl = escapeForJson(fileUrl)
|
||||
val escapedFileName = fileName?.let { escapeForJson(it) } ?: ""
|
||||
cards.add("""["file",{"src":"$escapedFileUrl","fileName":"$escapedFileName","fileSize":$fileSize}]""")
|
||||
cardSections.add("[10,${cards.size - 1}]")
|
||||
}
|
||||
|
||||
val cardsJson = cards.joinToString(",")
|
||||
val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
|
@ -110,8 +111,17 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// File picker
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
viewModel.addFile(uri)
|
||||
}
|
||||
}
|
||||
|
||||
val canSubmit by remember {
|
||||
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) }
|
||||
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.fileUri != null) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -384,6 +394,12 @@ fun ComposerScreen(
|
|||
OutlinedIconButton(onClick = { showLinkDialog = true }) {
|
||||
Icon(Icons.Default.Link, "Add link")
|
||||
}
|
||||
OutlinedIconButton(
|
||||
onClick = { filePickerLauncher.launch("application/*") },
|
||||
enabled = state.fileUri == null
|
||||
) {
|
||||
Icon(Icons.Default.AttachFile, "Attach file")
|
||||
}
|
||||
}
|
||||
|
||||
// Image grid preview (multi-image) — 120dp thumbnails
|
||||
|
|
@ -433,6 +449,23 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// File attachment card
|
||||
AnimatedVisibility(
|
||||
visible = state.fileUri != null,
|
||||
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
|
||||
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FileAttachmentComposerCard(
|
||||
fileName = state.fileName ?: "file",
|
||||
fileSize = state.fileSize ?: 0,
|
||||
fileMimeType = state.fileMimeType,
|
||||
onRemove = viewModel::removeFile
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Link preview
|
||||
AnimatedVisibility(
|
||||
visible = state.isLoadingLink,
|
||||
|
|
@ -1008,3 +1041,82 @@ fun TagsSection(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an appropriate icon tint color based on file MIME type.
|
||||
*/
|
||||
@Composable
|
||||
private fun fileTypeColor(mimeType: String?): Color {
|
||||
return when {
|
||||
mimeType == null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
mimeType.contains("pdf") -> Color(0xFFD32F2F) // red for PDF
|
||||
mimeType.contains("word") || mimeType.contains("doc") -> Color(0xFF1565C0) // blue for DOC
|
||||
mimeType.contains("text") -> Color(0xFF757575) // gray for TXT
|
||||
mimeType.contains("spreadsheet") || mimeType.contains("excel") -> Color(0xFF2E7D32) // green for spreadsheets
|
||||
mimeType.contains("presentation") || mimeType.contains("powerpoint") -> Color(0xFFE65100) // orange for presentations
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats file size in human-readable units.
|
||||
*/
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File attachment card shown in the composer with file info and remove button.
|
||||
*/
|
||||
@Composable
|
||||
fun FileAttachmentComposerCard(
|
||||
fileName: String,
|
||||
fileSize: Long,
|
||||
fileMimeType: String?,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val iconTint = fileTypeColor(fileMimeType)
|
||||
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = "File",
|
||||
modifier = Modifier.size(32.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
|
||||
)
|
||||
if (fileSize > 0) {
|
||||
Text(
|
||||
text = formatFileSize(fileSize),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove file",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,57 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a file to the post. Reads filename and size from ContentResolver.
|
||||
* Validates type and size (max 50 MB).
|
||||
*/
|
||||
fun addFile(uri: Uri) {
|
||||
val contentResolver = appContext.contentResolver
|
||||
var name: String? = null
|
||||
var size: Long? = null
|
||||
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||
if (nameIndex >= 0) name = cursor.getString(nameIndex)
|
||||
if (sizeIndex >= 0) size = cursor.getLong(sizeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
|
||||
// Validate size: max 50 MB
|
||||
val maxSize = 50L * 1024 * 1024
|
||||
if (size != null && size!! > maxSize) {
|
||||
_uiState.update { it.copy(error = "File too large. Maximum file size is 50 MB.") }
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
fileUri = uri,
|
||||
fileName = name ?: "file",
|
||||
fileSize = size,
|
||||
fileMimeType = mimeType,
|
||||
uploadedFileUrl = null,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFile() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
fileUri = null,
|
||||
fileName = null,
|
||||
fileSize = null,
|
||||
fileMimeType = null,
|
||||
uploadedFileUrl = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLinkPreview(url: String) {
|
||||
if (url.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
|
|
@ -234,7 +285,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
|
||||
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
||||
val state = _uiState.value
|
||||
if (state.text.isBlank() && state.imageUris.isEmpty()) return
|
||||
if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||
|
|
@ -265,7 +316,9 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
linkImageUrl = state.linkPreview?.imageUrl,
|
||||
scheduledAt = state.scheduledAt,
|
||||
tags = tagsJson,
|
||||
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
|
||||
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus,
|
||||
fileUri = state.fileUri?.toString(),
|
||||
fileName = state.fileName
|
||||
)
|
||||
repository.saveLocalPost(localPost)
|
||||
|
||||
|
|
@ -290,12 +343,28 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
)
|
||||
}
|
||||
|
||||
// Upload file if attached
|
||||
var uploadedFileUrl = state.uploadedFileUrl
|
||||
if (state.fileUri != null && uploadedFileUrl == null) {
|
||||
val fileResult = repository.uploadFile(state.fileUri)
|
||||
fileResult.fold(
|
||||
onSuccess = { url -> uploadedFileUrl = url },
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isSubmitting = false, error = "File upload failed: ${e.message}") }
|
||||
return@launch
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val featureImage = uploadedImageUrls.firstOrNull()
|
||||
|
||||
val mobiledoc = MobiledocBuilder.build(
|
||||
state.text, uploadedImageUrls,
|
||||
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||
altText
|
||||
altText,
|
||||
fileUrl = uploadedFileUrl,
|
||||
fileName = state.fileName,
|
||||
fileSize = state.fileSize ?: 0
|
||||
)
|
||||
val ghostTags = allTags.map { GhostTag(name = it) }
|
||||
|
||||
|
|
@ -342,7 +411,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
linkImageUrl = state.linkPreview?.imageUrl,
|
||||
scheduledAt = state.scheduledAt,
|
||||
tags = tagsJson,
|
||||
queueStatus = offlineQueueStatus
|
||||
queueStatus = offlineQueueStatus,
|
||||
fileUri = state.fileUri?.toString(),
|
||||
uploadedFileUrl = uploadedFileUrl,
|
||||
fileName = state.fileName
|
||||
)
|
||||
repository.saveLocalPost(localPost)
|
||||
PostUploadWorker.enqueue(appContext)
|
||||
|
|
@ -382,7 +454,13 @@ data class ComposerUiState(
|
|||
val isEditing: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isPreviewMode: Boolean = false,
|
||||
val previewHtml: String = ""
|
||||
val previewHtml: String = "",
|
||||
// File attachment
|
||||
val fileUri: Uri? = null,
|
||||
val fileName: String? = null,
|
||||
val fileSize: Long? = null,
|
||||
val fileMimeType: String? = null,
|
||||
val uploadedFileUrl: String? = null
|
||||
) {
|
||||
/**
|
||||
* Backwards compatibility: returns the first image URI or null.
|
||||
|
|
|
|||
|
|
@ -66,9 +66,24 @@ class PostUploadWorker(
|
|||
featureImage = allImageUrls.first()
|
||||
}
|
||||
|
||||
// Upload file if needed
|
||||
var uploadedFileUrl = post.uploadedFileUrl
|
||||
if (uploadedFileUrl == null && post.fileUri != null) {
|
||||
val fileResult = repository.uploadFile(Uri.parse(post.fileUri))
|
||||
if (fileResult.isFailure) {
|
||||
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
uploadedFileUrl = fileResult.getOrNull()
|
||||
}
|
||||
|
||||
val mobiledoc = MobiledocBuilder.build(
|
||||
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
||||
post.imageAlt
|
||||
post.imageAlt,
|
||||
fileUrl = uploadedFileUrl,
|
||||
fileName = post.fileName,
|
||||
fileSize = 0 // Size not stored in LocalPost; Ghost only needs src/fileName
|
||||
)
|
||||
|
||||
// Parse tags from JSON stored in LocalPost
|
||||
|
|
|
|||
|
|
@ -597,4 +597,116 @@ class MobiledocBuilderTest {
|
|||
val secondCard = cards.get(1).asJsonArray.get(1).asJsonObject
|
||||
assertEquals("", secondCard.get("alt").asString)
|
||||
}
|
||||
|
||||
// --- File card tests ---
|
||||
|
||||
@Test
|
||||
fun `build with file card produces valid JSON`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Post text", emptyList(), null, null, null, null,
|
||||
fileUrl = "https://example.com/files/report.pdf",
|
||||
fileName = "report.pdf",
|
||||
fileSize = 102400
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card includes file type`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", emptyList(), null, null, null, null,
|
||||
fileUrl = "https://example.com/files/doc.pdf",
|
||||
fileName = "doc.pdf",
|
||||
fileSize = 5000
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
assertEquals(1, cards.size())
|
||||
val card = cards.get(0).asJsonArray
|
||||
assertEquals("file", card.get(0).asString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card includes src fileName and fileSize`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", emptyList(), null, null, null, null,
|
||||
fileUrl = "https://example.com/files/report.pdf",
|
||||
fileName = "report.pdf",
|
||||
fileSize = 204800
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
val cardData = cards.get(0).asJsonArray.get(1).asJsonObject
|
||||
assertEquals("https://example.com/files/report.pdf", cardData.get("src").asString)
|
||||
assertEquals("report.pdf", cardData.get("fileName").asString)
|
||||
assertEquals(204800, cardData.get("fileSize").asLong)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card has correct section count`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", emptyList(), null, null, null, null,
|
||||
fileUrl = "https://example.com/file.pdf",
|
||||
fileName = "file.pdf",
|
||||
fileSize = 1000
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
assertEquals("Should have text section and file card section", 2, sections.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card comes after image and bookmark cards`() {
|
||||
val images = listOf("https://example.com/img.jpg")
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", images, "https://link.com", "Link Title", "Desc", "Alt",
|
||||
fileUrl = "https://example.com/file.pdf",
|
||||
fileName = "file.pdf",
|
||||
fileSize = 1024
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
assertEquals(3, cards.size())
|
||||
// Image first, bookmark second, file last
|
||||
assertEquals("image", cards.get(0).asJsonArray.get(0).asString)
|
||||
assertEquals("bookmark", cards.get(1).asJsonArray.get(0).asString)
|
||||
assertEquals("file", cards.get(2).asJsonArray.get(0).asString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card and all attachments has correct section count`() {
|
||||
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", images, "https://link.com", "Title", "Desc", null,
|
||||
fileUrl = "https://example.com/file.pdf",
|
||||
fileName = "file.pdf",
|
||||
fileSize = 500
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
// 1 text + 2 image + 1 bookmark + 1 file = 5
|
||||
assertEquals(5, json.getAsJsonArray("sections").size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build without file produces no file card`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", emptyList(), null, null, null, null,
|
||||
fileUrl = null, fileName = null, fileSize = 0
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertTrue(json.getAsJsonArray("cards").isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card escapes fileName`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", emptyList(), null, null, null, null,
|
||||
fileUrl = "https://example.com/file.pdf",
|
||||
fileName = "my \"special\" file.pdf",
|
||||
fileSize = 100
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull("Should produce valid JSON with escaped file name", json)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue