feat: add post preview with HTML rendering in WebView

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:05 +01:00
parent 74f42fd2f1
commit 6927259a41
No known key found for this signature in database
7 changed files with 1119 additions and 152 deletions

View file

@ -0,0 +1,205 @@
package com.swoosh.microblog.data
import com.swoosh.microblog.data.model.LinkPreview
/**
* Builds a responsive HTML preview from post content.
* Used to show "how it will look on the blog" before publishing.
*/
object PreviewHtmlBuilder {
/**
* Generates a complete HTML document from post content, optional image URL,
* and optional link preview data. The output uses a Ghost-like responsive
* styling with dark mode support.
*/
fun build(
text: String,
imageUrl: String? = null,
linkPreview: LinkPreview? = null
): String {
val contentHtml = buildContentHtml(text, imageUrl, linkPreview)
return wrapInTemplate(contentHtml)
}
/**
* Wraps existing HTML content (e.g. from the Ghost API html field) in
* the preview template. Use this for published posts that already have
* rendered HTML.
*/
fun wrapExistingHtml(html: String): String {
return wrapInTemplate(html)
}
internal fun buildContentHtml(
text: String,
imageUrl: String?,
linkPreview: LinkPreview?
): String {
val sb = StringBuilder()
// Convert text to HTML paragraphs
if (text.isNotBlank()) {
val paragraphs = text.split("\n\n")
for (paragraph in paragraphs) {
val trimmed = paragraph.trim()
if (trimmed.isNotEmpty()) {
val escaped = escapeHtml(trimmed)
// Convert single newlines within a paragraph to <br>
val withBreaks = escaped.replace("\n", "<br>")
sb.append("<p>$withBreaks</p>\n")
}
}
}
// Add feature image
if (!imageUrl.isNullOrBlank()) {
sb.append("<figure class=\"kg-card kg-image-card\">\n")
sb.append(" <img src=\"${escapeHtml(imageUrl)}\" alt=\"Post image\" />\n")
sb.append("</figure>\n")
}
// Add bookmark card for link preview
if (linkPreview != null) {
sb.append(buildBookmarkCard(linkPreview))
}
return sb.toString()
}
internal fun buildBookmarkCard(linkPreview: LinkPreview): String {
val sb = StringBuilder()
sb.append("<div class=\"bookmark-card\">\n")
if (!linkPreview.imageUrl.isNullOrBlank()) {
sb.append(" <img class=\"bookmark-image\" src=\"${escapeHtml(linkPreview.imageUrl)}\" alt=\"\" />\n")
}
sb.append(" <div class=\"bookmark-content\">\n")
val title = linkPreview.title
if (!title.isNullOrBlank()) {
sb.append(" <div class=\"bookmark-title\">${escapeHtml(title)}</div>\n")
}
val description = linkPreview.description
if (!description.isNullOrBlank()) {
sb.append(" <div class=\"bookmark-description\">${escapeHtml(description)}</div>\n")
}
sb.append(" <div class=\"bookmark-url\">${escapeHtml(linkPreview.url)}</div>\n")
sb.append(" </div>\n")
sb.append("</div>\n")
return sb.toString()
}
internal fun escapeHtml(value: String): String {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}
internal fun wrapInTemplate(content: String): String {
return """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
padding: 20px 16px;
line-height: 1.7;
max-width: 600px;
margin: 0 auto;
color: #1a1a1a;
background: #ffffff;
font-size: 17px;
}
p {
margin: 0 0 1.2em 0;
}
figure {
margin: 1.5em 0;
padding: 0;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
.bookmark-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
margin: 1.5em 0;
background: #fafafa;
}
.bookmark-card .bookmark-image {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 0;
}
.bookmark-content {
padding: 14px 16px;
}
.bookmark-title {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 4px;
color: #1a1a1a;
}
.bookmark-description {
font-size: 14px;
line-height: 1.5;
color: #666;
margin-bottom: 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bookmark-url {
font-size: 13px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a2e;
color: #e0e0e0;
}
.bookmark-card {
border-color: #333;
background: #222244;
}
.bookmark-title {
color: #e0e0e0;
}
.bookmark-description {
color: #aaa;
}
.bookmark-url {
color: #888;
}
}
</style>
</head>
<body>
$content
</body>
</html>"""
}
}

View file

@ -8,10 +8,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -23,6 +20,7 @@ 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
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -32,6 +30,7 @@ import java.time.format.DateTimeFormatter
fun ComposerScreen( fun ComposerScreen(
editPost: FeedPost? = null, editPost: FeedPost? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onFullScreenPreview: ((String) -> Unit)? = null,
viewModel: ComposerViewModel = viewModel() viewModel: ComposerViewModel = viewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@ -70,6 +69,16 @@ fun ComposerScreen(
}) { }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
} }
},
actions = {
// Full-screen preview button (only in preview mode)
if (state.isPreviewMode && state.previewHtml.isNotEmpty()) {
IconButton(onClick = {
onFullScreenPreview?.invoke(state.previewHtml)
}) {
Icon(Icons.Default.Fullscreen, "Full screen preview")
}
}
} }
) )
} }
@ -78,179 +87,251 @@ fun ComposerScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) { ) {
// Text field with character counter // Edit / Preview segmented button row
OutlinedTextField( SingleChoiceSegmentedButtonRow(
value = state.text,
onValueChange = viewModel::updateText,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 150.dp), .padding(horizontal = 16.dp, vertical = 8.dp)
placeholder = { Text("What's on your mind?") },
supportingText = {
Text(
"${state.text.length} characters",
style = MaterialTheme.typography.labelSmall,
color = if (state.text.length > 280)
MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
Spacer(modifier = Modifier.height(12.dp))
// Attachment buttons row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
OutlinedIconButton(onClick = { imagePickerLauncher.launch("image/*") }) { SegmentedButton(
Icon(Icons.Default.Image, "Attach image") selected = !state.isPreviewMode,
onClick = { viewModel.setPreviewMode(false) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
icon = {
SegmentedButtonDefaults.Icon(active = !state.isPreviewMode) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
)
}
}
) {
Text("Edit")
} }
OutlinedIconButton(onClick = { showLinkDialog = true }) { SegmentedButton(
Icon(Icons.Default.Link, "Add link") selected = state.isPreviewMode,
onClick = { viewModel.setPreviewMode(true) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
icon = {
SegmentedButtonDefaults.Icon(active = state.isPreviewMode) {
Icon(
Icons.Default.Visibility,
contentDescription = null,
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
)
}
}
) {
Text("Preview")
} }
} }
// Image preview if (state.isPreviewMode) {
if (state.imageUri != null) { // Preview mode: show rendered HTML
Spacer(modifier = Modifier.height(12.dp)) if (state.text.isBlank() && state.imageUri == null && state.linkPreview == null) {
Box { Box(
AsyncImage(
model = state.imageUri,
contentDescription = "Selected image",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.height(200.dp) .weight(1f),
.clip(MaterialTheme.shapes.medium), contentAlignment = Alignment.Center
contentScale = ContentScale.Crop
)
IconButton(
onClick = { viewModel.setImage(null) },
modifier = Modifier.align(Alignment.TopEnd)
) { ) {
Icon( Text(
Icons.Default.Close, "Remove image", text = "Nothing to preview yet.\nSwitch to Edit and write something.",
tint = MaterialTheme.colorScheme.onSurface style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} else {
HtmlPreviewWebView(
html = state.previewHtml,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
} }
} } else {
// Edit mode: original composer UI
// Link preview Column(
if (state.isLoadingLink) { modifier = Modifier
Spacer(modifier = Modifier.height(12.dp)) .fillMaxSize()
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) .weight(1f)
} .verticalScroll(rememberScrollState())
.padding(16.dp)
if (state.linkPreview != null) { ) {
Spacer(modifier = Modifier.height(12.dp)) // Text field with character counter
OutlinedCard(modifier = Modifier.fillMaxWidth()) { OutlinedTextField(
Column(modifier = Modifier.padding(12.dp)) { value = state.text,
Row( onValueChange = viewModel::updateText,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
horizontalArrangement = Arrangement.SpaceBetween, .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically .heightIn(min = 150.dp),
) { placeholder = { Text("What's on your mind?") },
supportingText = {
Text( Text(
text = state.linkPreview!!.title ?: state.linkPreview!!.url, "${state.text.length} characters",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.weight(1f) color = if (state.text.length > 280)
) MaterialTheme.colorScheme.error
IconButton(onClick = viewModel::removeLinkPreview) { else MaterialTheme.colorScheme.onSurfaceVariant
Icon(Icons.Default.Close, "Remove link", Modifier.size(18.dp))
}
}
if (state.linkPreview!!.description != null) {
Text(
text = state.linkPreview!!.description!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
) )
} }
if (state.linkPreview!!.imageUrl != null) { )
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
// Attachment buttons row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedIconButton(onClick = { imagePickerLauncher.launch("image/*") }) {
Icon(Icons.Default.Image, "Attach image")
}
OutlinedIconButton(onClick = { showLinkDialog = true }) {
Icon(Icons.Default.Link, "Add link")
}
}
// Image preview
if (state.imageUri != null) {
Spacer(modifier = Modifier.height(12.dp))
Box {
AsyncImage( AsyncImage(
model = state.linkPreview!!.imageUrl, model = state.imageUri,
contentDescription = null, contentDescription = "Selected image",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(120.dp) .height(200.dp)
.clip(MaterialTheme.shapes.small), .clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
IconButton(
onClick = { viewModel.setImage(null) },
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
Icons.Default.Close, "Remove image",
tint = MaterialTheme.colorScheme.onSurface
)
}
} }
} }
}
}
// Scheduled time display // Link preview
if (state.scheduledAt != null) { if (state.isLoadingLink) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
AssistChip( LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
onClick = { showDatePicker = true }, }
label = { Text("Scheduled: ${state.scheduledAt}") },
leadingIcon = { Icon(Icons.Default.Schedule, null, Modifier.size(18.dp)) }, if (state.linkPreview != null) {
trailingIcon = { Spacer(modifier = Modifier.height(12.dp))
IconButton( OutlinedCard(modifier = Modifier.fillMaxWidth()) {
onClick = { viewModel.setScheduledDate(null) }, Column(modifier = Modifier.padding(12.dp)) {
modifier = Modifier.size(18.dp) Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = state.linkPreview!!.title ?: state.linkPreview!!.url,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = viewModel::removeLinkPreview) {
Icon(Icons.Default.Close, "Remove link", Modifier.size(18.dp))
}
}
if (state.linkPreview!!.description != null) {
Text(
text = state.linkPreview!!.description!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
if (state.linkPreview!!.imageUrl != null) {
Spacer(modifier = Modifier.height(8.dp))
AsyncImage(
model = state.linkPreview!!.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
}
}
}
}
// Scheduled time display
if (state.scheduledAt != null) {
Spacer(modifier = Modifier.height(12.dp))
AssistChip(
onClick = { showDatePicker = true },
label = { Text("Scheduled: ${state.scheduledAt}") },
leadingIcon = { Icon(Icons.Default.Schedule, null, Modifier.size(18.dp)) },
trailingIcon = {
IconButton(
onClick = { viewModel.setScheduledDate(null) },
modifier = Modifier.size(18.dp)
) {
Icon(Icons.Default.Close, "Clear schedule", Modifier.size(14.dp))
}
}
)
}
if (state.error != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.weight(1f))
// Action buttons
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = viewModel::publish,
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSubmitting && state.text.isNotBlank()
) { ) {
Icon(Icons.Default.Close, "Clear schedule", Modifier.size(14.dp)) if (state.isSubmitting) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
}
Text(if (state.isEditing) "Update & Publish" else "Publish now")
} }
}
)
}
if (state.error != null) { Row(
Spacer(modifier = Modifier.height(12.dp)) modifier = Modifier.fillMaxWidth(),
Text( horizontalArrangement = Arrangement.spacedBy(8.dp)
text = state.error!!, ) {
color = MaterialTheme.colorScheme.error, OutlinedButton(
style = MaterialTheme.typography.bodySmall onClick = viewModel::saveDraft,
) modifier = Modifier.weight(1f),
} enabled = !state.isSubmitting
) {
Text("Save draft")
}
Spacer(modifier = Modifier.height(24.dp)) OutlinedButton(
Spacer(modifier = Modifier.weight(1f)) onClick = { showDatePicker = true },
modifier = Modifier.weight(1f),
// Action buttons enabled = !state.isSubmitting
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ) {
Button( Icon(Icons.Default.Schedule, null, Modifier.size(18.dp))
onClick = viewModel::publish, Spacer(Modifier.width(4.dp))
modifier = Modifier.fillMaxWidth(), Text("Schedule")
enabled = !state.isSubmitting && state.text.isNotBlank() }
) { }
if (state.isSubmitting) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
}
Text(if (state.isEditing) "Update & Publish" else "Publish now")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = viewModel::saveDraft,
modifier = Modifier.weight(1f),
enabled = !state.isSubmitting
) {
Text("Save draft")
}
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.weight(1f),
enabled = !state.isSubmitting
) {
Icon(Icons.Default.Schedule, null, Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("Schedule")
} }
} }
} }

View file

@ -5,10 +5,13 @@ import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.PreviewHtmlBuilder
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
import com.swoosh.microblog.worker.PostUploadWorker import com.swoosh.microblog.worker.PostUploadWorker
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -27,6 +30,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private var editingGhostId: String? = null private var editingGhostId: String? = null
private var editingUpdatedAt: String? = null private var editingUpdatedAt: String? = null
private var previewDebounceJob: Job? = null
fun loadForEdit(post: FeedPost) { fun loadForEdit(post: FeedPost) {
editingLocalId = post.localId editingLocalId = post.localId
editingGhostId = post.ghostId editingGhostId = post.ghostId
@ -48,10 +53,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
fun updateText(text: String) { fun updateText(text: String) {
_uiState.update { it.copy(text = text) } _uiState.update { it.copy(text = text) }
if (_uiState.value.isPreviewMode) {
debouncedPreviewUpdate()
}
} }
fun setImage(uri: Uri?) { fun setImage(uri: Uri?) {
_uiState.update { it.copy(imageUri = uri) } _uiState.update { it.copy(imageUri = uri) }
if (_uiState.value.isPreviewMode) {
debouncedPreviewUpdate()
}
} }
fun fetchLinkPreview(url: String) { fun fetchLinkPreview(url: String) {
@ -60,17 +71,63 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(isLoadingLink = true) } _uiState.update { it.copy(isLoadingLink = true) }
val preview = OpenGraphFetcher.fetch(url) val preview = OpenGraphFetcher.fetch(url)
_uiState.update { it.copy(linkPreview = preview, isLoadingLink = false) } _uiState.update { it.copy(linkPreview = preview, isLoadingLink = false) }
if (_uiState.value.isPreviewMode) {
generatePreviewHtml()
}
} }
} }
fun removeLinkPreview() { fun removeLinkPreview() {
_uiState.update { it.copy(linkPreview = null) } _uiState.update { it.copy(linkPreview = null) }
if (_uiState.value.isPreviewMode) {
debouncedPreviewUpdate()
}
} }
fun setScheduledDate(dateTimeIso: String?) { fun setScheduledDate(dateTimeIso: String?) {
_uiState.update { it.copy(scheduledAt = dateTimeIso) } _uiState.update { it.copy(scheduledAt = dateTimeIso) }
} }
/**
* Toggle between edit and preview modes.
*/
fun togglePreviewMode() {
val currentlyInPreview = _uiState.value.isPreviewMode
if (!currentlyInPreview) {
// Switching to preview: generate HTML immediately
generatePreviewHtml()
}
_uiState.update { it.copy(isPreviewMode = !currentlyInPreview) }
}
/**
* Switch to a specific mode directly.
*/
fun setPreviewMode(preview: Boolean) {
if (preview && !_uiState.value.isPreviewMode) {
generatePreviewHtml()
}
_uiState.update { it.copy(isPreviewMode = preview) }
}
private fun debouncedPreviewUpdate() {
previewDebounceJob?.cancel()
previewDebounceJob = viewModelScope.launch {
delay(PREVIEW_DEBOUNCE_MS)
generatePreviewHtml()
}
}
internal fun generatePreviewHtml() {
val state = _uiState.value
val html = PreviewHtmlBuilder.build(
text = state.text,
imageUrl = state.imageUri?.toString(),
linkPreview = state.linkPreview
)
_uiState.update { it.copy(previewHtml = html) }
}
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH) fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE) fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
@ -175,11 +232,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
} }
fun reset() { fun reset() {
previewDebounceJob?.cancel()
editingLocalId = null editingLocalId = null
editingGhostId = null editingGhostId = null
editingUpdatedAt = null editingUpdatedAt = null
_uiState.value = ComposerUiState() _uiState.value = ComposerUiState()
} }
companion object {
const val PREVIEW_DEBOUNCE_MS = 500L
}
} }
data class ComposerUiState( data class ComposerUiState(
@ -191,5 +253,7 @@ data class ComposerUiState(
val isSubmitting: Boolean = false, val isSubmitting: Boolean = false,
val isSuccess: Boolean = false, val isSuccess: Boolean = false,
val isEditing: Boolean = false, val isEditing: Boolean = false,
val error: String? = null val error: String? = null,
val isPreviewMode: Boolean = false,
val previewHtml: String = ""
) )

View file

@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -14,7 +15,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.PreviewHtmlBuilder
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.LinkPreview
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
@ -24,7 +27,8 @@ fun DetailScreen(
post: FeedPost, post: FeedPost,
onBack: () -> Unit, onBack: () -> Unit,
onEdit: (FeedPost) -> Unit, onEdit: (FeedPost) -> Unit,
onDelete: (FeedPost) -> Unit onDelete: (FeedPost) -> Unit,
onPreview: ((String) -> Unit)? = null
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
@ -38,6 +42,31 @@ fun DetailScreen(
} }
}, },
actions = { actions = {
// Preview button - show rendered HTML
IconButton(onClick = {
val html = if (!post.htmlContent.isNullOrBlank()) {
// Published post: use the HTML from the Ghost API
PreviewHtmlBuilder.wrapExistingHtml(post.htmlContent)
} else {
// Local/draft post: generate preview from content
val linkPreview = if (post.linkUrl != null) {
LinkPreview(
url = post.linkUrl,
title = post.linkTitle,
description = post.linkDescription,
imageUrl = post.linkImageUrl
)
} else null
PreviewHtmlBuilder.build(
text = post.textContent,
imageUrl = post.imageUrl,
linkPreview = linkPreview
)
}
onPreview?.invoke(html)
}) {
Icon(Icons.Default.Visibility, "View preview")
}
IconButton(onClick = { onEdit(post) }) { IconButton(onClick = { onEdit(post) }) {
Icon(Icons.Default.Edit, "Edit") Icon(Icons.Default.Edit, "Edit")
} }
@ -132,7 +161,7 @@ fun DetailScreen(
// Metadata // Metadata
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Divider() HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
if (post.createdAt != null) { if (post.createdAt != null) {

View file

@ -11,6 +11,7 @@ import com.swoosh.microblog.ui.composer.ComposerViewModel
import com.swoosh.microblog.ui.detail.DetailScreen import com.swoosh.microblog.ui.detail.DetailScreen
import com.swoosh.microblog.ui.feed.FeedScreen import com.swoosh.microblog.ui.feed.FeedScreen
import com.swoosh.microblog.ui.feed.FeedViewModel import com.swoosh.microblog.ui.feed.FeedViewModel
import com.swoosh.microblog.ui.preview.PreviewScreen
import com.swoosh.microblog.ui.settings.SettingsScreen import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen import com.swoosh.microblog.ui.setup.SetupScreen
@ -20,6 +21,7 @@ object Routes {
const val COMPOSER = "composer" const val COMPOSER = "composer"
const val DETAIL = "detail" const val DETAIL = "detail"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val PREVIEW = "preview"
} }
@Composable @Composable
@ -30,6 +32,7 @@ fun SwooshNavGraph(
// Shared state for passing posts between screens // Shared state for passing posts between screens
var selectedPost by remember { mutableStateOf<FeedPost?>(null) } var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
var editPost by remember { mutableStateOf<FeedPost?>(null) } var editPost by remember { mutableStateOf<FeedPost?>(null) }
var previewHtml by remember { mutableStateOf("") }
val feedViewModel: FeedViewModel = viewModel() val feedViewModel: FeedViewModel = viewModel()
@ -67,6 +70,10 @@ fun SwooshNavGraph(
onDismiss = { onDismiss = {
feedViewModel.refresh() feedViewModel.refresh()
navController.popBackStack() navController.popBackStack()
},
onFullScreenPreview = { html ->
previewHtml = html
navController.navigate(Routes.PREVIEW)
} }
) )
} }
@ -84,6 +91,10 @@ fun SwooshNavGraph(
onDelete = { p -> onDelete = { p ->
feedViewModel.deletePost(p) feedViewModel.deletePost(p)
navController.popBackStack() navController.popBackStack()
},
onPreview = { html ->
previewHtml = html
navController.navigate(Routes.PREVIEW)
} }
) )
} }
@ -99,5 +110,12 @@ fun SwooshNavGraph(
} }
) )
} }
composable(Routes.PREVIEW) {
PreviewScreen(
html = previewHtml,
onBack = { navController.popBackStack() }
)
}
} }
} }

View file

@ -0,0 +1,78 @@
package com.swoosh.microblog.ui.preview
import android.annotation.SuppressLint
import android.webkit.WebView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
/**
* Full-screen preview of rendered HTML content.
* Used from both the Composer (for draft preview) and the Detail screen
* (for viewing published post HTML).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PreviewScreen(
html: String,
title: String = "Preview",
onBack: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
HtmlPreviewWebView(
html = html,
modifier = Modifier
.fillMaxSize()
.padding(padding)
)
}
}
/**
* Composable that embeds a WebView to render HTML content.
* JavaScript is disabled for security. Viewport meta tag support is enabled.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun HtmlPreviewWebView(
html: String,
modifier: Modifier = Modifier
) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = false
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.setSupportZoom(false)
settings.builtInZoomControls = false
settings.displayZoomControls = false
// Prevent WebView from navigating away
setWebViewClient(android.webkit.WebViewClient())
loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
},
update = { webView ->
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
},
modifier = modifier
)
}

View file

@ -0,0 +1,492 @@
package com.swoosh.microblog.data
import com.swoosh.microblog.data.model.LinkPreview
import org.junit.Assert.*
import org.junit.Test
class PreviewHtmlBuilderTest {
// --- Basic HTML structure ---
@Test
fun `build produces valid HTML document with DOCTYPE`() {
val result = PreviewHtmlBuilder.build("Hello world")
assertTrue(result.startsWith("<!DOCTYPE html>"))
}
@Test
fun `build contains viewport meta tag`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("<meta name=\"viewport\""))
assertTrue(result.contains("width=device-width"))
}
@Test
fun `build contains charset meta tag`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("<meta charset=\"UTF-8\">"))
}
@Test
fun `build wraps content in html and body tags`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("<html>"))
assertTrue(result.contains("</html>"))
assertTrue(result.contains("<body>"))
assertTrue(result.contains("</body>"))
}
@Test
fun `build includes style block`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("<style>"))
assertTrue(result.contains("</style>"))
}
// --- Dark mode CSS ---
@Test
fun `build contains dark mode media query`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("prefers-color-scheme: dark"))
}
@Test
fun `dark mode sets dark background color`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(
"Dark mode should set a dark background",
result.contains("background: #1a1a2e")
)
}
@Test
fun `dark mode sets light text color`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(
"Dark mode should set light text",
result.contains("color: #e0e0e0")
)
}
// --- Plain text rendering ---
@Test
fun `build with plain text wraps in paragraph tag`() {
val result = PreviewHtmlBuilder.build("Hello world")
assertTrue(result.contains("<p>Hello world</p>"))
}
@Test
fun `build with multiline text creates multiple paragraphs`() {
val result = PreviewHtmlBuilder.build("First paragraph\n\nSecond paragraph")
assertTrue(result.contains("<p>First paragraph</p>"))
assertTrue(result.contains("<p>Second paragraph</p>"))
}
@Test
fun `build with single newlines converts to br tags`() {
val result = PreviewHtmlBuilder.build("Line one\nLine two")
assertTrue(result.contains("Line one<br>Line two"))
}
@Test
fun `build preserves paragraph separation with double newlines`() {
val html = PreviewHtmlBuilder.build("Para 1\n\nPara 2\n\nPara 3")
// Should have 3 paragraph tags
val paragraphCount = "<p>".toRegex().findAll(html).count()
assertEquals(3, paragraphCount)
}
// --- Empty content ---
@Test
fun `build with empty text produces valid HTML`() {
val result = PreviewHtmlBuilder.build("")
assertTrue(result.contains("<!DOCTYPE html>"))
assertTrue(result.contains("<body>"))
}
@Test
fun `build with blank text does not produce paragraph tags`() {
val result = PreviewHtmlBuilder.build(" ")
assertFalse(result.contains("<p>"))
}
@Test
fun `build with only whitespace produces valid document`() {
val result = PreviewHtmlBuilder.build(" \n\n \n ")
assertTrue(result.startsWith("<!DOCTYPE html>"))
}
// --- Image rendering ---
@Test
fun `build with image includes img tag`() {
val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
assertTrue(result.contains("<img"))
assertTrue(result.contains("https://example.com/photo.jpg"))
}
@Test
fun `build with image wraps in figure tag`() {
val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
assertTrue(result.contains("<figure"))
assertTrue(result.contains("</figure>"))
}
@Test
fun `build with image includes alt text`() {
val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
assertTrue(result.contains("alt=\"Post image\""))
}
@Test
fun `build with null image does not include img tag`() {
val result = PreviewHtmlBuilder.build("Text", imageUrl = null)
assertFalse(result.contains("<img"))
assertFalse(result.contains("<figure"))
}
@Test
fun `build with empty image does not include img tag`() {
val result = PreviewHtmlBuilder.build("Text", imageUrl = "")
assertFalse(result.contains("<img"))
}
@Test
fun `image appears after text paragraphs`() {
val result = PreviewHtmlBuilder.build("Some text", imageUrl = "https://example.com/img.jpg")
val textIndex = result.indexOf("<p>Some text</p>")
val imgIndex = result.indexOf("<img")
assertTrue("Image should appear after text", imgIndex > textIndex)
}
// --- Link preview / bookmark card ---
@Test
fun `build with link preview includes bookmark card`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Example Site",
description = "A great site",
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("bookmark-card"))
}
@Test
fun `build with link preview includes title`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Example Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("Example Title"))
}
@Test
fun `build with link preview includes description`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = "A description of the page",
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("A description of the page"))
}
@Test
fun `build with link preview includes URL`() {
val preview = LinkPreview(
url = "https://example.com/article",
title = "Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("https://example.com/article"))
}
@Test
fun `build with link preview and image includes bookmark image`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = null,
imageUrl = "https://example.com/og-image.jpg"
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("bookmark-image"))
assertTrue(result.contains("https://example.com/og-image.jpg"))
}
@Test
fun `build with link preview without image does not include bookmark-image in content`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = "Desc",
imageUrl = null
)
val result = PreviewHtmlBuilder.buildContentHtml("Text", null, preview)
assertFalse(result.contains("bookmark-image"))
}
@Test
fun `build with null link preview does not include bookmark card in content`() {
val result = PreviewHtmlBuilder.buildContentHtml("Text", null, null)
assertFalse(result.contains("bookmark-card"))
}
@Test
fun `bookmark card appears after text and image in content`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.buildContentHtml(
"Some text",
"https://example.com/img.jpg",
preview
)
val textIndex = result.indexOf("<p>Some text</p>")
val imgIndex = result.indexOf("<figure")
val bookmarkIndex = result.indexOf("bookmark-card")
assertTrue("Text should come first", textIndex < imgIndex)
assertTrue("Image should come before bookmark", imgIndex < bookmarkIndex)
}
// --- HTML escaping ---
@Test
fun `escapeHtml escapes ampersand`() {
assertEquals("&amp;", PreviewHtmlBuilder.escapeHtml("&"))
}
@Test
fun `escapeHtml escapes less-than`() {
assertEquals("&lt;", PreviewHtmlBuilder.escapeHtml("<"))
}
@Test
fun `escapeHtml escapes greater-than`() {
assertEquals("&gt;", PreviewHtmlBuilder.escapeHtml(">"))
}
@Test
fun `escapeHtml escapes double quotes`() {
assertEquals("&quot;", PreviewHtmlBuilder.escapeHtml("\""))
}
@Test
fun `escapeHtml escapes single quotes`() {
assertEquals("&#39;", PreviewHtmlBuilder.escapeHtml("'"))
}
@Test
fun `escapeHtml handles combined special characters`() {
assertEquals(
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
PreviewHtmlBuilder.escapeHtml("<script>alert('xss')</script>")
)
}
@Test
fun `escapeHtml leaves normal text unchanged`() {
assertEquals("Hello world", PreviewHtmlBuilder.escapeHtml("Hello world"))
}
@Test
fun `escapeHtml handles empty string`() {
assertEquals("", PreviewHtmlBuilder.escapeHtml(""))
}
@Test
fun `build escapes HTML in text content`() {
val result = PreviewHtmlBuilder.build("<script>alert('xss')</script>")
assertFalse("Should not contain unescaped script tag", result.contains("<script>alert"))
assertTrue(result.contains("&lt;script&gt;"))
}
@Test
fun `build escapes HTML in link preview title`() {
val preview = LinkPreview(
url = "https://example.com",
title = "<b>Bold</b>",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("&lt;b&gt;Bold&lt;/b&gt;"))
}
// --- wrapExistingHtml ---
@Test
fun `wrapExistingHtml produces valid HTML document`() {
val result = PreviewHtmlBuilder.wrapExistingHtml("<p>Hello</p>")
assertTrue(result.startsWith("<!DOCTYPE html>"))
assertTrue(result.contains("<p>Hello</p>"))
}
@Test
fun `wrapExistingHtml includes same styling as build`() {
val fromBuild = PreviewHtmlBuilder.build("Hello")
val fromWrap = PreviewHtmlBuilder.wrapExistingHtml("<p>Hello</p>")
// Both should contain the same CSS
assertTrue(fromBuild.contains("bookmark-card"))
assertTrue(fromWrap.contains("bookmark-card"))
assertTrue(fromBuild.contains("prefers-color-scheme: dark"))
assertTrue(fromWrap.contains("prefers-color-scheme: dark"))
}
@Test
fun `wrapExistingHtml preserves raw HTML content`() {
val html = "<h1>Title</h1><p>Paragraph with <strong>bold</strong> text.</p>"
val result = PreviewHtmlBuilder.wrapExistingHtml(html)
assertTrue(result.contains(html))
}
@Test
fun `wrapExistingHtml handles empty HTML`() {
val result = PreviewHtmlBuilder.wrapExistingHtml("")
assertTrue(result.startsWith("<!DOCTYPE html>"))
}
// --- buildContentHtml (internal) ---
@Test
fun `buildContentHtml with all content types produces all sections`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = "Desc",
imageUrl = "https://example.com/image.jpg"
)
val result = PreviewHtmlBuilder.buildContentHtml(
text = "Hello world",
imageUrl = "https://example.com/photo.jpg",
linkPreview = preview
)
assertTrue("Should contain paragraph", result.contains("<p>Hello world</p>"))
assertTrue("Should contain feature image", result.contains("photo.jpg"))
assertTrue("Should contain bookmark card", result.contains("bookmark-card"))
assertTrue("Should contain bookmark image", result.contains("bookmark-image"))
}
@Test
fun `buildContentHtml with no content produces empty string`() {
val result = PreviewHtmlBuilder.buildContentHtml(
text = "",
imageUrl = null,
linkPreview = null
)
assertEquals("", result)
}
// --- buildBookmarkCard (internal) ---
@Test
fun `buildBookmarkCard includes all fields when present`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Test Title",
description = "Test Description",
imageUrl = "https://example.com/thumb.jpg"
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertTrue(result.contains("bookmark-card"))
assertTrue(result.contains("bookmark-image"))
assertTrue(result.contains("bookmark-title"))
assertTrue(result.contains("Test Title"))
assertTrue(result.contains("bookmark-description"))
assertTrue(result.contains("Test Description"))
assertTrue(result.contains("bookmark-url"))
assertTrue(result.contains("https://example.com"))
}
@Test
fun `buildBookmarkCard without image omits image tag`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertFalse(result.contains("bookmark-image"))
assertFalse(result.contains("<img"))
}
@Test
fun `buildBookmarkCard without title omits title div`() {
val preview = LinkPreview(
url = "https://example.com",
title = null,
description = "Desc",
imageUrl = null
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertFalse(result.contains("bookmark-title"))
}
@Test
fun `buildBookmarkCard without description omits description div`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertFalse(result.contains("bookmark-description"))
}
@Test
fun `buildBookmarkCard always includes URL`() {
val preview = LinkPreview(
url = "https://example.com/article",
title = null,
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertTrue(result.contains("bookmark-url"))
assertTrue(result.contains("https://example.com/article"))
}
// --- CSS styling ---
@Test
fun `template includes responsive image styling`() {
val result = PreviewHtmlBuilder.build("Test")
assertTrue(result.contains("max-width: 100%"))
}
@Test
fun `template includes responsive body max-width`() {
val result = PreviewHtmlBuilder.build("Test")
assertTrue(result.contains("max-width: 600px"))
}
@Test
fun `template includes system font stack`() {
val result = PreviewHtmlBuilder.build("Test")
assertTrue(result.contains("-apple-system"))
assertTrue(result.contains("sans-serif"))
}
@Test
fun `template includes border-radius for bookmark card`() {
val result = PreviewHtmlBuilder.build("Test")
assertTrue(result.contains("border-radius: 8px"))
}
}