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 7632193..6fd2c79 100644 --- a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt +++ b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt @@ -3,7 +3,7 @@ package com.swoosh.microblog.data import com.swoosh.microblog.data.model.LinkPreview /** - * Builds Ghost mobiledoc JSON from text content and optional link preview. + * Builds Ghost mobiledoc JSON from text content, optional images, and optional link preview. * Extracted as a shared utility used by both ComposerViewModel and PostUploadWorker. */ object MobiledocBuilder { @@ -18,9 +18,12 @@ object MobiledocBuilder { linkTitle: String?, linkDescription: String? ): String { - return build(text, linkUrl, linkTitle, linkDescription, null, null) + return build(text, emptyList(), linkUrl, linkTitle, linkDescription, null) } + /** + * Build with a single image URL and optional alt text (HEAD's 6-param overload). + */ fun build( text: String, linkUrl: String?, @@ -28,32 +31,65 @@ object MobiledocBuilder { linkDescription: String?, imageUrl: String?, imageAlt: String? + ): String { + val imageUrls = if (imageUrl != null) listOf(imageUrl) else emptyList() + return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt) + } + + /** + * Build with multiple image URLs but no alt text (multi-image branch's 5-param overload). + */ + fun build( + text: String, + imageUrls: List, + linkUrl: String?, + linkTitle: String?, + linkDescription: String? + ): String { + return build(text, imageUrls, linkUrl, linkTitle, linkDescription, null) + } + + /** + * Builds mobiledoc JSON with support for multiple images (with optional alt text on the first) + * and an optional link preview. + * Each image becomes an image card in the mobiledoc format. + * The bookmark card (link preview) is added after image cards. + */ + fun build( + text: String, + imageUrls: List, + linkUrl: String?, + linkTitle: String?, + linkDescription: String?, + imageAlt: String? ): String { val escapedText = escapeForJson(text).replace("\n", "\\n") - val cardsList = mutableListOf() + val cards = mutableListOf() val cardSections = mutableListOf() - // Image card - if (imageUrl != null) { - val escapedImgUrl = escapeForJson(imageUrl) - val escapedAlt = imageAlt?.let { escapeForJson(it) } ?: "" - cardSections.add("[10,${cardsList.size}]") - cardsList.add("""["image",{"src":"$escapedImgUrl","alt":"$escapedAlt","caption":""}]""") + // Add image cards + for ((index, url) in imageUrls.withIndex()) { + val escapedUrl = escapeForJson(url) + // Apply alt text to the first image only + val alt = if (index == 0 && imageAlt != null) escapeForJson(imageAlt) else "" + cards.add("""["image",{"src":"$escapedUrl","alt":"$alt","caption":""}]""") + cardSections.add("[10,${cards.size - 1}]") } - // Bookmark card + // Add bookmark card if link is present if (linkUrl != null) { val escapedUrl = escapeForJson(linkUrl) val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: "" val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: "" - cardSections.add("[10,${cardsList.size}]") - cardsList.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""") + cards.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""") + cardSections.add("[10,${cards.size - 1}]") } - val cards = cardsList.joinToString(",") - val extraSections = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else "" - return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$extraSections]}""" + val cardsJson = cards.joinToString(",") + val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else "" + + return """{"version":"0.3.1","atoms":[],"cards":[$cardsJson],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSectionsJson]}""" } internal fun escapeForJson(value: String): String { diff --git a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt index 080f57f..f176078 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt @@ -24,6 +24,8 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL") db.execSQL("ALTER TABLE local_posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'") + db.execSQL("ALTER TABLE local_posts ADD COLUMN imageUris TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE local_posts ADD COLUMN uploadedImageUrls TEXT DEFAULT NULL") } } diff --git a/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt b/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt index e6f4dee..d5c5ad2 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt @@ -1,6 +1,8 @@ package com.swoosh.microblog.data.db import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.swoosh.microblog.data.model.PostStatus import com.swoosh.microblog.data.model.QueueStatus @@ -16,4 +18,31 @@ class Converters { @TypeConverter fun toQueueStatus(value: String): QueueStatus = QueueStatus.valueOf(value) + + companion object { + private val gson = Gson() + + /** + * Serializes a list of strings to a JSON array string. + * Returns null for null or empty lists. + */ + fun stringListToJson(list: List?): String? { + if (list.isNullOrEmpty()) return null + return gson.toJson(list) + } + + /** + * Deserializes a JSON array string to a list of strings. + * Returns empty list for null or empty input. + */ + fun jsonToStringList(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + } } 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 2e73e6e..57c9e29 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 @@ -75,6 +75,8 @@ data class LocalPost( val featured: Boolean = false, val imageUri: String? = null, val uploadedImageUrl: String? = null, + val imageUris: String? = null, // JSON array of local URIs + val uploadedImageUrls: String? = null, // JSON array of uploaded URLs val linkUrl: String? = null, val linkTitle: String? = null, val linkDescription: String? = null, @@ -113,6 +115,7 @@ data class FeedPost( val htmlContent: String?, val imageUrl: String?, val imageAlt: String? = null, + val imageUrls: List = emptyList(), val linkUrl: String?, val linkTitle: String?, val linkDescription: String?, diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index 6c0d72b..ed96f95 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -128,6 +128,29 @@ class PostRepository(private val context: Context) { } } + /** + * Uploads multiple images and returns all uploaded URLs. + * If any upload fails, returns failure with the error. + */ + suspend fun uploadImages(uris: List): Result> = + withContext(Dispatchers.IO) { + try { + val urls = mutableListOf() + for (uri in uris) { + val result = uploadImage(uri) + if (result.isFailure) { + return@withContext Result.failure( + result.exceptionOrNull() ?: Exception("Image upload failed") + ) + } + urls.add(result.getOrThrow()) + } + Result.success(urls) + } catch (e: Exception) { + Result.failure(e) + } + } + private fun copyUriToTempFile(uri: Uri): File { val inputStream = context.contentResolver.openInputStream(uri) ?: throw IllegalStateException("Cannot open URI") 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 b75b751..012485b 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 @@ -5,6 +5,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -67,10 +70,22 @@ fun ComposerScreen( } } + // Multi-image picker + val multiImagePickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetMultipleContents() + ) { uris: List -> + if (uris.isNotEmpty()) { + viewModel.addImages(uris) + } + } + + // Single image picker (legacy) val imagePickerLauncher = rememberLauncherForActivityResult( ActivityResultContracts.GetContent() ) { uri: Uri? -> - viewModel.setImage(uri) + if (uri != null) { + viewModel.addImages(listOf(uri)) + } } Scaffold( @@ -151,7 +166,7 @@ fun ComposerScreen( if (state.isPreviewMode) { // Preview mode: show rendered HTML - if (state.text.isBlank() && state.imageUri == null && state.linkPreview == null) { + if (state.text.isBlank() && state.imageUris.isEmpty() && state.linkPreview == null) { Box( modifier = Modifier .fillMaxSize() @@ -250,59 +265,24 @@ fun ComposerScreen( Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - OutlinedIconButton(onClick = { imagePickerLauncher.launch("image/*") }) { - Icon(Icons.Default.Image, "Attach image") + OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) { + Icon(Icons.Default.Image, "Attach images") } OutlinedIconButton(onClick = { showLinkDialog = true }) { Icon(Icons.Default.Link, "Add link") } } - // Image preview with alt text - if (state.imageUri != null) { + // Image grid preview (multi-image) + if (state.imageUris.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) - Box { - AsyncImage( - model = state.imageUri, - contentDescription = state.imageAlt.ifBlank { "Selected image" }, - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .clip(MaterialTheme.shapes.medium) - .semantics { - contentDescription = state.imageAlt.ifBlank { "Selected image" } - }, - contentScale = ContentScale.Crop - ) - IconButton( - onClick = { viewModel.setImage(null) }, - modifier = Modifier.align(Alignment.TopEnd) - ) { - Icon( - Icons.Default.Close, "Remove image", - tint = MaterialTheme.colorScheme.onSurface - ) - } - // ALT badge when alt text is set - if (state.imageAlt.isNotBlank()) { - Text( - text = "ALT", - modifier = Modifier - .align(Alignment.BottomStart) - .padding(8.dp) - .background( - MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f), - RoundedCornerShape(4.dp) - ) - .padding(horizontal = 6.dp, vertical = 2.dp), - color = MaterialTheme.colorScheme.inverseOnSurface, - fontSize = 11.sp, - fontWeight = FontWeight.Bold - ) - } - } + ImageGridPreview( + imageUris = state.imageUris, + onRemoveImage = viewModel::removeImage, + onAddMore = { multiImagePickerLauncher.launch("image/*") } + ) - // Add alt text button + // Alt text for the first/primary image Spacer(modifier = Modifier.height(4.dp)) TextButton( onClick = { showAltTextDialog = true }, @@ -314,6 +294,21 @@ fun ComposerScreen( color = MaterialTheme.colorScheme.primary ) } + // ALT badge when alt text is set + if (state.imageAlt.isNotBlank()) { + Text( + text = "ALT", + modifier = Modifier + .background( + MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f), + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.inverseOnSurface, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } } // Link preview @@ -426,7 +421,7 @@ fun ComposerScreen( Button( onClick = viewModel::publish, modifier = Modifier.fillMaxWidth(), - enabled = !state.isSubmitting && state.text.isNotBlank() + enabled = !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) ) { if (state.isSubmitting) { CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) @@ -553,6 +548,86 @@ fun ComposerScreen( } } +/** + * Displays a 2-column grid of image thumbnails with remove buttons. + * Includes an "Add more" button at the end. + */ +@Composable +fun ImageGridPreview( + imageUris: List, + onRemoveImage: (Int) -> Unit, + onAddMore: () -> Unit +) { + val itemCount = imageUris.size + 1 // +1 for "add more" button + val rows = (itemCount + 1) / 2 // ceiling division for 2 columns + val gridHeight = (rows * 140).dp // approx height per row + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = gridHeight), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + userScrollEnabled = false + ) { + itemsIndexed(imageUris) { index, uri -> + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium) + ) { + AsyncImage( + model = uri, + contentDescription = "Image ${index + 1}", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + IconButton( + onClick = { onRemoveImage(index) }, + modifier = Modifier.align(Alignment.TopEnd), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + ) + ) { + Icon( + Icons.Default.Close, "Remove image", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } + } + + item { + OutlinedCard( + onClick = onAddMore, + modifier = Modifier.aspectRatio(1f) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.AddPhotoAlternate, + contentDescription = "Add more images", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Add more", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScheduleDateTimePicker( 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 cc4a399..65b4282 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.PreviewHtmlBuilder +import com.swoosh.microblog.data.db.Converters import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.repository.OpenGraphFetcher import com.swoosh.microblog.data.repository.PostRepository @@ -39,10 +40,19 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application editingLocalId = post.localId editingGhostId = post.ghostId editingUpdatedAt = post.updatedAt + + // Build image URIs list from available data + val imageUris = mutableListOf() + if (post.imageUrls.isNotEmpty()) { + imageUris.addAll(post.imageUrls.map { Uri.parse(it) }) + } else if (post.imageUrl != null) { + imageUris.add(Uri.parse(post.imageUrl)) + } + _uiState.update { it.copy( text = post.textContent, - imageUri = post.imageUrl?.let { url -> Uri.parse(url) }, + imageUris = imageUris, imageAlt = post.imageAlt ?: "", linkPreview = if (post.linkUrl != null) LinkPreview( url = post.linkUrl, @@ -65,13 +75,14 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application } } + /** + * Legacy single image setter - adds to the list. + */ fun setImage(uri: Uri?) { - _uiState.update { - if (uri == null) { - it.copy(imageUri = null, imageAlt = "") - } else { - it.copy(imageUri = uri) - } + if (uri != null) { + addImages(listOf(uri)) + } else { + _uiState.update { it.copy(imageUris = emptyList(), imageAlt = "") } } if (_uiState.value.isPreviewMode) { debouncedPreviewUpdate() @@ -82,6 +93,26 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application _uiState.update { it.copy(imageAlt = alt.take(250)) } } + /** + * Adds multiple images to the current selection. + */ + fun addImages(uris: List) { + _uiState.update { state -> + state.copy(imageUris = state.imageUris + uris) + } + } + + /** + * Removes a specific image by index. + */ + fun removeImage(index: Int) { + _uiState.update { state -> + if (index in state.imageUris.indices) { + state.copy(imageUris = state.imageUris.toMutableList().apply { removeAt(index) }) + } else state + } + } + fun fetchLinkPreview(url: String) { if (url.isBlank()) return viewModelScope.launch { @@ -160,7 +191,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) { val state = _uiState.value - if (state.text.isBlank() && state.imageUri == null) return + if (state.text.isBlank() && state.imageUris.isEmpty()) return viewModelScope.launch { _uiState.update { it.copy(isSubmitting = true, error = null) } @@ -180,7 +211,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application content = state.text, status = status, featured = if (status != PostStatus.DRAFT) state.featured else false, - imageUri = state.imageUri?.toString(), + imageUri = state.imageUris.firstOrNull()?.toString(), + imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }), imageAlt = altText, linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, @@ -200,11 +232,12 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application return@launch } - // Online: upload image first if needed - var featureImage: String? = null - if (state.imageUri != null) { - repository.uploadImage(state.imageUri).fold( - onSuccess = { url -> featureImage = url }, + // Online: upload all images first + val uploadedImageUrls = mutableListOf() + if (state.imageUris.isNotEmpty()) { + val imagesResult = repository.uploadImages(state.imageUris) + imagesResult.fold( + onSuccess = { urls -> uploadedImageUrls.addAll(urls) }, onFailure = { e -> _uiState.update { it.copy(isSubmitting = false, error = "Image upload failed: ${e.message}") } return@launch @@ -212,12 +245,11 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application ) } + val featureImage = uploadedImageUrls.firstOrNull() + val mobiledoc = MobiledocBuilder.build( - state.text, - state.linkPreview?.url, - state.linkPreview?.title, - state.linkPreview?.description, - featureImage, + state.text, uploadedImageUrls, + state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description, altText ) val ghostTags = extractedTags.map { GhostTag(name = it) } @@ -254,8 +286,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application content = state.text, status = status, featured = if (status != PostStatus.DRAFT) state.featured else false, - imageUri = state.imageUri?.toString(), + imageUri = state.imageUris.firstOrNull()?.toString(), + imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }), uploadedImageUrl = featureImage, + uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls), imageAlt = altText, linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, @@ -288,7 +322,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application data class ComposerUiState( val text: String = "", - val imageUri: Uri? = null, + val imageUris: List = emptyList(), val imageAlt: String = "", val linkPreview: LinkPreview? = null, val isLoadingLink: Boolean = false, @@ -301,4 +335,9 @@ data class ComposerUiState( val error: String? = null, val isPreviewMode: Boolean = false, val previewHtml: String = "" -) +) { + /** + * Backwards compatibility: returns the first image URI or null. + */ + val imageUri: Uri? get() = imageUris.firstOrNull() +} 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 c5fb52c..7c2ca1b 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 @@ -7,6 +7,7 @@ import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -46,6 +47,7 @@ import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.LinkPreview import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.QueueStatus +import com.swoosh.microblog.ui.feed.FullScreenGallery import com.swoosh.microblog.ui.feed.StatusBadge import com.swoosh.microblog.ui.feed.formatRelativeTime import kotlinx.coroutines.launch @@ -71,6 +73,18 @@ fun DetailScreen( val postUrl = remember(post, baseUrl) { ShareUtils.resolvePostUrl(post, baseUrl) } val canShare = isPublished && postUrl != null + var showGallery by remember { mutableStateOf(false) } + var galleryStartIndex by remember { mutableIntStateOf(0) } + + // Determine images to show + val allImages = if (post.imageUrls.isNotEmpty()) { + post.imageUrls + } else if (post.imageUrl != null) { + listOf(post.imageUrl) + } else { + emptyList() + } + Scaffold( topBar = { TopAppBar( @@ -225,19 +239,15 @@ fun DetailScreen( } } - // Full image - if (post.imageUrl != null) { + // Image gallery + if (allImages.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) - AsyncImage( - model = post.imageUrl, - contentDescription = post.imageAlt ?: "Post image", - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .semantics { - contentDescription = post.imageAlt ?: "Post image" - }, - contentScale = ContentScale.FillWidth + DetailImageGallery( + images = allImages, + onImageClick = { index -> + galleryStartIndex = index + showGallery = true + } ) // Alt text display if (!post.imageAlt.isNullOrBlank()) { @@ -320,6 +330,41 @@ fun DetailScreen( } ) } + + // Full-screen gallery + if (showGallery && allImages.isNotEmpty()) { + FullScreenGallery( + images = allImages, + startIndex = galleryStartIndex, + onDismiss = { showGallery = false } + ) + } +} + +/** + * Scrollable image gallery for the detail screen. + * Shows all images in a column with tap-to-zoom. + */ +@Composable +fun DetailImageGallery( + images: List, + onImageClick: (Int) -> Unit +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + images.forEachIndexed { index, imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = "Image ${index + 1} of ${images.size}", + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable { onImageClick(index) }, + contentScale = ContentScale.FillWidth + ) + } + } } @Composable @@ -402,6 +447,12 @@ private fun PostStatsSection(post: FeedPost) { if (post.featured) { MetadataRow("Featured", "Pinned") } + val allImages = if (post.imageUrls.isNotEmpty()) post.imageUrls + else if (post.imageUrl != null) listOf(post.imageUrl) + else emptyList() + if (allImages.isNotEmpty()) { + MetadataRow("Images", "${allImages.size}") + } Spacer(modifier = Modifier.height(8.dp)) 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 827bf48..5042153 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 @@ -12,12 +12,15 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import kotlinx.coroutines.launch import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi @@ -37,6 +40,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -52,6 +57,8 @@ 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 androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -940,6 +947,19 @@ fun PostCardContent( val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank() + // Gallery viewer state + var showGallery by remember { mutableStateOf(false) } + var galleryStartIndex by remember { mutableIntStateOf(0) } + + // Determine which images to show + val allImages = if (post.imageUrls.isNotEmpty()) { + post.imageUrls + } else if (post.imageUrl != null) { + listOf(post.imageUrl) + } else { + emptyList() + } + Card( modifier = Modifier .fillMaxWidth() @@ -1013,57 +1033,50 @@ fun PostCardContent( } } - // Image thumbnail with alt text - if (post.imageUrl != null) { + // Image grid (multi-image support) + if (allImages.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) - var showAltPopup by remember { mutableStateOf(false) } - Box { - AsyncImage( - model = post.imageUrl, - contentDescription = post.imageAlt ?: "Post image", - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(MaterialTheme.shapes.medium) - .semantics { - contentDescription = post.imageAlt ?: "Post image" - }, - contentScale = ContentScale.Crop - ) - if (!post.imageAlt.isNullOrBlank()) { - Text( - text = "ALT", - modifier = Modifier - .align(Alignment.BottomStart) - .padding(8.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f) - ) - .clickable { showAltPopup = !showAltPopup } - .padding(horizontal = 6.dp, vertical = 2.dp), - color = MaterialTheme.colorScheme.inverseOnSurface, - fontSize = 11.sp, - fontWeight = FontWeight.Bold - ) + PostImageGrid( + images = allImages, + onImageClick = { index -> + galleryStartIndex = index + showGallery = true } - } - // Alt text popup - if (showAltPopup && !post.imageAlt.isNullOrBlank()) { - Card( + ) + // Alt text badge + if (!post.imageAlt.isNullOrBlank()) { + var showAltPopup by remember { mutableStateOf(false) } + Text( + text = "ALT", modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Text( - text = post.imageAlt, - modifier = Modifier.padding(8.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + .padding(top = 4.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f) + ) + .clickable { showAltPopup = !showAltPopup } + .padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.inverseOnSurface, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + // Alt text popup + if (showAltPopup) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Text( + text = post.imageAlt, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } @@ -1279,6 +1292,314 @@ fun PostCardContent( } } } + + // Gallery viewer + if (showGallery && allImages.isNotEmpty()) { + FullScreenGallery( + images = allImages, + startIndex = galleryStartIndex, + onDismiss = { showGallery = false } + ) + } +} + +/** + * Social media-style image grid layout: + * - 1 image: full width + * - 2 images: side by side + * - 3 images: one large + two small stacked + * - 4+ images: 2x2 grid (with +N overlay on last if more than 4) + */ +@Composable +fun PostImageGrid( + images: List, + onImageClick: (Int) -> Unit +) { + val shape = MaterialTheme.shapes.medium + + when (images.size) { + 1 -> { + // Full width single image + AsyncImage( + model = images[0], + contentDescription = "Post image", + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(shape) + .clickable { onImageClick(0) }, + contentScale = ContentScale.Crop + ) + } + 2 -> { + // Side by side + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[0], + contentDescription = "Post image 1", + modifier = Modifier + .weight(1f) + .height(180.dp) + .clip(shape) + .clickable { onImageClick(0) }, + contentScale = ContentScale.Crop + ) + AsyncImage( + model = images[1], + contentDescription = "Post image 2", + modifier = Modifier + .weight(1f) + .height(180.dp) + .clip(shape) + .clickable { onImageClick(1) }, + contentScale = ContentScale.Crop + ) + } + } + 3 -> { + // One large + two small stacked + Row( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[0], + contentDescription = "Post image 1", + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(shape) + .clickable { onImageClick(0) }, + contentScale = ContentScale.Crop + ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[1], + contentDescription = "Post image 2", + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(shape) + .clickable { onImageClick(1) }, + contentScale = ContentScale.Crop + ) + AsyncImage( + model = images[2], + contentDescription = "Post image 3", + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(shape) + .clickable { onImageClick(2) }, + contentScale = ContentScale.Crop + ) + } + } + } + else -> { + // 2x2 grid with +N overlay on last cell + val remaining = images.size - 4 + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[0], + contentDescription = "Post image 1", + modifier = Modifier + .weight(1f) + .height(120.dp) + .clip(shape) + .clickable { onImageClick(0) }, + contentScale = ContentScale.Crop + ) + AsyncImage( + model = images[1], + contentDescription = "Post image 2", + modifier = Modifier + .weight(1f) + .height(120.dp) + .clip(shape) + .clickable { onImageClick(1) }, + contentScale = ContentScale.Crop + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[2], + contentDescription = "Post image 3", + modifier = Modifier + .weight(1f) + .height(120.dp) + .clip(shape) + .clickable { onImageClick(2) }, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .weight(1f) + .height(120.dp) + .clip(shape) + .clickable { onImageClick(3) } + ) { + AsyncImage( + model = images[3], + contentDescription = "Post image 4", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + if (remaining > 0) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text( + text = "+$remaining", + style = MaterialTheme.typography.headlineSmall, + color = Color.White + ) + } + } + } + } + } + } + } +} + +/** + * Full-screen gallery viewer with paging and pinch-to-zoom. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FullScreenGallery( + images: List, + startIndex: Int, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + val pagerState = rememberPagerState( + initialPage = startIndex, + pageCount = { images.size } + ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + ZoomableImage( + model = images[page], + contentDescription = "Image ${page + 1} of ${images.size}" + ) + } + + // Close button + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White + ) + ) { + Icon(Icons.Default.Close, contentDescription = "Close gallery") + } + + // Page indicator + if (images.size > 1) { + Text( + text = "${pagerState.currentPage + 1} / ${images.size}", + style = MaterialTheme.typography.labelLarge, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .background( + Color.Black.copy(alpha = 0.5f), + MaterialTheme.shapes.small + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + } + } +} + +/** + * Image with pinch-to-zoom support. + */ +@Composable +fun ZoomableImage( + model: Any?, + contentDescription: String? +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { + offsetX += pan.x + offsetY += pan.y + } else { + offsetX = 0f + offsetY = 0f + } + } + }, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ), + contentScale = ContentScale.Fit + ) + } } // Keep the old PostCard signature for backward compatibility (used in tests/other screens) 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 23c0ed7..0140b2c 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 @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.FeedPreferences import com.swoosh.microblog.data.HashtagParser +import com.swoosh.microblog.data.db.Converters import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.repository.PostRepository import com.google.gson.Gson @@ -418,27 +419,62 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { _uiState.update { it.copy(posts = sorted) } } - private fun GhostPost.toFeedPost(): FeedPost = FeedPost( - ghostId = id, - slug = slug, - url = url, - title = title ?: "", - textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "", - htmlContent = html, - imageUrl = feature_image, - imageAlt = feature_image_alt, - linkUrl = null, - linkTitle = null, - linkDescription = null, - linkImageUrl = null, - tags = tags?.map { it.name } ?: emptyList(), - status = status ?: "draft", - featured = featured ?: false, - publishedAt = published_at, - createdAt = created_at, - updatedAt = updated_at, - isLocal = false - ) + private fun GhostPost.toFeedPost(): FeedPost { + val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc) + // Use feature_image as primary, then add mobiledoc images (avoiding duplicates) + val allImages = mutableListOf() + if (feature_image != null) { + allImages.add(feature_image) + } + for (url in imageUrls) { + if (url !in allImages) { + allImages.add(url) + } + } + return FeedPost( + ghostId = id, + slug = slug, + url = url, + title = title ?: "", + textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "", + htmlContent = html, + imageUrl = allImages.firstOrNull(), + imageAlt = feature_image_alt, + imageUrls = allImages, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + tags = tags?.map { it.name } ?: emptyList(), + status = status ?: "draft", + featured = featured ?: false, + publishedAt = published_at, + createdAt = created_at, + updatedAt = updated_at, + isLocal = false + ) + } + + /** + * Extracts image URLs from Ghost mobiledoc JSON. + * Image cards have the format: ["image", {"src": "url"}] + */ + private fun extractImageUrlsFromMobiledoc(mobiledoc: String?): List { + if (mobiledoc == null) return emptyList() + return try { + val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject + val cards = json.getAsJsonArray("cards") ?: return emptyList() + cards.mapNotNull { card -> + val cardArray = card.asJsonArray + if (cardArray.size() >= 2 && cardArray[0].asString == "image") { + val cardData = cardArray[1].asJsonObject + cardData.get("src")?.asString + } else null + } + } catch (e: Exception) { + emptyList() + } + } private fun LocalPost.toFeedPost(): FeedPost { val tagNames: List = try { @@ -446,14 +482,24 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { } catch (e: Exception) { emptyList() } + val uploadedUrls = Converters.jsonToStringList(uploadedImageUrls) + val localUris = Converters.jsonToStringList(imageUris) + val allImageUrls = when { + uploadedUrls.isNotEmpty() -> uploadedUrls + localUris.isNotEmpty() -> localUris + else -> if (uploadedImageUrl != null) listOf(uploadedImageUrl) + else if (imageUri != null) listOf(imageUri) + else emptyList() + } return FeedPost( localId = localId, ghostId = ghostId, title = title, textContent = content, htmlContent = htmlContent, - imageUrl = uploadedImageUrl ?: imageUri, + imageUrl = allImageUrls.firstOrNull(), imageAlt = imageAlt, + imageUrls = allImageUrls, linkUrl = linkUrl, linkTitle = linkTitle, linkDescription = linkDescription, 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 a042013..7ebb324 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.work.* import com.swoosh.microblog.data.MobiledocBuilder +import com.swoosh.microblog.data.db.Converters import com.swoosh.microblog.data.model.GhostPost import com.swoosh.microblog.data.model.GhostTag import com.swoosh.microblog.data.model.QueueStatus @@ -28,7 +29,10 @@ class PostUploadWorker( for (post in queuedPosts) { repository.updateQueueStatus(post.localId, QueueStatus.UPLOADING) - // Upload image if needed + // Upload multiple images if needed + val allImageUrls = mutableListOf() + + // Handle legacy single image field var featureImage = post.uploadedImageUrl if (featureImage == null && post.imageUri != null) { val imageResult = repository.uploadImage(Uri.parse(post.imageUri)) @@ -40,9 +44,31 @@ class PostUploadWorker( featureImage = imageResult.getOrNull() } + // Handle multi-image fields + val existingUploadedUrls = Converters.jsonToStringList(post.uploadedImageUrls) + val pendingUris = Converters.jsonToStringList(post.imageUris) + + if (existingUploadedUrls.isNotEmpty()) { + allImageUrls.addAll(existingUploadedUrls) + } else if (pendingUris.isNotEmpty()) { + val uris = pendingUris.map { Uri.parse(it) } + val imagesResult = repository.uploadImages(uris) + if (imagesResult.isFailure) { + repository.updateQueueStatus(post.localId, QueueStatus.FAILED) + allSuccess = false + continue + } + allImageUrls.addAll(imagesResult.getOrThrow()) + } + + // Use first image as feature image if no legacy feature image + if (featureImage == null && allImageUrls.isNotEmpty()) { + featureImage = allImageUrls.first() + } + val mobiledoc = MobiledocBuilder.build( - post.content, post.linkUrl, post.linkTitle, post.linkDescription, - featureImage, post.imageAlt + post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription, + post.imageAlt ) // 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 6d884ea..97ba7c3 100644 --- a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt @@ -87,7 +87,7 @@ class MobiledocBuilderTest { @Test fun `build handles text with special chars`() { - val result = MobiledocBuilder.build("café & résumé ", null as LinkPreview?) + val result = MobiledocBuilder.build("cafe & resume ", null as LinkPreview?) val json = JsonParser.parseString(result).asJsonObject assertNotNull("Should produce valid JSON even with special chars", json) } @@ -252,7 +252,7 @@ class MobiledocBuilderTest { assertEquals("", MobiledocBuilder.escapeForJson("")) } - // --- Image card with alt text --- + // --- Image card with alt text (HEAD tests) --- @Test fun `build with image card produces valid JSON`() { @@ -408,4 +408,193 @@ class MobiledocBuilderTest { assertEquals(10, bookmarkSection.get(0).asInt) assertEquals(1, bookmarkSection.get(1).asInt) } + + // --- Multi-image support (multi-image branch tests) --- + + @Test + fun `build with single image produces valid JSON`() { + val result = MobiledocBuilder.build( + "Hello", listOf("https://example.com/img.jpg"), null, null, null + ) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + } + + @Test + fun `build with single image has one image card`() { + val result = MobiledocBuilder.build( + "Hello", listOf("https://example.com/img.jpg"), null, null, null + ) + val json = JsonParser.parseString(result).asJsonObject + assertEquals(1, json.getAsJsonArray("cards").size()) + + val card = json.getAsJsonArray("cards").get(0).asJsonArray + assertEquals("image", card.get(0).asString) + assertEquals("https://example.com/img.jpg", card.get(1).asJsonObject.get("src").asString) + } + + @Test + fun `build with single image has two sections`() { + val result = MobiledocBuilder.build( + "Hello", listOf("https://example.com/img.jpg"), null, null, null + ) + val json = JsonParser.parseString(result).asJsonObject + assertEquals(2, json.getAsJsonArray("sections").size()) + } + + @Test + fun `build with multiple images produces valid JSON`() { + val images = listOf( + "https://example.com/img1.jpg", + "https://example.com/img2.jpg", + "https://example.com/img3.jpg" + ) + val result = MobiledocBuilder.build("Hello", images, null, null, null) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + } + + @Test + fun `build with multiple images has correct number of cards`() { + val images = listOf( + "https://example.com/img1.jpg", + "https://example.com/img2.jpg", + "https://example.com/img3.jpg" + ) + val result = MobiledocBuilder.build("Hello", images, null, null, null) + val json = JsonParser.parseString(result).asJsonObject + assertEquals(3, json.getAsJsonArray("cards").size()) + } + + @Test + fun `build with multiple images has correct number of sections`() { + val images = listOf( + "https://example.com/img1.jpg", + "https://example.com/img2.jpg" + ) + val result = MobiledocBuilder.build("Hello", images, null, null, null) + val json = JsonParser.parseString(result).asJsonObject + // 1 text section + 2 card sections + assertEquals(3, json.getAsJsonArray("sections").size()) + } + + @Test + fun `build with multiple images all cards are image type`() { + val images = listOf( + "https://example.com/img1.jpg", + "https://example.com/img2.jpg" + ) + val result = MobiledocBuilder.build("Hello", images, null, null, null) + val json = JsonParser.parseString(result).asJsonObject + val cards = json.getAsJsonArray("cards") + for (i in 0 until cards.size()) { + val card = cards.get(i).asJsonArray + assertEquals("image", card.get(0).asString) + } + } + + @Test + fun `build with multiple images preserves correct src URLs`() { + val images = listOf( + "https://example.com/img1.jpg", + "https://example.com/img2.jpg", + "https://example.com/img3.jpg" + ) + val result = MobiledocBuilder.build("Hello", images, null, null, null) + val json = JsonParser.parseString(result).asJsonObject + val cards = json.getAsJsonArray("cards") + assertEquals("https://example.com/img1.jpg", cards.get(0).asJsonArray.get(1).asJsonObject.get("src").asString) + assertEquals("https://example.com/img2.jpg", cards.get(1).asJsonArray.get(1).asJsonObject.get("src").asString) + assertEquals("https://example.com/img3.jpg", cards.get(2).asJsonArray.get(1).asJsonObject.get("src").asString) + } + + @Test + fun `build with images and link has both image and bookmark cards`() { + val images = listOf("https://example.com/img1.jpg") + val result = MobiledocBuilder.build( + "Hello", images, "https://example.com", "Title", "Desc" + ) + val json = JsonParser.parseString(result).asJsonObject + val cards = json.getAsJsonArray("cards") + assertEquals(2, cards.size()) + + // First card is image + assertEquals("image", cards.get(0).asJsonArray.get(0).asString) + // Second card is bookmark + assertEquals("bookmark", cards.get(1).asJsonArray.get(0).asString) + } + + @Test + fun `build with images and link has correct number of sections`() { + val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") + val result = MobiledocBuilder.build( + "Hello", images, "https://example.com", "Title", "Desc" + ) + val json = JsonParser.parseString(result).asJsonObject + // 1 text section + 2 image card sections + 1 bookmark card section + assertEquals(4, json.getAsJsonArray("sections").size()) + } + + @Test + fun `build with images card sections reference correct card indices`() { + val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") + val result = MobiledocBuilder.build("Hello", images, null, null, null) + val json = JsonParser.parseString(result).asJsonObject + val sections = json.getAsJsonArray("sections") + + // sections[0] is text: [1, "p", ...] + assertEquals(1, sections.get(0).asJsonArray.get(0).asInt) + + // sections[1] references card 0: [10, 0] + assertEquals(10, sections.get(1).asJsonArray.get(0).asInt) + assertEquals(0, sections.get(1).asJsonArray.get(1).asInt) + + // sections[2] references card 1: [10, 1] + assertEquals(10, sections.get(2).asJsonArray.get(0).asInt) + assertEquals(1, sections.get(2).asJsonArray.get(1).asInt) + } + + @Test + fun `build with empty image list produces no image cards`() { + val result = MobiledocBuilder.build("Hello", emptyList(), null, null, null) + val json = JsonParser.parseString(result).asJsonObject + assertTrue(json.getAsJsonArray("cards").isEmpty) + assertEquals(1, json.getAsJsonArray("sections").size()) + } + + @Test + fun `build with empty image list matches no-image build`() { + val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?) + val resultB = MobiledocBuilder.build("Hello", emptyList(), null, null, null) + assertEquals(resultA, resultB) + } + + @Test + fun `build with image URL containing special chars produces valid JSON`() { + val images = listOf("https://example.com/img?id=1&name=\"test\"") + val result = MobiledocBuilder.build("Hello", images, null, null, null) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + } + + // --- Multi-image with alt text (merged feature tests) --- + + @Test + fun `build with multiple images and alt text applies alt to first image only`() { + val images = listOf( + "https://example.com/img1.jpg", + "https://example.com/img2.jpg" + ) + val result = MobiledocBuilder.build("Text", images, null, null, null, "First image alt") + val json = JsonParser.parseString(result).asJsonObject + val cards = json.getAsJsonArray("cards") + + // First image should have alt text + val firstCard = cards.get(0).asJsonArray.get(1).asJsonObject + assertEquals("First image alt", firstCard.get("alt").asString) + + // Second image should have empty alt + val secondCard = cards.get(1).asJsonArray.get(1).asJsonObject + assertEquals("", secondCard.get("alt").asString) + } } diff --git a/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt b/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt index 4c53962..9eaea79 100644 --- a/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt @@ -116,4 +116,102 @@ class ConvertersTest { fun `toQueueStatus throws on invalid string`() { converters.toQueueStatus("NONEXISTENT") } + + // --- String list JSON serialization --- + + @Test + fun `stringListToJson with null returns null`() { + assertNull(Converters.stringListToJson(null)) + } + + @Test + fun `stringListToJson with empty list returns null`() { + assertNull(Converters.stringListToJson(emptyList())) + } + + @Test + fun `stringListToJson with single item returns JSON array`() { + val result = Converters.stringListToJson(listOf("https://example.com/img.jpg")) + assertNotNull(result) + assertTrue(result!!.startsWith("[")) + assertTrue(result.endsWith("]")) + assertTrue(result.contains("https://example.com/img.jpg")) + } + + @Test + fun `stringListToJson with multiple items returns JSON array`() { + val result = Converters.stringListToJson( + listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") + ) + assertNotNull(result) + assertTrue(result!!.contains("img1.jpg")) + assertTrue(result.contains("img2.jpg")) + } + + @Test + fun `jsonToStringList with null returns empty list`() { + assertEquals(emptyList(), Converters.jsonToStringList(null)) + } + + @Test + fun `jsonToStringList with empty string returns empty list`() { + assertEquals(emptyList(), Converters.jsonToStringList("")) + } + + @Test + fun `jsonToStringList with blank string returns empty list`() { + assertEquals(emptyList(), Converters.jsonToStringList(" ")) + } + + @Test + fun `jsonToStringList with invalid JSON returns empty list`() { + assertEquals(emptyList(), Converters.jsonToStringList("not json")) + } + + @Test + fun `jsonToStringList with single item JSON array`() { + val result = Converters.jsonToStringList("""["https://example.com/img.jpg"]""") + assertEquals(1, result.size) + assertEquals("https://example.com/img.jpg", result[0]) + } + + @Test + fun `jsonToStringList with multiple items JSON array`() { + val result = Converters.jsonToStringList( + """["https://example.com/img1.jpg","https://example.com/img2.jpg","https://example.com/img3.jpg"]""" + ) + assertEquals(3, result.size) + assertEquals("https://example.com/img1.jpg", result[0]) + assertEquals("https://example.com/img2.jpg", result[1]) + assertEquals("https://example.com/img3.jpg", result[2]) + } + + @Test + fun `stringListToJson and jsonToStringList round-trip`() { + val original = listOf( + "https://example.com/img1.jpg", + "https://example.com/img2.jpg", + "https://example.com/img3.jpg" + ) + val json = Converters.stringListToJson(original) + val restored = Converters.jsonToStringList(json) + assertEquals(original, restored) + } + + @Test + fun `stringListToJson handles URLs with special characters`() { + val urls = listOf( + "https://example.com/img?id=1&name=test", + "https://example.com/path/to/image (1).jpg" + ) + val json = Converters.stringListToJson(urls) + val restored = Converters.jsonToStringList(json) + assertEquals(urls, restored) + } + + @Test + fun `jsonToStringList with empty JSON array returns empty list`() { + val result = Converters.jsonToStringList("[]") + assertEquals(emptyList(), result) + } } diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt index 255e08f..1915767 100644 --- a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt @@ -46,6 +46,18 @@ class GhostModelsTest { assertNull(post.imageUri) } + @Test + fun `LocalPost default imageUris is null`() { + val post = LocalPost() + assertNull(post.imageUris) + } + + @Test + fun `LocalPost default uploadedImageUrls is null`() { + val post = LocalPost() + assertNull(post.uploadedImageUrls) + } + @Test fun `LocalPost createdAt is set on construction`() { val before = System.currentTimeMillis() @@ -131,6 +143,48 @@ class GhostModelsTest { assertEquals(QueueStatus.NONE, post.queueStatus) } + @Test + fun `FeedPost default imageUrls is empty`() { + val post = FeedPost( + title = "Test", + textContent = "Content", + htmlContent = null, + imageUrl = null, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = "published", + publishedAt = null, + createdAt = null, + updatedAt = null + ) + assertTrue(post.imageUrls.isEmpty()) + } + + @Test + fun `FeedPost imageUrls stores multiple URLs`() { + val urls = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") + val post = FeedPost( + title = "Test", + textContent = "Content", + htmlContent = null, + imageUrl = "https://example.com/img1.jpg", + imageUrls = urls, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = "published", + publishedAt = null, + createdAt = null, + updatedAt = null + ) + assertEquals(2, post.imageUrls.size) + assertEquals("https://example.com/img1.jpg", post.imageUrls[0]) + assertEquals("https://example.com/img2.jpg", post.imageUrls[1]) + } + // --- GSON serialization --- @Test