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