feat: add multi-image gallery with grid layout, picker, and pinch-to-zoom

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:13 +01:00
parent 74f42fd2f1
commit 0265a1159d
No known key found for this signature in database
14 changed files with 1101 additions and 107 deletions

View file

@ -3,7 +3,7 @@ package com.swoosh.microblog.data
import com.swoosh.microblog.data.model.LinkPreview 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. * Extracted as a shared utility used by both ComposerViewModel and PostUploadWorker.
*/ */
object MobiledocBuilder { object MobiledocBuilder {
@ -17,18 +17,47 @@ object MobiledocBuilder {
linkUrl: String?, linkUrl: String?,
linkTitle: String?, linkTitle: String?,
linkDescription: String? linkDescription: String?
): String {
return build(text, emptyList(), linkUrl, linkTitle, linkDescription)
}
/**
* Builds mobiledoc JSON with support for multiple images 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?
): String { ): String {
val escapedText = escapeForJson(text).replace("\n", "\\n") val escapedText = escapeForJson(text).replace("\n", "\\n")
val cards = if (linkUrl != null) { val cards = mutableListOf<String>()
val cardSections = mutableListOf<String>()
// Add image cards
for (url in imageUrls) {
val escapedUrl = escapeForJson(url)
cards.add("""["image",{"src":"$escapedUrl"}]""")
cardSections.add("[10,${cards.size - 1}]")
}
// Add bookmark card if link is present
if (linkUrl != null) {
val escapedUrl = escapeForJson(linkUrl) val escapedUrl = escapeForJson(linkUrl)
val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: "" val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: ""
val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: "" val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: ""
"""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""" cards.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""")
} else "" cardSections.add("[10,${cards.size - 1}]")
}
val cardSection = if (linkUrl != null) ",[10,0]" else "" val cardsJson = cards.joinToString(",")
return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSection]}""" 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 { internal fun escapeForJson(value: String): String {

View file

@ -5,9 +5,11 @@ import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.swoosh.microblog.data.model.LocalPost import com.swoosh.microblog.data.model.LocalPost
@Database(entities = [LocalPost::class], version = 1, exportSchema = false) @Database(entities = [LocalPost::class], version = 2, exportSchema = false)
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -17,13 +19,22 @@ abstract class AppDatabase : RoomDatabase() {
@Volatile @Volatile
private var INSTANCE: AppDatabase? = null private var INSTANCE: AppDatabase? = null
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageUris TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN uploadedImageUrls TEXT DEFAULT NULL")
}
}
fun getInstance(context: Context): AppDatabase { fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
context.applicationContext, context.applicationContext,
AppDatabase::class.java, AppDatabase::class.java,
"swoosh_database" "swoosh_database"
).build() )
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance INSTANCE = instance
instance instance
} }

View file

@ -1,6 +1,8 @@
package com.swoosh.microblog.data.db package com.swoosh.microblog.data.db
import androidx.room.TypeConverter 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.PostStatus
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
@ -16,4 +18,31 @@ class Converters {
@TypeConverter @TypeConverter
fun toQueueStatus(value: String): QueueStatus = QueueStatus.valueOf(value) 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

@ -62,6 +62,8 @@ data class LocalPost(
val status: PostStatus = PostStatus.DRAFT, val status: PostStatus = PostStatus.DRAFT,
val imageUri: String? = null, val imageUri: String? = null,
val uploadedImageUrl: 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 linkUrl: String? = null,
val linkTitle: String? = null, val linkTitle: String? = null,
val linkDescription: String? = null, val linkDescription: String? = null,
@ -95,6 +97,7 @@ data class FeedPost(
val textContent: String, val textContent: String,
val htmlContent: String?, val htmlContent: String?,
val imageUrl: String?, val imageUrl: String?,
val imageUrls: List<String> = emptyList(),
val linkUrl: String?, val linkUrl: String?,
val linkTitle: String?, val linkTitle: String?,
val linkDescription: String?, val linkDescription: String?,

View file

@ -109,6 +109,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 { private fun copyUriToTempFile(uri: Uri): File {
val inputStream = context.contentResolver.openInputStream(uri) val inputStream = context.contentResolver.openInputStream(uri)
?: throw IllegalStateException("Cannot open URI") ?: throw IllegalStateException("Cannot open URI")

View file

@ -4,10 +4,14 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
@ -53,10 +57,13 @@ fun ComposerScreen(
} }
} }
val imagePickerLauncher = rememberLauncherForActivityResult( // Multi-image picker
ActivityResultContracts.GetContent() val multiImagePickerLauncher = rememberLauncherForActivityResult(
) { uri: Uri? -> ActivityResultContracts.GetMultipleContents()
viewModel.setImage(uri) ) { uris: List<Uri> ->
if (uris.isNotEmpty()) {
viewModel.addImages(uris)
}
} }
Scaffold( Scaffold(
@ -106,37 +113,22 @@ fun ComposerScreen(
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
OutlinedIconButton(onClick = { imagePickerLauncher.launch("image/*") }) { OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
Icon(Icons.Default.Image, "Attach image") Icon(Icons.Default.Image, "Attach images")
} }
OutlinedIconButton(onClick = { showLinkDialog = true }) { OutlinedIconButton(onClick = { showLinkDialog = true }) {
Icon(Icons.Default.Link, "Add link") Icon(Icons.Default.Link, "Add link")
} }
} }
// Image preview // Image grid preview
if (state.imageUri != null) { if (state.imageUris.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Box { ImageGridPreview(
AsyncImage( imageUris = state.imageUris,
model = state.imageUri, onRemoveImage = viewModel::removeImage,
contentDescription = "Selected image", onAddMore = { multiImagePickerLauncher.launch("image/*") }
modifier = Modifier )
.fillMaxWidth()
.height(200.dp)
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
)
IconButton(
onClick = { viewModel.setImage(null) },
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
Icons.Default.Close, "Remove image",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
} }
// Link preview // Link preview
@ -222,7 +214,7 @@ fun ComposerScreen(
Button( Button(
onClick = viewModel::publish, onClick = viewModel::publish,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !state.isSubmitting && state.text.isNotBlank() enabled = !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty())
) { ) {
if (state.isSubmitting) { if (state.isSubmitting) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
@ -304,6 +296,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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ScheduleDateTimePicker( fun ScheduleDateTimePicker(

View file

@ -5,6 +5,7 @@ import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.db.Converters
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.OpenGraphFetcher import com.swoosh.microblog.data.repository.OpenGraphFetcher
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
@ -31,10 +32,19 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
editingLocalId = post.localId editingLocalId = post.localId
editingGhostId = post.ghostId editingGhostId = post.ghostId
editingUpdatedAt = post.updatedAt 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 { _uiState.update {
it.copy( it.copy(
text = post.textContent, text = post.textContent,
imageUri = post.imageUrl?.let { url -> Uri.parse(url) }, imageUris = imageUris,
linkPreview = if (post.linkUrl != null) LinkPreview( linkPreview = if (post.linkUrl != null) LinkPreview(
url = post.linkUrl, url = post.linkUrl,
title = post.linkTitle, title = post.linkTitle,
@ -50,8 +60,35 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(text = text) } _uiState.update { it.copy(text = text) }
} }
/**
* Legacy single image setter - adds to the list.
*/
fun setImage(uri: Uri?) { fun setImage(uri: Uri?) {
_uiState.update { it.copy(imageUri = uri) } if (uri != null) {
addImages(listOf(uri))
} else {
_uiState.update { it.copy(imageUris = emptyList()) }
}
}
/**
* 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) { fun fetchLinkPreview(url: String) {
@ -82,7 +119,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) { private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
val state = _uiState.value val state = _uiState.value
if (state.text.isBlank() && state.imageUri == null) return if (state.text.isBlank() && state.imageUris.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, error = null) } _uiState.update { it.copy(isSubmitting = true, error = null) }
@ -97,7 +134,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
title = title, title = title,
content = state.text, content = state.text,
status = status, status = status,
imageUri = state.imageUri?.toString(), imageUri = state.imageUris.firstOrNull()?.toString(),
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title, linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description, linkDescription = state.linkPreview?.description,
@ -115,11 +153,12 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
return@launch return@launch
} }
// Online: upload image first if needed // Online: upload all images first
var featureImage: String? = null val uploadedImageUrls = mutableListOf<String>()
if (state.imageUri != null) { if (state.imageUris.isNotEmpty()) {
repository.uploadImage(state.imageUri).fold( val imagesResult = repository.uploadImages(state.imageUris)
onSuccess = { url -> featureImage = url }, imagesResult.fold(
onSuccess = { urls -> uploadedImageUrls.addAll(urls) },
onFailure = { e -> onFailure = { e ->
_uiState.update { it.copy(isSubmitting = false, error = "Image upload failed: ${e.message}") } _uiState.update { it.copy(isSubmitting = false, error = "Image upload failed: ${e.message}") }
return@launch return@launch
@ -127,7 +166,12 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
) )
} }
val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview) val featureImage = uploadedImageUrls.firstOrNull()
val mobiledoc = MobiledocBuilder.build(
state.text, uploadedImageUrls,
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description
)
val ghostPost = GhostPost( val ghostPost = GhostPost(
title = title, title = title,
@ -157,8 +201,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
title = title, title = title,
content = state.text, content = state.text,
status = status, status = status,
imageUri = state.imageUri?.toString(), imageUri = state.imageUris.firstOrNull()?.toString(),
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
uploadedImageUrl = featureImage, uploadedImageUrl = featureImage,
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title, linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description, linkDescription = state.linkPreview?.description,
@ -184,7 +230,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
data class ComposerUiState( data class ComposerUiState(
val text: String = "", val text: String = "",
val imageUri: Uri? = null, val imageUris: List<Uri> = emptyList(),
val linkPreview: LinkPreview? = null, val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false, val isLoadingLink: Boolean = false,
val scheduledAt: String? = null, val scheduledAt: String? = null,
@ -192,4 +238,9 @@ data class ComposerUiState(
val isSuccess: Boolean = false, val isSuccess: Boolean = false,
val isEditing: Boolean = false, val isEditing: Boolean = false,
val error: String? = null val error: String? = null
) ) {
/**
* Backwards compatibility: returns the first image URI or null.
*/
val imageUri: Uri? get() = imageUris.firstOrNull()
}

View file

@ -1,5 +1,6 @@
package com.swoosh.microblog.ui.detail package com.swoosh.microblog.ui.detail
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -15,6 +16,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.ui.feed.FullScreenGallery
import com.swoosh.microblog.ui.feed.StatusBadge import com.swoosh.microblog.ui.feed.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime import com.swoosh.microblog.ui.feed.formatRelativeTime
@ -27,6 +29,17 @@ fun DetailScreen(
onDelete: (FeedPost) -> Unit onDelete: (FeedPost) -> Unit
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
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( Scaffold(
topBar = { topBar = {
@ -76,16 +89,15 @@ fun DetailScreen(
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
// Full image // Image gallery
if (post.imageUrl != null) { if (allImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
AsyncImage( DetailImageGallery(
model = post.imageUrl, images = allImages,
contentDescription = "Post image", onImageClick = { index ->
modifier = Modifier galleryStartIndex = index
.fillMaxWidth() showGallery = true
.clip(MaterialTheme.shapes.medium), }
contentScale = ContentScale.FillWidth
) )
} }
@ -132,7 +144,7 @@ fun DetailScreen(
// Metadata // Metadata
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Divider() HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
if (post.createdAt != null) { if (post.createdAt != null) {
@ -142,6 +154,9 @@ fun DetailScreen(
MetadataRow("Published", post.publishedAt) MetadataRow("Published", post.publishedAt)
} }
MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() }) MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() })
if (allImages.isNotEmpty()) {
MetadataRow("Images", "${allImages.size}")
}
} }
} }
@ -166,6 +181,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 @Composable

View file

@ -1,13 +1,19 @@
package com.swoosh.microblog.ui.feed package com.swoosh.microblog.ui.feed
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.filled.WifiOff
@ -19,10 +25,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
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.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -204,6 +215,19 @@ fun PostCard(
post.textContent.take(280) + "..." post.textContent.take(280) + "..."
} }
// 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( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -248,17 +272,15 @@ fun PostCard(
} }
} }
// Image thumbnail // Image grid
if (post.imageUrl != null) { if (allImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
AsyncImage( PostImageGrid(
model = post.imageUrl, images = allImages,
contentDescription = "Post image", onImageClick = { index ->
modifier = Modifier galleryStartIndex = index
.fillMaxWidth() showGallery = true
.height(180.dp) }
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
) )
} }
@ -322,6 +344,314 @@ fun PostCard(
} }
} }
} }
// 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
)
}
} }
@Composable @Composable

View file

@ -3,6 +3,7 @@ package com.swoosh.microblog.ui.feed
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.db.Converters
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -138,41 +139,88 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(posts = allPosts) } _uiState.update { it.copy(posts = allPosts) }
} }
private fun GhostPost.toFeedPost(): FeedPost = FeedPost( private fun GhostPost.toFeedPost(): FeedPost {
ghostId = id, val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc)
title = title ?: "", // Use feature_image as primary, then add mobiledoc images (avoiding duplicates)
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "", val allImages = mutableListOf<String>()
htmlContent = html, if (feature_image != null) {
imageUrl = feature_image, allImages.add(feature_image)
linkUrl = null, }
linkTitle = null, for (url in imageUrls) {
linkDescription = null, if (url !in allImages) {
linkImageUrl = null, allImages.add(url)
status = status ?: "draft", }
publishedAt = published_at, }
createdAt = created_at, return FeedPost(
updatedAt = updated_at, ghostId = id,
isLocal = false title = title ?: "",
) textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
htmlContent = html,
imageUrl = allImages.firstOrNull(),
imageUrls = allImages,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = status ?: "draft",
publishedAt = published_at,
createdAt = created_at,
updatedAt = updated_at,
isLocal = false
)
}
private fun LocalPost.toFeedPost(): FeedPost = FeedPost( /**
localId = localId, * Extracts image URLs from Ghost mobiledoc JSON.
ghostId = ghostId, * Image cards have the format: ["image", {"src": "url"}]
title = title, */
textContent = content, private fun extractImageUrlsFromMobiledoc(mobiledoc: String?): List<String> {
htmlContent = htmlContent, if (mobiledoc == null) return emptyList()
imageUrl = uploadedImageUrl ?: imageUri, return try {
linkUrl = linkUrl, val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject
linkTitle = linkTitle, val cards = json.getAsJsonArray("cards") ?: return emptyList()
linkDescription = linkDescription, cards.mapNotNull { card ->
linkImageUrl = linkImageUrl, val cardArray = card.asJsonArray
status = status.name.lowercase(), if (cardArray.size() >= 2 && cardArray[0].asString == "image") {
publishedAt = null, val cardData = cardArray[1].asJsonObject
createdAt = null, cardData.get("src")?.asString
updatedAt = null, } else null
isLocal = true, }
queueStatus = queueStatus } catch (e: Exception) {
) emptyList()
}
}
private fun LocalPost.toFeedPost(): FeedPost {
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 = allImageUrls.firstOrNull(),
imageUrls = allImageUrls,
linkUrl = linkUrl,
linkTitle = linkTitle,
linkDescription = linkDescription,
linkImageUrl = linkImageUrl,
status = status.name.lowercase(),
publishedAt = null,
createdAt = null,
updatedAt = null,
isLocal = true,
queueStatus = queueStatus
)
}
} }
data class FeedUiState( data class FeedUiState(

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.work.* import androidx.work.*
import com.swoosh.microblog.data.MobiledocBuilder 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.GhostPost
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
@ -25,7 +26,10 @@ class PostUploadWorker(
for (post in queuedPosts) { for (post in queuedPosts) {
repository.updateQueueStatus(post.localId, QueueStatus.UPLOADING) 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 var featureImage = post.uploadedImageUrl
if (featureImage == null && post.imageUri != null) { if (featureImage == null && post.imageUri != null) {
val imageResult = repository.uploadImage(Uri.parse(post.imageUri)) val imageResult = repository.uploadImage(Uri.parse(post.imageUri))
@ -37,7 +41,31 @@ class PostUploadWorker(
featureImage = imageResult.getOrNull() featureImage = imageResult.getOrNull()
} }
val mobiledoc = MobiledocBuilder.build(post.content, post.linkUrl, post.linkTitle, post.linkDescription) // 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, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription
)
val ghostPost = GhostPost( val ghostPost = GhostPost(
title = post.title, title = post.title,

View file

@ -87,7 +87,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build handles text with special chars`() { 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 val json = JsonParser.parseString(result).asJsonObject
assertNotNull("Should produce valid JSON even with special chars", json) assertNotNull("Should produce valid JSON even with special chars", json)
} }
@ -251,4 +251,172 @@ class MobiledocBuilderTest {
fun `escapeForJson handles empty string`() { fun `escapeForJson handles empty string`() {
assertEquals("", MobiledocBuilder.escapeForJson("")) assertEquals("", MobiledocBuilder.escapeForJson(""))
} }
// --- Multi-image support ---
@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)
}
} }

View file

@ -116,4 +116,102 @@ class ConvertersTest {
fun `toQueueStatus throws on invalid string`() { fun `toQueueStatus throws on invalid string`() {
converters.toQueueStatus("NONEXISTENT") 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) 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 @Test
fun `LocalPost createdAt is set on construction`() { fun `LocalPost createdAt is set on construction`() {
val before = System.currentTimeMillis() val before = System.currentTimeMillis()
@ -119,6 +131,48 @@ class GhostModelsTest {
assertEquals(QueueStatus.NONE, post.queueStatus) 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 --- // --- GSON serialization ---
@Test @Test