diff --git a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt index 6fd2c79..8bfc973 100644 --- a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt +++ b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt @@ -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 "" diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index 5c92c4b..d477692 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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) + ) + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index be46fc8..81f88b1 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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. diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index 7ebb324..94c9c59 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -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 diff --git a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt index 97ba7c3..7031b2e 100644 --- a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt @@ -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) + } }