mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add post preview with HTML rendering in WebView
This commit is contained in:
parent
74f42fd2f1
commit
6927259a41
7 changed files with 1119 additions and 152 deletions
|
|
@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
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>"""
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -23,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
|
@ -32,6 +30,7 @@ import java.time.format.DateTimeFormatter
|
|||
fun ComposerScreen(
|
||||
editPost: FeedPost? = null,
|
||||
onDismiss: () -> Unit,
|
||||
onFullScreenPreview: ((String) -> Unit)? = null,
|
||||
viewModel: ComposerViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -70,6 +69,16 @@ fun ComposerScreen(
|
|||
}) {
|
||||
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
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Text field with character counter
|
||||
OutlinedTextField(
|
||||
value = state.text,
|
||||
onValueChange = viewModel::updateText,
|
||||
// Edit / Preview segmented button row
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 150.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)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
OutlinedIconButton(onClick = { imagePickerLauncher.launch("image/*") }) {
|
||||
Icon(Icons.Default.Image, "Attach image")
|
||||
SegmentedButton(
|
||||
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 }) {
|
||||
Icon(Icons.Default.Link, "Add link")
|
||||
SegmentedButton(
|
||||
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.imageUri != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = state.imageUri,
|
||||
contentDescription = "Selected image",
|
||||
if (state.isPreviewMode) {
|
||||
// Preview mode: show rendered HTML
|
||||
if (state.text.isBlank() && state.imageUri == null && state.linkPreview == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.setImage(null) },
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
.fillMaxSize()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close, "Remove image",
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
Text(
|
||||
text = "Nothing to preview yet.\nSwitch to Edit and write something.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
HtmlPreviewWebView(
|
||||
html = state.previewHtml,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Link preview
|
||||
if (state.isLoadingLink) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (state.linkPreview != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
} else {
|
||||
// Edit mode: original composer UI
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Text field with character counter
|
||||
OutlinedTextField(
|
||||
value = state.text,
|
||||
onValueChange = viewModel::updateText,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 150.dp),
|
||||
placeholder = { Text("What's on your mind?") },
|
||||
supportingText = {
|
||||
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
|
||||
"${state.text.length} characters",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (state.text.length > 280)
|
||||
MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
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(
|
||||
model = state.linkPreview!!.imageUrl,
|
||||
contentDescription = null,
|
||||
model = state.imageUri,
|
||||
contentDescription = "Selected image",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
.height(200.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.setImage(null) },
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close, "Remove image",
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Link preview
|
||||
if (state.isLoadingLink) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (state.linkPreview != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.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) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = state.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = viewModel::saveDraft,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSubmitting
|
||||
) {
|
||||
Text("Save draft")
|
||||
}
|
||||
|
||||
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()
|
||||
) {
|
||||
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")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ import android.net.Uri
|
|||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.MobiledocBuilder
|
||||
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
||||
import com.swoosh.microblog.data.model.*
|
||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.worker.PostUploadWorker
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -27,6 +30,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
private var editingGhostId: String? = null
|
||||
private var editingUpdatedAt: String? = null
|
||||
|
||||
private var previewDebounceJob: Job? = null
|
||||
|
||||
fun loadForEdit(post: FeedPost) {
|
||||
editingLocalId = post.localId
|
||||
editingGhostId = post.ghostId
|
||||
|
|
@ -48,10 +53,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
|
||||
fun updateText(text: String) {
|
||||
_uiState.update { it.copy(text = text) }
|
||||
if (_uiState.value.isPreviewMode) {
|
||||
debouncedPreviewUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
fun setImage(uri: Uri?) {
|
||||
_uiState.update { it.copy(imageUri = uri) }
|
||||
if (_uiState.value.isPreviewMode) {
|
||||
debouncedPreviewUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLinkPreview(url: String) {
|
||||
|
|
@ -60,17 +71,63 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
_uiState.update { it.copy(isLoadingLink = true) }
|
||||
val preview = OpenGraphFetcher.fetch(url)
|
||||
_uiState.update { it.copy(linkPreview = preview, isLoadingLink = false) }
|
||||
if (_uiState.value.isPreviewMode) {
|
||||
generatePreviewHtml()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeLinkPreview() {
|
||||
_uiState.update { it.copy(linkPreview = null) }
|
||||
if (_uiState.value.isPreviewMode) {
|
||||
debouncedPreviewUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
fun setScheduledDate(dateTimeIso: String?) {
|
||||
_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 saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
|
||||
|
|
@ -175,11 +232,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
}
|
||||
|
||||
fun reset() {
|
||||
previewDebounceJob?.cancel()
|
||||
editingLocalId = null
|
||||
editingGhostId = null
|
||||
editingUpdatedAt = null
|
||||
_uiState.value = ComposerUiState()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREVIEW_DEBOUNCE_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
data class ComposerUiState(
|
||||
|
|
@ -191,5 +253,7 @@ data class ComposerUiState(
|
|||
val isSubmitting: Boolean = false,
|
||||
val isSuccess: Boolean = false,
|
||||
val isEditing: Boolean = false,
|
||||
val error: String? = null
|
||||
val error: String? = null,
|
||||
val isPreviewMode: Boolean = false,
|
||||
val previewHtml: String = ""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
||||
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.formatRelativeTime
|
||||
|
||||
|
|
@ -24,7 +27,8 @@ fun DetailScreen(
|
|||
post: FeedPost,
|
||||
onBack: () -> Unit,
|
||||
onEdit: (FeedPost) -> Unit,
|
||||
onDelete: (FeedPost) -> Unit
|
||||
onDelete: (FeedPost) -> Unit,
|
||||
onPreview: ((String) -> Unit)? = null
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -38,6 +42,31 @@ fun DetailScreen(
|
|||
}
|
||||
},
|
||||
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) }) {
|
||||
Icon(Icons.Default.Edit, "Edit")
|
||||
}
|
||||
|
|
@ -132,7 +161,7 @@ fun DetailScreen(
|
|||
|
||||
// Metadata
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (post.createdAt != null) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.swoosh.microblog.ui.composer.ComposerViewModel
|
|||
import com.swoosh.microblog.ui.detail.DetailScreen
|
||||
import com.swoosh.microblog.ui.feed.FeedScreen
|
||||
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.setup.SetupScreen
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ object Routes {
|
|||
const val COMPOSER = "composer"
|
||||
const val DETAIL = "detail"
|
||||
const val SETTINGS = "settings"
|
||||
const val PREVIEW = "preview"
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -30,6 +32,7 @@ fun SwooshNavGraph(
|
|||
// Shared state for passing posts between screens
|
||||
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||
var editPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||
var previewHtml by remember { mutableStateOf("") }
|
||||
|
||||
val feedViewModel: FeedViewModel = viewModel()
|
||||
|
||||
|
|
@ -67,6 +70,10 @@ fun SwooshNavGraph(
|
|||
onDismiss = {
|
||||
feedViewModel.refresh()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onFullScreenPreview = { html ->
|
||||
previewHtml = html
|
||||
navController.navigate(Routes.PREVIEW)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -84,6 +91,10 @@ fun SwooshNavGraph(
|
|||
onDelete = { p ->
|
||||
feedViewModel.deletePost(p)
|
||||
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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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("&", PreviewHtmlBuilder.escapeHtml("&"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `escapeHtml escapes less-than`() {
|
||||
assertEquals("<", PreviewHtmlBuilder.escapeHtml("<"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `escapeHtml escapes greater-than`() {
|
||||
assertEquals(">", PreviewHtmlBuilder.escapeHtml(">"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `escapeHtml escapes double quotes`() {
|
||||
assertEquals(""", PreviewHtmlBuilder.escapeHtml("\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `escapeHtml escapes single quotes`() {
|
||||
assertEquals("'", PreviewHtmlBuilder.escapeHtml("'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `escapeHtml handles combined special characters`() {
|
||||
assertEquals(
|
||||
"<script>alert('xss')</script>",
|
||||
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("<script>"))
|
||||
}
|
||||
|
||||
@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("<b>Bold</b>"))
|
||||
}
|
||||
|
||||
// --- 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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue