mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +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
|
import com.swoosh.microblog.data.model.LinkPreview
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds Ghost mobiledoc JSON from text content and optional link preview.
|
* Builds Ghost mobiledoc JSON from text content, optional images, and optional link preview.
|
||||||
* Extracted as a shared utility used by both ComposerViewModel and PostUploadWorker.
|
* Extracted as a shared utility used by both ComposerViewModel and PostUploadWorker.
|
||||||
*/
|
*/
|
||||||
object MobiledocBuilder {
|
object MobiledocBuilder {
|
||||||
|
|
@ -18,9 +18,12 @@ object MobiledocBuilder {
|
||||||
linkTitle: String?,
|
linkTitle: String?,
|
||||||
linkDescription: String?
|
linkDescription: String?
|
||||||
): 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(
|
fun build(
|
||||||
text: String,
|
text: String,
|
||||||
linkUrl: String?,
|
linkUrl: String?,
|
||||||
|
|
@ -28,32 +31,65 @@ object MobiledocBuilder {
|
||||||
linkDescription: String?,
|
linkDescription: String?,
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
imageAlt: 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 {
|
): String {
|
||||||
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
||||||
|
|
||||||
val cardsList = mutableListOf<String>()
|
val cards = mutableListOf<String>()
|
||||||
val cardSections = mutableListOf<String>()
|
val cardSections = mutableListOf<String>()
|
||||||
|
|
||||||
// Image card
|
// Add image cards
|
||||||
if (imageUrl != null) {
|
for ((index, url) in imageUrls.withIndex()) {
|
||||||
val escapedImgUrl = escapeForJson(imageUrl)
|
val escapedUrl = escapeForJson(url)
|
||||||
val escapedAlt = imageAlt?.let { escapeForJson(it) } ?: ""
|
// Apply alt text to the first image only
|
||||||
cardSections.add("[10,${cardsList.size}]")
|
val alt = if (index == 0 && imageAlt != null) escapeForJson(imageAlt) else ""
|
||||||
cardsList.add("""["image",{"src":"$escapedImgUrl","alt":"$escapedAlt","caption":""}]""")
|
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) {
|
if (linkUrl != null) {
|
||||||
val escapedUrl = escapeForJson(linkUrl)
|
val escapedUrl = escapeForJson(linkUrl)
|
||||||
val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: ""
|
val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: ""
|
||||||
val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: ""
|
val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: ""
|
||||||
cardSections.add("[10,${cardsList.size}]")
|
cards.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""")
|
||||||
cardsList.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""")
|
cardSections.add("[10,${cards.size - 1}]")
|
||||||
}
|
}
|
||||||
|
|
||||||
val cards = cardsList.joinToString(",")
|
val cardsJson = cards.joinToString(",")
|
||||||
val extraSections = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
||||||
return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$extraSections]}"""
|
|
||||||
|
return """{"version":"0.3.1","atoms":[],"cards":[$cardsJson],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSectionsJson]}"""
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun escapeForJson(value: String): String {
|
internal fun escapeForJson(value: String): String {
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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
|
package com.swoosh.microblog.data.db
|
||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
import com.swoosh.microblog.data.model.PostStatus
|
import com.swoosh.microblog.data.model.PostStatus
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
|
|
||||||
|
|
@ -16,4 +18,31 @@ class Converters {
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun toQueueStatus(value: String): QueueStatus = QueueStatus.valueOf(value)
|
fun toQueueStatus(value: String): QueueStatus = QueueStatus.valueOf(value)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes a list of strings to a JSON array string.
|
||||||
|
* Returns null for null or empty lists.
|
||||||
|
*/
|
||||||
|
fun stringListToJson(list: List<String>?): String? {
|
||||||
|
if (list.isNullOrEmpty()) return null
|
||||||
|
return gson.toJson(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes a JSON array string to a list of strings.
|
||||||
|
* Returns empty list for null or empty input.
|
||||||
|
*/
|
||||||
|
fun jsonToStringList(json: String?): List<String> {
|
||||||
|
if (json.isNullOrBlank()) return emptyList()
|
||||||
|
return try {
|
||||||
|
val type = object : TypeToken<List<String>>() {}.type
|
||||||
|
gson.fromJson(json, type) ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ data class LocalPost(
|
||||||
val featured: Boolean = false,
|
val featured: Boolean = false,
|
||||||
val imageUri: String? = null,
|
val imageUri: String? = null,
|
||||||
val uploadedImageUrl: String? = null,
|
val uploadedImageUrl: String? = null,
|
||||||
|
val imageUris: String? = null, // JSON array of local URIs
|
||||||
|
val uploadedImageUrls: String? = null, // JSON array of uploaded URLs
|
||||||
val linkUrl: String? = null,
|
val linkUrl: String? = null,
|
||||||
val linkTitle: String? = null,
|
val linkTitle: String? = null,
|
||||||
val linkDescription: String? = null,
|
val linkDescription: String? = null,
|
||||||
|
|
@ -113,6 +115,7 @@ data class FeedPost(
|
||||||
val htmlContent: String?,
|
val htmlContent: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val imageAlt: String? = null,
|
val imageAlt: String? = null,
|
||||||
|
val imageUrls: List<String> = emptyList(),
|
||||||
val linkUrl: String?,
|
val linkUrl: String?,
|
||||||
val linkTitle: String?,
|
val linkTitle: String?,
|
||||||
val linkDescription: 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 {
|
private fun copyUriToTempFile(uri: Uri): File {
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
?: throw IllegalStateException("Cannot open URI")
|
?: throw IllegalStateException("Cannot open URI")
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
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(
|
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.GetContent()
|
ActivityResultContracts.GetContent()
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
viewModel.setImage(uri)
|
if (uri != null) {
|
||||||
|
viewModel.addImages(listOf(uri))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -151,7 +166,7 @@ fun ComposerScreen(
|
||||||
|
|
||||||
if (state.isPreviewMode) {
|
if (state.isPreviewMode) {
|
||||||
// Preview mode: show rendered HTML
|
// 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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -250,59 +265,24 @@ fun ComposerScreen(
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
OutlinedIconButton(onClick = { imagePickerLauncher.launch("image/*") }) {
|
OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
|
||||||
Icon(Icons.Default.Image, "Attach image")
|
Icon(Icons.Default.Image, "Attach images")
|
||||||
}
|
}
|
||||||
OutlinedIconButton(onClick = { showLinkDialog = true }) {
|
OutlinedIconButton(onClick = { showLinkDialog = true }) {
|
||||||
Icon(Icons.Default.Link, "Add link")
|
Icon(Icons.Default.Link, "Add link")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image preview with alt text
|
// Image grid preview (multi-image)
|
||||||
if (state.imageUri != null) {
|
if (state.imageUris.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Box {
|
ImageGridPreview(
|
||||||
AsyncImage(
|
imageUris = state.imageUris,
|
||||||
model = state.imageUri,
|
onRemoveImage = viewModel::removeImage,
|
||||||
contentDescription = state.imageAlt.ifBlank { "Selected image" },
|
onAddMore = { multiImagePickerLauncher.launch("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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add alt text button
|
// Alt text for the first/primary image
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { showAltTextDialog = true },
|
onClick = { showAltTextDialog = true },
|
||||||
|
|
@ -314,6 +294,21 @@ fun ComposerScreen(
|
||||||
color = MaterialTheme.colorScheme.primary
|
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
|
// Link preview
|
||||||
|
|
@ -426,7 +421,7 @@ fun ComposerScreen(
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::publish,
|
onClick = viewModel::publish,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = !state.isSubmitting && state.text.isNotBlank()
|
enabled = !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty())
|
||||||
) {
|
) {
|
||||||
if (state.isSubmitting) {
|
if (state.isSubmitting) {
|
||||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
|
@ -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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ScheduleDateTimePicker(
|
fun ScheduleDateTimePicker(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.swoosh.microblog.data.HashtagParser
|
import com.swoosh.microblog.data.HashtagParser
|
||||||
import com.swoosh.microblog.data.MobiledocBuilder
|
import com.swoosh.microblog.data.MobiledocBuilder
|
||||||
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
||||||
|
import com.swoosh.microblog.data.db.Converters
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
|
|
@ -39,10 +40,19 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
editingLocalId = post.localId
|
editingLocalId = post.localId
|
||||||
editingGhostId = post.ghostId
|
editingGhostId = post.ghostId
|
||||||
editingUpdatedAt = post.updatedAt
|
editingUpdatedAt = post.updatedAt
|
||||||
|
|
||||||
|
// Build image URIs list from available data
|
||||||
|
val imageUris = mutableListOf<Uri>()
|
||||||
|
if (post.imageUrls.isNotEmpty()) {
|
||||||
|
imageUris.addAll(post.imageUrls.map { Uri.parse(it) })
|
||||||
|
} else if (post.imageUrl != null) {
|
||||||
|
imageUris.add(Uri.parse(post.imageUrl))
|
||||||
|
}
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
text = post.textContent,
|
text = post.textContent,
|
||||||
imageUri = post.imageUrl?.let { url -> Uri.parse(url) },
|
imageUris = imageUris,
|
||||||
imageAlt = post.imageAlt ?: "",
|
imageAlt = post.imageAlt ?: "",
|
||||||
linkPreview = if (post.linkUrl != null) LinkPreview(
|
linkPreview = if (post.linkUrl != null) LinkPreview(
|
||||||
url = post.linkUrl,
|
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?) {
|
fun setImage(uri: Uri?) {
|
||||||
_uiState.update {
|
if (uri != null) {
|
||||||
if (uri == null) {
|
addImages(listOf(uri))
|
||||||
it.copy(imageUri = null, imageAlt = "")
|
} else {
|
||||||
} else {
|
_uiState.update { it.copy(imageUris = emptyList(), imageAlt = "") }
|
||||||
it.copy(imageUri = uri)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (_uiState.value.isPreviewMode) {
|
if (_uiState.value.isPreviewMode) {
|
||||||
debouncedPreviewUpdate()
|
debouncedPreviewUpdate()
|
||||||
|
|
@ -82,6 +93,26 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
_uiState.update { it.copy(imageAlt = alt.take(250)) }
|
_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) {
|
fun fetchLinkPreview(url: String) {
|
||||||
if (url.isBlank()) return
|
if (url.isBlank()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -160,7 +191,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.text.isBlank() && state.imageUri == null) return
|
if (state.text.isBlank() && state.imageUris.isEmpty()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||||
|
|
@ -180,7 +211,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
content = state.text,
|
content = state.text,
|
||||||
status = status,
|
status = status,
|
||||||
featured = if (status != PostStatus.DRAFT) state.featured else false,
|
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,
|
imageAlt = altText,
|
||||||
linkUrl = state.linkPreview?.url,
|
linkUrl = state.linkPreview?.url,
|
||||||
linkTitle = state.linkPreview?.title,
|
linkTitle = state.linkPreview?.title,
|
||||||
|
|
@ -200,11 +232,12 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Online: upload image first if needed
|
// Online: upload all images first
|
||||||
var featureImage: String? = null
|
val uploadedImageUrls = mutableListOf<String>()
|
||||||
if (state.imageUri != null) {
|
if (state.imageUris.isNotEmpty()) {
|
||||||
repository.uploadImage(state.imageUri).fold(
|
val imagesResult = repository.uploadImages(state.imageUris)
|
||||||
onSuccess = { url -> featureImage = url },
|
imagesResult.fold(
|
||||||
|
onSuccess = { urls -> uploadedImageUrls.addAll(urls) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
_uiState.update { it.copy(isSubmitting = false, error = "Image upload failed: ${e.message}") }
|
_uiState.update { it.copy(isSubmitting = false, error = "Image upload failed: ${e.message}") }
|
||||||
return@launch
|
return@launch
|
||||||
|
|
@ -212,12 +245,11 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val featureImage = uploadedImageUrls.firstOrNull()
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
state.text,
|
state.text, uploadedImageUrls,
|
||||||
state.linkPreview?.url,
|
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||||
state.linkPreview?.title,
|
|
||||||
state.linkPreview?.description,
|
|
||||||
featureImage,
|
|
||||||
altText
|
altText
|
||||||
)
|
)
|
||||||
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
||||||
|
|
@ -254,8 +286,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
content = state.text,
|
content = state.text,
|
||||||
status = status,
|
status = status,
|
||||||
featured = if (status != PostStatus.DRAFT) state.featured else false,
|
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,
|
uploadedImageUrl = featureImage,
|
||||||
|
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
|
||||||
imageAlt = altText,
|
imageAlt = altText,
|
||||||
linkUrl = state.linkPreview?.url,
|
linkUrl = state.linkPreview?.url,
|
||||||
linkTitle = state.linkPreview?.title,
|
linkTitle = state.linkPreview?.title,
|
||||||
|
|
@ -288,7 +322,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
data class ComposerUiState(
|
data class ComposerUiState(
|
||||||
val text: String = "",
|
val text: String = "",
|
||||||
val imageUri: Uri? = null,
|
val imageUris: List<Uri> = emptyList(),
|
||||||
val imageAlt: String = "",
|
val imageAlt: String = "",
|
||||||
val linkPreview: LinkPreview? = null,
|
val linkPreview: LinkPreview? = null,
|
||||||
val isLoadingLink: Boolean = false,
|
val isLoadingLink: Boolean = false,
|
||||||
|
|
@ -301,4 +335,9 @@ data class ComposerUiState(
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isPreviewMode: Boolean = false,
|
val isPreviewMode: Boolean = false,
|
||||||
val previewHtml: String = ""
|
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.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
|
@ -46,6 +47,7 @@ import com.swoosh.microblog.data.model.FeedPost
|
||||||
import com.swoosh.microblog.data.model.LinkPreview
|
import com.swoosh.microblog.data.model.LinkPreview
|
||||||
import com.swoosh.microblog.data.model.PostStats
|
import com.swoosh.microblog.data.model.PostStats
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
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.StatusBadge
|
||||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -71,6 +73,18 @@ fun DetailScreen(
|
||||||
val postUrl = remember(post, baseUrl) { ShareUtils.resolvePostUrl(post, baseUrl) }
|
val postUrl = remember(post, baseUrl) { ShareUtils.resolvePostUrl(post, baseUrl) }
|
||||||
val canShare = isPublished && postUrl != null
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -225,19 +239,15 @@ fun DetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full image
|
// Image gallery
|
||||||
if (post.imageUrl != null) {
|
if (allImages.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AsyncImage(
|
DetailImageGallery(
|
||||||
model = post.imageUrl,
|
images = allImages,
|
||||||
contentDescription = post.imageAlt ?: "Post image",
|
onImageClick = { index ->
|
||||||
modifier = Modifier
|
galleryStartIndex = index
|
||||||
.fillMaxWidth()
|
showGallery = true
|
||||||
.clip(MaterialTheme.shapes.medium)
|
}
|
||||||
.semantics {
|
|
||||||
contentDescription = post.imageAlt ?: "Post image"
|
|
||||||
},
|
|
||||||
contentScale = ContentScale.FillWidth
|
|
||||||
)
|
)
|
||||||
// Alt text display
|
// Alt text display
|
||||||
if (!post.imageAlt.isNullOrBlank()) {
|
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
|
@Composable
|
||||||
|
|
@ -402,6 +447,12 @@ private fun PostStatsSection(post: FeedPost) {
|
||||||
if (post.featured) {
|
if (post.featured) {
|
||||||
MetadataRow("Featured", "Pinned")
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,15 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
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.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
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.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
@ -940,6 +947,19 @@ fun PostCardContent(
|
||||||
val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
|
val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
|
||||||
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
|
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -1013,57 +1033,50 @@ fun PostCardContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image thumbnail with alt text
|
// Image grid (multi-image support)
|
||||||
if (post.imageUrl != null) {
|
if (allImages.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
var showAltPopup by remember { mutableStateOf(false) }
|
PostImageGrid(
|
||||||
Box {
|
images = allImages,
|
||||||
AsyncImage(
|
onImageClick = { index ->
|
||||||
model = post.imageUrl,
|
galleryStartIndex = index
|
||||||
contentDescription = post.imageAlt ?: "Post image",
|
showGallery = true
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
// Alt text popup
|
// Alt text badge
|
||||||
if (showAltPopup && !post.imageAlt.isNullOrBlank()) {
|
if (!post.imageAlt.isNullOrBlank()) {
|
||||||
Card(
|
var showAltPopup by remember { mutableStateOf(false) }
|
||||||
|
Text(
|
||||||
|
text = "ALT",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.padding(top = 4.dp)
|
||||||
.padding(top = 4.dp),
|
.clip(RoundedCornerShape(4.dp))
|
||||||
colors = CardDefaults.cardColors(
|
.background(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f)
|
||||||
)
|
)
|
||||||
) {
|
.clickable { showAltPopup = !showAltPopup }
|
||||||
Text(
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
text = post.imageAlt,
|
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||||
modifier = Modifier.padding(8.dp),
|
fontSize = 11.sp,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontWeight = FontWeight.Bold
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
)
|
||||||
)
|
// 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)
|
// 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.CredentialsManager
|
||||||
import com.swoosh.microblog.data.FeedPreferences
|
import com.swoosh.microblog.data.FeedPreferences
|
||||||
import com.swoosh.microblog.data.HashtagParser
|
import com.swoosh.microblog.data.HashtagParser
|
||||||
|
import com.swoosh.microblog.data.db.Converters
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
|
@ -418,27 +419,62 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
_uiState.update { it.copy(posts = sorted) }
|
_uiState.update { it.copy(posts = sorted) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun GhostPost.toFeedPost(): FeedPost = FeedPost(
|
private fun GhostPost.toFeedPost(): FeedPost {
|
||||||
ghostId = id,
|
val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc)
|
||||||
slug = slug,
|
// Use feature_image as primary, then add mobiledoc images (avoiding duplicates)
|
||||||
url = url,
|
val allImages = mutableListOf<String>()
|
||||||
title = title ?: "",
|
if (feature_image != null) {
|
||||||
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
|
allImages.add(feature_image)
|
||||||
htmlContent = html,
|
}
|
||||||
imageUrl = feature_image,
|
for (url in imageUrls) {
|
||||||
imageAlt = feature_image_alt,
|
if (url !in allImages) {
|
||||||
linkUrl = null,
|
allImages.add(url)
|
||||||
linkTitle = null,
|
}
|
||||||
linkDescription = null,
|
}
|
||||||
linkImageUrl = null,
|
return FeedPost(
|
||||||
tags = tags?.map { it.name } ?: emptyList(),
|
ghostId = id,
|
||||||
status = status ?: "draft",
|
slug = slug,
|
||||||
featured = featured ?: false,
|
url = url,
|
||||||
publishedAt = published_at,
|
title = title ?: "",
|
||||||
createdAt = created_at,
|
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
|
||||||
updatedAt = updated_at,
|
htmlContent = html,
|
||||||
isLocal = false
|
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 {
|
private fun LocalPost.toFeedPost(): FeedPost {
|
||||||
val tagNames: List<String> = try {
|
val tagNames: List<String> = try {
|
||||||
|
|
@ -446,14 +482,24 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
emptyList()
|
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(
|
return FeedPost(
|
||||||
localId = localId,
|
localId = localId,
|
||||||
ghostId = ghostId,
|
ghostId = ghostId,
|
||||||
title = title,
|
title = title,
|
||||||
textContent = content,
|
textContent = content,
|
||||||
htmlContent = htmlContent,
|
htmlContent = htmlContent,
|
||||||
imageUrl = uploadedImageUrl ?: imageUri,
|
imageUrl = allImageUrls.firstOrNull(),
|
||||||
imageAlt = imageAlt,
|
imageAlt = imageAlt,
|
||||||
|
imageUrls = allImageUrls,
|
||||||
linkUrl = linkUrl,
|
linkUrl = linkUrl,
|
||||||
linkTitle = linkTitle,
|
linkTitle = linkTitle,
|
||||||
linkDescription = linkDescription,
|
linkDescription = linkDescription,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import com.swoosh.microblog.data.MobiledocBuilder
|
import com.swoosh.microblog.data.MobiledocBuilder
|
||||||
|
import com.swoosh.microblog.data.db.Converters
|
||||||
import com.swoosh.microblog.data.model.GhostPost
|
import com.swoosh.microblog.data.model.GhostPost
|
||||||
import com.swoosh.microblog.data.model.GhostTag
|
import com.swoosh.microblog.data.model.GhostTag
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
|
|
@ -28,7 +29,10 @@ class PostUploadWorker(
|
||||||
for (post in queuedPosts) {
|
for (post in queuedPosts) {
|
||||||
repository.updateQueueStatus(post.localId, QueueStatus.UPLOADING)
|
repository.updateQueueStatus(post.localId, QueueStatus.UPLOADING)
|
||||||
|
|
||||||
// Upload image if needed
|
// Upload multiple images if needed
|
||||||
|
val allImageUrls = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Handle legacy single image field
|
||||||
var featureImage = post.uploadedImageUrl
|
var featureImage = post.uploadedImageUrl
|
||||||
if (featureImage == null && post.imageUri != null) {
|
if (featureImage == null && post.imageUri != null) {
|
||||||
val imageResult = repository.uploadImage(Uri.parse(post.imageUri))
|
val imageResult = repository.uploadImage(Uri.parse(post.imageUri))
|
||||||
|
|
@ -40,9 +44,31 @@ class PostUploadWorker(
|
||||||
featureImage = imageResult.getOrNull()
|
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(
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
post.content, post.linkUrl, post.linkTitle, post.linkDescription,
|
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
||||||
featureImage, post.imageAlt
|
post.imageAlt
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse tags from JSON stored in LocalPost
|
// Parse tags from JSON stored in LocalPost
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class MobiledocBuilderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `build handles text with special chars`() {
|
fun `build handles text with special chars`() {
|
||||||
val result = MobiledocBuilder.build("café & résumé <html>", null as LinkPreview?)
|
val result = MobiledocBuilder.build("cafe & resume <html>", null as LinkPreview?)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertNotNull("Should produce valid JSON even with special chars", json)
|
assertNotNull("Should produce valid JSON even with special chars", json)
|
||||||
}
|
}
|
||||||
|
|
@ -252,7 +252,7 @@ class MobiledocBuilderTest {
|
||||||
assertEquals("", MobiledocBuilder.escapeForJson(""))
|
assertEquals("", MobiledocBuilder.escapeForJson(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Image card with alt text ---
|
// --- Image card with alt text (HEAD tests) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card produces valid JSON`() {
|
fun `build with image card produces valid JSON`() {
|
||||||
|
|
@ -408,4 +408,193 @@ class MobiledocBuilderTest {
|
||||||
assertEquals(10, bookmarkSection.get(0).asInt)
|
assertEquals(10, bookmarkSection.get(0).asInt)
|
||||||
assertEquals(1, bookmarkSection.get(1).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`() {
|
fun `toQueueStatus throws on invalid string`() {
|
||||||
converters.toQueueStatus("NONEXISTENT")
|
converters.toQueueStatus("NONEXISTENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- String list JSON serialization ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stringListToJson with null returns null`() {
|
||||||
|
assertNull(Converters.stringListToJson(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stringListToJson with empty list returns null`() {
|
||||||
|
assertNull(Converters.stringListToJson(emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stringListToJson with single item returns JSON array`() {
|
||||||
|
val result = Converters.stringListToJson(listOf("https://example.com/img.jpg"))
|
||||||
|
assertNotNull(result)
|
||||||
|
assertTrue(result!!.startsWith("["))
|
||||||
|
assertTrue(result.endsWith("]"))
|
||||||
|
assertTrue(result.contains("https://example.com/img.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stringListToJson with multiple items returns JSON array`() {
|
||||||
|
val result = Converters.stringListToJson(
|
||||||
|
listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||||
|
)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertTrue(result!!.contains("img1.jpg"))
|
||||||
|
assertTrue(result.contains("img2.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonToStringList with null returns empty list`() {
|
||||||
|
assertEquals(emptyList<String>(), Converters.jsonToStringList(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonToStringList with empty string returns empty list`() {
|
||||||
|
assertEquals(emptyList<String>(), Converters.jsonToStringList(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonToStringList with blank string returns empty list`() {
|
||||||
|
assertEquals(emptyList<String>(), Converters.jsonToStringList(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonToStringList with invalid JSON returns empty list`() {
|
||||||
|
assertEquals(emptyList<String>(), Converters.jsonToStringList("not json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonToStringList with single item JSON array`() {
|
||||||
|
val result = Converters.jsonToStringList("""["https://example.com/img.jpg"]""")
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("https://example.com/img.jpg", result[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonToStringList with multiple items JSON array`() {
|
||||||
|
val result = Converters.jsonToStringList(
|
||||||
|
"""["https://example.com/img1.jpg","https://example.com/img2.jpg","https://example.com/img3.jpg"]"""
|
||||||
|
)
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
assertEquals("https://example.com/img1.jpg", result[0])
|
||||||
|
assertEquals("https://example.com/img2.jpg", result[1])
|
||||||
|
assertEquals("https://example.com/img3.jpg", result[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stringListToJson and jsonToStringList round-trip`() {
|
||||||
|
val original = listOf(
|
||||||
|
"https://example.com/img1.jpg",
|
||||||
|
"https://example.com/img2.jpg",
|
||||||
|
"https://example.com/img3.jpg"
|
||||||
|
)
|
||||||
|
val json = Converters.stringListToJson(original)
|
||||||
|
val restored = Converters.jsonToStringList(json)
|
||||||
|
assertEquals(original, restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stringListToJson handles URLs with special characters`() {
|
||||||
|
val urls = listOf(
|
||||||
|
"https://example.com/img?id=1&name=test",
|
||||||
|
"https://example.com/path/to/image (1).jpg"
|
||||||
|
)
|
||||||
|
val json = Converters.stringListToJson(urls)
|
||||||
|
val restored = Converters.jsonToStringList(json)
|
||||||
|
assertEquals(urls, restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonToStringList with empty JSON array returns empty list`() {
|
||||||
|
val result = Converters.jsonToStringList("[]")
|
||||||
|
assertEquals(emptyList<String>(), result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,18 @@ class GhostModelsTest {
|
||||||
assertNull(post.imageUri)
|
assertNull(post.imageUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LocalPost default imageUris is null`() {
|
||||||
|
val post = LocalPost()
|
||||||
|
assertNull(post.imageUris)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LocalPost default uploadedImageUrls is null`() {
|
||||||
|
val post = LocalPost()
|
||||||
|
assertNull(post.uploadedImageUrls)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `LocalPost createdAt is set on construction`() {
|
fun `LocalPost createdAt is set on construction`() {
|
||||||
val before = System.currentTimeMillis()
|
val before = System.currentTimeMillis()
|
||||||
|
|
@ -131,6 +143,48 @@ class GhostModelsTest {
|
||||||
assertEquals(QueueStatus.NONE, post.queueStatus)
|
assertEquals(QueueStatus.NONE, post.queueStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `FeedPost default imageUrls is empty`() {
|
||||||
|
val post = FeedPost(
|
||||||
|
title = "Test",
|
||||||
|
textContent = "Content",
|
||||||
|
htmlContent = null,
|
||||||
|
imageUrl = null,
|
||||||
|
linkUrl = null,
|
||||||
|
linkTitle = null,
|
||||||
|
linkDescription = null,
|
||||||
|
linkImageUrl = null,
|
||||||
|
status = "published",
|
||||||
|
publishedAt = null,
|
||||||
|
createdAt = null,
|
||||||
|
updatedAt = null
|
||||||
|
)
|
||||||
|
assertTrue(post.imageUrls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `FeedPost imageUrls stores multiple URLs`() {
|
||||||
|
val urls = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||||
|
val post = FeedPost(
|
||||||
|
title = "Test",
|
||||||
|
textContent = "Content",
|
||||||
|
htmlContent = null,
|
||||||
|
imageUrl = "https://example.com/img1.jpg",
|
||||||
|
imageUrls = urls,
|
||||||
|
linkUrl = null,
|
||||||
|
linkTitle = null,
|
||||||
|
linkDescription = null,
|
||||||
|
linkImageUrl = null,
|
||||||
|
status = "published",
|
||||||
|
publishedAt = null,
|
||||||
|
createdAt = null,
|
||||||
|
updatedAt = null
|
||||||
|
)
|
||||||
|
assertEquals(2, post.imageUrls.size)
|
||||||
|
assertEquals("https://example.com/img1.jpg", post.imageUrls[0])
|
||||||
|
assertEquals("https://example.com/img2.jpg", post.imageUrls[1])
|
||||||
|
}
|
||||||
|
|
||||||
// --- GSON serialization ---
|
// --- GSON serialization ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue