merge: integrate multi-image gallery feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 11:20:33 +01:00
commit 7d1caa65ea
No known key found for this signature in database
14 changed files with 1164 additions and 172 deletions

View file

@ -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<String>,
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<String>,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?,
imageAlt: String?
): String {
val escapedText = escapeForJson(text).replace("\n", "\\n")
val cardsList = mutableListOf<String>()
val cards = mutableListOf<String>()
val cardSections = mutableListOf<String>()
// 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 {

View file

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

View file

@ -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>?): 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<String> {
if (json.isNullOrBlank()) return emptyList()
return try {
val type = object : TypeToken<List<String>>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
}
}

View file

@ -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<String> = emptyList(),
val linkUrl: String?,
val linkTitle: String?,
val linkDescription: String?,

View file

@ -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<Uri>): Result<List<String>> =
withContext(Dispatchers.IO) {
try {
val urls = mutableListOf<String>()
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")

View file

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

View file

@ -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<Uri>()
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<Uri>) {
_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<String>()
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<Uri> = 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()
}

View file

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

View file

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

View file

@ -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<String>()
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<String> {
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<String> = 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,

View file

@ -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<String>()
// 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

View file

@ -87,7 +87,7 @@ class MobiledocBuilderTest {
@Test
fun `build handles text with special chars`() {
val result = MobiledocBuilder.build("café & résumé <html>", null as LinkPreview?)
val result = MobiledocBuilder.build("cafe & resume <html>", 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)
}
}

View file

@ -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<String>(), Converters.jsonToStringList(null))
}
@Test
fun `jsonToStringList with empty string returns empty list`() {
assertEquals(emptyList<String>(), Converters.jsonToStringList(""))
}
@Test
fun `jsonToStringList with blank string returns empty list`() {
assertEquals(emptyList<String>(), Converters.jsonToStringList(" "))
}
@Test
fun `jsonToStringList with invalid JSON returns empty list`() {
assertEquals(emptyList<String>(), 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<String>(), result)
}
}

View file

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