diff --git a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt index 0714851..e08f588 100644 --- a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt +++ b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt @@ -3,7 +3,7 @@ package com.swoosh.microblog.data import com.swoosh.microblog.data.model.LinkPreview /** - * Builds Ghost mobiledoc JSON from text content and optional link preview. + * Builds Ghost mobiledoc JSON from text content, optional images, and optional link preview. * Extracted as a shared utility used by both ComposerViewModel and PostUploadWorker. */ object MobiledocBuilder { @@ -17,18 +17,47 @@ object MobiledocBuilder { linkUrl: String?, linkTitle: 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, + linkUrl: String?, + linkTitle: String?, + linkDescription: String? ): String { val escapedText = escapeForJson(text).replace("\n", "\\n") - val cards = if (linkUrl != null) { + val cards = mutableListOf() + val cardSections = mutableListOf() + + // 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 escapedTitle = linkTitle?.let { escapeForJson(it) } ?: "" val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: "" - """["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""" - } else "" + cards.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""") + cardSections.add("[10,${cards.size - 1}]") + } - val cardSection = if (linkUrl != null) ",[10,0]" else "" - return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSection]}""" + val cardsJson = cards.joinToString(",") + val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else "" + + return """{"version":"0.3.1","atoms":[],"cards":[$cardsJson],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSectionsJson]}""" } internal fun escapeForJson(value: String): String { diff --git a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt index 3b3f23f..5d0b426 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt @@ -5,9 +5,11 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase 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) abstract class AppDatabase : RoomDatabase() { @@ -17,13 +19,22 @@ abstract class AppDatabase : RoomDatabase() { @Volatile 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 { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "swoosh_database" - ).build() + ) + .addMigrations(MIGRATION_1_2) + .build() INSTANCE = instance instance } diff --git a/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt b/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt index e6f4dee..d5c5ad2 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt @@ -1,6 +1,8 @@ package com.swoosh.microblog.data.db import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.swoosh.microblog.data.model.PostStatus import com.swoosh.microblog.data.model.QueueStatus @@ -16,4 +18,31 @@ class Converters { @TypeConverter fun toQueueStatus(value: String): QueueStatus = QueueStatus.valueOf(value) + + companion object { + private val gson = Gson() + + /** + * Serializes a list of strings to a JSON array string. + * Returns null for null or empty lists. + */ + fun stringListToJson(list: List?): String? { + if (list.isNullOrEmpty()) return null + return gson.toJson(list) + } + + /** + * Deserializes a JSON array string to a list of strings. + * Returns empty list for null or empty input. + */ + fun jsonToStringList(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + } } diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 9adfc02..f075549 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -62,6 +62,8 @@ data class LocalPost( val status: PostStatus = PostStatus.DRAFT, 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, @@ -95,6 +97,7 @@ data class FeedPost( val textContent: String, val htmlContent: String?, val imageUrl: String?, + val imageUrls: List = emptyList(), val linkUrl: String?, val linkTitle: String?, val linkDescription: String?, diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index 8a567ab..933487e 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -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): Result> = + withContext(Dispatchers.IO) { + try { + val urls = mutableListOf() + for (uri in uris) { + val result = uploadImage(uri) + if (result.isFailure) { + return@withContext Result.failure( + result.exceptionOrNull() ?: Exception("Image upload failed") + ) + } + urls.add(result.getOrThrow()) + } + Result.success(urls) + } catch (e: Exception) { + Result.failure(e) + } + } + private fun copyUriToTempFile(uri: Uri): File { val inputStream = context.contentResolver.openInputStream(uri) ?: throw IllegalStateException("Cannot open URI") diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index b26f002..a6a1852 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -4,10 +4,14 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.verticalScroll import androidx.compose.material.icons.Icons 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.Image import androidx.compose.material.icons.filled.Link @@ -53,10 +57,13 @@ fun ComposerScreen( } } - val imagePickerLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.GetContent() - ) { uri: Uri? -> - viewModel.setImage(uri) + // Multi-image picker + val multiImagePickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetMultipleContents() + ) { uris: List -> + if (uris.isNotEmpty()) { + viewModel.addImages(uris) + } } Scaffold( @@ -106,37 +113,22 @@ 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 - if (state.imageUri != null) { + // Image grid preview + if (state.imageUris.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) - Box { - AsyncImage( - model = state.imageUri, - contentDescription = "Selected 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 - ) - } - } + ImageGridPreview( + imageUris = state.imageUris, + onRemoveImage = viewModel::removeImage, + onAddMore = { multiImagePickerLauncher.launch("image/*") } + ) } // Link preview @@ -222,7 +214,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) @@ -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, + onRemoveImage: (Int) -> Unit, + onAddMore: () -> Unit +) { + val itemCount = imageUris.size + 1 // +1 for "add more" button + val rows = (itemCount + 1) / 2 // ceiling division for 2 columns + val gridHeight = (rows * 140).dp // approx height per row + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = gridHeight), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + userScrollEnabled = false + ) { + itemsIndexed(imageUris) { index, uri -> + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium) + ) { + AsyncImage( + model = uri, + contentDescription = "Image ${index + 1}", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + IconButton( + onClick = { onRemoveImage(index) }, + modifier = Modifier.align(Alignment.TopEnd), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + ) + ) { + Icon( + Icons.Default.Close, "Remove image", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } + } + + item { + OutlinedCard( + onClick = onAddMore, + modifier = Modifier.aspectRatio(1f) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.AddPhotoAlternate, + contentDescription = "Add more images", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Add more", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScheduleDateTimePicker( diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 86e9683..5b4e887 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -5,6 +5,7 @@ import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.swoosh.microblog.data.MobiledocBuilder +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 @@ -31,10 +32,19 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application editingLocalId = post.localId editingGhostId = post.ghostId editingUpdatedAt = post.updatedAt + + // Build image URIs list from available data + val imageUris = mutableListOf() + if (post.imageUrls.isNotEmpty()) { + imageUris.addAll(post.imageUrls.map { Uri.parse(it) }) + } else if (post.imageUrl != null) { + imageUris.add(Uri.parse(post.imageUrl)) + } + _uiState.update { it.copy( text = post.textContent, - imageUri = post.imageUrl?.let { url -> Uri.parse(url) }, + imageUris = imageUris, linkPreview = if (post.linkUrl != null) LinkPreview( url = post.linkUrl, title = post.linkTitle, @@ -50,8 +60,35 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application _uiState.update { it.copy(text = text) } } + /** + * Legacy single image setter - adds to the list. + */ 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) { + _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) { @@ -82,7 +119,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) } @@ -97,7 +134,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application title = title, content = state.text, status = status, - imageUri = state.imageUri?.toString(), + imageUri = state.imageUris.firstOrNull()?.toString(), + imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }), linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, linkDescription = state.linkPreview?.description, @@ -115,11 +153,12 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application return@launch } - // Online: upload image first if needed - var featureImage: String? = null - if (state.imageUri != null) { - repository.uploadImage(state.imageUri).fold( - onSuccess = { url -> featureImage = url }, + // Online: upload all images first + val uploadedImageUrls = mutableListOf() + if (state.imageUris.isNotEmpty()) { + val imagesResult = repository.uploadImages(state.imageUris) + imagesResult.fold( + onSuccess = { urls -> uploadedImageUrls.addAll(urls) }, onFailure = { e -> _uiState.update { it.copy(isSubmitting = false, error = "Image upload failed: ${e.message}") } return@launch @@ -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( title = title, @@ -157,8 +201,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application title = title, content = state.text, status = status, - imageUri = state.imageUri?.toString(), + imageUri = state.imageUris.firstOrNull()?.toString(), + imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }), uploadedImageUrl = featureImage, + uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls), linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, linkDescription = state.linkPreview?.description, @@ -184,7 +230,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application data class ComposerUiState( val text: String = "", - val imageUri: Uri? = null, + val imageUris: List = emptyList(), val linkPreview: LinkPreview? = null, val isLoadingLink: Boolean = false, val scheduledAt: String? = null, @@ -192,4 +238,9 @@ data class ComposerUiState( val isSuccess: Boolean = false, val isEditing: Boolean = false, val error: String? = null -) +) { + /** + * Backwards compatibility: returns the first image URI or null. + */ + val imageUri: Uri? get() = imageUris.firstOrNull() +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index bc6d465..320a597 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -1,5 +1,6 @@ package com.swoosh.microblog.ui.detail +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -15,6 +16,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil.compose.AsyncImage 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.formatRelativeTime @@ -27,6 +29,17 @@ fun DetailScreen( onDelete: (FeedPost) -> Unit ) { 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( topBar = { @@ -76,16 +89,15 @@ fun DetailScreen( style = MaterialTheme.typography.bodyLarge ) - // Full image - if (post.imageUrl != null) { + // Image gallery + if (allImages.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) - AsyncImage( - model = post.imageUrl, - contentDescription = "Post image", - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.FillWidth + DetailImageGallery( + images = allImages, + onImageClick = { index -> + galleryStartIndex = index + showGallery = true + } ) } @@ -132,7 +144,7 @@ fun DetailScreen( // Metadata Spacer(modifier = Modifier.height(24.dp)) - Divider() + HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) if (post.createdAt != null) { @@ -142,6 +154,9 @@ fun DetailScreen( MetadataRow("Published", post.publishedAt) } 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, + 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 diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..0973768 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -1,13 +1,19 @@ 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.gestures.detectTransformGestures import androidx.compose.foundation.layout.* 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.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons 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.Settings import androidx.compose.material.icons.filled.WifiOff @@ -19,10 +25,15 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow 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.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -204,6 +215,19 @@ fun PostCard( 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( modifier = Modifier .fillMaxWidth() @@ -248,17 +272,15 @@ fun PostCard( } } - // Image thumbnail - if (post.imageUrl != null) { + // Image grid + if (allImages.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) - AsyncImage( - model = post.imageUrl, - contentDescription = "Post image", - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.Crop + PostImageGrid( + images = allImages, + onImageClick = { index -> + galleryStartIndex = index + showGallery = true + } ) } @@ -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, + onImageClick: (Int) -> Unit +) { + val shape = MaterialTheme.shapes.medium + + when (images.size) { + 1 -> { + // Full width single image + AsyncImage( + model = images[0], + contentDescription = "Post image", + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(shape) + .clickable { onImageClick(0) }, + contentScale = ContentScale.Crop + ) + } + 2 -> { + // Side by side + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[0], + contentDescription = "Post image 1", + modifier = Modifier + .weight(1f) + .height(180.dp) + .clip(shape) + .clickable { onImageClick(0) }, + contentScale = ContentScale.Crop + ) + AsyncImage( + model = images[1], + contentDescription = "Post image 2", + modifier = Modifier + .weight(1f) + .height(180.dp) + .clip(shape) + .clickable { onImageClick(1) }, + contentScale = ContentScale.Crop + ) + } + } + 3 -> { + // One large + two small stacked + Row( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[0], + contentDescription = "Post image 1", + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(shape) + .clickable { onImageClick(0) }, + contentScale = ContentScale.Crop + ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[1], + contentDescription = "Post image 2", + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(shape) + .clickable { onImageClick(1) }, + contentScale = ContentScale.Crop + ) + AsyncImage( + model = images[2], + contentDescription = "Post image 3", + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(shape) + .clickable { onImageClick(2) }, + contentScale = ContentScale.Crop + ) + } + } + } + else -> { + // 2x2 grid with +N overlay on last cell + val remaining = images.size - 4 + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[0], + contentDescription = "Post image 1", + modifier = Modifier + .weight(1f) + .height(120.dp) + .clip(shape) + .clickable { onImageClick(0) }, + contentScale = ContentScale.Crop + ) + AsyncImage( + model = images[1], + contentDescription = "Post image 2", + modifier = Modifier + .weight(1f) + .height(120.dp) + .clip(shape) + .clickable { onImageClick(1) }, + contentScale = ContentScale.Crop + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AsyncImage( + model = images[2], + contentDescription = "Post image 3", + modifier = Modifier + .weight(1f) + .height(120.dp) + .clip(shape) + .clickable { onImageClick(2) }, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .weight(1f) + .height(120.dp) + .clip(shape) + .clickable { onImageClick(3) } + ) { + AsyncImage( + model = images[3], + contentDescription = "Post image 4", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + if (remaining > 0) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text( + text = "+$remaining", + style = MaterialTheme.typography.headlineSmall, + color = Color.White + ) + } + } + } + } + } + } + } +} + +/** + * Full-screen gallery viewer with paging and pinch-to-zoom. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FullScreenGallery( + images: List, + startIndex: Int, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + val pagerState = rememberPagerState( + initialPage = startIndex, + pageCount = { images.size } + ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + ZoomableImage( + model = images[page], + contentDescription = "Image ${page + 1} of ${images.size}" + ) + } + + // Close button + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White + ) + ) { + Icon(Icons.Default.Close, contentDescription = "Close gallery") + } + + // Page indicator + if (images.size > 1) { + Text( + text = "${pagerState.currentPage + 1} / ${images.size}", + style = MaterialTheme.typography.labelLarge, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .background( + Color.Black.copy(alpha = 0.5f), + MaterialTheme.shapes.small + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + } + } +} + +/** + * Image with pinch-to-zoom support. + */ +@Composable +fun ZoomableImage( + model: Any?, + contentDescription: String? +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { + offsetX += pan.x + offsetY += pan.y + } else { + offsetX = 0f + offsetY = 0f + } + } + }, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ), + contentScale = ContentScale.Fit + ) + } } @Composable diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 350de2c..52295f8 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -3,6 +3,7 @@ package com.swoosh.microblog.ui.feed import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.swoosh.microblog.data.db.Converters import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.repository.PostRepository import kotlinx.coroutines.flow.* @@ -138,41 +139,88 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { _uiState.update { it.copy(posts = allPosts) } } - private fun GhostPost.toFeedPost(): FeedPost = FeedPost( - ghostId = id, - title = title ?: "", - textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "", - htmlContent = html, - imageUrl = feature_image, - linkUrl = null, - linkTitle = null, - linkDescription = null, - linkImageUrl = null, - status = status ?: "draft", - publishedAt = published_at, - createdAt = created_at, - updatedAt = updated_at, - isLocal = false - ) + private fun GhostPost.toFeedPost(): FeedPost { + val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc) + // Use feature_image as primary, then add mobiledoc images (avoiding duplicates) + val allImages = mutableListOf() + if (feature_image != null) { + allImages.add(feature_image) + } + for (url in imageUrls) { + if (url !in allImages) { + allImages.add(url) + } + } + return FeedPost( + ghostId = id, + 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, - ghostId = ghostId, - title = title, - textContent = content, - htmlContent = htmlContent, - imageUrl = uploadedImageUrl ?: imageUri, - linkUrl = linkUrl, - linkTitle = linkTitle, - linkDescription = linkDescription, - linkImageUrl = linkImageUrl, - status = status.name.lowercase(), - publishedAt = null, - createdAt = null, - updatedAt = null, - isLocal = true, - queueStatus = queueStatus - ) + /** + * Extracts image URLs from Ghost mobiledoc JSON. + * Image cards have the format: ["image", {"src": "url"}] + */ + private fun extractImageUrlsFromMobiledoc(mobiledoc: String?): List { + if (mobiledoc == null) return emptyList() + return try { + val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject + val cards = json.getAsJsonArray("cards") ?: return emptyList() + cards.mapNotNull { card -> + val cardArray = card.asJsonArray + if (cardArray.size() >= 2 && cardArray[0].asString == "image") { + val cardData = cardArray[1].asJsonObject + cardData.get("src")?.asString + } else null + } + } catch (e: Exception) { + emptyList() + } + } + + private fun LocalPost.toFeedPost(): FeedPost { + val 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( diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index 17c9357..d038940 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.work.* import com.swoosh.microblog.data.MobiledocBuilder +import com.swoosh.microblog.data.db.Converters import com.swoosh.microblog.data.model.GhostPost import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.repository.PostRepository @@ -25,7 +26,10 @@ class PostUploadWorker( for (post in queuedPosts) { repository.updateQueueStatus(post.localId, QueueStatus.UPLOADING) - // Upload image if needed + // Upload multiple images if needed + val allImageUrls = mutableListOf() + + // Handle legacy single image field var featureImage = post.uploadedImageUrl if (featureImage == null && post.imageUri != null) { val imageResult = repository.uploadImage(Uri.parse(post.imageUri)) @@ -37,7 +41,31 @@ class PostUploadWorker( 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( title = post.title, diff --git a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt index 4a36c39..dd668d3 100644 --- a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt @@ -87,7 +87,7 @@ class MobiledocBuilderTest { @Test fun `build handles text with special chars`() { - val result = MobiledocBuilder.build("café & résumé ", null as LinkPreview?) + val result = MobiledocBuilder.build("cafe & resume ", null as LinkPreview?) val json = JsonParser.parseString(result).asJsonObject assertNotNull("Should produce valid JSON even with special chars", json) } @@ -251,4 +251,172 @@ class MobiledocBuilderTest { fun `escapeForJson handles empty string`() { 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) + } } diff --git a/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt b/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt index 4c53962..9eaea79 100644 --- a/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt @@ -116,4 +116,102 @@ class ConvertersTest { fun `toQueueStatus throws on invalid string`() { converters.toQueueStatus("NONEXISTENT") } + + // --- String list JSON serialization --- + + @Test + fun `stringListToJson with null returns null`() { + assertNull(Converters.stringListToJson(null)) + } + + @Test + fun `stringListToJson with empty list returns null`() { + assertNull(Converters.stringListToJson(emptyList())) + } + + @Test + fun `stringListToJson with single item returns JSON array`() { + val result = Converters.stringListToJson(listOf("https://example.com/img.jpg")) + assertNotNull(result) + assertTrue(result!!.startsWith("[")) + assertTrue(result.endsWith("]")) + assertTrue(result.contains("https://example.com/img.jpg")) + } + + @Test + fun `stringListToJson with multiple items returns JSON array`() { + val result = Converters.stringListToJson( + listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg") + ) + assertNotNull(result) + assertTrue(result!!.contains("img1.jpg")) + assertTrue(result.contains("img2.jpg")) + } + + @Test + fun `jsonToStringList with null returns empty list`() { + assertEquals(emptyList(), Converters.jsonToStringList(null)) + } + + @Test + fun `jsonToStringList with empty string returns empty list`() { + assertEquals(emptyList(), Converters.jsonToStringList("")) + } + + @Test + fun `jsonToStringList with blank string returns empty list`() { + assertEquals(emptyList(), Converters.jsonToStringList(" ")) + } + + @Test + fun `jsonToStringList with invalid JSON returns empty list`() { + assertEquals(emptyList(), Converters.jsonToStringList("not json")) + } + + @Test + fun `jsonToStringList with single item JSON array`() { + val result = Converters.jsonToStringList("""["https://example.com/img.jpg"]""") + assertEquals(1, result.size) + assertEquals("https://example.com/img.jpg", result[0]) + } + + @Test + fun `jsonToStringList with multiple items JSON array`() { + val result = Converters.jsonToStringList( + """["https://example.com/img1.jpg","https://example.com/img2.jpg","https://example.com/img3.jpg"]""" + ) + assertEquals(3, result.size) + assertEquals("https://example.com/img1.jpg", result[0]) + assertEquals("https://example.com/img2.jpg", result[1]) + assertEquals("https://example.com/img3.jpg", result[2]) + } + + @Test + fun `stringListToJson and jsonToStringList round-trip`() { + val original = listOf( + "https://example.com/img1.jpg", + "https://example.com/img2.jpg", + "https://example.com/img3.jpg" + ) + val json = Converters.stringListToJson(original) + val restored = Converters.jsonToStringList(json) + assertEquals(original, restored) + } + + @Test + fun `stringListToJson handles URLs with special characters`() { + val urls = listOf( + "https://example.com/img?id=1&name=test", + "https://example.com/path/to/image (1).jpg" + ) + val json = Converters.stringListToJson(urls) + val restored = Converters.jsonToStringList(json) + assertEquals(urls, restored) + } + + @Test + fun `jsonToStringList with empty JSON array returns empty list`() { + val result = Converters.jsonToStringList("[]") + assertEquals(emptyList(), result) + } } diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt index 6f4f370..3237a06 100644 --- a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt @@ -46,6 +46,18 @@ class GhostModelsTest { assertNull(post.imageUri) } + @Test + fun `LocalPost default imageUris is null`() { + val post = LocalPost() + assertNull(post.imageUris) + } + + @Test + fun `LocalPost default uploadedImageUrls is null`() { + val post = LocalPost() + assertNull(post.uploadedImageUrls) + } + @Test fun `LocalPost createdAt is set on construction`() { val before = System.currentTimeMillis() @@ -119,6 +131,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