diff --git a/app/src/main/java/com/swoosh/microblog/data/PreviewHtmlBuilder.kt b/app/src/main/java/com/swoosh/microblog/data/PreviewHtmlBuilder.kt new file mode 100644 index 0000000..3fa0702 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/PreviewHtmlBuilder.kt @@ -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
+ val withBreaks = escaped.replace("\n", "
") + sb.append("

$withBreaks

\n") + } + } + } + + // Add feature image + if (!imageUrl.isNullOrBlank()) { + sb.append("
\n") + sb.append(" \"Post\n") + sb.append("
\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("
\n") + + if (!linkPreview.imageUrl.isNullOrBlank()) { + sb.append(" \"\"\n") + } + + sb.append("
\n") + + val title = linkPreview.title + if (!title.isNullOrBlank()) { + sb.append("
${escapeHtml(title)}
\n") + } + + val description = linkPreview.description + if (!description.isNullOrBlank()) { + sb.append("
${escapeHtml(description)}
\n") + } + + sb.append("
${escapeHtml(linkPreview.url)}
\n") + sb.append("
\n") + sb.append("
\n") + + return sb.toString() + } + + internal fun escapeHtml(value: String): String { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } + + internal fun wrapInTemplate(content: String): String { + return """ + + + + + + + +$content + +""" + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index b26f002..49df32f 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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") + } + } } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 86e9683..795fdb0 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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 = "" ) diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index bc6d465..e740e0e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -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) { diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index a33cc63..9a24936 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -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(null) } var editPost by remember { mutableStateOf(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() } + ) + } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/preview/PreviewScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/preview/PreviewScreen.kt new file mode 100644 index 0000000..a2e1469 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/preview/PreviewScreen.kt @@ -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 + ) +} diff --git a/app/src/test/java/com/swoosh/microblog/data/PreviewHtmlBuilderTest.kt b/app/src/test/java/com/swoosh/microblog/data/PreviewHtmlBuilderTest.kt new file mode 100644 index 0000000..6d59787 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/PreviewHtmlBuilderTest.kt @@ -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("")) + } + + @Test + fun `build contains viewport meta tag`() { + val result = PreviewHtmlBuilder.build("Hello") + assertTrue(result.contains("")) + } + + @Test + fun `build wraps content in html and body tags`() { + val result = PreviewHtmlBuilder.build("Hello") + assertTrue(result.contains("")) + assertTrue(result.contains("")) + assertTrue(result.contains("")) + assertTrue(result.contains("")) + } + + @Test + fun `build includes style block`() { + val result = PreviewHtmlBuilder.build("Hello") + assertTrue(result.contains("")) + } + + // --- 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("

Hello world

")) + } + + @Test + fun `build with multiline text creates multiple paragraphs`() { + val result = PreviewHtmlBuilder.build("First paragraph\n\nSecond paragraph") + assertTrue(result.contains("

First paragraph

")) + assertTrue(result.contains("

Second paragraph

")) + } + + @Test + fun `build with single newlines converts to br tags`() { + val result = PreviewHtmlBuilder.build("Line one\nLine two") + assertTrue(result.contains("Line one
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 = "

".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("")) + assertTrue(result.contains("")) + } + + @Test + fun `build with blank text does not produce paragraph tags`() { + val result = PreviewHtmlBuilder.build(" ") + assertFalse(result.contains("

")) + } + + @Test + fun `build with only whitespace produces valid document`() { + val result = PreviewHtmlBuilder.build(" \n\n \n ") + assertTrue(result.startsWith("")) + } + + // --- Image rendering --- + + @Test + fun `build with image includes img tag`() { + val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg") + assertTrue(result.contains("")) + } + + @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("Some text

") + val imgIndex = result.indexOf(" 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("

Some text

") + val imgIndex = result.indexOf("")) + } + + @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("") + ) + } + + @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("") + assertFalse("Should not contain unescaped script tag", result.contains("