mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add alt text support for images with accessibility
This commit is contained in:
parent
74f42fd2f1
commit
202e25b572
11 changed files with 500 additions and 23 deletions
|
|
@ -17,18 +17,43 @@ object MobiledocBuilder {
|
||||||
linkUrl: String?,
|
linkUrl: String?,
|
||||||
linkTitle: String?,
|
linkTitle: String?,
|
||||||
linkDescription: String?
|
linkDescription: String?
|
||||||
|
): String {
|
||||||
|
return build(text, linkUrl, linkTitle, linkDescription, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
text: String,
|
||||||
|
linkUrl: String?,
|
||||||
|
linkTitle: String?,
|
||||||
|
linkDescription: String?,
|
||||||
|
imageUrl: String?,
|
||||||
|
imageAlt: String?
|
||||||
): String {
|
): String {
|
||||||
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
||||||
|
|
||||||
val cards = if (linkUrl != null) {
|
val cardsList = 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":""}]""")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmark card
|
||||||
|
if (linkUrl != null) {
|
||||||
val escapedUrl = escapeForJson(linkUrl)
|
val escapedUrl = escapeForJson(linkUrl)
|
||||||
val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: ""
|
val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: ""
|
||||||
val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: ""
|
val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: ""
|
||||||
"""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]"""
|
cardSections.add("[10,${cardsList.size}]")
|
||||||
} else ""
|
cardsList.add("""["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""")
|
||||||
|
}
|
||||||
|
|
||||||
val cardSection = if (linkUrl != null) ",[10,0]" else ""
|
val cards = cardsList.joinToString(",")
|
||||||
return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSection]}"""
|
val extraSections = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
||||||
|
return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$extraSections]}"""
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun escapeForJson(value: String): String {
|
internal fun escapeForJson(value: String): String {
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.swoosh.microblog.data.model.LocalPost
|
import com.swoosh.microblog.data.model.LocalPost
|
||||||
|
|
||||||
@Database(entities = [LocalPost::class], version = 1, exportSchema = false)
|
@Database(entities = [LocalPost::class], version = 2, exportSchema = false)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
|
@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: AppDatabase? = null
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): AppDatabase {
|
fun getInstance(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"swoosh_database"
|
"swoosh_database"
|
||||||
).build()
|
)
|
||||||
|
.addMigrations(MIGRATION_1_2)
|
||||||
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ data class GhostPost(
|
||||||
val mobiledoc: String? = null,
|
val mobiledoc: String? = null,
|
||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val feature_image: String? = null,
|
val feature_image: String? = null,
|
||||||
|
val feature_image_alt: String? = null,
|
||||||
val created_at: String? = null,
|
val created_at: String? = null,
|
||||||
val updated_at: String? = null,
|
val updated_at: String? = null,
|
||||||
val published_at: String? = null,
|
val published_at: String? = null,
|
||||||
|
|
@ -66,6 +67,7 @@ data class LocalPost(
|
||||||
val linkTitle: String? = null,
|
val linkTitle: String? = null,
|
||||||
val linkDescription: String? = null,
|
val linkDescription: String? = null,
|
||||||
val linkImageUrl: String? = null,
|
val linkImageUrl: String? = null,
|
||||||
|
val imageAlt: String? = null,
|
||||||
val scheduledAt: String? = null,
|
val scheduledAt: String? = null,
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val updatedAt: Long = System.currentTimeMillis(),
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
|
|
@ -95,6 +97,7 @@ data class FeedPost(
|
||||||
val textContent: String,
|
val textContent: String,
|
||||||
val htmlContent: String?,
|
val htmlContent: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
|
val imageAlt: String? = null,
|
||||||
val linkUrl: String?,
|
val linkUrl: String?,
|
||||||
val linkTitle: String?,
|
val linkTitle: String?,
|
||||||
val linkDescription: String?,
|
val linkDescription: String?,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package com.swoosh.microblog.ui.composer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
|
@ -18,7 +20,11 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
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
|
||||||
|
|
@ -37,6 +43,7 @@ fun ComposerScreen(
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
var showLinkDialog by remember { mutableStateOf(false) }
|
var showLinkDialog by remember { mutableStateOf(false) }
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
var showAltTextDialog by remember { mutableStateOf(false) }
|
||||||
var linkInput by remember { mutableStateOf("") }
|
var linkInput by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// Load post for editing
|
// Load post for editing
|
||||||
|
|
@ -120,11 +127,14 @@ fun ComposerScreen(
|
||||||
Box {
|
Box {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = state.imageUri,
|
model = state.imageUri,
|
||||||
contentDescription = "Selected image",
|
contentDescription = state.imageAlt.ifBlank { "Selected image" },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp)
|
.height(200.dp)
|
||||||
.clip(MaterialTheme.shapes.medium),
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = state.imageAlt.ifBlank { "Selected image" }
|
||||||
|
},
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -136,6 +146,36 @@ fun ComposerScreen(
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
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
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = { showAltTextDialog = true },
|
||||||
|
contentPadding = PaddingValues(horizontal = 0.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (state.imageAlt.isBlank()) "Add alt text" else "Edit alt text",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,6 +342,49 @@ fun ComposerScreen(
|
||||||
onDismiss = { showDatePicker = false }
|
onDismiss = { showDatePicker = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alt text dialog
|
||||||
|
if (showAltTextDialog) {
|
||||||
|
var altInput by remember { mutableStateOf(state.imageAlt) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showAltTextDialog = false },
|
||||||
|
title = { Text("Alt Text") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = altInput,
|
||||||
|
onValueChange = { altInput = it.take(250) },
|
||||||
|
placeholder = { Text("Describe this image for people who can't see it") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 2,
|
||||||
|
maxLines = 4,
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
"${altInput.length}/250",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (altInput.length >= 250)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
viewModel.updateImageAlt(altInput)
|
||||||
|
showAltTextDialog = false
|
||||||
|
}) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showAltTextDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
it.copy(
|
it.copy(
|
||||||
text = post.textContent,
|
text = post.textContent,
|
||||||
imageUri = post.imageUrl?.let { url -> Uri.parse(url) },
|
imageUri = post.imageUrl?.let { url -> Uri.parse(url) },
|
||||||
|
imageAlt = post.imageAlt ?: "",
|
||||||
linkPreview = if (post.linkUrl != null) LinkPreview(
|
linkPreview = if (post.linkUrl != null) LinkPreview(
|
||||||
url = post.linkUrl,
|
url = post.linkUrl,
|
||||||
title = post.linkTitle,
|
title = post.linkTitle,
|
||||||
|
|
@ -51,7 +52,17 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setImage(uri: Uri?) {
|
fun setImage(uri: Uri?) {
|
||||||
_uiState.update { it.copy(imageUri = uri) }
|
_uiState.update {
|
||||||
|
if (uri == null) {
|
||||||
|
it.copy(imageUri = null, imageAlt = "")
|
||||||
|
} else {
|
||||||
|
it.copy(imageUri = uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateImageAlt(alt: String) {
|
||||||
|
_uiState.update { it.copy(imageAlt = alt.take(250)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchLinkPreview(url: String) {
|
fun fetchLinkPreview(url: String) {
|
||||||
|
|
@ -89,6 +100,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
val title = state.text.take(60)
|
val title = state.text.take(60)
|
||||||
|
|
||||||
|
val altText = state.imageAlt.ifBlank { null }
|
||||||
|
|
||||||
if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) {
|
if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) {
|
||||||
// Save locally
|
// Save locally
|
||||||
val localPost = LocalPost(
|
val localPost = LocalPost(
|
||||||
|
|
@ -98,6 +111,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
content = state.text,
|
content = state.text,
|
||||||
status = status,
|
status = status,
|
||||||
imageUri = state.imageUri?.toString(),
|
imageUri = state.imageUri?.toString(),
|
||||||
|
imageAlt = altText,
|
||||||
linkUrl = state.linkPreview?.url,
|
linkUrl = state.linkPreview?.url,
|
||||||
linkTitle = state.linkPreview?.title,
|
linkTitle = state.linkPreview?.title,
|
||||||
linkDescription = state.linkPreview?.description,
|
linkDescription = state.linkPreview?.description,
|
||||||
|
|
@ -127,13 +141,21 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview)
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
|
state.text,
|
||||||
|
state.linkPreview?.url,
|
||||||
|
state.linkPreview?.title,
|
||||||
|
state.linkPreview?.description,
|
||||||
|
featureImage,
|
||||||
|
altText
|
||||||
|
)
|
||||||
|
|
||||||
val ghostPost = GhostPost(
|
val ghostPost = GhostPost(
|
||||||
title = title,
|
title = title,
|
||||||
mobiledoc = mobiledoc,
|
mobiledoc = mobiledoc,
|
||||||
status = status.name.lowercase(),
|
status = status.name.lowercase(),
|
||||||
feature_image = featureImage,
|
feature_image = featureImage,
|
||||||
|
feature_image_alt = altText,
|
||||||
published_at = state.scheduledAt,
|
published_at = state.scheduledAt,
|
||||||
visibility = "public"
|
visibility = "public"
|
||||||
)
|
)
|
||||||
|
|
@ -159,6 +181,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
status = status,
|
status = status,
|
||||||
imageUri = state.imageUri?.toString(),
|
imageUri = state.imageUri?.toString(),
|
||||||
uploadedImageUrl = featureImage,
|
uploadedImageUrl = featureImage,
|
||||||
|
imageAlt = altText,
|
||||||
linkUrl = state.linkPreview?.url,
|
linkUrl = state.linkPreview?.url,
|
||||||
linkTitle = state.linkPreview?.title,
|
linkTitle = state.linkPreview?.title,
|
||||||
linkDescription = state.linkPreview?.description,
|
linkDescription = state.linkPreview?.description,
|
||||||
|
|
@ -185,6 +208,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
data class ComposerUiState(
|
data class ComposerUiState(
|
||||||
val text: String = "",
|
val text: String = "",
|
||||||
val imageUri: Uri? = null,
|
val imageUri: Uri? = null,
|
||||||
|
val imageAlt: String = "",
|
||||||
val linkPreview: LinkPreview? = null,
|
val linkPreview: LinkPreview? = null,
|
||||||
val isLoadingLink: Boolean = false,
|
val isLoadingLink: Boolean = false,
|
||||||
val scheduledAt: String? = null,
|
val scheduledAt: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
|
|
@ -81,12 +84,26 @@ fun DetailScreen(
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = post.imageUrl,
|
model = post.imageUrl,
|
||||||
contentDescription = "Post image",
|
contentDescription = post.imageAlt ?: "Post image",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(MaterialTheme.shapes.medium),
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = post.imageAlt ?: "Post image"
|
||||||
|
},
|
||||||
contentScale = ContentScale.FillWidth
|
contentScale = ContentScale.FillWidth
|
||||||
)
|
)
|
||||||
|
// Alt text display
|
||||||
|
if (!post.imageAlt.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Alt: ${post.imageAlt}",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontStyle = FontStyle.Italic
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link preview
|
// Link preview
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package com.swoosh.microblog.ui.feed
|
package com.swoosh.microblog.ui.feed
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
|
@ -20,9 +22,13 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
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
|
||||||
|
|
@ -251,15 +257,56 @@ fun PostCard(
|
||||||
// Image thumbnail
|
// Image thumbnail
|
||||||
if (post.imageUrl != null) {
|
if (post.imageUrl != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
var showAltPopup by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = post.imageUrl,
|
model = post.imageUrl,
|
||||||
contentDescription = "Post image",
|
contentDescription = post.imageAlt ?: "Post image",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(180.dp)
|
.height(180.dp)
|
||||||
.clip(MaterialTheme.shapes.medium),
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = post.imageAlt ?: "Post image"
|
||||||
|
},
|
||||||
contentScale = ContentScale.Crop
|
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
|
||||||
|
if (showAltPopup && !post.imageAlt.isNullOrBlank()) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link preview
|
// Link preview
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
|
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
|
||||||
htmlContent = html,
|
htmlContent = html,
|
||||||
imageUrl = feature_image,
|
imageUrl = feature_image,
|
||||||
|
imageAlt = feature_image_alt,
|
||||||
linkUrl = null,
|
linkUrl = null,
|
||||||
linkTitle = null,
|
linkTitle = null,
|
||||||
linkDescription = null,
|
linkDescription = null,
|
||||||
|
|
@ -162,6 +163,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
textContent = content,
|
textContent = content,
|
||||||
htmlContent = htmlContent,
|
htmlContent = htmlContent,
|
||||||
imageUrl = uploadedImageUrl ?: imageUri,
|
imageUrl = uploadedImageUrl ?: imageUri,
|
||||||
|
imageAlt = imageAlt,
|
||||||
linkUrl = linkUrl,
|
linkUrl = linkUrl,
|
||||||
linkTitle = linkTitle,
|
linkTitle = linkTitle,
|
||||||
linkDescription = linkDescription,
|
linkDescription = linkDescription,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,10 @@ class PostUploadWorker(
|
||||||
featureImage = imageResult.getOrNull()
|
featureImage = imageResult.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(post.content, post.linkUrl, post.linkTitle, post.linkDescription)
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
|
post.content, post.linkUrl, post.linkTitle, post.linkDescription,
|
||||||
|
featureImage, post.imageAlt
|
||||||
|
)
|
||||||
|
|
||||||
val ghostPost = GhostPost(
|
val ghostPost = GhostPost(
|
||||||
title = post.title,
|
title = post.title,
|
||||||
|
|
@ -48,6 +51,7 @@ class PostUploadWorker(
|
||||||
else -> "draft"
|
else -> "draft"
|
||||||
},
|
},
|
||||||
feature_image = featureImage,
|
feature_image = featureImage,
|
||||||
|
feature_image_alt = post.imageAlt,
|
||||||
published_at = post.scheduledAt,
|
published_at = post.scheduledAt,
|
||||||
visibility = "public"
|
visibility = "public"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -251,4 +251,161 @@ class MobiledocBuilderTest {
|
||||||
fun `escapeForJson handles empty string`() {
|
fun `escapeForJson handles empty string`() {
|
||||||
assertEquals("", MobiledocBuilder.escapeForJson(""))
|
assertEquals("", MobiledocBuilder.escapeForJson(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Image card with alt text ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card produces valid JSON`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Post text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", "A sunset"
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
assertNotNull(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card includes image type`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", "Alt text"
|
||||||
|
)
|
||||||
|
assertTrue("Should contain image card type", result.contains("\"image\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card includes src`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", "Alt text"
|
||||||
|
)
|
||||||
|
assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card includes alt text`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", "A beautiful sunset"
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
assertEquals(1, cards.size())
|
||||||
|
val card = cards.get(0).asJsonArray
|
||||||
|
assertEquals("image", card.get(0).asString)
|
||||||
|
val cardData = card.get(1).asJsonObject
|
||||||
|
assertEquals("A beautiful sunset", cardData.get("alt").asString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card and null alt uses empty string`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", null
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
val card = cards.get(0).asJsonArray
|
||||||
|
val cardData = card.get(1).asJsonObject
|
||||||
|
assertEquals("", cardData.get("alt").asString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card includes caption field`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", "Alt"
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val card = json.getAsJsonArray("cards").get(0).asJsonArray
|
||||||
|
val cardData = card.get(1).asJsonObject
|
||||||
|
assertEquals("", cardData.get("caption").asString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card has card section`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", "Alt"
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
assertEquals("Should have text section and image card section", 2, sections.size())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image and link has both cards`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", "https://link.com", "Link Title", "Link Desc",
|
||||||
|
"https://example.com/photo.jpg", "Image alt"
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
assertEquals("Should have image card and bookmark card", 2, cards.size())
|
||||||
|
// Image card comes first
|
||||||
|
assertEquals("image", cards.get(0).asJsonArray.get(0).asString)
|
||||||
|
assertEquals("bookmark", cards.get(1).asJsonArray.get(0).asString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image and link has three sections`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", "https://link.com", "Title", "Desc",
|
||||||
|
"https://example.com/photo.jpg", "Alt"
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
assertEquals("Should have text, image card, and bookmark card sections", 3, sections.size())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card escapes alt text`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", "He said \"hello\""
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
assertNotNull("Should produce valid JSON with escaped alt text", json)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build without image produces no image card`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
null, null
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image card section references correct card index`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
"https://example.com/photo.jpg", "Alt"
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
val cardSection = sections.get(1).asJsonArray
|
||||||
|
assertEquals(10, cardSection.get(0).asInt)
|
||||||
|
assertEquals(0, cardSection.get(1).asInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image and link card sections reference correct indices`() {
|
||||||
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", "https://link.com", "Title", null,
|
||||||
|
"https://example.com/photo.jpg", "Alt"
|
||||||
|
)
|
||||||
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
// Image card section at index 0
|
||||||
|
val imgSection = sections.get(1).asJsonArray
|
||||||
|
assertEquals(10, imgSection.get(0).asInt)
|
||||||
|
assertEquals(0, imgSection.get(1).asInt)
|
||||||
|
// Bookmark card section at index 1
|
||||||
|
val bookmarkSection = sections.get(2).asJsonArray
|
||||||
|
assertEquals(10, bookmarkSection.get(0).asInt)
|
||||||
|
assertEquals(1, bookmarkSection.get(1).asInt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ class GhostModelsTest {
|
||||||
assertNull(post.mobiledoc)
|
assertNull(post.mobiledoc)
|
||||||
assertNull(post.status)
|
assertNull(post.status)
|
||||||
assertNull(post.feature_image)
|
assertNull(post.feature_image)
|
||||||
|
assertNull(post.feature_image_alt)
|
||||||
assertNull(post.created_at)
|
assertNull(post.created_at)
|
||||||
assertNull(post.updated_at)
|
assertNull(post.updated_at)
|
||||||
assertNull(post.published_at)
|
assertNull(post.published_at)
|
||||||
|
|
@ -252,4 +253,108 @@ class GhostModelsTest {
|
||||||
assertNull(preview.description)
|
assertNull(preview.description)
|
||||||
assertNull(preview.imageUrl)
|
assertNull(preview.imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Alt text ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LocalPost default imageAlt is null`() {
|
||||||
|
val post = LocalPost()
|
||||||
|
assertNull(post.imageAlt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LocalPost stores imageAlt`() {
|
||||||
|
val post = LocalPost(imageAlt = "A sunset over the ocean")
|
||||||
|
assertEquals("A sunset over the ocean", post.imageAlt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPost default feature_image_alt is null`() {
|
||||||
|
val post = GhostPost()
|
||||||
|
assertNull(post.feature_image_alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPost stores feature_image_alt`() {
|
||||||
|
val post = GhostPost(
|
||||||
|
feature_image = "https://example.com/img.jpg",
|
||||||
|
feature_image_alt = "A beautiful landscape"
|
||||||
|
)
|
||||||
|
assertEquals("A beautiful landscape", post.feature_image_alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPost serializes feature_image_alt to JSON`() {
|
||||||
|
val post = GhostPost(
|
||||||
|
title = "Test",
|
||||||
|
feature_image = "https://example.com/img.jpg",
|
||||||
|
feature_image_alt = "Alt description"
|
||||||
|
)
|
||||||
|
val json = gson.toJson(post)
|
||||||
|
assertTrue(json.contains("\"feature_image_alt\":\"Alt description\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPost deserializes feature_image_alt from JSON`() {
|
||||||
|
val json = """{"id":"1","feature_image":"https://example.com/img.jpg","feature_image_alt":"Test alt"}"""
|
||||||
|
val post = gson.fromJson(json, GhostPost::class.java)
|
||||||
|
assertEquals("Test alt", post.feature_image_alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPost deserializes with missing feature_image_alt`() {
|
||||||
|
val json = """{"id":"1","feature_image":"https://example.com/img.jpg"}"""
|
||||||
|
val post = gson.fromJson(json, GhostPost::class.java)
|
||||||
|
assertNull(post.feature_image_alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `FeedPost default imageAlt is null`() {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
assertNull(post.imageAlt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `FeedPost stores imageAlt`() {
|
||||||
|
val post = FeedPost(
|
||||||
|
title = "Test",
|
||||||
|
textContent = "Content",
|
||||||
|
htmlContent = null,
|
||||||
|
imageUrl = "https://example.com/img.jpg",
|
||||||
|
imageAlt = "Image description",
|
||||||
|
linkUrl = null,
|
||||||
|
linkTitle = null,
|
||||||
|
linkDescription = null,
|
||||||
|
linkImageUrl = null,
|
||||||
|
status = "published",
|
||||||
|
publishedAt = null,
|
||||||
|
createdAt = null,
|
||||||
|
updatedAt = null
|
||||||
|
)
|
||||||
|
assertEquals("Image description", post.imageAlt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PostWrapper includes feature_image_alt in serialized JSON`() {
|
||||||
|
val wrapper = PostWrapper(listOf(GhostPost(
|
||||||
|
title = "Post with alt",
|
||||||
|
feature_image = "https://example.com/photo.jpg",
|
||||||
|
feature_image_alt = "Photo description"
|
||||||
|
)))
|
||||||
|
val json = gson.toJson(wrapper)
|
||||||
|
assertTrue(json.contains("\"feature_image_alt\":\"Photo description\""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue