mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate share sheet feature (resolve conflicts)
This commit is contained in:
commit
0e954e15d5
7 changed files with 639 additions and 97 deletions
78
app/src/main/java/com/swoosh/microblog/data/ShareUtils.kt
Normal file
78
app/src/main/java/com/swoosh/microblog/data/ShareUtils.kt
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
|
||||
/**
|
||||
* Utility functions for sharing posts via Android share sheet and clipboard.
|
||||
*/
|
||||
object ShareUtils {
|
||||
|
||||
/**
|
||||
* Constructs a post URL from the Ghost base URL and the post slug.
|
||||
* Ghost default URL format: https://yourblog.com/slug/
|
||||
*
|
||||
* @param baseUrl The Ghost instance base URL (e.g. "https://blog.example.com" or "https://blog.example.com/")
|
||||
* @param slug The post slug (e.g. "my-first-post")
|
||||
* @return The full post URL with trailing slash
|
||||
*/
|
||||
fun getPostUrl(baseUrl: String, slug: String): String {
|
||||
val normalizedBase = baseUrl.trimEnd('/')
|
||||
val normalizedSlug = slug.trim('/')
|
||||
return "$normalizedBase/$normalizedSlug/"
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the shareable URL for a post.
|
||||
* Prefers the GhostPost url field if available, otherwise constructs from baseUrl + slug.
|
||||
*
|
||||
* @param post The FeedPost to get the URL for
|
||||
* @param baseUrl The Ghost instance base URL
|
||||
* @return The post URL, or null if no slug or url is available
|
||||
*/
|
||||
fun resolvePostUrl(post: FeedPost, baseUrl: String?): String? {
|
||||
// Prefer the url field from the Ghost API response
|
||||
if (!post.url.isNullOrBlank()) {
|
||||
return post.url
|
||||
}
|
||||
// Fall back to constructing from base URL + slug
|
||||
if (!post.slug.isNullOrBlank() && !baseUrl.isNullOrBlank()) {
|
||||
return getPostUrl(baseUrl, post.slug)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats share content for a post.
|
||||
* Includes the first 280 chars of text content, then "Read more:" link.
|
||||
* For posts with link previews, the linked URL is included.
|
||||
*
|
||||
* @param post The FeedPost to format
|
||||
* @param postUrl The resolved URL for the post
|
||||
* @return Formatted share text
|
||||
*/
|
||||
fun formatShareContent(post: FeedPost, postUrl: String): String {
|
||||
val content = buildString {
|
||||
// Post text content, truncated to 280 chars
|
||||
val text = post.textContent.trim()
|
||||
if (text.isNotEmpty()) {
|
||||
if (text.length > 280) {
|
||||
append(text.take(280))
|
||||
append("...")
|
||||
} else {
|
||||
append(text)
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a link preview URL, include it
|
||||
if (!post.linkUrl.isNullOrBlank()) {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
append(post.linkUrl)
|
||||
}
|
||||
|
||||
// Always include the post URL
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
append("Read more: $postUrl")
|
||||
}
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ data class PostWrapper(
|
|||
data class GhostPost(
|
||||
val id: String? = null,
|
||||
val title: String? = null,
|
||||
val slug: String? = null,
|
||||
val url: String? = null,
|
||||
val html: String? = null,
|
||||
val plaintext: String? = null,
|
||||
val mobiledoc: String? = null,
|
||||
|
|
@ -92,6 +94,8 @@ enum class QueueStatus {
|
|||
data class FeedPost(
|
||||
val localId: Long? = null,
|
||||
val ghostId: String? = null,
|
||||
val slug: String? = null,
|
||||
val url: String? = null,
|
||||
val title: String,
|
||||
val textContent: String,
|
||||
val htmlContent: String?,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package com.swoosh.microblog.ui.detail
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
|
|
@ -10,12 +14,15 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
|
|
@ -23,12 +30,17 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.CredentialsManager
|
||||
import com.swoosh.microblog.data.ShareUtils
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.PostStats
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
import com.swoosh.microblog.ui.feed.StatusBadge
|
||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -39,6 +51,15 @@ fun DetailScreen(
|
|||
onDelete: (FeedPost) -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showOverflowMenu by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val baseUrl = remember { CredentialsManager(context).ghostUrl }
|
||||
|
||||
val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
|
||||
val postUrl = remember(post, baseUrl) { ShareUtils.resolvePostUrl(post, baseUrl) }
|
||||
val canShare = isPublished && postUrl != null
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -50,15 +71,67 @@ fun DetailScreen(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
// Share button - only for published posts with a URL
|
||||
if (canShare) {
|
||||
IconButton(onClick = {
|
||||
val shareText = ShareUtils.formatShareContent(post, postUrl!!)
|
||||
val sendIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
|
||||
}) {
|
||||
Icon(Icons.Default.Share, "Share")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onEdit(post) }) {
|
||||
Icon(Icons.Default.Edit, "Edit")
|
||||
}
|
||||
IconButton(onClick = { showDeleteDialog = true }) {
|
||||
Icon(Icons.Default.Delete, "Delete")
|
||||
// Overflow menu with Copy link and Delete
|
||||
Box {
|
||||
IconButton(onClick = { showOverflowMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, "More options")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showOverflowMenu,
|
||||
onDismissRequest = { showOverflowMenu = false }
|
||||
) {
|
||||
if (canShare) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy link") },
|
||||
onClick = {
|
||||
showOverflowMenu = false
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar("Link copied to clipboard")
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||
}
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
onClick = {
|
||||
showOverflowMenu = false
|
||||
showDeleteDialog = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
package com.swoosh.microblog.ui.feed
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
|
@ -10,10 +16,12 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.BrightnessAuto
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
|
|
@ -24,12 +32,16 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.CredentialsManager
|
||||
import com.swoosh.microblog.data.ShareUtils
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.PostStats
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
|
|
@ -48,6 +60,9 @@ fun FeedScreen(
|
|||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val baseUrl = remember { CredentialsManager(context).ghostUrl }
|
||||
|
||||
// Pull-to-refresh
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
|
|
@ -98,7 +113,8 @@ fun FeedScreen(
|
|||
FloatingActionButton(onClick = onCompose) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New post")
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -171,7 +187,26 @@ fun FeedScreen(
|
|||
PostCard(
|
||||
post = post,
|
||||
onClick = { onPostClick(post) },
|
||||
onCancelQueue = { viewModel.cancelQueuedPost(post) }
|
||||
onCancelQueue = { viewModel.cancelQueuedPost(post) },
|
||||
onShare = {
|
||||
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
|
||||
if (postUrl != null) {
|
||||
val shareText = ShareUtils.formatShareContent(post, postUrl)
|
||||
val sendIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
|
||||
}
|
||||
},
|
||||
onCopyLink = {
|
||||
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
|
||||
if (postUrl != null) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
|
||||
}
|
||||
},
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -211,134 +246,206 @@ fun FeedScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PostCard(
|
||||
post: FeedPost,
|
||||
onClick: () -> Unit,
|
||||
onCancelQueue: () -> Unit
|
||||
onCancelQueue: () -> Unit,
|
||||
onShare: () -> Unit = {},
|
||||
onCopyLink: () -> Unit = {},
|
||||
snackbarHostState: SnackbarHostState? = null
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val displayText = if (expanded || post.textContent.length <= 280) {
|
||||
post.textContent
|
||||
} else {
|
||||
post.textContent.take(280) + "..."
|
||||
}
|
||||
|
||||
val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
|
||||
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
if (isPublished && hasShareableUrl) {
|
||||
showContextMenu = true
|
||||
}
|
||||
}
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Status row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StatusBadge(post)
|
||||
|
||||
Text(
|
||||
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Content
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 8,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (!expanded && post.textContent.length > 280) {
|
||||
TextButton(
|
||||
onClick = { expanded = true },
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
Box {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Status row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Show more", style = MaterialTheme.typography.labelMedium)
|
||||
StatusBadge(post)
|
||||
|
||||
Text(
|
||||
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Image thumbnail
|
||||
if (post.imageUrl != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
AsyncImage(
|
||||
model = post.imageUrl,
|
||||
contentDescription = "Post image",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
// Content
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 8,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// Link preview
|
||||
if (post.linkUrl != null && post.linkTitle != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
if (post.linkImageUrl != null) {
|
||||
AsyncImage(
|
||||
model = post.linkImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = post.linkTitle,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (post.linkDescription != null) {
|
||||
if (!expanded && post.textContent.length > 280) {
|
||||
TextButton(
|
||||
onClick = { expanded = true },
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Show more", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
|
||||
// Image thumbnail
|
||||
if (post.imageUrl != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
AsyncImage(
|
||||
model = post.imageUrl,
|
||||
contentDescription = "Post image",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
// Link preview
|
||||
if (post.linkUrl != null && post.linkTitle != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
if (post.linkImageUrl != null) {
|
||||
AsyncImage(
|
||||
model = post.linkImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = post.linkDescription,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = post.linkTitle,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (post.linkDescription != null) {
|
||||
Text(
|
||||
text = post.linkDescription,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue status
|
||||
if (post.queueStatus != QueueStatus.NONE) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val queueLabel = when (post.queueStatus) {
|
||||
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
|
||||
QueueStatus.UPLOADING -> "Uploading..."
|
||||
QueueStatus.FAILED -> "Upload failed"
|
||||
else -> ""
|
||||
}
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(queueLabel, style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) {
|
||||
TextButton(onClick = onCancelQueue) {
|
||||
Text("Cancel", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Share button row for published posts
|
||||
if (isPublished && hasShareableUrl) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onShare,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Share,
|
||||
contentDescription = "Share",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue status
|
||||
if (post.queueStatus != QueueStatus.NONE) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val queueLabel = when (post.queueStatus) {
|
||||
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
|
||||
QueueStatus.UPLOADING -> "Uploading..."
|
||||
QueueStatus.FAILED -> "Upload failed"
|
||||
else -> ""
|
||||
}
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(queueLabel, style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) {
|
||||
TextButton(onClick = onCancelQueue) {
|
||||
Text("Cancel", style = MaterialTheme.typography.labelSmall)
|
||||
// Long-press context menu for copy link
|
||||
DropdownMenu(
|
||||
expanded = showContextMenu,
|
||||
onDismissRequest = { showContextMenu = false },
|
||||
offset = DpOffset(16.dp, 0.dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy link") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onCopyLink()
|
||||
snackbarHostState?.let { host ->
|
||||
coroutineScope.launch {
|
||||
host.showSnackbar("Link copied to clipboard")
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Share") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onShare()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Share, contentDescription = null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Post stats badges
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
|
||||
private fun GhostPost.toFeedPost(): FeedPost = FeedPost(
|
||||
ghostId = id,
|
||||
slug = slug,
|
||||
url = url,
|
||||
title = title ?: "",
|
||||
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
|
||||
htmlContent = html,
|
||||
|
|
|
|||
276
app/src/test/java/com/swoosh/microblog/data/ShareUtilsTest.kt
Normal file
276
app/src/test/java/com/swoosh/microblog/data/ShareUtilsTest.kt
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ShareUtilsTest {
|
||||
|
||||
// --- getPostUrl tests ---
|
||||
|
||||
@Test
|
||||
fun `getPostUrl constructs URL with base and slug`() {
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-post/",
|
||||
ShareUtils.getPostUrl("https://blog.example.com", "my-post")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPostUrl handles base URL with trailing slash`() {
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-post/",
|
||||
ShareUtils.getPostUrl("https://blog.example.com/", "my-post")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPostUrl handles base URL with multiple trailing slashes`() {
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-post/",
|
||||
ShareUtils.getPostUrl("https://blog.example.com///", "my-post")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPostUrl handles slug with leading slash`() {
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-post/",
|
||||
ShareUtils.getPostUrl("https://blog.example.com", "/my-post")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPostUrl handles slug with trailing slash`() {
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-post/",
|
||||
ShareUtils.getPostUrl("https://blog.example.com", "my-post/")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPostUrl handles slug with both leading and trailing slashes`() {
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-post/",
|
||||
ShareUtils.getPostUrl("https://blog.example.com", "/my-post/")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPostUrl handles subdomain base URL`() {
|
||||
assertEquals(
|
||||
"https://my-blog.ghost.io/hello-world/",
|
||||
ShareUtils.getPostUrl("https://my-blog.ghost.io", "hello-world")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPostUrl handles http base URL`() {
|
||||
assertEquals(
|
||||
"http://localhost:2368/test-post/",
|
||||
ShareUtils.getPostUrl("http://localhost:2368", "test-post")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPostUrl handles slug with hyphens`() {
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-long-post-title-here/",
|
||||
ShareUtils.getPostUrl("https://blog.example.com", "my-long-post-title-here")
|
||||
)
|
||||
}
|
||||
|
||||
// --- resolvePostUrl tests ---
|
||||
|
||||
@Test
|
||||
fun `resolvePostUrl prefers url field when available`() {
|
||||
val post = createFeedPost(
|
||||
url = "https://blog.example.com/custom-url/",
|
||||
slug = "my-slug"
|
||||
)
|
||||
assertEquals(
|
||||
"https://blog.example.com/custom-url/",
|
||||
ShareUtils.resolvePostUrl(post, "https://blog.example.com")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvePostUrl falls back to slug when url is null`() {
|
||||
val post = createFeedPost(slug = "my-slug", url = null)
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-slug/",
|
||||
ShareUtils.resolvePostUrl(post, "https://blog.example.com")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvePostUrl falls back to slug when url is blank`() {
|
||||
val post = createFeedPost(slug = "my-slug", url = " ")
|
||||
assertEquals(
|
||||
"https://blog.example.com/my-slug/",
|
||||
ShareUtils.resolvePostUrl(post, "https://blog.example.com")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvePostUrl returns null when no url and no slug`() {
|
||||
val post = createFeedPost(slug = null, url = null)
|
||||
assertNull(ShareUtils.resolvePostUrl(post, "https://blog.example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvePostUrl returns null when no url and blank slug`() {
|
||||
val post = createFeedPost(slug = "", url = null)
|
||||
assertNull(ShareUtils.resolvePostUrl(post, "https://blog.example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvePostUrl returns null when no url, has slug, but no base URL`() {
|
||||
val post = createFeedPost(slug = "my-slug", url = null)
|
||||
assertNull(ShareUtils.resolvePostUrl(post, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvePostUrl returns null when no url, has slug, but blank base URL`() {
|
||||
val post = createFeedPost(slug = "my-slug", url = null)
|
||||
assertNull(ShareUtils.resolvePostUrl(post, ""))
|
||||
}
|
||||
|
||||
// --- formatShareContent tests ---
|
||||
|
||||
@Test
|
||||
fun `formatShareContent includes text and read more link`() {
|
||||
val post = createFeedPost(textContent = "Hello world")
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/hello/")
|
||||
assertEquals(
|
||||
"Hello world\n\nRead more: https://blog.example.com/hello/",
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatShareContent truncates text over 280 chars`() {
|
||||
val longText = "a".repeat(300)
|
||||
val post = createFeedPost(textContent = longText)
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/")
|
||||
assertTrue(result.startsWith("a".repeat(280) + "..."))
|
||||
assertTrue(result.contains("Read more: https://blog.example.com/post/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatShareContent does not truncate text at exactly 280 chars`() {
|
||||
val text = "b".repeat(280)
|
||||
val post = createFeedPost(textContent = text)
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/")
|
||||
assertTrue(result.startsWith(text))
|
||||
assertFalse(result.contains("..."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatShareContent includes link URL when present`() {
|
||||
val post = createFeedPost(
|
||||
textContent = "Check this out",
|
||||
linkUrl = "https://external.com/article"
|
||||
)
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/")
|
||||
assertEquals(
|
||||
"Check this out\n\nhttps://external.com/article\n\nRead more: https://blog.example.com/post/",
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatShareContent does not include link URL when null`() {
|
||||
val post = createFeedPost(textContent = "Hello", linkUrl = null)
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/")
|
||||
assertFalse(result.contains("https://external"))
|
||||
assertEquals("Hello\n\nRead more: https://blog.example.com/post/", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatShareContent does not include link URL when blank`() {
|
||||
val post = createFeedPost(textContent = "Hello", linkUrl = " ")
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/")
|
||||
assertEquals("Hello\n\nRead more: https://blog.example.com/post/", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatShareContent handles empty text content`() {
|
||||
val post = createFeedPost(textContent = "")
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/")
|
||||
assertEquals("Read more: https://blog.example.com/post/", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatShareContent handles whitespace-only text content`() {
|
||||
val post = createFeedPost(textContent = " ")
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/")
|
||||
assertEquals("Read more: https://blog.example.com/post/", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatShareContent handles empty text with link URL`() {
|
||||
val post = createFeedPost(textContent = "", linkUrl = "https://external.com")
|
||||
val result = ShareUtils.formatShareContent(post, "https://blog.example.com/post/")
|
||||
assertEquals("https://external.com\n\nRead more: https://blog.example.com/post/", result)
|
||||
}
|
||||
|
||||
// --- Share visibility tests ---
|
||||
|
||||
@Test
|
||||
fun `published post with slug is shareable`() {
|
||||
val post = createFeedPost(status = "published", slug = "my-post")
|
||||
val url = ShareUtils.resolvePostUrl(post, "https://blog.example.com")
|
||||
assertNotNull(url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draft post is not shareable via url resolution`() {
|
||||
// Drafts don't typically have a slug or url from Ghost
|
||||
val post = createFeedPost(status = "draft", slug = null, url = null)
|
||||
val url = ShareUtils.resolvePostUrl(post, "https://blog.example.com")
|
||||
assertNull(url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `published post with url field is shareable`() {
|
||||
val post = createFeedPost(
|
||||
status = "published",
|
||||
slug = null,
|
||||
url = "https://blog.example.com/my-post/"
|
||||
)
|
||||
val url = ShareUtils.resolvePostUrl(post, "https://blog.example.com")
|
||||
assertEquals("https://blog.example.com/my-post/", url)
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun createFeedPost(
|
||||
textContent: String = "Test content",
|
||||
slug: String? = null,
|
||||
url: String? = null,
|
||||
status: String = "published",
|
||||
linkUrl: String? = null,
|
||||
imageUrl: String? = null
|
||||
) = FeedPost(
|
||||
localId = null,
|
||||
ghostId = "test-id",
|
||||
slug = slug,
|
||||
url = url,
|
||||
title = "",
|
||||
textContent = textContent,
|
||||
htmlContent = null,
|
||||
imageUrl = imageUrl,
|
||||
linkUrl = linkUrl,
|
||||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
status = status,
|
||||
publishedAt = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
isLocal = false,
|
||||
queueStatus = QueueStatus.NONE
|
||||
)
|
||||
}
|
||||
|
|
@ -67,6 +67,8 @@ class GhostModelsTest {
|
|||
val post = GhostPost()
|
||||
assertNull(post.id)
|
||||
assertNull(post.title)
|
||||
assertNull(post.slug)
|
||||
assertNull(post.url)
|
||||
assertNull(post.html)
|
||||
assertNull(post.plaintext)
|
||||
assertNull(post.mobiledoc)
|
||||
|
|
|
|||
Loading…
Reference in a new issue